refactor(core): ♻️ 重构 Telegram Bot 安全机制与缓存逻辑
重构核心转发逻辑并引入多项安全加固措施,主要变更包括: - 增强安全性:新增 Webhook secret 校验、WebApp initData 验证及 nonce 随机数检查。 - 优化缓存:升级缓存系统,新增管理员权限缓存(admin cache)以降低数据库 D1 的读写压力。 - 管理员匹配:优化管理员识别逻辑,支持精确匹配与正则表达式安全检查。 - 文档更新:在 README 中完善项目来源致谢,并详细说明 v3.68 版本的安全加固与无回执体验调整。 此版本重点提升了机器人在高频使用场景下的性能表现与防护能力。
This commit is contained in:
@@ -1,10 +1,27 @@
|
|||||||
# Telegram Bot Worker
|
# Telegram Bot Worker
|
||||||
|
|
||||||
`tg-bot.js` 是一个部署在 Cloudflare Workers 上的 Telegram Bot。它把用户私聊消息转发到管理员论坛群的独立话题中,并提供验证、过滤、封禁、备注、自动回复、备份和配置面板。
|
`tg-bot.js` 是一个部署在 Cloudflare Workers 上的 Telegram 双向私聊中继 Bot。它会把用户私聊消息转发到管理员论坛群的独立 topic 中,管理员在对应 topic 内回复即可把消息发回用户,同时提供验证、过滤、封禁、备注、自动回复、备份和 Telegram 内联管理面板。
|
||||||
|
|
||||||
|
## 来源与致谢
|
||||||
|
|
||||||
|
本脚本基于 [huliyoudiangou/TG_Chat_Bot-D1](https://github.com/huliyoudiangou/TG_Chat_Bot-D1) 二次修改与自用优化。原项目是一个基于 Cloudflare Worker 和 D1 数据库的 Telegram 双向机器人,并在 GitHub 页面中标注为 forked from [moistrr/TGbot-D1](https://github.com/moistrr/TGbot-D1)。
|
||||||
|
|
||||||
|
感谢原作者提供 Cloudflare Worker + D1 + Telegram forum topic 的完整实现思路。本仓库版本主要保留原项目的核心工作流,并针对个人使用习惯做了精简、无回执体验、消息编辑同步和安全加固。
|
||||||
|
|
||||||
|
## 本版本调整
|
||||||
|
|
||||||
|
- 去掉用户侧“已送达”和管理员侧“已回复”回执,减少打扰和额外 API 调用。
|
||||||
|
- 兼容无 `username` 的 Telegram 用户,资料卡仍可通过 `tg://user?id=...` 建立用户链接。
|
||||||
|
- 支持用户编辑消息后,在管理员 topic 中记录修改前后内容。
|
||||||
|
- 支持管理员编辑 topic 内消息后,主动通知用户“对方修改了消息”。
|
||||||
|
- 支持 Webhook secret 校验,配置 `TELEGRAM_WEBHOOK_SECRET` 后会拒绝非 Telegram webhook 请求。
|
||||||
|
- 网页验证提交会校验 Telegram WebApp `initData`,并使用 nonce 防止伪造 `user_id`。
|
||||||
|
- 管理员 ID 与协管 ID 使用精确匹配,避免字符串片段误判。
|
||||||
|
- 屏蔽词和自动回复正则使用安全包装,降低坏正则导致 Worker 异常或 ReDoS 的风险。
|
||||||
|
|
||||||
## 核心功能
|
## 核心功能
|
||||||
|
|
||||||
- 私聊用户消息转发到管理员群论坛话题。
|
- 私聊用户消息转发到管理员群论坛 topic。
|
||||||
- 每个用户自动创建独立 topic,并推送用户身份卡片。
|
- 每个用户自动创建独立 topic,并推送用户身份卡片。
|
||||||
- 管理员在对应 topic 内回复,即可把消息复制回用户私聊。
|
- 管理员在对应 topic 内回复,即可把消息复制回用户私聊。
|
||||||
- 支持 Cloudflare Turnstile 或 Google reCAPTCHA 人机验证。
|
- 支持 Cloudflare Turnstile 或 Google reCAPTCHA 人机验证。
|
||||||
@@ -14,7 +31,7 @@
|
|||||||
- 支持黑名单 topic、用户解封、备注、资料卡置顶。
|
- 支持黑名单 topic、用户解封、备注、资料卡置顶。
|
||||||
- 支持消息编辑记录同步。
|
- 支持消息编辑记录同步。
|
||||||
- 支持消息备份到指定群或频道。
|
- 支持消息备份到指定群或频道。
|
||||||
- 内置 Telegram 管理面板,管理员通过 `/start` 打开。
|
- 内置 Telegram 管理面板,主管理员通过 `/start` 打开。
|
||||||
|
|
||||||
## 运行环境
|
## 运行环境
|
||||||
|
|
||||||
@@ -29,10 +46,25 @@
|
|||||||
| 名称 | 说明 |
|
| 名称 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `BOT_TOKEN` | Telegram Bot Token |
|
| `BOT_TOKEN` | Telegram Bot Token |
|
||||||
| `ADMIN_IDS` | 管理员 Telegram user id,多个用英文或中文逗号分隔 |
|
| `ADMIN_IDS` | 主管理员 Telegram user id,多个用英文或中文逗号分隔 |
|
||||||
| `ADMIN_GROUP_ID` | 管理员论坛群 ID,通常是 `-100...` |
|
| `ADMIN_GROUP_ID` | 管理员论坛群 ID,通常是 `-100...` |
|
||||||
| `WORKER_URL` | Worker 公开访问地址,不要带结尾斜杠 |
|
| `WORKER_URL` | Worker 公开访问地址,不要带结尾斜杠 |
|
||||||
|
|
||||||
|
## 可选环境变量
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key |
|
||||||
|
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key |
|
||||||
|
| `RECAPTCHA_SITE_KEY` | Google reCAPTCHA site key |
|
||||||
|
| `RECAPTCHA_SECRET_KEY` | Google reCAPTCHA secret key |
|
||||||
|
| `WELCOME_MESSAGE` | 默认欢迎语,对应配置项 `welcome_msg` |
|
||||||
|
| `VERIF_QUESTION` | 默认问答验证问题,对应 `verif_q` |
|
||||||
|
| `VERIF_ANSWER` | 默认问答验证答案,对应 `verif_a` |
|
||||||
|
| `TELEGRAM_WEBHOOK_SECRET` | Telegram Webhook secret token,配置后会校验请求头 |
|
||||||
|
|
||||||
|
大多数运行时配置也可以在管理员面板中调整,并优先保存到 D1。
|
||||||
|
|
||||||
## D1 绑定
|
## D1 绑定
|
||||||
|
|
||||||
Worker 需要绑定一个 D1 数据库,绑定名必须是:
|
Worker 需要绑定一个 D1 数据库,绑定名必须是:
|
||||||
@@ -47,20 +79,6 @@ TG_BOT_DB
|
|||||||
- `users`:用户状态、封禁状态、topic 映射、用户资料。
|
- `users`:用户状态、封禁状态、topic 映射、用户资料。
|
||||||
- `messages`:用户消息与管理员群 topic 消息的映射。
|
- `messages`:用户消息与管理员群 topic 消息的映射。
|
||||||
|
|
||||||
## 可选环境变量
|
|
||||||
|
|
||||||
| 名称 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key |
|
|
||||||
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key |
|
|
||||||
| `RECAPTCHA_SITE_KEY` | Google reCAPTCHA site key |
|
|
||||||
| `RECAPTCHA_SECRET_KEY` | Google reCAPTCHA secret key |
|
|
||||||
| `WELCOME_MESSAGE` | 默认欢迎语,对应配置项 `welcome_msg` |
|
|
||||||
| `VERIF_QUESTION` | 默认问答验证问题,对应 `verif_q` |
|
|
||||||
| `VERIF_ANSWER` | 默认问答验证答案,对应 `verif_a` |
|
|
||||||
|
|
||||||
大多数运行时配置也可以在管理员面板中调整,并优先保存到 D1。
|
|
||||||
|
|
||||||
## 默认配置
|
## 默认配置
|
||||||
|
|
||||||
| 配置项 | 默认值 | 说明 |
|
| 配置项 | 默认值 | 说明 |
|
||||||
@@ -73,7 +91,7 @@ TG_BOT_DB
|
|||||||
| `verif_a` | `3` | 问题验证答案 |
|
| `verif_a` | `3` | 问题验证答案 |
|
||||||
| `block_threshold` | `5` | 命中屏蔽词多少次后封禁 |
|
| `block_threshold` | `5` | 命中屏蔽词多少次后封禁 |
|
||||||
| `busy_mode` | `false` | 是否启用非营业自动回复 |
|
| `busy_mode` | `false` | 是否启用非营业自动回复 |
|
||||||
| `enable_admin_receipt` | `false` | 管理员回执开关 |
|
| `enable_admin_receipt` | `false` | 管理员回执开关,当前版本默认不发送回执 |
|
||||||
|
|
||||||
## 部署流程
|
## 部署流程
|
||||||
|
|
||||||
@@ -83,14 +101,25 @@ TG_BOT_DB
|
|||||||
4. 在 Cloudflare 创建 D1 数据库并绑定为 `TG_BOT_DB`。
|
4. 在 Cloudflare 创建 D1 数据库并绑定为 `TG_BOT_DB`。
|
||||||
5. 创建 Worker,把 [tg-bot.js](tg-bot.js) 作为 Worker 代码。
|
5. 创建 Worker,把 [tg-bot.js](tg-bot.js) 作为 Worker 代码。
|
||||||
6. 配置环境变量 `BOT_TOKEN`、`ADMIN_IDS`、`ADMIN_GROUP_ID`、`WORKER_URL`。
|
6. 配置环境变量 `BOT_TOKEN`、`ADMIN_IDS`、`ADMIN_GROUP_ID`、`WORKER_URL`。
|
||||||
7. 设置 Telegram Webhook:
|
7. 如果使用网页验证,配置 Turnstile 或 reCAPTCHA 的 site key 与 secret key。
|
||||||
|
8. 设置 Telegram Webhook。
|
||||||
|
|
||||||
|
不使用 secret token 时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=<WORKER_URL>"
|
curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=<WORKER_URL>"
|
||||||
```
|
```
|
||||||
|
|
||||||
8. 访问 Worker 根路径,若返回 `Bot v3.67 Active`,说明 Worker 基本可用。
|
推荐配置 `TELEGRAM_WEBHOOK_SECRET`,并设置 Webhook secret token:
|
||||||
9. 管理员私聊 Bot 发送 `/start`,打开控制面板。
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook" \
|
||||||
|
-d "url=<WORKER_URL>" \
|
||||||
|
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
|
||||||
|
```
|
||||||
|
|
||||||
|
9. 访问 Worker 根路径,若返回 `Bot v3.68 Active`,说明 Worker 基本可用。
|
||||||
|
10. 主管理员私聊 Bot 发送 `/start`,打开控制面板。
|
||||||
|
|
||||||
## Webhook 路由
|
## Webhook 路由
|
||||||
|
|
||||||
@@ -103,7 +132,7 @@ curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=<WORKER_URL>"
|
|||||||
|
|
||||||
## 管理员面板
|
## 管理员面板
|
||||||
|
|
||||||
管理员私聊 Bot 发送 `/start` 后会打开配置面板,包含:
|
主管理员私聊 Bot 发送 `/start` 后会打开配置面板,包含:
|
||||||
|
|
||||||
- 基础:欢迎语、验证问题、验证答案、验证码模式、问题验证开关。
|
- 基础:欢迎语、验证问题、验证答案、验证码模式、问题验证开关。
|
||||||
- 自动回复:添加或删除关键词自动回复。
|
- 自动回复:添加或删除关键词自动回复。
|
||||||
@@ -133,12 +162,18 @@ curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=<WORKER_URL>"
|
|||||||
4. 如果启用问答验证,用户继续回答问题。
|
4. 如果启用问答验证,用户继续回答问题。
|
||||||
5. 验证通过后,用户消息会转发到管理员群中的个人 topic。
|
5. 验证通过后,用户消息会转发到管理员群中的个人 topic。
|
||||||
6. 管理员在 topic 中回复,Bot 会把回复发送给该用户。
|
6. 管理员在 topic 中回复,Bot 会把回复发送给该用户。
|
||||||
|
7. 用户或管理员编辑消息时,Bot 会同步对应的编辑提示。
|
||||||
|
|
||||||
## 注意事项
|
## 安全建议
|
||||||
|
|
||||||
- `ADMIN_IDS` 使用字符串包含判断,建议使用完整 ID 并用逗号分隔,避免 ID 片段误匹配。
|
- 推荐配置 `TELEGRAM_WEBHOOK_SECRET`;未配置时会保持兼容模式,不强制校验 Telegram Webhook secret。
|
||||||
|
- `ADMIN_IDS` 和协管列表会按逗号拆分后精确匹配。
|
||||||
- 管理员群必须开启 Topics,否则自动创建用户话题会失败。
|
- 管理员群必须开启 Topics,否则自动创建用户话题会失败。
|
||||||
- `WORKER_URL` 要使用 HTTPS 公开地址,否则 Telegram Web App 验证页面无法正常工作。
|
- `WORKER_URL` 要使用 HTTPS 公开地址,否则 Telegram Web App 验证页面无法正常工作。
|
||||||
- Turnstile 和 reCAPTCHA 至少配置一种;如果关闭网页验证,可只使用问答验证。
|
- Turnstile 和 reCAPTCHA 至少配置一种;如果关闭网页验证,可只使用问答验证。
|
||||||
- Worker 使用内存缓存减少 D1 读取,配置修改后脚本会主动清缓存。
|
- 屏蔽词和自动回复支持正则,但建议保持简单,避免复杂表达式造成匹配性能问题。
|
||||||
- 请妥善保护 `BOT_TOKEN`、验证码 secret、D1 数据库和 Cloudflare 账号权限。
|
- 请妥善保护 `BOT_TOKEN`、验证码 secret、D1 数据库和 Cloudflare 账号权限。
|
||||||
|
|
||||||
|
## 许可证与上游
|
||||||
|
|
||||||
|
原项目 [huliyoudiangou/TG_Chat_Bot-D1](https://github.com/huliyoudiangou/TG_Chat_Bot-D1) 采用 MIT License。本仓库中的修改版沿用原项目开源精神,仅作个人维护与自用优化;如需完整部署教程、上游更新和问题讨论,请优先参考原作者仓库。
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Telegram Bot Worker v3.67 (No-Receipt Mod & No-Username Fix)
|
* Telegram Bot Worker v3.68 (No-Receipt Mod, No-Username Fix & Security Hardening)
|
||||||
* 完整功能版:保留所有备份、配置面板、辅助函数
|
* 完整功能版:保留所有备份、配置面板、辅助函数
|
||||||
* * 修改 1: 修复了无用户名用户无法推送卡片的问题
|
* * 修改 1: 修复了无用户名用户无法推送卡片的问题
|
||||||
* * 修改 2: 彻底移除了管理员回复的“✅ 已回复”提示
|
* * 修改 2: 彻底移除了管理员回复的“✅ 已回复”提示
|
||||||
* * 修改 3: 彻底移除了用户发送消息后的“✅ 已送达”回执
|
* * 修改 3: 彻底移除了用户发送消息后的“✅ 已送达”回执
|
||||||
|
* * 修改 4: 增加 Webhook secret、WebApp initData、nonce、管理员精确匹配与正则安全检查
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// --- 1. 静态配置与常量 ---
|
// --- 1. 静态配置与常量 ---
|
||||||
// 缓存系统,用于减少数据库读写压力,降低 Worker KV/D1 计费
|
// 缓存系统,用于减少数据库读写压力,降低 Worker KV/D1 计费
|
||||||
const CACHE = { data: {}, ts: 0, ttl: 60000, user_locks: {}, warn_cd: {} };
|
const CACHE = { data: {}, ts: 0, ttl: 60000, user_locks: {}, warn_cd: {}, admin: { ts: 0, ttl: 60000, primary: new Set(), auth: new Set() } };
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
// 基础设置
|
// 基础设置
|
||||||
@@ -45,6 +46,17 @@ const MSG_TYPES = [
|
|||||||
{ check: m => m.text, key: 'enable_text_forwarding', name: "纯文本" }
|
{ check: m => m.text, key: 'enable_text_forwarding', name: "纯文本" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REGEX_MAX_PATTERN_LEN = 256;
|
||||||
|
const REGEX_MAX_TEXT_LEN = 512;
|
||||||
|
const REGEX_REJECT_PATTERNS = [
|
||||||
|
/\([^)]*\)\s*[+*{]/,
|
||||||
|
/\(\s*\.\*\s*\)\s*\+/,
|
||||||
|
/\(\s*\.\+\s*\)\s*\+/,
|
||||||
|
/\\[1-9]/,
|
||||||
|
/\(\?<=[\s\S]*\)/,
|
||||||
|
/\(\?<![\s\S]*\)/
|
||||||
|
];
|
||||||
|
|
||||||
// --- 2. 核心入口 (Entry Point) ---
|
// --- 2. 核心入口 (Entry Point) ---
|
||||||
export default {
|
export default {
|
||||||
async fetch(req, env, ctx) {
|
async fetch(req, env, ctx) {
|
||||||
@@ -56,11 +68,12 @@ export default {
|
|||||||
// GET 请求处理:验证页面加载或连通性测试
|
// GET 请求处理:验证页面加载或连通性测试
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
if (url.pathname === "/verify") return handleVerifyPage(url, env);
|
if (url.pathname === "/verify") return handleVerifyPage(url, env);
|
||||||
if (url.pathname === "/") return new Response("Bot v3.67 Active", { status: 200 });
|
if (url.pathname === "/") return new Response("Bot v3.68 Active", { status: 200 });
|
||||||
}
|
}
|
||||||
// POST 请求处理:Telegram Webhook 核心逻辑接收端
|
// POST 请求处理:Telegram Webhook 核心逻辑接收端
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
if (url.pathname === "/submit_token") return handleTokenSubmit(req, env);
|
if (url.pathname === "/submit_token") return handleTokenSubmit(req, env);
|
||||||
|
if (!isTelegramWebhook(req, env)) return new Response("Forbidden", { status: 403 });
|
||||||
try {
|
try {
|
||||||
const update = await req.json();
|
const update = await req.json();
|
||||||
ctx.waitUntil(handleUpdate(update, env, ctx));
|
ctx.waitUntil(handleUpdate(update, env, ctx));
|
||||||
@@ -116,6 +129,7 @@ async function getCfg(key, env) {
|
|||||||
async function setCfg(key, val, env) {
|
async function setCfg(key, val, env) {
|
||||||
await sql(env, "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", [key, val]);
|
await sql(env, "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", [key, val]);
|
||||||
CACHE.ts = 0;
|
CACHE.ts = 0;
|
||||||
|
if (key === 'authorized_admins') CACHE.admin.ts = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取或初始化用户信息实体
|
// 获取或初始化用户信息实体
|
||||||
@@ -177,8 +191,8 @@ async function registerCommands(env) {
|
|||||||
try {
|
try {
|
||||||
await api(env.BOT_TOKEN, "deleteMyCommands", { scope: { type: "default" } });
|
await api(env.BOT_TOKEN, "deleteMyCommands", { scope: { type: "default" } });
|
||||||
await api(env.BOT_TOKEN, "setMyCommands", { commands: [{ command: "start", description: "开始 / Start" }], scope: { type: "default" } });
|
await api(env.BOT_TOKEN, "setMyCommands", { commands: [{ command: "start", description: "开始 / Start" }], scope: { type: "default" } });
|
||||||
const list = [...(env.ADMIN_IDS||"").split(/[,,]/), ...(safeParse(await getCfg('authorized_admins', env), []))];
|
const sets = await getAdminSets(env);
|
||||||
const admins = [...new Set(list.map(i=>i.trim()).filter(Boolean))];
|
const admins = [...sets.auth];
|
||||||
for (const id of admins) await api(env.BOT_TOKEN, "setMyCommands", { commands: [{ command: "start", description: "⚙️ 管理面板" }, { command: "help", description: "📄 帮助说明" }], scope: { type: "chat", chat_id: id } });
|
for (const id of admins) await api(env.BOT_TOKEN, "setMyCommands", { commands: [{ command: "start", description: "⚙️ 管理面板" }, { command: "help", description: "📄 帮助说明" }], scope: { type: "chat", chat_id: id } });
|
||||||
} catch (e) { console.error("Register Commands Failed:", e); }
|
} catch (e) { console.error("Register Commands Failed:", e); }
|
||||||
}
|
}
|
||||||
@@ -219,11 +233,11 @@ async function handleAdminEdit(msg, env) {
|
|||||||
async function handlePrivate(msg, env, ctx) {
|
async function handlePrivate(msg, env, ctx) {
|
||||||
const id = msg.chat.id.toString();
|
const id = msg.chat.id.toString();
|
||||||
const text = msg.text || "";
|
const text = msg.text || "";
|
||||||
const isAdm = (env.ADMIN_IDS || "").includes(id);
|
const isAdm = await isPrimaryAdmin(id, env);
|
||||||
const u = await getUser(id, env);
|
const u = await getUser(id, env);
|
||||||
|
|
||||||
// 人机验证拦截器 (非管理人员未完成验证则阻断)
|
// 人机验证拦截器 (非管理人员未完成验证则阻断)
|
||||||
if (text !== "/start" && !isAdm) {
|
if (text !== "/start" && u.user_state !== 'pending_verification' && !isAdm) {
|
||||||
const isCaptchaOn = await getBool('enable_verify', env);
|
const isCaptchaOn = await getBool('enable_verify', env);
|
||||||
const isQAOn = await getBool('enable_qa_verify', env);
|
const isQAOn = await getBool('enable_qa_verify', env);
|
||||||
|
|
||||||
@@ -336,11 +350,13 @@ async function sendStart(id, msg, env) {
|
|||||||
const isQAOn = await getBool('enable_qa_verify', env);
|
const isQAOn = await getBool('enable_qa_verify', env);
|
||||||
|
|
||||||
if (isCaptchaOn && url && hasKey) {
|
if (isCaptchaOn && url && hasKey) {
|
||||||
|
const nonce = genNonce();
|
||||||
|
await updUser(id, { user_state: "pending_turnstile", user_info: { ...u.user_info, verify_nonce: nonce, verify_nonce_ts: Date.now() } }, env);
|
||||||
return api(env.BOT_TOKEN, "sendMessage", {
|
return api(env.BOT_TOKEN, "sendMessage", {
|
||||||
chat_id: id,
|
chat_id: id,
|
||||||
text: "🛡️ <b>安全验证</b>\n请点击下方按钮完成人机验证以继续。",
|
text: "🛡️ <b>安全验证</b>\n请点击下方按钮完成人机验证以继续。",
|
||||||
parse_mode: "HTML",
|
parse_mode: "HTML",
|
||||||
reply_markup: { inline_keyboard: [[{ text: "点击进行验证", web_app: { url: `${url}/verify?user_id=${id}` } }]] }
|
reply_markup: { inline_keyboard: [[{ text: "点击进行验证", web_app: { url: `${url}/verify?user_id=${encodeURIComponent(id)}&nonce=${encodeURIComponent(nonce)}` } }]] }
|
||||||
});
|
});
|
||||||
} else if (!isCaptchaOn && isQAOn) {
|
} else if (!isCaptchaOn && isQAOn) {
|
||||||
await updUser(id, { user_state: "pending_verification" }, env);
|
await updUser(id, { user_state: "pending_verification" }, env);
|
||||||
@@ -359,7 +375,7 @@ async function handleVerifiedMsg(msg, u, env) {
|
|||||||
// 敏感词屏蔽预检系统
|
// 敏感词屏蔽预检系统
|
||||||
if (text) {
|
if (text) {
|
||||||
const kws = await getJsonCfg('block_keywords', env);
|
const kws = await getJsonCfg('block_keywords', env);
|
||||||
if (kws.some(k => new RegExp(k, 'gi').test(text))) {
|
if ((Array.isArray(kws) ? kws : []).some(k => safeRegexTest(k, text))) {
|
||||||
const c = u.block_count + 1, max = parseInt(await getCfg('block_threshold', env)) || 5;
|
const c = u.block_count + 1, max = parseInt(await getCfg('block_threshold', env)) || 5;
|
||||||
const willBlock = c >= max;
|
const willBlock = c >= max;
|
||||||
await updUser(id, { block_count: c, is_blocked: willBlock }, env);
|
await updUser(id, { block_count: c, is_blocked: willBlock }, env);
|
||||||
@@ -395,7 +411,7 @@ async function handleVerifiedMsg(msg, u, env) {
|
|||||||
// 关键词自动回复钩子
|
// 关键词自动回复钩子
|
||||||
if (text) {
|
if (text) {
|
||||||
const rules = await getJsonCfg('keyword_responses', env);
|
const rules = await getJsonCfg('keyword_responses', env);
|
||||||
const match = rules.find(r => new RegExp(r.keywords, 'gi').test(text));
|
const match = (Array.isArray(rules) ? rules : []).find(r => safeRegexTest(r?.keywords, text));
|
||||||
if (match) return api(env.BOT_TOKEN, "sendMessage", { chat_id: id, text: "自动回复:\n" + match.response });
|
if (match) return api(env.BOT_TOKEN, "sendMessage", { chat_id: id, text: "自动回复:\n" + match.response });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,18 +697,27 @@ async function handleEdit(msg, env) {
|
|||||||
// --- 7. Web验证外设接口组件 ---
|
// --- 7. Web验证外设接口组件 ---
|
||||||
async function handleVerifyPage(url, env) {
|
async function handleVerifyPage(url, env) {
|
||||||
const uid = url.searchParams.get('user_id');
|
const uid = url.searchParams.get('user_id');
|
||||||
|
const nonce = url.searchParams.get('nonce') || "";
|
||||||
const mode = await getCfg('captcha_mode', env);
|
const mode = await getCfg('captcha_mode', env);
|
||||||
const siteKey = mode === 'recaptcha' ? env.RECAPTCHA_SITE_KEY : env.TURNSTILE_SITE_KEY;
|
const siteKey = mode === 'recaptcha' ? env.RECAPTCHA_SITE_KEY : env.TURNSTILE_SITE_KEY;
|
||||||
if (!uid || !siteKey) return new Response("Miss Config (Check Mode/Key)", { status: 400 });
|
if (!uid || !siteKey) return new Response("Miss Config (Check Mode/Key)", { status: 400 });
|
||||||
const scriptUrl = mode === 'recaptcha' ? "https://www.google.com/recaptcha/api.js" : "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
const scriptUrl = mode === 'recaptcha' ? "https://www.google.com/recaptcha/api.js" : "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||||
const divClass = mode === 'recaptcha' ? "g-recaptcha" : "cf-turnstile";
|
const divClass = mode === 'recaptcha' ? "g-recaptcha" : "cf-turnstile";
|
||||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><script src="https://telegram.org/js/telegram-web-app.js"></script><script src="${scriptUrl}" async defer></script><style>body{display:flex;justify-content:center;align-items:center;height:100vh;background:#fff;font-family:sans-serif}#c{text-align:center;padding:20px;background:#f0f0f0;border-radius:10px}</style></head><body><div id="c"><h3>🛡️ 安全验证</h3><div class="${divClass}" data-sitekey="${siteKey}" data-callback="S"></div><div id="m"></div></div><script>const tg=window.Telegram.WebApp;tg.ready();function S(t){document.getElementById('m').innerText='验证中...';fetch('/submit_token',{method:'POST',body:JSON.stringify({token:t,userId:'${uid}'})}).then(r=>r.json()).then(d=>{if(d.success){document.getElementById('m').innerText='✅';setTimeout(()=>{tg.close();window.close();},1000)}else{document.getElementById('m').innerText='❌'}}).catch(e=>{document.getElementById('m').innerText='Error'})}</script></body></html>`;
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><script src="https://telegram.org/js/telegram-web-app.js"></script><script src="${scriptUrl}" async defer></script><style>body{display:flex;justify-content:center;align-items:center;height:100vh;background:#fff;font-family:sans-serif}#c{text-align:center;padding:20px;background:#f0f0f0;border-radius:10px}</style></head><body><div id="c"><h3>🛡️ 安全验证</h3><div class="${divClass}" data-sitekey="${escape(siteKey)}" data-callback="S"></div><div id="m"></div></div><script>const tg=window.Telegram.WebApp;tg.ready();const UI_USER_ID=${JSON.stringify(uid)};const UI_NONCE=${JSON.stringify(nonce)};function S(t){document.getElementById('m').innerText='验证中...';fetch('/submit_token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:t,userId:UI_USER_ID,nonce:UI_NONCE,initData:tg.initData||''})}).then(r=>r.json()).then(d=>{if(d.success){document.getElementById('m').innerText='✅';setTimeout(()=>{tg.close();window.close();},1000)}else{document.getElementById('m').innerText='❌'}}).catch(e=>{document.getElementById('m').innerText='Error'})}</script></body></html>`;
|
||||||
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTokenSubmit(req, env) {
|
async function handleTokenSubmit(req, env) {
|
||||||
try {
|
try {
|
||||||
const { token, userId } = await req.json();
|
const { token, userId, nonce, initData } = await req.json();
|
||||||
|
const parsed = await verifyTelegramInitData(initData || "", env.BOT_TOKEN, 600);
|
||||||
|
const verifiedUserId = parsed?.userId?.toString();
|
||||||
|
if (!verifiedUserId || (userId && userId.toString() !== verifiedUserId)) throw new Error("Invalid Telegram initData");
|
||||||
|
const user = await getUser(verifiedUserId, env);
|
||||||
|
if (user.is_blocked && !(await isAuthAdmin(verifiedUserId, env))) throw new Error("Blocked user");
|
||||||
|
if (!nonce || user.user_info.verify_nonce !== nonce || Date.now() - (user.user_info.verify_nonce_ts || 0) > 15 * 60 * 1000) {
|
||||||
|
throw new Error("Invalid verification nonce");
|
||||||
|
}
|
||||||
const mode = await getCfg('captcha_mode', env);
|
const mode = await getCfg('captcha_mode', env);
|
||||||
let success = false;
|
let success = false;
|
||||||
const verifyUrl = mode === 'recaptcha' ? 'https://www.google.com/recaptcha/api/siteverify' : 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
const verifyUrl = mode === 'recaptcha' ? 'https://www.google.com/recaptcha/api/siteverify' : 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
@@ -704,11 +729,11 @@ async function handleTokenSubmit(req, env) {
|
|||||||
if (!success) throw new Error("Invalid Token");
|
if (!success) throw new Error("Invalid Token");
|
||||||
|
|
||||||
if (await getBool('enable_qa_verify', env)) {
|
if (await getBool('enable_qa_verify', env)) {
|
||||||
await updUser(userId, { user_state: "pending_verification" }, env);
|
await updUser(verifiedUserId, { user_state: "pending_verification", user_info: { ...user.user_info, verify_nonce: "", verify_nonce_ts: 0 } }, env);
|
||||||
await api(env.BOT_TOKEN, "sendMessage", { chat_id: userId, text: "✅ 验证通过!\n请回答:\n" + await getCfg('verif_q', env) });
|
await api(env.BOT_TOKEN, "sendMessage", { chat_id: verifiedUserId, text: "✅ 验证通过!\n请回答:\n" + await getCfg('verif_q', env) });
|
||||||
} else {
|
} else {
|
||||||
await updUser(userId, { user_state: "verified" }, env);
|
await updUser(verifiedUserId, { user_state: "verified", user_info: { ...user.user_info, verify_nonce: "", verify_nonce_ts: 0 } }, env);
|
||||||
await api(env.BOT_TOKEN, "sendMessage", { chat_id: userId, text: "✅ 验证通过!\n现在您可以直接发送消息,我会帮您转达给管理员。" });
|
await api(env.BOT_TOKEN, "sendMessage", { chat_id: verifiedUserId, text: "✅ 验证通过!\n现在您可以直接发送消息,我会帮您转达给管理员。" });
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify({ success: true }));
|
return new Response(JSON.stringify({ success: true }));
|
||||||
} catch { return new Response(JSON.stringify({ success: false }), { status: 400 }); }
|
} catch { return new Response(JSON.stringify({ success: false }), { status: 400 }); }
|
||||||
@@ -727,12 +752,15 @@ async function handleCallback(cb, env) {
|
|||||||
const [act, p1, p2, p3] = data.split(':');
|
const [act, p1, p2, p3] = data.split(':');
|
||||||
|
|
||||||
if (act === 'note' && p1 === 'set') {
|
if (act === 'note' && p1 === 'set') {
|
||||||
|
if (!msg || msg.chat.id.toString() !== env.ADMIN_GROUP_ID || !(await isAuthAdmin(from.id, env))) {
|
||||||
|
return api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id, text: "无操作权限", show_alert: true });
|
||||||
|
}
|
||||||
await setCfg(`admin_state:${from.id}`, JSON.stringify({ action: 'input_note', target: p2 }), env);
|
await setCfg(`admin_state:${from.id}`, JSON.stringify({ action: 'input_note', target: p2 }), env);
|
||||||
return api(env.BOT_TOKEN, "sendMessage", { chat_id: msg.chat.id, message_thread_id: msg.message_thread_id, text: "⌨️ 请回复备注内容 (回复 /clear 清除):" });
|
return api(env.BOT_TOKEN, "sendMessage", { chat_id: msg.chat.id, message_thread_id: msg.message_thread_id, text: "⌨️ 请回复备注内容 (回复 /clear 清除):" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (act === 'config') {
|
if (act === 'config') {
|
||||||
if (!(env.ADMIN_IDS||"").includes(from.id.toString())) return api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id, text: "无操作权限", show_alert: true });
|
if (!(await isPrimaryAdmin(from.id, env))) return api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id, text: "无操作权限", show_alert: true });
|
||||||
|
|
||||||
if (p1 === 'rotate_mode') {
|
if (p1 === 'rotate_mode') {
|
||||||
const currentMode = await getCfg('captcha_mode', env);
|
const currentMode = await getCfg('captcha_mode', env);
|
||||||
@@ -750,7 +778,10 @@ async function handleCallback(cb, env) {
|
|||||||
return handleAdminConfig(msg.chat.id, msg.message_id, p1, p2, p3, env);
|
return handleAdminConfig(msg.chat.id, msg.message_id, p1, p2, p3, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.chat.id.toString() === env.ADMIN_GROUP_ID) {
|
if (msg && msg.chat.id.toString() === env.ADMIN_GROUP_ID) {
|
||||||
|
if (!(await isAuthAdmin(from.id, env))) {
|
||||||
|
return api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id, text: "无操作权限", show_alert: true });
|
||||||
|
}
|
||||||
await api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id });
|
await api(env.BOT_TOKEN, "answerCallbackQuery", { callback_query_id: cb.id });
|
||||||
if (act === 'pin_card') api(env.BOT_TOKEN, "pinChatMessage", { chat_id: msg.chat.id, message_id: msg.message_id, message_thread_id: msg.message_thread_id });
|
if (act === 'pin_card') api(env.BOT_TOKEN, "pinChatMessage", { chat_id: msg.chat.id, message_id: msg.message_id, message_thread_id: msg.message_thread_id });
|
||||||
else if (['block','unblock'].includes(act)) {
|
else if (['block','unblock'].includes(act)) {
|
||||||
@@ -852,11 +883,87 @@ async function handleAdminInput(id, msg, state, env) {
|
|||||||
const getBool = async (k, e) => (await getCfg(k, e)) === 'true';
|
const getBool = async (k, e) => (await getCfg(k, e)) === 'true';
|
||||||
const getJsonCfg = async (k, e) => safeParse(await getCfg(k, e), []);
|
const getJsonCfg = async (k, e) => safeParse(await getCfg(k, e), []);
|
||||||
const escape = t => (t||"").toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const escape = t => (t||"").toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
const isAuthAdmin = async (id, e) => {
|
|
||||||
const idStr = id.toString();
|
function parseIdsToSet(str) {
|
||||||
if ((e.ADMIN_IDS||"").includes(idStr)) return true;
|
return new Set((str || "").toString().split(/[,,]/).map(s => s.trim()).filter(Boolean));
|
||||||
const list = await getJsonCfg('authorized_admins', e);
|
}
|
||||||
return list.includes(idStr);
|
|
||||||
|
async function getAdminSets(env) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (CACHE.admin.ts && now - CACHE.admin.ts < CACHE.admin.ttl) return CACHE.admin;
|
||||||
|
|
||||||
|
const primary = parseIdsToSet(env.ADMIN_IDS || "");
|
||||||
|
const authList = await getJsonCfg('authorized_admins', env);
|
||||||
|
const auth = new Set([...primary, ...((Array.isArray(authList) ? authList : []).map(x => x.toString()))]);
|
||||||
|
CACHE.admin = { ts: now, ttl: CACHE.admin.ttl, primary, auth };
|
||||||
|
return CACHE.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrimaryAdmin = async (id, e) => (await getAdminSets(e)).primary.has(id.toString());
|
||||||
|
const isAuthAdmin = async (id, e) => (await getAdminSets(e)).auth.has(id.toString());
|
||||||
|
|
||||||
|
function safeRegexTest(pattern, text) {
|
||||||
|
try {
|
||||||
|
if (!pattern || typeof pattern !== "string") return false;
|
||||||
|
const p = pattern.trim();
|
||||||
|
if (!p || p.length > REGEX_MAX_PATTERN_LEN) return false;
|
||||||
|
if (REGEX_REJECT_PATTERNS.some(re => re.test(p))) return false;
|
||||||
|
const t = (text || "").toString();
|
||||||
|
return new RegExp(p, 'gi').test(t.length > REGEX_MAX_TEXT_LEN ? t.slice(0, REGEX_MAX_TEXT_LEN) : t);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTelegramWebhook(req, env) {
|
||||||
|
const secret = (env.TELEGRAM_WEBHOOK_SECRET || "").toString();
|
||||||
|
if (!secret) return true;
|
||||||
|
const header = req.headers.get("X-Telegram-Bot-Api-Secret-Token") || "";
|
||||||
|
return timingSafeEqualStr(header, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualStr(a, b) {
|
||||||
|
const aa = (a || "").toString();
|
||||||
|
const bb = (b || "").toString();
|
||||||
|
let out = aa.length ^ bb.length;
|
||||||
|
const len = Math.max(aa.length, bb.length);
|
||||||
|
for (let i = 0; i < len; i++) out |= (aa.charCodeAt(i) || 0) ^ (bb.charCodeAt(i) || 0);
|
||||||
|
return out === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genNonce(bytes = 24) {
|
||||||
|
const data = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(data);
|
||||||
|
return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256(key, data) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const cryptoKey = await crypto.subtle.importKey("raw", key instanceof Uint8Array ? key : enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hex(bytes) {
|
||||||
|
return [...bytes].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTelegramInitData(initData, botToken, maxAgeSec = 600) {
|
||||||
|
if (!initData || !botToken) return null;
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const receivedHash = params.get("hash") || "";
|
||||||
|
if (!receivedHash) return null;
|
||||||
|
params.delete("hash");
|
||||||
|
|
||||||
|
const authDate = Number(params.get("auth_date") || 0);
|
||||||
|
if (!authDate || Math.abs(Math.floor(Date.now() / 1000) - authDate) > maxAgeSec) return null;
|
||||||
|
|
||||||
|
const checkString = [...params.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join("\n");
|
||||||
|
const secret = await hmacSha256("WebAppData", botToken);
|
||||||
|
const calcHash = hex(await hmacSha256(secret, checkString));
|
||||||
|
if (!timingSafeEqualStr(calcHash, receivedHash)) return null;
|
||||||
|
|
||||||
|
const user = safeParse(params.get("user"), null);
|
||||||
|
return user?.id ? { userId: user.id.toString(), user } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// HTML `<a>` 标签组装逻辑:穿透安全审查拦截,对无用户名账号建立伪链接
|
// HTML `<a>` 标签组装逻辑:穿透安全审查拦截,对无用户名账号建立伪链接
|
||||||
@@ -895,4 +1002,4 @@ const getBtns = (id, blk, username) => {
|
|||||||
btns.push([{ text: "✏️ 录入备注", callback_data: `note:set:${id}` }, { text: "📌 提升置顶", callback_data: `pin_card:${id}` }]);
|
btns.push([{ text: "✏️ 录入备注", callback_data: `note:set:${id}` }, { text: "📌 提升置顶", callback_data: `pin_card:${id}` }]);
|
||||||
|
|
||||||
return { inline_keyboard: btns };
|
return { inline_keyboard: btns };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user