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