Add TeleWatchdog Cloudflare Worker

This commit is contained in:
zimk
2026-04-19 00:28:19 +08:00
commit 5c013e3abb
7 changed files with 2561 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
.DS_Store
*.log
wrangler.toml

165
README.md Normal file
View File

@@ -0,0 +1,165 @@
# TeleWatchdog
TeleWatchdog is a `Cloudflare Workers` Telegram join-request watchdog for private groups.
It reviews join requests with a simple pipeline:
1. Check whether the applicant has an avatar.
2. Check whether the applicant has a bio.
3. If either is missing, require verification.
4. If both exist, ask an AI model for a binary decision.
5. If the AI returns `approve`, accept the join request immediately.
6. Otherwise, send a verification message with a Telegram Web App button.
Users who need verification can choose either:
- `Cloudflare Turnstile`
- `Telegram WebApp BiometricManager`
If verification is not completed within 10 minutes, a scheduled task declines the request and cleans up the stored state.
## Features
- Telegram `chat_join_request` webhook support
- Cloudflare Worker deployment model
- Telegram Web App verification page
- Turnstile verification
- Telegram biometric verification via `BiometricManager`
- AI-based binary profile review
- Automatic cleanup of expired verification records
- Automatic deletion of verification messages after success or timeout
## Stack
- `Cloudflare Workers`
- `Cloudflare KV`
- `Telegram Bot API`
- `Cloudflare Turnstile`
- OpenAI-compatible chat completion API
## Project Structure
```text
src/index.ts Main Worker implementation
package.json Project metadata and scripts
tsconfig.json TypeScript config
wrangler.toml.example Example Wrangler config
```
## Required Secrets
Set these with `wrangler secret put`:
- `BOT_TOKEN`
- `TG_WEBHOOK_SECRET`
- `AI_BASE_URL`
- `AI_API_KEY`
- `TURNSTILE_SECRET`
## Required Variables
Set these in `wrangler.toml`:
- `AI_MODEL`
- `TURNSTILE_SITE_KEY`
- `VERIFICATION_ORIGIN`
## KV Setup
Create a KV namespace:
```bash
npx wrangler kv namespace create PENDING_JOINS
```
Copy the returned namespace id into your `wrangler.toml`.
## Local Setup
```bash
npm install
copy wrangler.toml.example wrangler.toml
```
Then edit `wrangler.toml` and add your real values.
## Deploy
```bash
npx wrangler deploy
```
## Telegram Webhook
After deployment, configure the webhook to point to:
```text
https://your-worker-domain/telegram/webhook
```
Example PowerShell:
```powershell
$botToken = "YOUR_BOT_TOKEN"
$secret = "YOUR_TG_WEBHOOK_SECRET"
$body = @{
url = "https://your-worker-domain/telegram/webhook"
secret_token = $secret
allowed_updates = @("chat_join_request")
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$botToken/setWebhook" -ContentType "application/json" -Body $body
```
## Telegram Permissions
The bot must:
- be added to the target group
- be an administrator
- have permission to approve join requests
The group must be configured to require approval for join requests.
## Verification Flow
### Auto-approve path
- user has avatar
- user has bio
- AI returns `approve`
- request is approved immediately
- no message is sent to the user
### Verification path
- avatar missing, or
- bio missing, or
- AI returns `challenge`, or
- AI request fails
Then:
- a single verification message is sent
- user opens the Telegram Web App
- user completes Turnstile or biometric verification
- the bot approves the request
- the verification message is deleted
- KV records are deleted
### Timeout path
- request stays pending for 10 minutes
- scheduled Worker declines the join request
- verification message is deleted
- KV records are deleted
## Notes
- Telegram biometric verification here uses `Telegram.WebApp.BiometricManager`, not WebAuthn.
- Telegram Web App `initData` is verified server-side before accepting either verification method.
- Public repository users should create their own Worker domain, Turnstile site, KV namespace, and AI credentials.
## License
Add your preferred license before publishing if needed.

1527
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "telegram-join-guard-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"check": "wrangler deploy --dry-run"
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260416.0",
"typescript": "^5.8.3",
"wrangler": "^4.11.1"
}
}

807
src/index.ts Normal file
View File

@@ -0,0 +1,807 @@
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;
};
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;
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 = 24 * 60 * 60;
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();
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,
};
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,
};
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,
};
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("链接无效或已失效。"), 404);
}
if (record.expiresAt <= Date.now()) {
return html(renderStatusPage("验证已超时,请重新申请入群。"), 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 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 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` +
`验证完成后会自动放行入群申请。`,
reply_markup: {
inline_keyboard: [[{ text: "打开验证中心", 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 token = record.token;
const displayName = escapeHtml(record.displayName);
const chatTitle = escapeHtml(record.chatTitle);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>入群验证</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">${displayName} 申请加入 ${chatTitle}</div>
<div class="sub">请完成一次简短验证后继续</div>
</div>
<div class="section">
<div class="label">方式一Turnstile 人机验证</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>
<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("验证通过5 秒后自动关闭",true);
try{if(tg&&tg.MainButton){tg.MainButton.setText("关闭");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);
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)});
};
function initBio(){
var bioBtn=document.getElementById("bio");
if(!bm){bioBtn.textContent="当前环境不支持生物识别";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"?"指纹验证":"生物识别验证";
bioBtn.textContent=label;
if(!bm.isAccessGranted){
bm.requestAccess({reason:"用于入群身份验证"},function(ok){
if(!ok){bioBtn.textContent="生物识别权限被拒绝";bioBtn.disabled=true}
});
}
});
}
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);
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)});
});
});
</script>
</body>
</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 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);
}
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"ES2022",
"WebWorker"
],
"types": [
"@cloudflare/workers-types"
]
},
"include": [
"src/**/*.ts"
]
}

19
wrangler.toml.example Normal file
View File

@@ -0,0 +1,19 @@
name = "telewatchdog"
main = "src/index.ts"
compatibility_date = "2026-04-18"
[observability.logs]
enabled = true
invocation_logs = true
[[kv_namespaces]]
binding = "PENDING_JOINS"
id = "replace-with-your-kv-namespace-id"
[vars]
AI_MODEL = "replace-with-your-model-name"
TURNSTILE_SITE_KEY = "replace-with-your-turnstile-site-key"
VERIFICATION_ORIGIN = "https://your-worker.your-subdomain.workers.dev"
[triggers]
crons = ["*/10 * * * *"]