Add TeleWatchdog Cloudflare Worker
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
wrangler.toml
|
||||
165
README.md
Normal file
165
README.md
Normal 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
1527
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
807
src/index.ts
Normal 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, "&")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
19
wrangler.toml.example
Normal 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 * * * *"]
|
||||
Reference in New Issue
Block a user