Files
TeleWatchdog/src/index.ts

907 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T> {
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<Response> {
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<void> {
await processExpiredRequests(env, controller.scheduledTime ?? Date.now());
},
} satisfies ExportedHandler<Env>;
async function handleTelegramWebhook({ env, request }: RequestContext): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<void> {
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<boolean> {
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<boolean> {
try {
return await fetchAvatarForUser(env, userId);
} catch {
return false;
}
}
async function reviewWithAi(
env: Env,
input: {
displayName: string;
bio: string;
},
): Promise<AiReview> {
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<AiReview>;
return {
decision: parsed.decision === "approve" ? "approve" : "challenge",
};
}
async function safelyReviewWithAi(
env: Env,
input: {
displayName: string;
bio: string;
},
): Promise<AiReview | null> {
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<void> {
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<PendingJoinRecord | null> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId });
}
async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise<void> {
await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId });
}
async function telegramCall<T>(env: Env, method: string, payload: Record<string, unknown>): Promise<T> {
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<T>;
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 `<!doctype html>
<html lang="${record.locale === "zh" ? "zh-CN" : "en"}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>${escapeHtml(copy.pageTitle)}</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#eee;padding:12px;font-size:14px;line-height:1.5}
.info{background:#16213e;border-radius:10px;padding:12px;margin-bottom:12px}
.info .title{font-size:15px;font-weight:600;margin-bottom:4px}
.info .sub{font-size:12px;color:#8892b0}
.section{background:#16213e;border-radius:10px;padding:12px;margin-bottom:10px}
.section .label{font-size:13px;font-weight:600;color:#a78bfa;margin-bottom:8px}
.cf-turnstile{display:flex;justify-content:center}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;text-align:center;background:#7c3aed;color:#fff}
.btn:active{opacity:.8}
#msg{margin-top:10px;padding:10px;border-radius:8px;font-size:13px;font-weight:600;text-align:center;display:none}
#msg.ok{display:block;background:#064e3b;color:#6ee7b7}
#msg.err{display:block;background:#450a0a;color:#fca5a5}
</style>
</head>
<body>
<div class="info">
<div class="title">${escapeHtml(copy.headerTitle(displayName, chatTitle))}</div>
<div class="sub">${escapeHtml(copy.headerSubtitle)}</div>
</div>
<div class="section">
<div class="label">${escapeHtml(copy.turnstileLabel)}</div>
<div class="cf-turnstile" data-sitekey="${escapeHtml(turnstileSiteKey)}" data-callback="onTurnstile" data-theme="dark" data-size="normal"></div>
</div>
<div class="section">
<div class="label">${escapeHtml(copy.biometricLabel)}</div>
<button type="button" class="btn" id="bio">${escapeHtml(copy.startVerification)}</button>
</div>
<div id="msg"></div>
<script>
var T=${JSON.stringify(token)};
var O=window.location.origin;
var tg=window.Telegram&&window.Telegram.WebApp||null;
var bm=tg&&tg.BiometricManager||null;
var initData=tg&&tg.initData||"";
var msg=document.getElementById("msg");
try{if(tg){tg.ready();tg.expand()}}catch(e){}
function show(t,ok){msg.textContent=t;msg.className=ok?"ok":"err"}
function done(){
show(${JSON.stringify("__SUCCESS__")},true);
if(msg.textContent==="__SUCCESS__")msg.textContent=${JSON.stringify(copy.success)};
try{if(tg&&tg.MainButton){tg.MainButton.setText(${JSON.stringify(copy.close)});tg.MainButton.show();tg.MainButton.onClick(function(){tg.close()})}}catch(e){}
try{if(tg){setTimeout(function(){try{tg.close()}catch(e){}},5000)}}catch(e){}
}
window.onTurnstile=function(tk){
if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
show(${JSON.stringify(copy.verifying)},true);
fetch(O+"/api/verify/turnstile",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,turnstileToken:tk,initData:initData})})
.then(function(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
};
function initBio(){
var bioBtn=document.getElementById("bio");
if(!bm){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
bm.init(function(){
if(!bm.isInited){bioBtn.textContent=${JSON.stringify(copy.biometricInitFailed)};bioBtn.disabled=true;return}
if(!bm.isBiometricAvailable){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
var label=bm.biometricType==="face"?${JSON.stringify(copy.faceId)}:bm.biometricType==="finger"?${JSON.stringify(copy.fingerprint)}:${JSON.stringify(copy.biometricLabel)};
bioBtn.textContent=label;
if(!bm.isAccessGranted){
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
if(!ok){bioBtn.textContent=${JSON.stringify(copy.biometricAccessDenied)};bioBtn.disabled=true}
});
}
});
}
initBio();
document.getElementById("bio").addEventListener("click",function(){
if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
if(!bm||!bm.isInited||!bm.isBiometricAvailable){show(${JSON.stringify(copy.biometricUseTurnstile)},false);return}
bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok){
if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
show(${JSON.stringify(copy.submittingVerification)},true);
fetch(O+"/api/verify/biometric",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,initData:initData})})
.then(function(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
});
});
</script>
</body>
</html>`;
}
function renderStatusPage(message: string, locale: Locale): string {
const copy = getCopy(locale);
return `<!doctype html><html lang="${locale === "zh" ? "zh-CN" : "en"}"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>${escapeHtml(copy.statusPageTitle)}</title><style>body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:grid;place-items:center;min-height:100vh;margin:0}.box{padding:24px 28px;border-radius:18px;background:#111827;border:1px solid rgba(255,255,255,.08);max-width:420px}</style></head><body><div class="box">${escapeHtml(message)}</div></body></html>`;
}
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<Uint8Array> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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);
}
}