907 lines
31 KiB
TypeScript
907 lines
31 KiB
TypeScript
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, "&")
|
||
.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);
|
||
}
|
||
}
|