type RiskLevel = "low" | "high"; type PendingStatus = "pending" | "approved" | "declined"; type VerificationMethod = "turnstile" | "biometric"; interface Env { PENDING_JOINS: KVNamespace; BOT_TOKEN: string; TG_WEBHOOK_SECRET: string; AI_BASE_URL: string; AI_API_KEY: string; TURNSTILE_SECRET: string; TURNSTILE_SITE_KEY: string; AI_MODEL?: string; VERIFICATION_ORIGIN?: string; } interface TelegramUpdate { update_id: number; chat_join_request?: TelegramJoinRequest; } interface TelegramJoinRequest { chat: { id: number; title: string; username?: string; }; from: { id: number; is_bot: boolean; first_name: string; last_name?: string; username?: string; language_code?: string; }; user_chat_id: number; date: number; bio?: string; } interface TelegramApiResponse { ok: boolean; result: T; description?: string; } interface AiReview { decision: "approve" | "challenge"; } interface PendingJoinRecord { token: string; chatId: number; chatTitle: string; chatUsername?: string; userId: number; userChatId: number; displayName: string; username?: string; bio?: string; hasAvatar: boolean; localReasons: string[]; riskLevel: RiskLevel; status: PendingStatus; createdAt: number; expiresAt: number; locale: Locale; verificationMethod?: VerificationMethod; verifyMessageId?: number; } interface RequestContext { env: Env; request: Request; } const JOIN_PREFIX = "join:"; const ACTIVE_PREFIX = "active:"; const CHALLENGE_TTL_MS = 10 * 60 * 1000; const RECORD_TTL_SECONDS = 30 * 60; const INIT_DATA_MAX_AGE_SECONDS = 10 * 60; type Locale = "zh" | "en"; export default { async fetch(request, env, ctx): Promise { try { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/") { return json({ ok: true, service: "telegram-join-guard" }); } if (request.method === "POST" && url.pathname === "/telegram/webhook") { return await handleTelegramWebhook({ env, request }); } if (request.method === "GET" && url.pathname === "/verify") { return await handleVerifyPage({ env, request }); } if (request.method === "POST" && url.pathname === "/api/verify/turnstile") { return await handleTurnstileVerification({ env, request }); } if (request.method === "POST" && url.pathname === "/api/verify/biometric") { return await handleBiometricVerification({ env, request }); } return new Response("Not Found", { status: 404 }); } catch (error) { return handleError(error); } }, async scheduled(controller, env): Promise { await processExpiredRequests(env, controller.scheduledTime ?? Date.now()); }, } satisfies ExportedHandler; async function handleTelegramWebhook({ env, request }: RequestContext): Promise { verifyTelegramSecret(request, env); const update = (await request.json()) as TelegramUpdate; if (!update.chat_join_request) { console.log("ignored update", { updateId: update.update_id, hasJoinRequest: false }); return json({ ok: true, ignored: true }); } const join = update.chat_join_request; console.log("join request received", { updateId: update.update_id, chatId: join.chat.id, userId: join.from.id, username: join.from.username ?? null, }); if (join.from.is_bot) { await declineJoinRequest(env, join.chat.id, join.from.id); console.log("join request declined", { chatId: join.chat.id, userId: join.from.id, reason: "bot_account" }); return json({ ok: true, declined: true, reason: "bot_account" }); } const displayName = [join.from.first_name, join.from.last_name].filter(Boolean).join(" ").trim(); const hasAvatar = await safelyFetchAvatarForUser(env, join.from.id); const hasBio = Boolean(join.bio?.trim()); const now = Date.now(); const locale = detectLocale(join.from.language_code); if (!hasAvatar || !hasBio) { const localReasons = [ ...(hasAvatar ? [] : ["missing avatar"]), ...(hasBio ? [] : ["missing bio"]), ]; const token = crypto.randomUUID(); const record: PendingJoinRecord = { token, chatId: join.chat.id, chatTitle: join.chat.title, chatUsername: join.chat.username, userId: join.from.id, userChatId: join.user_chat_id, displayName, username: join.from.username, bio: join.bio, hasAvatar, localReasons, riskLevel: "high", status: "pending", createdAt: now, expiresAt: now + CHALLENGE_TTL_MS, locale, }; const verifyMessageId = await sendVerificationPrompt(env, request, record); record.verifyMessageId = verifyMessageId; await persistPendingRecord(env, record); console.log("verification challenge sent", { chatId: record.chatId, userId: record.userId, risk: record.riskLevel, hasAvatar: record.hasAvatar, hasBio, source: "missing_profile_fields", }); return json({ ok: true, decision: "challenge", risk: "high" }); } const aiReview = await safelyReviewWithAi(env, { displayName, bio: join.bio!, }); if (!aiReview) { const token = crypto.randomUUID(); const record: PendingJoinRecord = { token, chatId: join.chat.id, chatTitle: join.chat.title, chatUsername: join.chat.username, userId: join.from.id, userChatId: join.user_chat_id, displayName, username: join.from.username, bio: join.bio, hasAvatar: true, localReasons: ["ai review unavailable"], riskLevel: "high", status: "pending", createdAt: now, expiresAt: now + CHALLENGE_TTL_MS, locale, }; const verifyMessageId = await sendVerificationPrompt(env, request, record); record.verifyMessageId = verifyMessageId; await persistPendingRecord(env, record); console.log("verification challenge sent", { chatId: record.chatId, userId: record.userId, risk: record.riskLevel, hasAvatar: record.hasAvatar, hasBio, source: "ai_unavailable", }); return json({ ok: true, decision: "challenge", risk: "high" }); } const risk = decideRiskLevel(aiReview); if (risk === "low") { await approveJoinRequest(env, join.chat.id, join.from.id); console.log("join request approved", { chatId: join.chat.id, userId: join.from.id, risk: "low" }); return json({ ok: true, decision: "approved" }); } const token = crypto.randomUUID(); const record: PendingJoinRecord = { token, chatId: join.chat.id, chatTitle: join.chat.title, chatUsername: join.chat.username, userId: join.from.id, userChatId: join.user_chat_id, displayName, username: join.from.username, bio: join.bio, hasAvatar: true, localReasons: [], riskLevel: risk, status: "pending", createdAt: now, expiresAt: now + CHALLENGE_TTL_MS, locale, }; const verifyMessageId = await sendVerificationPrompt(env, request, record); record.verifyMessageId = verifyMessageId; await persistPendingRecord(env, record); console.log("verification challenge sent", { chatId: record.chatId, userId: record.userId, risk: record.riskLevel, hasAvatar: record.hasAvatar, }); return json({ ok: true, decision: "challenge", risk }); } async function handleVerifyPage({ env, request }: RequestContext): Promise { const url = new URL(request.url); const token = url.searchParams.get("token"); if (!token) { return new Response("Missing token", { status: 400 }); } const record = await getPendingRecord(env, token); if (!record || record.status !== "pending") { return html(renderStatusPage(t("en", "invalidLink"), "en"), 404); } if (record.expiresAt <= Date.now()) { return html(renderStatusPage(t(record.locale, "expiredLink"), record.locale), 410); } return html(renderVerifyPage(record, env.TURNSTILE_SITE_KEY)); } async function handleTurnstileVerification({ env, request }: RequestContext): Promise { const body = (await request.json()) as { token?: string; turnstileToken?: string; initData?: string }; if (!body.token || !body.turnstileToken || !body.initData) { return json({ ok: false, error: "missing_fields" }, 400); } const record = await getPendingRecord(env, body.token); if (!record || record.status !== "pending") { return json({ ok: false, error: "invalid_token" }, 404); } if (record.expiresAt <= Date.now()) { return json({ ok: false, error: "expired" }, 410); } await verifyTelegramWebAppInitData(env, body.initData, record.userId); await verifyTurnstile(env, request, body.turnstileToken); await finalizeVerification(env, record, "turnstile"); console.log("verification approved", { chatId: record.chatId, userId: record.userId, method: "turnstile" }); return json({ ok: true, approved: true }); } async function handleBiometricVerification({ env, request }: RequestContext): Promise { const body = (await request.json()) as { token?: string; initData?: string }; if (!body.token || !body.initData) { return json({ ok: false, error: "missing_fields" }, 400); } const record = await getPendingRecord(env, body.token); if (!record || record.status !== "pending") { return json({ ok: false, error: "invalid_token" }, 404); } if (record.expiresAt <= Date.now()) { return json({ ok: false, error: "expired" }, 410); } await verifyTelegramWebAppInitData(env, body.initData, record.userId); await finalizeVerification(env, record, "biometric"); console.log("verification approved", { chatId: record.chatId, userId: record.userId, method: "biometric" }); return json({ ok: true, approved: true }); } function verifyTelegramSecret(request: Request, env: Env): void { const secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token"); if (!secret || secret !== env.TG_WEBHOOK_SECRET) { throw new HttpError(401, "invalid_telegram_secret"); } } async function verifyTelegramWebAppInitData(env: Env, initData: string, expectedUserId: number): Promise { const params = new URLSearchParams(initData); const providedHash = params.get("hash"); if (!providedHash) { throw new HttpError(401, "missing_init_data_hash"); } params.delete("hash"); const entries: Array<[string, string]> = []; params.forEach((value, key) => { entries.push([key, value]); }); const dataCheckString = entries .sort(([left], [right]) => left.localeCompare(right)) .map(([key, value]) => `${key}=${value}`) .join("\n"); const secretKey = await hmacSha256(new TextEncoder().encode("WebAppData"), env.BOT_TOKEN); const expectedHash = bytesToHex(await hmacSha256(secretKey, dataCheckString)); if (expectedHash !== providedHash) { throw new HttpError(401, "invalid_init_data_hash"); } const authDateRaw = params.get("auth_date"); if (!authDateRaw) { throw new HttpError(401, "missing_init_data_auth_date"); } const authDate = Number(authDateRaw); if (!Number.isFinite(authDate)) { throw new HttpError(401, "invalid_init_data_auth_date"); } const nowSeconds = Math.floor(Date.now() / 1000); if (authDate > nowSeconds + 30) { throw new HttpError(401, "invalid_init_data_auth_date"); } if (nowSeconds - authDate > INIT_DATA_MAX_AGE_SECONDS) { throw new HttpError(401, "expired_init_data"); } const userRaw = params.get("user"); if (!userRaw) { throw new HttpError(401, "missing_init_data_user"); } const user = JSON.parse(userRaw) as { id?: number }; if (user.id !== expectedUserId) { throw new HttpError(403, "init_data_user_mismatch"); } } async function fetchAvatarForUser(env: Env, userId: number): Promise { const profilePhotos = await telegramCall<{ total_count: number }>(env, "getUserProfilePhotos", { user_id: userId, limit: 1, }); return profilePhotos.total_count > 0; } async function safelyFetchAvatarForUser(env: Env, userId: number): Promise { try { return await fetchAvatarForUser(env, userId); } catch { return false; } } async function reviewWithAi( env: Env, input: { displayName: string; bio: string; }, ): Promise { const url = new URL("chat/completions", normalizeBaseUrl(env.AI_BASE_URL)).toString(); const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${env.AI_API_KEY}`, }, body: JSON.stringify({ model: env.AI_MODEL || "gpt-4.1-mini", temperature: 0, response_format: { type: "json_object" }, messages: [ { role: "system", content: "You review Telegram join requests for ad-spam risk using only display name and bio. Return strict JSON only with one field: decision. Decision must be exactly approve for normal genuine users and challenge for suspicious or uncertain users. If the profile looks like a real person, return approve.", }, { role: "user", content: JSON.stringify({ display_name: input.displayName, bio: input.bio, task: "Evaluate whether this looks like a real personal profile instead of an ad or spam account.", }), }, ], }), }); if (!response.ok) { throw new HttpError(502, `ai_request_failed:${response.status}`); } const payload = (await response.json()) as { choices?: Array<{ message?: { content?: string | Array<{ type: string; text?: string }> } }>; }; const rawContent = payload.choices?.[0]?.message?.content; const text = Array.isArray(rawContent) ? rawContent .map((item) => item.text ?? "") .join("\n") .trim() : rawContent?.trim(); if (!text) { throw new HttpError(502, "ai_empty_response"); } const parsed = parseJsonFromText(text) as Partial; return { decision: parsed.decision === "approve" ? "approve" : "challenge", }; } async function safelyReviewWithAi( env: Env, input: { displayName: string; bio: string; }, ): Promise { try { return await reviewWithAi(env, input); } catch { return null; } } function decideRiskLevel(aiReview: AiReview): RiskLevel { return aiReview.decision === "approve" ? "low" : "high"; } async function persistPendingRecord(env: Env, record: PendingJoinRecord): Promise { await env.PENDING_JOINS.put(`${JOIN_PREFIX}${record.token}`, JSON.stringify(record), { expirationTtl: RECORD_TTL_SECONDS }); await env.PENDING_JOINS.put(activeJoinKey(record.chatId, record.userId), record.token, { expirationTtl: RECORD_TTL_SECONDS }); } async function getPendingRecord(env: Env, token: string): Promise { const raw = await env.PENDING_JOINS.get(`${JOIN_PREFIX}${token}`); if (!raw) { return null; } return JSON.parse(raw) as PendingJoinRecord; } async function sendVerificationPrompt(env: Env, request: Request, record: PendingJoinRecord): Promise { const verifyUrl = `${getVerificationOrigin(env, request)}/verify?token=${encodeURIComponent(record.token)}`; const copy = getCopy(record.locale); const result = await telegramCall<{ message_id: number }>(env, "sendMessage", { chat_id: record.userChatId, text: copy.prompt(record.chatTitle), reply_markup: { inline_keyboard: [[{ text: copy.openVerification, web_app: { url: verifyUrl } }]], }, disable_web_page_preview: true, }); return result.message_id; } async function finalizeVerification(env: Env, record: PendingJoinRecord, method: VerificationMethod): Promise { const activeToken = await env.PENDING_JOINS.get(activeJoinKey(record.chatId, record.userId)); if (activeToken !== record.token) { throw new HttpError(409, "stale_token"); } await approveJoinRequest(env, record.chatId, record.userId); await deleteVerificationMessage(env, record); console.log("verification finalized", { chatId: record.chatId, userId: record.userId, method, }); await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId)); await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`); } async function processExpiredRequests(env: Env, now: number): Promise { let declinedCount = 0; let cursor: string | undefined; do { const list = await env.PENDING_JOINS.list({ prefix: JOIN_PREFIX, cursor, limit: 100 }); cursor = list.list_complete ? undefined : list.cursor; for (const entry of list.keys) { const raw = await env.PENDING_JOINS.get(entry.name); if (!raw) { continue; } const record = JSON.parse(raw) as PendingJoinRecord; if (record.status !== "pending" || record.expiresAt > now) { continue; } const activeToken = await env.PENDING_JOINS.get(activeJoinKey(record.chatId, record.userId)); if (activeToken !== record.token) { await env.PENDING_JOINS.delete(entry.name); continue; } await declineJoinRequest(env, record.chatId, record.userId); await deleteVerificationMessage(env, record); await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId)); await env.PENDING_JOINS.delete(entry.name); declinedCount += 1; } } while (cursor); console.log("scheduled cleanup complete", { declinedCount, ranAt: now }); } async function deleteVerificationMessage(env: Env, record: PendingJoinRecord): Promise { if (!record.verifyMessageId) { return; } try { await telegramCall(env, "deleteMessage", { chat_id: record.userChatId, message_id: record.verifyMessageId }); } catch { // message may already be deleted or unavailable } } async function verifyTurnstile(env: Env, request: Request, token: string): Promise { const form = new URLSearchParams(); form.set("secret", env.TURNSTILE_SECRET); form.set("response", token); const ip = request.headers.get("CF-Connecting-IP"); if (ip) { form.set("remoteip", ip); } const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form, }); const payload = (await response.json()) as { success?: boolean }; if (!payload.success) { throw new HttpError(403, "turnstile_failed"); } } async function approveJoinRequest(env: Env, chatId: number, userId: number): Promise { await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId }); } async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise { await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId }); } async function telegramCall(env: Env, method: string, payload: Record): Promise { const response = await fetch(`https://api.telegram.org/bot${env.BOT_TOKEN}/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const data = (await response.json()) as TelegramApiResponse; if (!response.ok || !data.ok) { throw new HttpError(502, `telegram_${method}_failed:${data.description || response.status}`); } return data.result; } function renderVerifyPage(record: PendingJoinRecord, turnstileSiteKey: string): string { const copy = getCopy(record.locale); const token = record.token; const displayName = escapeHtml(record.displayName); const chatTitle = escapeHtml(record.chatTitle); return ` ${escapeHtml(copy.pageTitle)}
${escapeHtml(copy.headerTitle(displayName, chatTitle))}
${escapeHtml(copy.headerSubtitle)}
${escapeHtml(copy.turnstileLabel)}
${escapeHtml(copy.biometricLabel)}
`; } function renderStatusPage(message: string, locale: Locale): string { const copy = getCopy(locale); return `${escapeHtml(copy.statusPageTitle)}
${escapeHtml(message)}
`; } function detectLocale(languageCode?: string): Locale { return languageCode?.toLowerCase().startsWith("zh") ? "zh" : "en"; } function t(locale: Locale, key: "invalidLink" | "expiredLink"): string { return getCopy(locale)[key]; } function getCopy(locale: Locale) { if (locale === "zh") { return { pageTitle: "入群验证", statusPageTitle: "验证状态", invalidLink: "链接无效或已失效。", expiredLink: "验证已超时,请重新申请入群。", openVerification: "打开验证中心", prompt: (chatTitle: string) => `你申请加入《${chatTitle}》需要做一次验证。\n请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n1. Cloudflare Turnstile\n2. 系统生物识别\n\n验证完成后会自动放行入群申请。`, headerTitle: (displayName: string, chatTitle: string) => `${displayName} 申请加入 ${chatTitle}`, headerSubtitle: "请完成一次简短验证后继续", turnstileLabel: "方式一:Turnstile 人机验证", biometricLabel: "方式二:系统生物识别", startVerification: "开始验证", success: "验证通过,5 秒后自动关闭", close: "关闭", invalidEnvironment: "当前验证环境无效,请回到 Telegram 重新打开", verifying: "正在验证...", verificationFailed: "验证失败", networkErrorPrefix: "网络错误:", biometricUnavailable: "当前环境不支持生物识别", biometricInitFailed: "生物识别初始化失败", faceId: "Face ID 验证", fingerprint: "指纹验证", biometricAccessReason: "用于入群身份验证", biometricAccessDenied: "生物识别权限被拒绝", biometricUseTurnstile: "当前环境不支持生物识别,请使用 Turnstile", biometricAuthenticateReason: "验证身份以加入群组", biometricFailed: "生物识别验证未通过", submittingVerification: "正在提交验证...", }; } return { pageTitle: "Join Verification", statusPageTitle: "Verification Status", invalidLink: "This link is invalid or has expired.", expiredLink: "Verification timed out. Please submit your join request again.", openVerification: "Open Verification", prompt: (chatTitle: string) => `Your request to join \"${chatTitle}\" requires verification.\nPlease tap the button below and complete one of these methods in Telegram within 10 minutes:\n1. Cloudflare Turnstile\n2. Biometric verification\n\nYour join request will be approved automatically after verification.`, headerTitle: (displayName: string, chatTitle: string) => `${displayName} requested to join ${chatTitle}`, headerSubtitle: "Please complete a short verification to continue", turnstileLabel: "Option 1: Turnstile verification", biometricLabel: "Option 2: Biometric verification", startVerification: "Start Verification", success: "Verification complete. This page will close in 5 seconds.", close: "Close", invalidEnvironment: "Invalid verification environment. Please reopen this page from Telegram.", verifying: "Verifying...", verificationFailed: "Verification failed", networkErrorPrefix: "Network error: ", biometricUnavailable: "Biometric verification is not available in this environment", biometricInitFailed: "Biometric initialization failed", faceId: "Face ID Verification", fingerprint: "Fingerprint Verification", biometricAccessReason: "Used to verify your identity for group access", biometricAccessDenied: "Biometric permission was denied", biometricUseTurnstile: "Biometric verification is unavailable here. Please use Turnstile.", biometricAuthenticateReason: "Verify your identity to join the group", biometricFailed: "Biometric verification failed", submittingVerification: "Submitting verification...", }; } function getVerificationOrigin(env: Env, request: Request): string { return env.VERIFICATION_ORIGIN || new URL(request.url).origin; } function activeJoinKey(chatId: number, userId: number): string { return `${ACTIVE_PREFIX}${chatId}:${userId}`; } function normalizeBaseUrl(value: string): string { return value.endsWith("/") ? value : `${value}/`; } async function hmacSha256(key: string | Uint8Array, value: string): Promise { const keyBytes = typeof key === "string" ? new TextEncoder().encode(key) : key; const keyBuffer = new ArrayBuffer(keyBytes.byteLength); new Uint8Array(keyBuffer).set(keyBytes); const cryptoKey = await crypto.subtle.importKey( "raw", keyBuffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(value)); return new Uint8Array(signature); } function bytesToHex(value: Uint8Array): string { return [...value].map((item) => item.toString(16).padStart(2, "0")).join(""); } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function parseJsonFromText(value: string): unknown { try { return JSON.parse(value); } catch { const start = value.indexOf("{"); const end = value.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) { throw new HttpError(502, "ai_invalid_json"); } return JSON.parse(value.slice(start, end + 1)); } } function json(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } function html(payload: string, status = 200): Response { return new Response(payload, { status, headers: { "content-type": "text/html; charset=utf-8" }, }); } function handleError(error: unknown): Response { console.error("request failed", error); if (error instanceof HttpError) { return json({ ok: false, error: error.message }, error.status); } return json({ ok: false, error: error instanceof Error ? error.message : "internal_error" }, 500); } class HttpError extends Error { constructor( public readonly status: number, message: string, ) { super(message); } }