Add locale-aware zh/en verification copy

This commit is contained in:
zimk
2026-04-19 18:43:25 +08:00
parent 44d9675867
commit c3139d6826
2 changed files with 120 additions and 36 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
BOT_TOKEN=
TG_WEBHOOK_SECRET=
AI_BASE_URL=
AI_API_KEY=
TURNSTILE_SECRET=

View File

@@ -31,6 +31,7 @@ interface TelegramJoinRequest {
first_name: string; first_name: string;
last_name?: string; last_name?: string;
username?: string; username?: string;
language_code?: string;
}; };
user_chat_id: number; user_chat_id: number;
date: number; date: number;
@@ -63,6 +64,7 @@ interface PendingJoinRecord {
status: PendingStatus; status: PendingStatus;
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
locale: Locale;
verificationMethod?: VerificationMethod; verificationMethod?: VerificationMethod;
verifyMessageId?: number; verifyMessageId?: number;
} }
@@ -76,6 +78,7 @@ const JOIN_PREFIX = "join:";
const ACTIVE_PREFIX = "active:"; const ACTIVE_PREFIX = "active:";
const CHALLENGE_TTL_MS = 10 * 60 * 1000; const CHALLENGE_TTL_MS = 10 * 60 * 1000;
const RECORD_TTL_SECONDS = 24 * 60 * 60; const RECORD_TTL_SECONDS = 24 * 60 * 60;
type Locale = "zh" | "en";
export default { export default {
async fetch(request, env, ctx): Promise<Response> { async fetch(request, env, ctx): Promise<Response> {
@@ -140,6 +143,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
const hasAvatar = await safelyFetchAvatarForUser(env, join.from.id); const hasAvatar = await safelyFetchAvatarForUser(env, join.from.id);
const hasBio = Boolean(join.bio?.trim()); const hasBio = Boolean(join.bio?.trim());
const now = Date.now(); const now = Date.now();
const locale = detectLocale(join.from.language_code);
if (!hasAvatar || !hasBio) { if (!hasAvatar || !hasBio) {
const localReasons = [ const localReasons = [
@@ -164,6 +168,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending", status: "pending",
createdAt: now, createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS, expiresAt: now + CHALLENGE_TTL_MS,
locale,
}; };
const verifyMessageId = await sendVerificationPrompt(env, request, record); const verifyMessageId = await sendVerificationPrompt(env, request, record);
@@ -204,6 +209,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending", status: "pending",
createdAt: now, createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS, expiresAt: now + CHALLENGE_TTL_MS,
locale,
}; };
const verifyMessageId = await sendVerificationPrompt(env, request, record); const verifyMessageId = await sendVerificationPrompt(env, request, record);
@@ -245,6 +251,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending", status: "pending",
createdAt: now, createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS, expiresAt: now + CHALLENGE_TTL_MS,
locale,
}; };
const verifyMessageId = await sendVerificationPrompt(env, request, record); const verifyMessageId = await sendVerificationPrompt(env, request, record);
@@ -269,11 +276,11 @@ async function handleVerifyPage({ env, request }: RequestContext): Promise<Respo
const record = await getPendingRecord(env, token); const record = await getPendingRecord(env, token);
if (!record || record.status !== "pending") { if (!record || record.status !== "pending") {
return html(renderStatusPage("链接无效或已失效。"), 404); return html(renderStatusPage(t("en", "invalidLink"), "en"), 404);
} }
if (record.expiresAt <= Date.now()) { if (record.expiresAt <= Date.now()) {
return html(renderStatusPage("验证已超时,请重新申请入群。"), 410); return html(renderStatusPage(t(record.locale, "expiredLink"), record.locale), 410);
} }
return html(renderVerifyPage(record, env.TURNSTILE_SITE_KEY)); return html(renderVerifyPage(record, env.TURNSTILE_SITE_KEY));
@@ -478,17 +485,14 @@ async function getPendingRecord(env: Env, token: string): Promise<PendingJoinRec
async function sendVerificationPrompt(env: Env, request: Request, record: PendingJoinRecord): Promise<number> { async function sendVerificationPrompt(env: Env, request: Request, record: PendingJoinRecord): Promise<number> {
const verifyUrl = `${getVerificationOrigin(env, request)}/verify?token=${encodeURIComponent(record.token)}`; const verifyUrl = `${getVerificationOrigin(env, request)}/verify?token=${encodeURIComponent(record.token)}`;
const copy = getCopy(record.locale);
const result = await telegramCall<{ message_id: number }>(env, "sendMessage", { const result = await telegramCall<{ message_id: number }>(env, "sendMessage", {
chat_id: record.userChatId, chat_id: record.userChatId,
text: text:
`你申请加入《${record.chatTitle}》需要做一次验证。\n` + copy.prompt(record.chatTitle),
`请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n` +
`1. Cloudflare Turnstile\n` +
`2. 系统生物识别\n\n` +
`验证完成后会自动放行入群申请。`,
reply_markup: { reply_markup: {
inline_keyboard: [[{ text: "打开验证中心", web_app: { url: verifyUrl } }]], inline_keyboard: [[{ text: copy.openVerification, web_app: { url: verifyUrl } }]],
}, },
disable_web_page_preview: true, disable_web_page_preview: true,
}); });
@@ -607,16 +611,17 @@ async function telegramCall<T>(env: Env, method: string, payload: Record<string,
} }
function renderVerifyPage(record: PendingJoinRecord, turnstileSiteKey: string): string { function renderVerifyPage(record: PendingJoinRecord, turnstileSiteKey: string): string {
const copy = getCopy(record.locale);
const token = record.token; const token = record.token;
const displayName = escapeHtml(record.displayName); const displayName = escapeHtml(record.displayName);
const chatTitle = escapeHtml(record.chatTitle); const chatTitle = escapeHtml(record.chatTitle);
return `<!doctype html> return `<!doctype html>
<html lang="zh-CN"> <html lang="${record.locale === "zh" ? "zh-CN" : "en"}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>入群验证</title> <title>${escapeHtml(copy.pageTitle)}</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style> <style>
@@ -638,18 +643,18 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#ee
<body> <body>
<div class="info"> <div class="info">
<div class="title">${displayName} 申请加入 ${chatTitle}</div> <div class="title">${escapeHtml(copy.headerTitle(displayName, chatTitle))}</div>
<div class="sub">请完成一次简短验证后继续</div> <div class="sub">${escapeHtml(copy.headerSubtitle)}</div>
</div> </div>
<div class="section"> <div class="section">
<div class="label">方式一Turnstile 人机验证</div> <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 class="cf-turnstile" data-sitekey="${escapeHtml(turnstileSiteKey)}" data-callback="onTurnstile" data-theme="dark" data-size="normal"></div>
</div> </div>
<div class="section"> <div class="section">
<div class="label">方式二:系统生物识别</div> <div class="label">${escapeHtml(copy.biometricLabel)}</div>
<button type="button" class="btn" id="bio">开始验证</button> <button type="button" class="btn" id="bio">${escapeHtml(copy.startVerification)}</button>
</div> </div>
<div id="msg"></div> <div id="msg"></div>
@@ -667,31 +672,32 @@ try{if(tg){tg.ready();tg.expand()}}catch(e){}
function show(t,ok){msg.textContent=t;msg.className=ok?"ok":"err"} function show(t,ok){msg.textContent=t;msg.className=ok?"ok":"err"}
function done(){ function done(){
show("验证通过5 秒后自动关闭",true); show(${JSON.stringify("__SUCCESS__")},true);
try{if(tg&&tg.MainButton){tg.MainButton.setText("关闭");tg.MainButton.show();tg.MainButton.onClick(function(){tg.close()})}}catch(e){} 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){} try{if(tg){setTimeout(function(){try{tg.close()}catch(e){}},5000)}}catch(e){}
} }
window.onTurnstile=function(tk){ window.onTurnstile=function(tk){
if(!initData){show("当前验证环境无效,请回到 Telegram 重新打开",false);return} if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
show("正在验证...",true); 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})}) 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(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||"验证失败",false)}}) .then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show("网络错误:"+e.message,false)}); .catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
}; };
function initBio(){ function initBio(){
var bioBtn=document.getElementById("bio"); var bioBtn=document.getElementById("bio");
if(!bm){bioBtn.textContent="当前环境不支持生物识别";bioBtn.disabled=true;return} if(!bm){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
bm.init(function(){ bm.init(function(){
if(!bm.isInited){bioBtn.textContent="生物识别初始化失败";bioBtn.disabled=true;return} if(!bm.isInited){bioBtn.textContent=${JSON.stringify(copy.biometricInitFailed)};bioBtn.disabled=true;return}
if(!bm.isBiometricAvailable){bioBtn.textContent="当前设备无生物识别硬件";bioBtn.disabled=true;return} if(!bm.isBiometricAvailable){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
var label=bm.biometricType==="face"?"Face ID 验证":bm.biometricType==="finger"?"指纹验证":"生物识别验证"; var label=bm.biometricType==="face"?${JSON.stringify(copy.faceId)}:bm.biometricType==="finger"?${JSON.stringify(copy.fingerprint)}:${JSON.stringify(copy.biometricLabel)};
bioBtn.textContent=label; bioBtn.textContent=label;
if(!bm.isAccessGranted){ if(!bm.isAccessGranted){
bm.requestAccess({reason:"用于入群身份验证"},function(ok){ bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
if(!ok){bioBtn.textContent="生物识别权限被拒绝";bioBtn.disabled=true} if(!ok){bioBtn.textContent=${JSON.stringify(copy.biometricAccessDenied)};bioBtn.disabled=true}
}); });
} }
}); });
@@ -700,15 +706,15 @@ function initBio(){
initBio(); initBio();
document.getElementById("bio").addEventListener("click",function(){ document.getElementById("bio").addEventListener("click",function(){
if(!initData){show("当前验证环境无效,请回到 Telegram 重新打开",false);return} if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
if(!bm||!bm.isInited||!bm.isBiometricAvailable){show("当前环境不支持生物识别,请使用 Turnstile",false);return} if(!bm||!bm.isInited||!bm.isBiometricAvailable){show(${JSON.stringify(copy.biometricUseTurnstile)},false);return}
bm.authenticate({reason:"验证身份以加入群组"},function(ok){ bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok){
if(!ok){show("生物识别验证未通过",false);return} if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
show("正在提交验证...",true); 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})}) 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(r){return r.json()})
.then(function(d){if(d.ok){done()}else{show(d.error||"验证失败",false)}}) .then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
.catch(function(e){show("网络错误:"+e.message,false)}); .catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
}); });
}); });
</script> </script>
@@ -716,8 +722,81 @@ document.getElementById("bio").addEventListener("click",function(){
</html>`; </html>`;
} }
function renderStatusPage(message: string): string { function renderStatusPage(message: string, locale: Locale): string {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>验证状态</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>`; 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 { function getVerificationOrigin(env: Env, request: Request): string {