feat(core): ✨ add Lsky Pro image upload worker
在 cf_worker 目录下新增 lsky-telegram-worker.js 脚本,实现将 Telegram 发送的图片上传至 Lsky Pro 图床并返回 Markdown 链接代码块。 同时对项目目录结构进行调整与重构: - 将原 telegram/ 目录下的脚本与文档移动并整合至 cf_worker/ 目录 - 更新项目主 README.md 以及子目录文档,完善 Cloudflare Workers 相关脚本的用途说明
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# Cloudflare Worker Scripts
|
||||
|
||||
这个目录保存 Cloudflare Workers 相关脚本:
|
||||
|
||||
| 脚本 | 用途 |
|
||||
| --- | --- |
|
||||
| `lsky-telegram-worker.js` | Telegram 收图后上传到 Lsky Pro,并返回 Markdown 图片链接代码块 |
|
||||
| `tg-bot.js` | Telegram 双向私聊中继 Bot,包含管理面板、验证、过滤、封禁、备份等功能 |
|
||||
|
||||
## Lsky Telegram Upload Worker
|
||||
|
||||
`lsky-telegram-worker.js` 用于把 Telegram 私聊发送的图片或图片文件上传到 Lsky Pro。上传成功后,Bot 会回复 Markdown 图片链接,并使用 Telegram HTML `<pre>` 代码块渲染,便于 Android 客户端点击复制。
|
||||
|
||||
### 功能
|
||||
|
||||
- 支持 Telegram `photo` 和图片类 `document`。
|
||||
- 支持 Telegram `media_group_id` 相册收集后批量上传。
|
||||
- 默认单文件大小限制为 `500.00 MB`,可用 `MAX_FILE_MB` 覆盖。
|
||||
- 默认上传队列最多收 `500` 张,可用 `MAX_UPLOAD_QUEUE_IMAGES` 覆盖。
|
||||
- 返回格式为 ``,并关闭网页预览。
|
||||
- 通过 `ALLOWED_TELEGRAM_USER_IDS` 限制可用用户。
|
||||
|
||||
### 必需绑定和环境变量
|
||||
|
||||
| 名称 | 说明 |
|
||||
| --- | --- |
|
||||
| `DB` | Cloudflare D1 数据库绑定名,用于暂存相册队列 |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram Webhook secret token,同时用于 `/init` 初始化鉴权 |
|
||||
| `ALLOWED_TELEGRAM_USER_IDS` | 允许使用的 Telegram user id,多个用英文逗号分隔 |
|
||||
| `LSKY_API_URL` | Lsky Pro 上传 API 地址 |
|
||||
| `LSKY_TOKEN` | Lsky Pro API token |
|
||||
|
||||
### 可选环境变量
|
||||
|
||||
| 名称 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MAX_FILE_MB` | `500.00` | 单个 Telegram 图片/文件大小上限,单位 MB |
|
||||
| `MAX_UPLOAD_QUEUE_IMAGES` | `500` | 单个上传队列最多暂存图片数量 |
|
||||
| `ALBUM_WAIT_SECONDS` | `5` | 相册收集等待秒数 |
|
||||
| `LSKY_PERMISSION` | `1` | 传给 Lsky Pro 的 `permission` |
|
||||
| `LSKY_STRATEGY_ID` | 空 | 传给 Lsky Pro 的 `strategy_id` |
|
||||
|
||||
### 部署流程
|
||||
|
||||
1. 创建 Telegram Bot,拿到 `TELEGRAM_BOT_TOKEN`。
|
||||
2. 创建 Cloudflare D1 数据库,并在 Worker 中绑定为 `DB`。
|
||||
3. 创建 Worker,把 [lsky-telegram-worker.js](lsky-telegram-worker.js) 作为 Worker 代码。
|
||||
4. 配置上方环境变量。
|
||||
5. 访问 `https://<WORKER_URL>/init?secret=<TELEGRAM_WEBHOOK_SECRET>` 初始化 D1 表。
|
||||
6. 设置 Telegram Webhook:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
|
||||
-d "url=<WORKER_URL>/webhook" \
|
||||
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
|
||||
```
|
||||
|
||||
7. 访问 Worker 根路径,若返回 `Lsky Telegram Worker OK - code-block-max500mb-queue500-2026-05-27`,说明 Worker 基本可用。
|
||||
|
||||
## Telegram Bot Worker
|
||||
|
||||
`tg-bot.js` 是一个部署在 Cloudflare Workers 上的 Telegram 双向私聊中继 Bot(版本 v3.68)。它会把用户私聊消息转发到管理员论坛群的独立 topic 中,管理员在对应 topic 内回复即可把消息发回用户,同时提供验证、过滤、封禁、备注、自动回复、备份和 Telegram 内联管理面板。
|
||||
|
||||
**核心特点:**
|
||||
- **无回执体验**:移除了用户侧"已送达"和管理员侧"已回复"回执提示,减少打扰
|
||||
- **安全加固**:支持 Webhook secret 校验、WebApp initData 验证、nonce 防伪造、管理员精确匹配
|
||||
- **消息编辑同步**:用户和管理员编辑消息时,对方会收到修改通知
|
||||
- **双向删除**:用户和管理员都可以通过 `/del` 命令删除消息
|
||||
|
||||
## 来源与致谢
|
||||
|
||||
本脚本基于 [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 的完整实现思路。本仓库版本主要保留原项目的核心工作流,并针对个人使用习惯做了精简、无回执体验、消息编辑同步和安全加固。
|
||||
|
||||
## 本版本调整 (v3.68)
|
||||
|
||||
### 用户体验优化
|
||||
- ✅ 去掉用户侧"已送达"和管理员侧"已回复"回执,减少打扰和额外 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 的风险
|
||||
- ✅ 内置正则安全检查机制,拒绝复杂表达式(反向引用、环视等)
|
||||
|
||||
### 新增功能
|
||||
- ✅ **双向消息删除**:用户和管理员都可以通过 `/del` 命令删除消息(详见下方说明)
|
||||
- ✅ **引用消息支持**:用户使用 `>`、`》` 或 `>` 开头的文本会被渲染为引用块
|
||||
- ✅ **媒体欢迎语**:支持图片/视频/GIF 作为欢迎语,可设置 caption
|
||||
- ✅ **话题自愈机制**:当 topic 失效时自动重建并迁移数据
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 消息转发
|
||||
- 私聊用户消息转发到管理员群论坛 topic
|
||||
- 每个用户自动创建独立 topic,并推送用户身份卡片
|
||||
- 管理员在对应 topic 内回复,即可把消息复制回用户私聊
|
||||
- 支持消息引用关系保持(用户和管理员均可引用回复)
|
||||
|
||||
### 验证系统
|
||||
- 支持 Cloudflare Turnstile 或 Google reCAPTCHA 人机验证
|
||||
- 支持二次问答验证
|
||||
- 支持验证码模式动态切换(Turnstile ↔ reCAPTCHA ↔ 关闭)
|
||||
- 验证状态持久化,无需重复验证
|
||||
|
||||
### 消息类型控制
|
||||
- 支持文本、媒体、链接、转发、频道转发、音频、贴纸/GIF 等类型过滤
|
||||
- 管理员可单独控制每种消息类型的转发开关
|
||||
- 管理员不受过滤限制,所有消息类型均可发送
|
||||
|
||||
### 智能回复
|
||||
- 支持关键词自动回复(格式:`关键词===回复内容`)
|
||||
- 支持屏蔽词计数封禁(可配置阈值)
|
||||
- 支持忙碌模式自动回复
|
||||
|
||||
### 用户管理
|
||||
- 支持黑名单 topic、用户解封、备注、资料卡置顶
|
||||
- 支持消息编辑记录同步
|
||||
- 支持消息备份到指定群或频道
|
||||
- 内置 Telegram 管理面板,主管理员通过 `/start` 打开
|
||||
|
||||
### 双向消息删除
|
||||
- **用户侧**:引用自己发送的消息,发送 `/del` 命令,可以删除该消息并通知管理员。
|
||||
- **管理员侧**:
|
||||
- **单条删除**:在 topic 中引用消息,发送 `/del` 命令,可以同时删除用户侧和管理员侧的消息。
|
||||
- **批量删除**:直接发送 `/del N`(如 `/del 3`),可删除当前话题内最近的 N 条消息(仅限管理员使用,单次最多 100 条)。
|
||||
- **全量清空**:主管理员可使用 `/del all` 清空当前话题的所有历史消息。
|
||||
- **权限控制**:用户只能删除自己发送的消息,无法删除管理员回复的消息;批量删除功能仅对管理员开放。
|
||||
|
||||
## 运行环境
|
||||
|
||||
- Cloudflare Workers
|
||||
- Cloudflare D1 数据库绑定
|
||||
- Telegram Bot Token
|
||||
- 启用话题的 Telegram 管理群
|
||||
- 可选:Cloudflare Turnstile 或 Google reCAPTCHA 密钥
|
||||
|
||||
## 必需环境变量
|
||||
|
||||
| 名称 | 说明 |
|
||||
| --- | --- |
|
||||
| `BOT_TOKEN` | Telegram Bot Token |
|
||||
| `ADMIN_IDS` | 主管理员 Telegram user id,多个用英文或中文逗号分隔 |
|
||||
| `ADMIN_GROUP_ID` | 管理员论坛群 ID,通常是 `-100...` |
|
||||
| `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 绑定
|
||||
|
||||
Worker 需要绑定一个 D1 数据库,绑定名必须是:
|
||||
|
||||
```text
|
||||
TG_BOT_DB
|
||||
```
|
||||
|
||||
脚本会自动创建和维护以下表:
|
||||
|
||||
- `config`:配置项。
|
||||
- `users`:用户状态、封禁状态、topic 映射、用户资料。
|
||||
- `messages`:用户消息与管理员群 topic 消息的映射。
|
||||
|
||||
## 默认配置
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `welcome_msg` | `欢迎 {name}!使用前请先完成验证。` | 欢迎语,支持 `{name}` 或 `{user}`,也可配置为媒体(图片/视频/GIF) |
|
||||
| `enable_verify` | `true` | 是否启用网页人机验证 |
|
||||
| `enable_qa_verify` | `true` | 是否启用问答验证 |
|
||||
| `captcha_mode` | `turnstile` | 验证模式:`turnstile` 或 `recaptcha` |
|
||||
| `verif_q` | `1+1=?\n提示:答案在简介中。` | 问题验证题目 |
|
||||
| `verif_a` | `3` | 问题验证答案 |
|
||||
| `block_threshold` | `5` | 命中屏蔽词多少次后封禁 |
|
||||
| `busy_mode` | `false` | 是否启用非营业自动回复 |
|
||||
| `enable_admin_receipt` | `false` | 管理员回执开关,当前版本默认不发送回执 |
|
||||
|
||||
**注意**:`welcome_msg` 支持两种格式:
|
||||
1. 纯文本:直接输入文字,支持 `{name}` 占位符
|
||||
2. 媒体配置:发送图片/视频/GIF 给机器人,会自动转换为 JSON 配置
|
||||
|
||||
## 部署流程
|
||||
|
||||
1. 在 Telegram 中创建 Bot,拿到 `BOT_TOKEN`。
|
||||
2. 创建一个启用 Topics 的 Telegram 群,把 Bot 拉入群。
|
||||
3. 给 Bot 管理员权限,至少需要发消息、管理话题、置顶消息等能力。
|
||||
4. 在 Cloudflare 创建 D1 数据库并绑定为 `TG_BOT_DB`。
|
||||
5. 创建 Worker,把 [tg-bot.js](tg-bot.js) 作为 Worker 代码。
|
||||
6. 配置环境变量 `BOT_TOKEN`、`ADMIN_IDS`、`ADMIN_GROUP_ID`、`WORKER_URL`。
|
||||
7. 如果使用网页验证,配置 Turnstile 或 reCAPTCHA 的 site key 与 secret key。
|
||||
8. 设置 Telegram Webhook。
|
||||
|
||||
不使用 secret token 时:
|
||||
|
||||
```bash
|
||||
curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=<WORKER_URL>"
|
||||
```
|
||||
|
||||
推荐配置 `TELEGRAM_WEBHOOK_SECRET`,并设置 Webhook secret token:
|
||||
|
||||
```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`,打开控制面板。
|
||||
11. **首次启动建议**:
|
||||
- 在面板中配置欢迎语(可选媒体)
|
||||
- 设置验证问题和答案
|
||||
- 添加协管管理员(如有需要)
|
||||
- 配置自动回复规则
|
||||
- 设置屏蔽词列表
|
||||
|
||||
## Webhook 路由
|
||||
|
||||
| 路径 | 方法 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `/` | `GET` | 健康检查 |
|
||||
| `/verify` | `GET` | Telegram Web App 验证页面 |
|
||||
| `/submit_token` | `POST` | 验证 token 提交 |
|
||||
| `/` | `POST` | Telegram Webhook 更新入口 |
|
||||
|
||||
## 管理员面板
|
||||
|
||||
主管理员私聊 Bot 发送 `/start` 后会打开配置面板,包含以下模块:
|
||||
|
||||
### 📝 基础配置
|
||||
- 欢迎语(支持文字或图片/视频/GIF)
|
||||
- 验证问题与答案
|
||||
- 验证码模式切换(Cloudflare / Google / 关闭)
|
||||
- 问题验证开关
|
||||
|
||||
### 🤖 自动回复
|
||||
- 添加或删除关键词自动回复
|
||||
- 格式:`关键词===回复内容`
|
||||
- 示例:`价格===请联系人工客服`
|
||||
|
||||
### 🚫 屏蔽词
|
||||
- 添加或删除正则关键词
|
||||
- 命中后累计计数,达到阈值自动封禁
|
||||
- 建议使用简单正则,避免复杂表达式
|
||||
|
||||
### 🛠 过滤设置
|
||||
- 控制转发、媒体、语音、贴纸、链接、频道、文本等类型
|
||||
- 可单独开启/关闭每种消息类型
|
||||
- 管理员不受过滤限制
|
||||
|
||||
### 👮 协管管理
|
||||
- 维护额外授权管理员列表
|
||||
- 支持批量输入:`123456,789012`
|
||||
- 协管拥有回复权限,但无法访问管理面板
|
||||
|
||||
### 💾 备份/通知
|
||||
- 设置备份群 ID,所有消息会异步归档
|
||||
- 重置黑名单 topic
|
||||
|
||||
### 🌙 营业状态
|
||||
- 切换忙碌模式
|
||||
- 修改非营业时间自动回复语
|
||||
|
||||
## 用户流程
|
||||
|
||||
1. 用户私聊 Bot 发送 `/start`。
|
||||
2. Bot 发送欢迎语(可能是文字或媒体)。
|
||||
3. 如果启用网页验证,用户点击按钮完成 Turnstile 或 reCAPTCHA。
|
||||
4. 如果启用问答验证,用户继续回答问题。
|
||||
5. 验证通过后,用户消息会转发到管理员群中的个人 topic。
|
||||
6. 管理员在 topic 中回复,Bot 会把回复发送给该用户。
|
||||
7. 用户或管理员编辑消息时,Bot 会同步对应的编辑提示。
|
||||
8. **引用消息**:用户可以使用 `>`、`》` 或 `>` 开头来引用之前的内容。
|
||||
9. **删除消息**:
|
||||
- **用户侧**:引用自己发送的消息,发送 `/del` 命令,可以删除该消息并通知管理员。
|
||||
- **管理员侧**:
|
||||
- 引用消息发送 `/del`:删除指定的单条双向记录。
|
||||
- 直接发送 `/del N`:删除当前话题内最近的 N 条消息(例如 `/del 5`,单次上限 100 条)。
|
||||
- 主管理员发送 `/del all`:清空当前话题的所有历史记录。
|
||||
- **注意**:用户只能删除自己发送的消息,无法删除管理员回复的消息;批量删除功能仅对管理员开放。
|
||||
|
||||
### 特殊语法
|
||||
- **引用块**:以 `>`、`》` 或 `>` 开头的文本会被渲染为 HTML 引用块
|
||||
- **媒体欢迎语**:管理员可以在面板中上传图片/视频/GIF 作为欢迎语
|
||||
|
||||
### ⚠️ 常见问题:用户屏蔽 Bot
|
||||
|
||||
如果管理员回复时收到错误提示:**"⚠️ 用户已屏蔽 Bot"**,说明该用户已在 Telegram 中屏蔽了机器人。
|
||||
|
||||
**症状:**
|
||||
- 管理员可以正常接收用户消息
|
||||
- 管理员回复时显示 "用户已屏蔽 Bot" 错误
|
||||
- 用户收不到管理员的回复
|
||||
|
||||
**原因:**
|
||||
用户在 Telegram 中点击了"屏蔽机器人"(Block Bot),导致 Bot 无法再主动发消息给用户。即使用户之前发送过消息,屏蔽后 Bot 也无法回复。
|
||||
|
||||
**自动标记功能:**
|
||||
系统会自动检测并标记被屏蔽的用户:
|
||||
- 当检测到用户屏蔽 Bot 时,会自动在用户卡片上显示屏蔽状态和时间
|
||||
- 用户卡片会显示 `⛔ 用户屏蔽Bot: 是 (时间)`
|
||||
- 当用户重新发送消息或管理员解封时,自动清除屏蔽标记
|
||||
|
||||
**解决方案:**
|
||||
需要通过其他方式联系该用户,让其按以下步骤解除屏蔽:
|
||||
1. 打开与机器人的聊天窗口
|
||||
2. 点击右上角菜单(三个点或机器人名称)
|
||||
3. 选择"解除屏蔽"或"Unblock bot"
|
||||
4. 重新发送 `/start` 命令激活机器人
|
||||
|
||||
**预防措施:**
|
||||
- 在欢迎语中提醒用户不要屏蔽机器人
|
||||
- 定期检查被屏蔽的用户列表(查看用户卡片上的屏蔽状态)
|
||||
- 对于重要用户,建议通过其他渠道保持联系
|
||||
|
||||
## 安全建议
|
||||
|
||||
### 🔐 认证与授权
|
||||
- 推荐配置 `TELEGRAM_WEBHOOK_SECRET`;未配置时会保持兼容模式,不强制校验 Telegram Webhook secret。
|
||||
- `ADMIN_IDS` 和协管列表会按逗号拆分后精确匹配,避免子串误判。
|
||||
- 请妥善保护 `BOT_TOKEN`、验证码 secret、D1 数据库和 Cloudflare 账号权限。
|
||||
|
||||
### 🛡️ 防护措施
|
||||
- 管理员群必须开启 Topics,否则自动创建用户话题会失败。
|
||||
- `WORKER_URL` 要使用 HTTPS 公开地址,否则 Telegram Web App 验证页面无法正常工作。
|
||||
- Turnstile 和 reCAPTCHA 至少配置一种;如果关闭网页验证,可只使用问答验证。
|
||||
- 屏蔽词和自动回复支持正则,但建议保持简单,避免复杂表达式造成匹配性能问题或被拒绝。
|
||||
|
||||
### ⚠️ 正则安全
|
||||
- 系统内置正则安全检查,会拒绝以下模式:
|
||||
- 嵌套量词(如 `(a+)+`)
|
||||
- 反向引用(如 `\1`)
|
||||
- 环视断言(如 `(?<=...)`、`(?<!...)`)
|
||||
- 超长模式(>256 字符)
|
||||
- 这可以有效防止 ReDoS(正则表达式拒绝服务攻击)
|
||||
|
||||
### 📊 性能优化
|
||||
- 系统使用内存缓存减少 D1 数据库读写(缓存 TTL 60 秒)
|
||||
- 用户 topic 创建有锁机制,防止并发冲突
|
||||
- 警告消息有 3 秒冷却时间,防止刷量攻击
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 数据库结构
|
||||
- **config**:存储配置项(键值对)
|
||||
- **users**:用户状态、封禁状态、topic 映射、用户资料(JSON)
|
||||
- **messages**:用户消息与管理员群 topic 消息的双向映射
|
||||
|
||||
### 缓存机制
|
||||
- 配置缓存:TTL 60 秒,减少 D1 查询
|
||||
- 管理员集合缓存:TTL 60 秒,加速权限检查
|
||||
- 用户锁机制:防止同一用户并发创建多个 topic
|
||||
|
||||
### API 调用策略
|
||||
- 使用 `copyMessage` 优先,保持消息引用关系
|
||||
- 降级使用 `forwardMessage`(不支持引用时)
|
||||
- 错误自愈:topic 失效时自动重建并重试
|
||||
|
||||
### 版本历史
|
||||
- **v3.68**(当前版本):无回执模式 + 安全加固 + 双向删除
|
||||
- 基于 [huliyoudiangou/TG_Chat_Bot-D1](https://github.com/huliyoudiangou/TG_Chat_Bot-D1) 二次开发
|
||||
- 上游 fork 自 [moistrr/TGbot-D1](https://github.com/moistrr/TGbot-D1)
|
||||
|
||||
## 许可证与上游
|
||||
|
||||
原项目 [huliyoudiangou/TG_Chat_Bot-D1](https://github.com/huliyoudiangou/TG_Chat_Bot-D1) 采用 MIT License。本仓库中的修改版沿用原项目开源精神,仅作个人维护与自用优化;如需完整部署教程、上游更新和问题讨论,请优先参考原作者仓库。
|
||||
@@ -0,0 +1,415 @@
|
||||
const DEFAULT_MAX_FILE_MB = 500;
|
||||
const DEFAULT_MAX_UPLOAD_QUEUE_IMAGES = 500;
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/init") {
|
||||
if (url.searchParams.get("secret") !== env.TELEGRAM_WEBHOOK_SECRET) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
await initDb(env);
|
||||
return new Response("DB initialized");
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
return new Response("Lsky Telegram Worker OK - code-block-max500mb-queue500-2026-05-27");
|
||||
}
|
||||
|
||||
if (url.pathname !== "/webhook" || request.method !== "POST") {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
|
||||
if (secret !== env.TELEGRAM_WEBHOOK_SECRET) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const update = await request.json();
|
||||
ctx.waitUntil(handleUpdate(update, env));
|
||||
return new Response("OK");
|
||||
},
|
||||
};
|
||||
|
||||
async function initDb(env) {
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS album_groups (
|
||||
group_key TEXT PRIMARY KEY,
|
||||
chat_id TEXT NOT NULL,
|
||||
reply_to INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`).run();
|
||||
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS album_messages (
|
||||
group_key TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (group_key, message_id)
|
||||
)
|
||||
`).run();
|
||||
}
|
||||
|
||||
async function handleUpdate(update, env) {
|
||||
const message = update.message;
|
||||
if (!message) return;
|
||||
|
||||
const chatId = message.chat?.id;
|
||||
const userId = message.from?.id;
|
||||
const messageId = message.message_id;
|
||||
|
||||
if (!isAllowedUser(userId, env.ALLOWED_TELEGRAM_USER_IDS)) {
|
||||
await sendTelegramText(env, chatId, "未授权。", messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.text?.startsWith("/start")) {
|
||||
await sendTelegramText(env, chatId, "发图片或图片文件给我,我会上传到 Lsky Pro。", messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.media_group_id) {
|
||||
const groupKey = `${chatId}:${message.media_group_id}`;
|
||||
const now = Date.now();
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT OR IGNORE INTO album_groups (group_key, chat_id, reply_to, status, updated_at)
|
||||
VALUES (?, ?, ?, 'collecting', ?)
|
||||
`).bind(groupKey, String(chatId), messageId, now).run();
|
||||
|
||||
await env.DB.prepare(`
|
||||
UPDATE album_groups
|
||||
SET updated_at = ?
|
||||
WHERE group_key = ? AND status = 'collecting'
|
||||
`).bind(now, groupKey).run();
|
||||
|
||||
const maxQueueImages = getMaxUploadQueueImages(env);
|
||||
const queued = await env.DB.prepare(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM album_messages
|
||||
WHERE group_key = ?
|
||||
`).bind(groupKey).first();
|
||||
|
||||
if (Number(queued?.count || 0) >= maxQueueImages) {
|
||||
await sendTelegramText(env, chatId, `上传队列已满,最多 ${maxQueueImages} 张。`, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT OR IGNORE INTO album_messages (group_key, message_id, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`).bind(groupKey, messageId, JSON.stringify(message)).run();
|
||||
|
||||
await sleep((Number(env.ALBUM_WAIT_SECONDS || "5") + 1) * 1000);
|
||||
await processAlbum(env, groupKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await uploadTelegramMessageToLsky(env, message);
|
||||
await sendTelegramText(env, chatId, toTelegramCodeBlock(toMarkdownImage(image.url, image.filename)), messageId, "HTML");
|
||||
} catch (err) {
|
||||
await sendTelegramText(env, chatId, `上传失败:${err.message}`, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAlbum(env, groupKey) {
|
||||
const waitMs = Number(env.ALBUM_WAIT_SECONDS || "5") * 1000;
|
||||
const group = await env.DB.prepare(`
|
||||
SELECT * FROM album_groups WHERE group_key = ?
|
||||
`).bind(groupKey).first();
|
||||
|
||||
if (!group || group.status !== "collecting") return;
|
||||
if (Date.now() - group.updated_at < waitMs) return;
|
||||
|
||||
const lock = await env.DB.prepare(`
|
||||
UPDATE album_groups
|
||||
SET status = 'processing'
|
||||
WHERE group_key = ? AND status = 'collecting'
|
||||
`).bind(groupKey).run();
|
||||
|
||||
if (!lock.meta || lock.meta.changes !== 1) return;
|
||||
|
||||
const rows = await env.DB.prepare(`
|
||||
SELECT message_id, payload
|
||||
FROM album_messages
|
||||
WHERE group_key = ?
|
||||
ORDER BY message_id ASC
|
||||
`).bind(groupKey).all();
|
||||
|
||||
const messages = rows.results.map((row) => JSON.parse(row.payload));
|
||||
const images = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
try {
|
||||
images.push(await uploadTelegramMessageToLsky(env, messages[i]));
|
||||
} catch (err) {
|
||||
errors.push(`${i + 1}. ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let text = images.length
|
||||
? "上传完成:\n\n" + images.map((image, i) => `${i + 1}.\n${toTelegramCodeBlock(toMarkdownImage(image.url, image.filename))}`).join("\n")
|
||||
: "这一组图片全部上传失败。";
|
||||
|
||||
if (errors.length) {
|
||||
text += "\n\n失败:\n" + errors.join("\n");
|
||||
}
|
||||
|
||||
await sendTelegramLongText(env, group.chat_id, text, group.reply_to, images.length ? "HTML" : undefined);
|
||||
|
||||
await env.DB.prepare(`DELETE FROM album_messages WHERE group_key = ?`).bind(groupKey).run();
|
||||
await env.DB.prepare(`DELETE FROM album_groups WHERE group_key = ?`).bind(groupKey).run();
|
||||
}
|
||||
|
||||
function isAllowedUser(userId, allowList) {
|
||||
return String(allowList || "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.includes(String(userId));
|
||||
}
|
||||
|
||||
function toMarkdownImage(url, filename = "image") {
|
||||
return ``;
|
||||
}
|
||||
|
||||
function escapeMarkdownAlt(value) {
|
||||
return String(value || "image")
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll("]", "\\]");
|
||||
}
|
||||
|
||||
function toTelegramCodeBlock(text) {
|
||||
return `<pre>${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function getMaxFileMb(env) {
|
||||
const value = Number(env.MAX_FILE_MB || DEFAULT_MAX_FILE_MB);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_FILE_MB;
|
||||
}
|
||||
|
||||
function formatFileMb(value) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function getMaxUploadQueueImages(env) {
|
||||
const value = Number(env.MAX_UPLOAD_QUEUE_IMAGES || DEFAULT_MAX_UPLOAD_QUEUE_IMAGES);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_MAX_UPLOAD_QUEUE_IMAGES;
|
||||
}
|
||||
|
||||
async function uploadTelegramMessageToLsky(env, message) {
|
||||
const image = extractImage(message, env);
|
||||
|
||||
const fileInfo = await telegramApi(env, "getFile", {
|
||||
file_id: image.fileId,
|
||||
});
|
||||
|
||||
const fileUrl = `https://api.telegram.org/file/bot${env.TELEGRAM_BOT_TOKEN}/${fileInfo.file_path}`;
|
||||
const fileResp = await fetch(fileUrl);
|
||||
|
||||
if (!fileResp.ok) {
|
||||
throw new Error(`下载 Telegram 文件失败: HTTP ${fileResp.status}`);
|
||||
}
|
||||
|
||||
const bytes = await fileResp.arrayBuffer();
|
||||
const filename = image.filename || guessFilename(fileInfo.file_path, image.mimeType);
|
||||
const url = await uploadToLsky(env, bytes, filename, image.mimeType);
|
||||
|
||||
return { url, filename };
|
||||
}
|
||||
|
||||
function extractImage(message, env) {
|
||||
const maxFileMb = getMaxFileMb(env);
|
||||
const maxBytes = maxFileMb * 1024 * 1024;
|
||||
|
||||
if (message.photo?.length) {
|
||||
const photo = message.photo[message.photo.length - 1];
|
||||
|
||||
if (photo.file_size && photo.file_size > maxBytes) {
|
||||
throw new Error(`图片超过 ${formatFileMb(maxFileMb)} MB`);
|
||||
}
|
||||
|
||||
return {
|
||||
fileId: photo.file_id,
|
||||
filename: `${photo.file_unique_id || photo.file_id}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
};
|
||||
}
|
||||
|
||||
if (message.document) {
|
||||
const doc = message.document;
|
||||
const mimeType = doc.mime_type || "application/octet-stream";
|
||||
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
throw new Error("请发送图片,或以文件形式发送图片");
|
||||
}
|
||||
|
||||
if (doc.file_size && doc.file_size > maxBytes) {
|
||||
throw new Error(`文件超过 ${formatFileMb(maxFileMb)} MB`);
|
||||
}
|
||||
|
||||
return {
|
||||
fileId: doc.file_id,
|
||||
filename: doc.file_name || `${doc.file_unique_id || doc.file_id}`,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("请发送图片,或以文件形式发送图片");
|
||||
}
|
||||
|
||||
async function uploadToLsky(env, bytes, filename, mimeType) {
|
||||
const form = new FormData();
|
||||
form.append("file", new File([bytes], filename, { type: mimeType }));
|
||||
form.append("permission", env.LSKY_PERMISSION || "1");
|
||||
|
||||
if (env.LSKY_STRATEGY_ID) {
|
||||
form.append("strategy_id", env.LSKY_STRATEGY_ID);
|
||||
}
|
||||
|
||||
const resp = await fetch(env.LSKY_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.LSKY_TOKEN}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const raw = await resp.text();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Lsky 返回非 JSON: HTTP ${resp.status} ${raw.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!resp.ok || result.status !== true) {
|
||||
throw new Error(`Lsky 上传失败: HTTP ${resp.status} ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
const data = result.data || {};
|
||||
const links = data.links || {};
|
||||
const url = links.url || data.url || data.pathname || data.path;
|
||||
|
||||
if (!url) {
|
||||
throw new Error(`Lsky 返回中找不到图片链接`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function telegramApi(env, method, payload) {
|
||||
const resp = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload || {}),
|
||||
});
|
||||
|
||||
const raw = await resp.text();
|
||||
const result = JSON.parse(raw);
|
||||
|
||||
if (!resp.ok || result.ok !== true) {
|
||||
throw new Error(`Telegram API ${method} 失败: ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async function sendTelegramText(env, chatId, text, replyToMessageId, parseMode) {
|
||||
if (!chatId) return;
|
||||
|
||||
const payload = {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
|
||||
if (parseMode) {
|
||||
payload.parse_mode = parseMode;
|
||||
}
|
||||
|
||||
if (replyToMessageId) {
|
||||
payload.reply_to_message_id = replyToMessageId;
|
||||
}
|
||||
|
||||
await telegramApi(env, "sendMessage", payload);
|
||||
}
|
||||
|
||||
async function sendTelegramLongText(env, chatId, text, replyToMessageId, parseMode) {
|
||||
const limit = 3900;
|
||||
const chunks = splitTelegramText(text, limit);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
await sendTelegramText(
|
||||
env,
|
||||
chatId,
|
||||
chunks[i],
|
||||
i === 0 ? replyToMessageId : undefined,
|
||||
parseMode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function splitTelegramText(text, limit) {
|
||||
const chunks = [];
|
||||
let current = "";
|
||||
|
||||
for (const line of String(text || "").split("\n")) {
|
||||
const next = current ? `${current}\n${line}` : line;
|
||||
|
||||
if (next.length <= limit) {
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
|
||||
if (line.length <= limit) {
|
||||
current = line;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < line.length; i += limit) {
|
||||
chunks.push(line.slice(i, i + limit));
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
}
|
||||
|
||||
return chunks.length ? chunks : [""];
|
||||
}
|
||||
|
||||
function guessFilename(filePath, mimeType) {
|
||||
const base = filePath.split("/").pop() || "image";
|
||||
|
||||
if (base.includes(".")) return base;
|
||||
if (mimeType === "image/png") return `${base}.png`;
|
||||
if (mimeType === "image/webp") return `${base}.webp`;
|
||||
if (mimeType === "image/gif") return `${base}.gif`;
|
||||
|
||||
return `${base}.jpg`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
+1617
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user