Skip to content

Instantly share code, notes, and snippets.

@mduc-dev
Last active June 28, 2025 08:12
Show Gist options
  • Select an option

  • Save mduc-dev/33d489a06ed9aa43ec6c445a7e911811 to your computer and use it in GitHub Desktop.

Select an option

Save mduc-dev/33d489a06ed9aa43ec6c445a7e911811 to your computer and use it in GitHub Desktop.
Cloudflare Worker: Forward Expo EAS Build Webhook to Lark
async function sendLarkRequest(webhookUrl, larkPayload) {
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
msg_type: "interactive",
card: larkPayload.card,
}),
});
return response;
} catch (error) {
console.error("Error sending Lark request:", error);
throw error;
}
}
function handleExpoStatus(body) {
const getFields = () => [
{
is_short: true,
text: {
tag: "lark_md",
content: `**📂 Project:**\n${body.projectName}`
},
},
{
is_short: true,
text: {
tag: "lark_md",
content: `**⚙️ Build Profile:**\n${body.metadata.buildProfile}`,
},
},
{
is_short: true,
text: {
tag: "lark_md",
content: `**🏷️ App Version:**\n${body.metadata.appVersion}`,
},
},
{
is_short: true,
text: {
tag: "lark_md",
content: `**🔖 Build Version:**\n${body.metadata.appBuildVersion || "N/A"}`,
},
},
];
const getActionElements = (hasDownload, downloadLabel, downloadUrl) => {
const elements = [
{
tag: "button",
text: { tag: "plain_text", content: "🔍 Open Build Details Page" },
url: body.buildDetailsPageUrl,
type: "default",
},
];
if (hasDownload) {
elements.unshift({
tag: "button",
text: { tag: "plain_text", content: downloadLabel },
url: downloadUrl,
type: "primary",
});
}
return elements;
};
if (body.platform === "ios" && body.status === "finished") {
// const itmsUrl = `itms-services://?action=download-manifest;url=https://exp.host/--/api/v2/projects/${body.appId}/builds/${body.id}/manifest.plist`;
// const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(itmsUrl)}&size=250x250&qzone=2`;
return {
card: {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: "[IOS] Build Completed Successfully", template: 'turquoise' } },
elements: [
{ tag: "div", fields: getFields() },
{ tag: "action", actions: getActionElements(true, "📦 Download IPA", body.artifacts.buildUrl) },
// { tag: "img", img_key: qrUrl, alt: { tag: "plain_text", content: "QR code" } }, //dong tam tag img do lark yeu cau upload anh truoc => sau do moi ra img_key
],
},
};
}
if (body.platform === "android" && body.status === "finished") {
// const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(body.artifacts.buildUrl)}&size=250x250&qzone=2`;
return {
card: {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: "[Android] Build Completed Successfully", template: 'turquoise' } },
elements: [
{ tag: "div", fields: getFields() },
{ tag: "action", actions: getActionElements(true, "📦 Download APK", body.artifacts.buildUrl) },
// { tag: "img", img_key: qrUrl, alt: { tag: "plain_text", content: "QR code" } }, //dong tam tag img do lark yeu cau upload anh truoc => sau do moi ra img_key
],
},
};
}
if (body.status === "errored") {
return {
card: {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: `Build failed for ${body.platform.toUpperCase()} ❌`, template: 'red' } },
elements: [
{ tag: "div", fields: getFields() },
{ tag: "action", actions: getActionElements(false) },
],
},
};
}
if (body.status === "canceled") {
return {
card: {
config: { wide_screen_mode: true },
header: { title: { tag: "plain_text", content: `Build was canceled for ${body.platform.toUpperCase()} 🚫`, template: 'red' } },
elements: [
{
tag: "div",
fields: [
...getFields(),
{ is_short: false, text: { tag: "lark_md", content: "**Reason:** User cancellation or timeout" } },
],
},
{ tag: "action", actions: getActionElements(false) },
],
},
};
}
}
export default {
async fetch(request, env) {
// IMPORTANT NOTE:
// Expo (or the webhook provider) generates the signature based on the original "raw minified" JSON string sent.
// If you "read" the body multiple times (bodyUsed becomes true), or parse JSON then stringify again,
// => the body string changes (different formatting, line breaks, etc.),
// => the signature will no longer match.
//
// => Solution: Always read the "raw body" (as ArrayBuffer) immediately & use it directly for verification.
const userAgent = request.headers.get('user-agent') || '';
if (/bot|crawl|spider|slurp|facebookexternalhit|mediapartners-google/i.test(userAgent)) {
console.log('🛑 Blocked bot:', userAgent);
return new Response('Access denied', { status: 403 });
}
// Read the raw body (original minified JSON) immediately upon receiving the request
const rawBody = await request.arrayBuffer();
// Get the signature header (Expo sends it as: sha1=<signature>)
const signatureHeader = request.headers.get('expo-signature');
if (!signatureHeader) {
console.error("❌ No signature provided");
return new Response('No signature provided', { status: 400 });
}
// Your secret key (HMAC key)
const secret = env.LARK_WEBHOOK;
if (!secret) {
console.error("❌ LARK_WEBHOOK not configured");
return new Response("Server configuration error", { status: 500 });
}
// Remove the "sha1=" prefix if present
const signature = signatureHeader.replace(/^sha1=/, '').trim();
// Compute HMAC-SHA1 using rawBody
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const expectedBuffer = await crypto.subtle.sign(
'HMAC',
key,
rawBody
);
// Convert ArrayBuffer to hex string (to compare with signature header)
const expectedSignature = Array.from(new Uint8Array(expectedBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (expectedSignature !== signature) {
console.log('=== Signature Mismatch ===');
console.log('Expected:', expectedSignature);
console.log('Actual:', signature);
console.log('🔴 Lý do hay gặp:');
console.log('✅ Body bị đọc nhiều lần (bodyUsed = true)');
console.log('✅ Hoặc JSON bị format khác (not minify)');
return new Response('Invalid signature', { status: 401 });
}
const rawText = new TextDecoder().decode(rawBody);
// Signature is valid ✅
const body = JSON.parse(rawText);
const larkPayload = handleExpoStatus(body);
if (!larkPayload) {
return new Response("No relevant status", { status: 400 });
}
const webhookUrl = `https://open.larksuite.com/open-apis/bot/v2/hook/${env.LARK_WEBHOOK}`;
await sendLarkRequest(webhookUrl, larkPayload);
// Return a response
return new Response('Webhook processed.', { status: 200 });
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment