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

View File

@@ -31,6 +31,7 @@ interface TelegramJoinRequest {
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
};
user_chat_id: number;
date: number;
@@ -63,6 +64,7 @@ interface PendingJoinRecord {
status: PendingStatus;
createdAt: number;
expiresAt: number;
locale: Locale;
verificationMethod?: VerificationMethod;
verifyMessageId?: number;
}
@@ -76,6 +78,7 @@ const JOIN_PREFIX = "join:";
const ACTIVE_PREFIX = "active:";
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
const RECORD_TTL_SECONDS = 24 * 60 * 60;
type Locale = "zh" | "en";
export default {
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 hasBio = Boolean(join.bio?.trim());
const now = Date.now();
const locale = detectLocale(join.from.language_code);
if (!hasAvatar || !hasBio) {
const localReasons = [
@@ -164,6 +168,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending",
createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS,
locale,
};
const verifyMessageId = await sendVerificationPrompt(env, request, record);
@@ -204,6 +209,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending",
createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS,
locale,
};
const verifyMessageId = await sendVerificationPrompt(env, request, record);
@@ -245,6 +251,7 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
status: "pending",
createdAt: now,
expiresAt: now + CHALLENGE_TTL_MS,
locale,
};
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);
if (!record || record.status !== "pending") {
return html(renderStatusPage("链接无效或已失效。"), 404);
return html(renderStatusPage(t("en", "invalidLink"), "en"), 404);
}
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));
@@ -478,17 +485,14 @@ async function getPendingRecord(env: Env, token: string): Promise<PendingJoinRec
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:
`你申请加入《${record.chatTitle}》需要做一次验证。\n` +
`请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n` +
`1. Cloudflare Turnstile\n` +
`2. 系统生物识别\n\n` +
`验证完成后会自动放行入群申请。`,
copy.prompt(record.chatTitle),
reply_markup: {
inline_keyboard: [[{ text: "打开验证中心", web_app: { url: verifyUrl } }]],
inline_keyboard: [[{ text: copy.openVerification, web_app: { url: verifyUrl } }]],
},
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 {
const copy = getCopy(record.locale);
const token = record.token;
const displayName = escapeHtml(record.displayName);
const chatTitle = escapeHtml(record.chatTitle);
return `<!doctype html>
<html lang="zh-CN">
<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>入群验证</title>
<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>
@@ -638,18 +643,18 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#ee
<body>
<div class="info">
<div class="title">${displayName} 申请加入 ${chatTitle}</div>
<div class="sub">请完成一次简短验证后继续</div>
<div class="title">${escapeHtml(copy.headerTitle(displayName, chatTitle))}</div>
<div class="sub">${escapeHtml(copy.headerSubtitle)}</div>
</div>
<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>
<div class="section">
<div class="label">方式二:系统生物识别</div>
<button type="button" class="btn" id="bio">开始验证</button>
<div class="label">${escapeHtml(copy.biometricLabel)}</div>
<button type="button" class="btn" id="bio">${escapeHtml(copy.startVerification)}</button>
</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 done(){
show("验证通过5 秒后自动关闭",true);
try{if(tg&&tg.MainButton){tg.MainButton.setText("关闭");tg.MainButton.show();tg.MainButton.onClick(function(){tg.close()})}}catch(e){}
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("当前验证环境无效,请回到 Telegram 重新打开",false);return}
show("正在验证...",true);
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||"验证失败",false)}})
.catch(function(e){show("网络错误:"+e.message,false)});
.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="当前环境不支持生物识别";bioBtn.disabled=true;return}
if(!bm){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
bm.init(function(){
if(!bm.isInited){bioBtn.textContent="生物识别初始化失败";bioBtn.disabled=true;return}
if(!bm.isBiometricAvailable){bioBtn.textContent="当前设备无生物识别硬件";bioBtn.disabled=true;return}
var label=bm.biometricType==="face"?"Face ID 验证":bm.biometricType==="finger"?"指纹验证":"生物识别验证";
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:"用于入群身份验证"},function(ok){
if(!ok){bioBtn.textContent="生物识别权限被拒绝";bioBtn.disabled=true}
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
if(!ok){bioBtn.textContent=${JSON.stringify(copy.biometricAccessDenied)};bioBtn.disabled=true}
});
}
});
@@ -700,15 +706,15 @@ function initBio(){
initBio();
document.getElementById("bio").addEventListener("click",function(){
if(!initData){show("当前验证环境无效,请回到 Telegram 重新打开",false);return}
if(!bm||!bm.isInited||!bm.isBiometricAvailable){show("当前环境不支持生物识别,请使用 Turnstile",false);return}
bm.authenticate({reason:"验证身份以加入群组"},function(ok){
if(!ok){show("生物识别验证未通过",false);return}
show("正在提交验证...",true);
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||"验证失败",false)}})
.catch(function(e){show("网络错误:"+e.message,false)});
.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>
@@ -716,8 +722,81 @@ document.getElementById("bio").addEventListener("click",function(){
</html>`;
}
function renderStatusPage(message: string): 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>`;
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 {