Add locale-aware zh/en verification copy
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
BOT_TOKEN=
|
||||
TG_WEBHOOK_SECRET=
|
||||
AI_BASE_URL=
|
||||
AI_API_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
151
src/index.ts
151
src/index.ts
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user