Telegram 投稿机器人(跑在 Cloudflare Workers 上)
开源版本说明:本仓库中的 Cloudflare 账号 ID、D1 ID、群/管理员 ID、邮箱、域名和 bot 用户名均已替换为占位符。
部署前先cp wrangler.example.toml wrangler.toml,填入自己的值;wrangler.toml已被 gitignore,真实配置不要提交。
真实部署的账号/群/回滚信息保存在本地.backup/(同样 gitignored)。迁移记录(2026-10-29):生产已从旧 Cloudflare 账号迁到新账号;旧 Worker 已删除,旧 D1 保留作归档。
下文提到的<OLD_ACCOUNT_ID>/submission-bot.<old-subdomain>.workers.dev均为迁移前示例。交接、部署步骤、运维规则和排查手册见 docs/HANDOVER.md。
别人私聊机器人 → 机器人一步一步问内容(也可以直接发图)→ 用户确认 → 机器人把投稿发到你的群组。
整套东西跑在 Cloudflare 上:Workers 处理消息 + D1 存数据,不需要服务器、不需要域名,免费额度够日常用。
功能
- 网页投稿(
/submit):不用 Telegram 也能投——访客打开一个网页表单填内容、传图,提交后走同一套审核与发布流程。给国内打不开 Telegram 的人用,见「网页投稿」 - 评论的评论:群里回复别人的评论同样算评论(
↩️ 张三 回复 李四:…),作者照样收到通知,点一下就回到那一条下面;作者/管理员的回复也能被继续回复 - 发布话题随时可改:进目标群的话题里发一次
/topic就发布到那个话题(/topic off回到主聊天);话题被关闭时先尝试自动重开,重开不了也只把这一条临时发到主聊天,绝不会把后续投稿永久丢进 General - 私聊投稿向导:分类 / 标题 / 正文 / 配图 / 署名 / 联系方式,每一步都能「跳过、上一步、取消」
- 文字格式保留:用户写的加粗、链接、代码块会原样带到群里
- 上传 ≠ 发布:图片/视频只是先收着,流程走完会给出一份完整预览(媒体真实回显 + 逐项摘要 + 发布后长什么样的最终文案),只有用户点「✅ 确认发布」才真正发到群里
- 确认页可以改、可以退、可以重来:
✏️ 编辑内容(跳到任意一项单独改,改完自动回确认页)、⬅️ 返回上一步(逐步往回走)、🗑 管理媒体(删除单个 / 调整顺序 / 重新选择全部)、❌ 取消 - 绝不多发:幂等守卫 + 投稿级发布租约,连点确认、Telegram 超时重投、程序重启都不会把同一条投稿发两遍;用户和管理员的发布路径共用同一把租约,谁也插不进谁
- 失败可原地重试:发布失败时给「🔁 重试发布 / ✏️ 返回修改 / ❌ 取消」,重试按批次续传——已经发出去的图片/视频和已经成功的目标都不会重发
- 部分失败也可恢复:多目标里有一个失败,用户直接点「重试发布」就能补上失败的那个;不会出现”以为全成功其实漏发”
- 内容不会走样:失败后改了正文再重试,数据库内容会同步更新,撤回重发不会发出旧稿
- 有长度兜底:正文/媒体 caption 超出 Telegram 上限时截断发布,不会让一条投稿永久发不出去
- 多草稿:手上没写完的投稿可以「🆕 新建投稿」挂起,之后用
/drafts或菜单里的「📂 我的草稿」接着写;草稿连已上传的附件一起保存(默认最多 5 份,MAX_DRAFTS) - 管理员可改媒体:
✏️ 编辑分「📝 改文案」和「🎬 改媒体」。媒体变少或不变时就地替换(editMessageMedia,点赞/评论/置顶和消息链接全部保留),多出来的媒体消息删掉、正文消息不动;只有”变多”才回退成删旧消息 + 重发(Telegram 不允许改变一条消息的媒体个数) - 人人可用的「💬 评论」入口:每条已发布投稿的第一个按钮就是「💬 评论」——它是URL 深链,任何人点一下都会跳到与机器人的私聊,写下内容后由机器人发到投稿下面。完全不依赖”机器人能不能收到群消息”,也不受隐私模式影响
- 普通用户不会再被”只有管理员能用”劝退:群里的内联按钮对所有人可见,以前普通用户点「💬 回复投稿者」只会看到一句拒绝;现在会明确告诉他点「💬 评论」或直接回复该投稿
- Telegram 官方评论区:任何人在群里回复已发布的投稿都算公开评论(管理员也一样,机器人不会偷偷删掉或转成私聊),作者会收到私聊通知;回复按钮直接带评论内容(不会出现”回复 1 是哪一个”的歧义),作者可以匿名回复指定的那一条
- 评论收不到?用
/diag定位:它按会话类型分别判断——频道没关联讨论组时直接指出”没人能评论”(这才是”只有管理员能评论”的真正原因),普通群则说明”回复投稿默认就能收到”,并给你一个 🧪 测一测 按钮做端到端验证 - 作者有评论收件箱:通知里的「📋 全部评论」能翻看这条投稿的所有评论(分页),每条都能单独回复
/diag评论区自检:直接告诉你”评论为什么收不到”——机器人是否在关联讨论组里、是不是管理员,并给出可执行的修复办法(见下方「评论收不到?」)- 按钮化管理面板:
/admin不再是文字墙,而是一排按钮(统计 / 待审 / 评论 / 黑名单 / 设置 / 发布目标 / 审计 / 管理员 / 自检);「📥 待审」是交互式队列,点一条看完整内容就能直接通过/拒绝/编辑,不用再去审核群里找 - 管理员能回复公开评论:
/comments 投稿ID每条评论带一个「↩️ 评论内容」按钮,点它输入内容就能以「管理员回复」发到评论区 - 评论通知会聚合:同一投稿在
COMMENT_NOTIFY_COOLDOWN_SECONDS窗口内的多条评论合并进同一条私聊消息(编辑更新,最多列COMMENT_NOTIFY_MAX条),爆款投稿也不会把作者刷屏 - 作者回复带署名:投稿时选了「显示我的昵称 / 自定义署名」,回复评论就照这个显示;选了匿名则保持「作者回复:」
- 多模板也能新建投稿:配了多个模板时,菜单里依然有「🆕 新建投稿」,点了先选类型;选类型时反悔点「↩️ 回到刚才那份」会原样恢复刚被挂起的草稿
/approve_all不再限制 20 条:分批循环处理到没有待审核为止(单次硬上限 200,剩余会提示)/comments支持分页:/comments 投稿ID [偏移],消息底部有「⬅️ 更早 / ➡️ 更新」按钮,几千条评论也能翻
代码质量工具链
npm run lint # ESLint(flat config)
npm run format # Prettier 写入
npm run format:check # 只检查格式
npm run check # lint + format:check + test 一条龙
规则保持很轻:只用 ESLint 官方 recommended,加上”不许 var、优先 const、未使用变量报错”。
lint 确实抓到过真 bug——例如 sendOrRecover 的兜底分支引用了不存在的 from,发送失败时会二次抛错,
这个路径在 97 个测试里都没被覆盖到。
- 发布中不会永久卡死:Worker 中途崩溃后,租约过期即自动降级为「发布失败」,用户和管理员都能重新发起
- 配图:最多 10 张(相册形式发布),不做图片中转,直接用 Telegram 自己的 file_id,零存储成本
- 署名方式:匿名 / 显示昵称 / 自定义署名(选「自定义」才追问署名,流程自动分支)
- 两种发布模式:直接发布,或「先发独立审核群 → 管理员点通过 → 才公开」
- 群内管理按钮:回复投稿者、删除投稿、拉黑用户;管理员还会私聊收到回复输入框
- 双向匿名对话:管理员回复后,投稿者可以直接接着聊,来回多轮,双方互不见身份
- 审核体验:一键预设驳回理由、审核时直接改文案再发、
/approve_all批量通过、审核消息里带该用户历史投稿数 - 防滥用:敏感词、禁链接、重复投稿检测、新用户冷却、投稿前表情验证
- 多种内容:图片、视频、文件、语音、位置、投票;图片/视频超过 10 个自动分批发
- 已发布投稿可操作:编辑、置顶、撤下(可一键重新发布)
- 多发布目标:Owner 把机器人拉进多个群组/频道后,可分别
/bind授权;D1 保存目标,投稿会发布到所有启用目标 - 发布目标面板:Owner 私聊
/targets,可以逐个启用/停用/删除目标;普通管理员不能授权新目标 - 频道支持:频道通过
channel_post处理/bind,并要求机器人 Owner 仍是频道管理员 - Topic 安全回退:绑定的话题被关闭时自动回退到目标聊天主区,不再因为
TOPIC_CLOSED让整条投稿失败 /my查看自己的投稿记录:最近 10 条的状态(待审/已发布/未通过/发布失败/已撤下)和链接- 草稿会过期:未完成的投稿默认保留 7 天(
SESSION_TTL_SECONDS),过期自动清理,不会突然冒出一份几个月前的旧草稿 - 配置随时改:群里发
/bind授权发布目标、/targets管理目标、/review on|off切换审核模式,改完立即生效,不用重新部署 - 自动多标签:按分类和内容类型生成
#投稿 #图片分享 #图片这样的标签 + 可点击的内容链接回执 - 防刷:每个用户单位时间内投稿次数可限制;黑名单
/ban - 中断可续:用户中途离开,回来点「继续上次投稿」接着填
- 数据都在 D1:
/stats看统计,随时能导出 - 完整管理员体系:owner / admin / moderator 三级角色,管理员可运行时添加、移除、调整角色,环境变量中的 owner 受保护
- 管理员后台指令:
/admins、/pending、/user、/audit,快速查看管理员、审核队列、用户档案和操作审计 - 用户管理:
/mute、/unmute、/note,可临时禁止投稿、保存管理员备注 - 管理审计:管理员变更、封禁/解封、静音/备注、发布失败等关键操作写入 D1,便于追踪责任
它是怎么工作的
用户在 Telegram 私聊机器人
│
▼
Telegram 服务器 ──HTTPS POST──▶ Cloudflare Worker(本项目)
│ 读写
▼
D1 数据库(会话 / 投稿 / 黑名单)
│ 调 Bot API 发消息
▼
你的群组(或审核群)
几个刻意的设计选择:
- Webhook 而不是轮询:Worker 没有常驻进程,所以由 Telegram 主动推送到
/webhook/<密钥>。 - 先回 200 再处理:真正的活丢给
waitUntil在后台做,避免 Telegram 等超时后重复投递;同时用update_id去重。 - 图片不落地:收到的是 Telegram 的
file_id,发布时直接把它交给群,不下载、不转存,省掉 R2 和带宽。 - 相册怎么收:Webhook 下没法用定时器攒相册,所以机器人对每张图回一个 ✅ 表情(不刷屏),用户发完点「✅ 就这些了」再进入下一步。
- 附件独立成表(
session_media,append-only):相册是多条 update 并发投递的,如果把附件塞进sessions.data的数组里做「读-改-写」,后到的写入会覆盖先到的,图片会静默丢失。一行一个附件就没有这个问题。 - 确认是一次显式动作:上传/预览阶段绝不碰目标群。确认页会先把媒体真实发回私聊让用户核对,再展示「发布后的样子」,用户点「✅ 确认发布」才发。
- 幂等靠数据库,不靠内存:每条会话有一个
nonce,组成幂等键w:<用户ID>:<nonce>写进submission_guards;「确认」和「通过」都会先抢锁(INSERT OR IGNORE+ 租约 UPDATE),抢不到的那次只回复「正在发布中」。发布时还会按目标跳过已经记录过消息的地方,所以部分失败后重试不会重复发。
投稿状态机
一次投稿的状态都放在 sessions.data.__state.phase,没有为了它做大重构:
IDLE ──/start──▶ COLLECTING ──最后一步答完──▶ PREVIEW
▲ │
│ 点「确认发布」
「编辑内容 / 返回上一步」 ▼
└────────────────────── PUBLISHING
│
┌─────────────────────────┴──────────┐
成功 ▼ 失败 ▼
PUBLISHED FAILED ──「🔁 重试发布」──▶ PUBLISHING
(清空会话与附件)
COLLECTING:正在逐步填写,sessions.step_id指向当前步骤PREVIEW:确认页(step_id为 NULL);此时打字不会污染数据PUBLISHING:已抢到发布租约(120 秒);Worker 中途崩溃后租约过期会自动降级为FAILED,用户回去点「继续投稿」即可重试FAILED:保留草稿和投稿记录,给「重试 / 返回修改 / 取消」- 任何阶段点「取消」都会清空会话与附件;草稿超过
SESSION_TTL_SECONDS自动作废
投稿记录本身的 submissions.status 相应扩展为 pending / publishing / published / rejected / recalled / failed,所有状态变更都走带条件的 UPDATE ... WHERE status IN (...),并发下只有一次能生效。
部署(大约 10 分钟)
0. 前置条件
- 本机有 Node.js 18+(
node -v能看版本) - 一个 Telegram 账号,以及一个群组(把机器人拉进去当管理员)
- 本机网络能访问 Cloudflare 和 Telegram(部署动作需要联网;机器人本身跑在云端)
cd /home/emo/code/bot
npm install # 只装 wrangler 这一个开发依赖
npx wrangler login # 浏览器里点一下授权
没法开浏览器时:在 Cloudflare 后台建一个 API Token(权限:Workers Scripts Edit、D1 Edit),
然后export CLOUDFLARE_API_TOKEN=xxx再执行后面的命令。
1. 建 D1 数据库
npx wrangler d1 create submission-bot-db
先复制模板并填入自己的值(wrangler.toml 已被 gitignore,不会提交):
cp wrangler.example.toml wrangler.toml
把 npx wrangler d1 create 输出里的 database_id 填进本地 wrangler.toml 的 database_id,
然后建表:
npm run db:init # 线上库;本地调试用 npm run db:init:local
schema.sql 可以放心重复执行:建表语句全部是 CREATE TABLE IF NOT EXISTS,本次新增的session_media(附件)和 submission_guards(发布幂等)也都是新表,不需要手动 ALTER 已有表。
2. 存密钥
到 Telegram 里找 @BotFather 发 /newbot,拿到 token,然后:
npx wrangler secret put BOT_TOKEN # 粘贴 BotFather 给的 token
npx wrangler secret put WEBHOOK_SECRET # 随便一串随机字符,比如 openssl rand -hex 24
WEBHOOK_SECRET 用来防止别人伪造请求打你的机器人,自己生成一串就好,不用告诉别人。
想用审核模式就再加一个独立审核群(必须是和发布群不同的会话):
npx wrangler secret put ADMIN_CHAT_ID # 审核群的 -100... ID(群里发 /chatid 可以拿)
3. 先部署一次
npm run deploy
部署后会得到一个地址,形如 https://submission-bot.<你的子域>.workers.dev。
4. 告诉 Telegram 往这个地址推送
浏览器打开(把两个变量换成你自己的):
https://submission-bot.<你的子域>.workers.dev/setup?secret=<你的 WEBHOOK_SECRET>
看到 "ok": true 和 "bot": "@你的机器人" 就成功了。这一步同时会注册指令菜单。
5. 把机器人接到你的群
- 私聊机器人发
/myid,拿到你自己的用户 ID,填进本地wrangler.toml的ADMIN_IDS(模板见 wrangler.example.toml),然后npm run deploy。
(这一步必须先做:ADMIN_IDS中的 Owner 是唯一可以授权发布目标的人。) - 把机器人拉进你的群组/频道。群组需要给机器人管理员权限;频道需要给机器人管理员权限。
- 在目标群组里,由 Owner 发
/bind;如果是论坛群,在希望发布的 Topic 里发/bind。这会把目标写入 D1,不需要再填群 ID、也不需要重新部署。 - 频道同样由 Owner 作为频道管理员发布
/bind;机器人会验证 Owner 仍是该频道管理员后登记。 - 私聊机器人
/targets,确认哪些目标启用。投稿默认发布到所有启用目标。
然后私聊机器人发 /start,走一遍投稿流程。想增加目标,就把机器人拉进新群/频道后再次 /bind;想停用某个目标,在 /targets 面板点它即可。
想看当前生效的设置(发布群、审核模式、限流、标签):群里或私聊发 /settings。
升级已有部署(老库要跑一次迁移)
如果你是已经部署过的库(有真实投稿数据),拉下这次代码后多跑一条命令,把「评论回复」和网页投稿限流需要的表/字段补上:
npm run db:migrate # 线上库;本地调试库用 npm run db:migrate:local
不跑也不会崩:机器人会自动退化成旧写法(评论照记,只是”回复某条评论”接不上),
群里发 /diag 也会明确提示”数据库还缺「评论回复」字段”。新部署不用做这一步,npm run db:init 建的库已经带上这些字段。
网页投稿(不用 Telegram 也能投)
网页投稿(不用 Telegram 也能投)
给「没有 Telegram / 国内打不开 Telegram」的访客准备的网页表单:GET /submit 是页面,POST /api/submit 是提交接口,都由同一个 Worker 提供,不依赖任何外部 CDN。
它是怎么接进现有流程的:
访客打开 /submit → 填内容 + 传图 → 图片直接传给你(换成 Telegram file_id,服务端不存文件)
→ 建投稿记录(user_id = 0,data.__source = "web")
→ 默认送审:审核卡片(带 ✅通过 / ❌拒绝 / ✏️编辑)发到 ADMIN_CHAT_ID 审核群;
没配审核群就私聊发给每个管理员
→ 管理员点「通过」→ 走的是和 Telegram 投稿完全相同的 publish(),发到全部启用目标(含话题)
几点说明:
- 默认必须审核(
WEB_REQUIRE_REVIEW=1)。网页没有 Telegram 身份,直发等于开了个公开灌水位;
要直发得同时把REQUIRE_REVIEW=0、WEB_REQUIRE_REVIEW=0都设上,风险自负。 - 媒体不留服务器:文件上传给 Telegram 换
file_id,群里发布时直接复用,不占 R2/带宽;
审核群里能看到真实图片,通过的正是你看到的那几张。上传时的暂存消息会保留(审核群里就是审核用的那几条;
直发模式、又没配审核群时它们会安静地留在第一个管理员的私聊里)——这是故意的:删掉源消息后file_id还能不能用,Telegram 没有承诺,留着它「撤下后重新发布 / 失败重试」才一定发得出去。 - 审核卡片里有 IP(
data.__ip):方便追查灌水,也只有管理员能看到。 - 网页投稿没有私聊通知:投稿者收不到「已通过 / 被评论」的私聊(他本来就没有 TG),
审核卡片上也会写明这一点;回复投稿者「拉黑」这类按钮在网页投稿的卡片上会自动隐藏。 - 防刷:填表最短用时(
WEB_MIN_FILL_SECONDS)+ 蜜罐字段 + 算式验证题(CAPTCHA=1时)- 按访客 IP 的 D1 原子限流(
WEB_RATE_LIMIT_COUNT/WEB_RATE_LIMIT_WINDOW_SECONDS) - 复用敏感词 / 重复内容筛查。这些都是”挡脚本”级别,不是”扛定向攻击”级别。
- 按访客 IP 的 D1 原子限流(
- 表单是照着提问模板自动生成的:多模板(
TEMPLATES_JSON)时网页用default那个;
想让网页少问几项,就把default模板改简单点(required: false的项在网页上可以直接留空)。 - 机器人本体也会推荐它:配了
WEB_PUBLIC_URL之后,/start的欢迎语里会带一段可复制/转发的
网页投稿地址,菜单最上面也会多一个「🌐 网页投稿」按钮(原来的 Telegram 向导照旧可用)。
注意:已经发出去的老消息里的按钮不会变(Telegram 不会回改已发送消息),要让用户看到新入口,
得让机器人重发一次菜单(重新/start)。 - 上传的文件总大小也要控制在
WEB_MAX_FILES × WEB_MAX_FILE_MB之内(超过会直接 413)。
国内访问:给 Worker 挂一个能打开的域名
/submit 默认在 https://<你的 worker>.workers.dev/submit,国内经常打不开。
两种做法(都和机器人本体无关,Webhook 继续走原来的地址):
A. 直接给 Worker 绑自定义域名:Cloudflare 后台 → Workers → 你的脚本 → Settings → Domains & Routes
→ Add Custom Domain(例如 tougao.example.com)。简单,但 Cloudflare 免费版在大陆没有境内入口,链路仍会绕道境外。
B. 前面加一跳国内可用的中继(推荐,本项目线上就是这么跑的):
用 Vercel(香港/新加坡边缘,国内可达)把这几条路径反代到 Worker,并覆写 x-relay-token
证明”这个请求确实来自中继”:
// vercel.json(本项目线上实际配置,src 用 $ 锚定,避免误伤 /submissions 等同前缀路径)
{
"framework": null,
"routes": [
{
"src": "/submit/?$",
"dest": "https://<你的 worker>.workers.dev/submit",
"transforms": [
{ "type": "request.headers", "op": "set", "target": { "key": "x-relay-token" },
"args": "$RELAY_SECRET_BOT", "env": ["RELAY_SECRET_BOT"] }
]
},
{ "src": "/api/submit$", "dest": "https://<你的 worker>.workers.dev/api/submit", "transforms": ["同上"] },
{ "src": "/api/submit/captcha$", "dest": "https://<你的 worker>.workers.dev/api/submit/captcha", "transforms": ["同上"] },
{ "src": "/(.*)", "dest": "https://<原来的站点>/$1", "transforms": ["本站原有的其它路由"] }
]
}
要点:
- 三条新路由必须放在原有的兜底路由前面,其余路径原样保留 → 中继上同时挂着两个站点也不会互相影响;
src一定要用$锚定(/api/submit$),否则/api/submissions之类同前缀接口会被误转发;- 环境变量在 Vercel 项目里加(Production 和 Preview 都要),改完必须重新部署,新值才会进入生效的部署;
- Worker 侧存同一个值:
npx wrangler secret put RELAY_SECRET # 与 Vercel 里的 RELAY_SECRET_BOT 保持同一个值
用
wrangler secret put时不要用管道喂值(printf ... | wrangler secret put):它会打印✨ Success!但存进去的值不可用。要么交互式粘贴,要么直接调 Cloudflare API:PUT /accounts/{account_id}/workers/scripts/{script}/secrets,body{"name":"RELAY_SECRET","text":"…","type":"secret_text"}。
有了 RELAY_SECRET 之后:只有带对 x-relay-token 的请求,机器人才会采信x-vercel-proxied-for / x-forwarded-for 里的访客 IP(中继必须覆写这些头);
直连请求一律只信 Cloudflare 注入的 cf-connecting-ip,客户端自己伪造的转发头不会被采信。
不配 RELAY_SECRET 时行为与改造前一致,回滚只需删掉这个 secret。
中继头怎么读(2026-10-29 改):
| 情况 | 行为 |
|---|---|
| 中继生效,且带了 IP 头 | 取最后一跳的值。x-forwarded-for 是逐跳追加的,最左边那段由客户端自己控制——取首跳等于把伪造权交给访客(每请求换一个假 IP 就是一个新桶) |
| 中继生效,但一个 IP 头都没带 | 不再回落到 cf-connecting-ip(那是中继自己的出口地址,会让全站访客共用同一个桶),而是记一条 warn 并跳过按 IP 限流,靠 WEB_DAILY_LIMIT 全天总闸兜底 |
| 中继未生效 | 只信 cf-connecting-ip |
所以中继必须覆写(op: set)而不是追加 x-vercel-proxied-for / x-forwarded-for;
用自定义头也行,把 RELAY_IP_HEADER 指向它即可。
怎么验证配对了? 通过中继提交一条测试投稿,然后看 D1:SELECT json_extract(data,'$.__ip') FROM submissions WHERE id = … ——
里面应该是访客的真实公网 IP;如果显示的是 Vercel 的出口地址,说明 x-relay-token 没被采信
(密钥不对、或者环境变量改完没重新部署)。日志里出现网页投稿:拿不到访客 IP,本次跳过按 IP 限流 也是同一个信号。
踩坑记录:Cloudflare 会把
x-real-ip覆写成”与它相连的那一跳”(即中继自己的出口地址),
所以代码故意不读x-real-ip,否则所有国内访客会被记成同一个 IP、限流桶全部塌成一个。
让评论支持回复(已经有部署的看这里)
「评论的评论」需要 discussion_comments 多三列:parent_message_id、parent_author_name、kind。
执行一次迁移即可(新库不用):
npm run db:migrate # 线上
npm run db:migrate:local # 本地调试库
db:migrate 现在跑的是 scripts/migrate.mjs:按文件名顺序把 migrations/*.sql
逐条语句执行,并且把 duplicate column name 当作”已执行过”跳过。
为什么改:以前它只指向
2026-10-16-comment-replies-and-web.sql一个文件,而那个文件
把CREATE TABLE web_thread_messages排在三条裸ALTER后面。老的库上第一条
ALTER 就会因”列已存在”失败,D1 整批回滚 → 这张表永远建不出来;修好这个的2026-10-17-web-thread-table.sql又没有脚本引用。结果就是:代码里到处写着
“老库缺表要降级”,而那张表在线上真的不存在。现在两个文件都会跑,并且逐条容错。
迁移之后:有人在群里回复某条评论(哪怕那条评论是作者/管理员回复的),机器人都会:
找到这条评论属于哪条投稿 → 记下”谁回复了谁” → 通知作者(正文形如 ↩️ 张三 回复 李四:…)→
作者点「回复」时直接回到那一条评论下面。群里发 /diag 可以随时确认迁移状态。
配置速查
都在本地 wrangler.toml 的 [vars] 里(改完 npm run deploy 生效;模板见 wrangler.example.toml);BOT_TOKEN、WEBHOOK_SECRET 用 wrangler secret put 存。
| 变量 | 默认 | 说明 |
|---|---|---|
TARGET_CHAT_ID |
空 | 旧版单目标兼容配置;正式版本推荐留空,由 Owner 用 /bind 管理 D1 多目标 |
ADMIN_IDS |
无(必填) | 管理员用户 ID,逗号分隔 |
ADMIN_CHAT_ID |
空 | 独立审核群的 ID。开启审核模式时必填:审核消息绝不会发到公开的发布群 |
REQUIRE_REVIEW |
0 |
1 = 先审核再公开(也可用 /review on 运行时切换,但同样要求已配置 ADMIN_CHAT_ID) |
TOPIC_ID |
空 | 旧版单目标 Topic 兼容配置;正式版本由每个 /bind 目标自己保存 Topic |
DELETE_USER_MESSAGES |
1 |
用户答完后自动删除他的消息,保持对话干净 |
MAX_PHOTOS |
9 |
单次投稿最多几张图(只影响提示文案) |
MAX_ATTACHMENTS |
10 |
实际生效的附件上限(图片/视频/文件/语音/位置/投票合计) |
MAX_TEXT_LENGTH |
2000 |
单个文本项字数上限 |
SESSION_TTL_SECONDS |
604800 |
未完成草稿保留多久(秒),0 = 永不过期 |
MAX_DRAFTS |
5 |
每个用户最多挂起几份草稿 |
DISCUSSION_COMMENTS |
1 |
1 = 接 Telegram 官方评论区(评论通知作者、作者可匿名回复) |
COMMENT_NOTIFY_COOLDOWN_SECONDS |
120 |
评论通知的聚合窗口(秒):窗口内合并成一条 |
COMMENT_NOTIFY_MAX |
5 |
一条通知消息里最多列几条评论 |
WEB_ENABLED |
1 |
1 = 开启网页投稿(/submit,给不用 Telegram 的人,见「网页投稿」一节) |
WEB_REQUIRE_REVIEW |
1 |
1 = 网页投稿必须管理员审核后才发布。不建议关:网页没有 Telegram 身份,直发等于开了一个公开灌水位 |
WEB_RATE_LIMIT_COUNT |
3 |
同一访客 IP 在窗口内最多提交几次,0 = 不限 |
WEB_RATE_LIMIT_WINDOW_SECONDS |
3600 |
网页投稿限流窗口(秒) |
WEB_MAX_FILES |
6 |
网页投稿单条最多几个文件 |
WEB_MAX_FILE_MB |
10 |
网页投稿单个文件上限(MB;图片另有 10MB、视频 20MB 的 Telegram 硬限制) |
WEB_MIN_FILL_SECONDS |
3 |
填表最短用时(秒),比这还快的提交当脚本丢掉 |
WEB_TITLE |
空 | 网页投稿页标题(默认「📮 投稿」) |
WEB_INTRO |
空 | 网页投稿页开头说明(默认一句中文说明) |
WEB_PUBLIC_URL |
空 | 对外的网页投稿地址(例如中继后的 https://你的域名/submit)。配了以后机器人菜单最上面会出现「🌐 网页投稿」按钮,欢迎语里也会给一段可转发的地址;留空则不显示 |
RELAY_SECRET |
空(建议用 secret 存) | 网页投稿被中继(Vercel 等)反代时用来采信真实访客 IP,见「网页投稿 → 国内访问」 |
RATE_LIMIT_COUNT |
5 |
限流窗口内最多投稿几次,0 = 不限 |
RATE_LIMIT_WINDOW_SECONDS |
600 |
限流窗口长度(秒) |
CAPTCHA |
0 |
1 = 投稿前需点对表情验证(防脚本刷投稿) |
BANNED_WORDS |
空 | 敏感词,逗号分隔。审核模式下只标给管理员看,普通模式下直接拦截 |
BLOCK_LINKS |
0 |
1 = 投稿里不允许带链接 |
DUPLICATE_WINDOW_SECONDS |
0 |
同一用户多久内不允许投相同内容(秒),0 = 不检查 |
NEW_USER_COOLDOWN_SECONDS |
0 |
新用户第一次投稿前要等多少秒,0 = 不限制 |
REJECT_REASONS |
见下 | 审核时一键驳回的预设理由,逗号分隔 |
AUTO_HASHTAGS |
1 |
按分类和内容类型自动生成多条标签 |
TAG |
#投稿 |
投稿标签 |
FOOTER |
空 | 发布内容末尾的一行小字 |
ANONYMOUS_LABEL |
匿名投稿 |
「匿名」选项的文字,改名时这里也要跟 |
PUBLISH_TEMPLATE |
见下 | 发布格式模板 |
TEMPLATES_JSON |
空 | 覆盖提问流程(见下一节) |
TEXTS_JSON |
空 | 覆盖所有文案(见下一节) |
TELEGRAM_API_BASE |
https://api.telegram.org |
一般不用改;本地测试会指向假服务 |
发布格式
PUBLISH_TEMPLATE 决定投稿在群里长什么样,可用占位符:
| 占位符 | 内容 |
|---|---|
{tag} |
标签,如 #投稿 |
{category} |
用户在第一个选项步骤里选的分类 |
{title} |
标题(自动加粗,没有则为空) |
{body} |
正文(保留用户写的格式) |
{meta} |
联系方式 + 投稿者署名 |
{from} |
只有署名那一行 |
{footer} |
FOOTER 的值 |
{id} |
投稿编号,如 #12 |
默认:
{tag} · {category}
{title}
{body}
{meta}
{footer}
空的字段会自动合并掉,不会留下多余空行。
自定义提问流程
想知道每个步骤长什么样,看 src/config.js 里的 DEFAULT_TEMPLATE,改完压缩成一行 JSON 填进 TEMPLATES_JSON 即可,例如加一个「是否允许转发」的选择题:
{"default":{"name":"默认投稿","steps":[
{"id":"category","type":"choice","prompt":"这次投稿属于哪一类?","options":["图片分享","文字分享"],"label":"分类"},
{"id":"body","type":"text","prompt":"写正文吧","required":true,"max_length":2000,"label":"正文"},
{"id":"photos","type":"media","prompt":"要配图吗?","required":false,"label":"图片"},
{"id":"author","type":"choice","prompt":"怎么署名?","options":["匿名投稿","显示我的昵称","自定义署名"],"label":"署名方式"},
{"id":"signature","type":"text","prompt":"请输入想让别人看到的署名","required":true,"max_length":32,"label":"署名","show_if":{"step":"author","equals":"自定义署名"}}
]}}
字段说明:type 是 text / choice / media;required: false 会出现「跳过」按钮;show_if 让这个步骤只在某个选择下才问;label 是确认页里显示的名字。id 只能用 1-24 位字母、数字、下划线或连字符(它会进入按钮的 callback_data,Telegram 上限 64 字节),
写错会在部署/启动时报出明确错误,而不是默默让某一步的按钮点不动。
配多个模板(比如「图文投稿」「求助提问」分开)就把 default 换成多个 key,用户点 /start 时会先选类型。
改文案
所有提示语都在 src/texts.js 的 TEXT_DEFAULTS 里,用 TEXTS_JSON 按 key 覆盖,例如:
TEXTS_JSON = '{"welcome":"👋 欢迎来投稿,写好后我会帮你发到群里。","btnStart":"写投稿"}'
管理操作
投稿者在私聊里的确认页长这样(这里还什么都没发出去):
| 按钮 | 作用 |
|---|---|
| ✅ 确认发布 | 真正发到所有启用的发布目标(审核模式下先发审核群) |
| ✏️ 编辑内容 | 列出所有步骤,点哪项就单独改哪项,改完自动回确认页(已填内容不会丢) |
| ⬅️ 返回上一步 | 从最后一个步骤开始逐步往回改;流程中途点它则保持原来的前进语义 |
| 🗑 管理媒体 | 打开媒体面板:每个附件一行 ⬆️ ⬇️ 🗑,另有「♻️ 重新选择全部」 |
| ❌ 取消 | 清空这次投稿(含已上传的附件) |
发布失败时给的是:
| 按钮 | 作用 |
|---|---|
| 🔁 重试发布 | 接着发没成功的目标;已经发出去的部分不会重复发送 |
| ✏️ 返回修改 | 回到编辑菜单改内容,改完再发 |
| ❌ 取消 | 放弃这次投稿 |
群里的每条投稿下面有按钮:
| 按钮 | 作用 |
|---|---|
| 💬 评论 | 投稿上的第一个按钮,人人可用:点它跳到机器人私聊,写下内容就公开贴在投稿下面(署名你的昵称),作者会收到通知 |
| 💬 回复投稿者 | 管理员专属:机器人私聊你,你输入内容,它转发给投稿者;对方能直接接着回你,来回多轮都行。 ⚠️ 注意:在群里直接回复投稿现在一律算公开评论(不再隐式转成私聊),想私下沟通请用这个按钮 |
| 📥 待审队列 | /admin →「📥 待审」:列表 → 点一条看完整内容 → 直接通过/拒绝/编辑/回复/拉黑,全程在私聊里完成 |
| ✏️ 编辑 | 先让你选「📝 改文案」或「🎬 改媒体」。改文案:把当前文案私聊给你,改完发回即替换。改媒体:把新的图片/视频/文件发到私聊,点「✅ 就这些了」;个数和类型没变就逐条就地替换(editMessageMedia,点赞/评论/置顶和链接都保住),个数变了才删旧消息+重发(这是 Telegram 的限制);想改成纯文字就点「🧹 清空并去掉媒体」 |
| 📌 置顶 | 置顶这条投稿(需要机器人有置顶权限) |
| 🧹 撤下 | 从群里删掉,记录保留,机器人会私聊你一个「♻️ 重新发布」按钮 |
| 🚫 拉黑 | 禁止该用户继续投稿;/unban 用户ID 解除 |
| ✅ 通过 / ❌ 拒绝 | 只在审核模式出现;拒绝会列出预设理由(也可自己写一句或不给理由) |
想跟投稿者聊天,有两个入口:点按钮,或者直接在群里回复那条投稿/那条补充消息——机器人会把你的话转达过去。
投稿者那边不需要任何操作,直接回复机器人发来的消息就能接着聊。
群里或私聊里可用的指令:
/start 开始投稿(有未完成草稿时继续上次的)
/cancel 取消当前投稿
/my 查看自己最近的投稿记录与状态
/drafts 查看/继续/删除挂起的草稿
/help 使用说明
/chatid 查看当前会话 ID
/myid 查看自己的用户 ID
/bind Owner 授权当前群/当前话题为发布目标
/topic [off|ID] Owner 把投稿发布位置设成当前话题(在群主聊天区发 = 回到主聊天)
/targets Owner 管理多个发布目标(启用/停用/删除)
/review on|off 开关审核模式(需要已配置 ADMIN_CHAT_ID)
/settings 查看当前生效的设置
/approve_all 通过所有待审核的投稿
/stats 投稿统计
/ban 用户ID [原因] 拉黑
/unban 用户ID 解除拉黑
/banlist 黑名单
/pending 查看待审核队列
/user 用户ID 查看用户档案和投稿统计
/mute 用户ID 分钟 临时禁止投稿
/unmute 用户ID 解除临时限制
/note 用户ID 内容 保存管理员备注
/comments 投稿ID 查看这条投稿收到的评论(可翻页、可直接回复)
/diag 评论区自检:机器人收得到评论吗(带"测一测"闭环验证)
/audit 查看最近管理操作
/admins 查看管理员列表
/addadmin ID 角色 添加管理员
/deladmin ID 移除管理员
/adminrole ID 角色 调整管理员角色
/bind 和 /targets 只有 Owner 能用;/review、/settings、/stats、/ban、/unban、/banlist、/pending、/user、/audit、/mute、/unmute、/note 由管理员角色控制。
管理员分三级:owner / admin / moderator。owner 才能执行 /addadmin、/deladmin、/adminrole;moderator 可以审核和处理用户,但不能修改核心配置。环境变量 ADMIN_IDS 中的管理员始终是 owner,并受保护。
发布目标、审核设置、管理员和用户管理状态都存 D1,所以运行时修改不需要重新部署。发布目标以 publish_targets 为主,投稿到多目标时实际消息记录在 submission_target_messages;每条已发布消息(含相册的第 2..N 条)都会在 thread_messages 里标成 published_media / published_text,所以在任意一条下面回复都能关联回这条投稿,撤回时也能一条不落地删干净。未完成的附件存在 session_media,发布幂等靠 submission_guards;关键管理动作写入 audit_logs,通过 /audit 查看。
开启审核模式前请先配好
ADMIN_CHAT_ID。机器人不会把未审核的投稿发到公开的发布群——没配审核群时/review on会被拒绝,投稿入口也会被拦住,并给出明确提示。
本地开发与测试
npm test # 端到端测试:假 Telegram + 内存 D1,跑完整投稿流程
npm run dev # 本地起 Worker(wrangler dev)
测试不需要联网,也不需要真的 Telegram 账号:test/fake-telegram.js 模拟 Bot API,test/d1.js 用 Node 内置 sqlite 模拟 D1。覆盖了发布、审核通过/拒绝、限流、重复投递去重、
按钮过期、黑名单、中途取消与上一步、以及用户内容转义(防止投稿内容注入群消息)。
test/regression.test.js 专门盯住这次修掉的问题:
- 确认前绝不发布:上传、预览、编辑、打字全程目标群必须一条消息都没有
- 确认页:媒体真的回显、最终文案真的展示、编辑不丢内容、确认页打字不污染
- 陈旧按钮:改过内容后旧确认按钮不能发布残缺投稿,取消后旧按钮全部失效
- 绝不重复:并发双击「确认发布」/「通过」/「重新发布」都只发一次
- 并发相册:5 张图同时投递必须一张不丢
- 失败恢复:重试只补没发出去的部分、不重发相册;发布中途崩溃后能恢复
- 评论关联:在相册第 2..N 条下面回复也能关联回投稿
- 审核安全:没配独立审核群、或审核群就是发布群时,都拒绝收稿,绝不把草稿发到公开群
- 对抗性评审回归:附件分批发送中途失败不重发已发部分、失败后改内容要同步数据库、有人正在发布时管理员插不进来、审核卡片不带发布目标的 topic、超长正文截断发布、管理员发布中途崩溃后能恢复
- 多草稿:新建投稿挂起而不销毁、草稿列表/继续/删除、附件跟着草稿走、超过上限清理最旧的、取消是丢弃不产生草稿
- 管理员改媒体:旧消息全部删除并用新媒体重发、投稿数据同步、清空媒体变纯文字、空收集不误伤、审核中的投稿重新送审
- 官方评论区:路人评论通知作者、作者匿名回复挂到原评论下、频道讨论组的 forward_origin 也能关联、管理员回复仍走私聊转达(不记成公开评论)、
/comments能列出 - 评论聚合与逐条回复:窗口内 3 条评论只发 1 条私聊、超过上限会说明只列了最新几条、点「回复 1」就回在第 1 条下面而不是最新那条
- 改媒体就地替换:媒体变少或不变时用
editMessageMedia(消息 id 不变、正文消息不动),只有”变多”才回退删+重发 - 作者回复署名:匿名投稿保持「作者回复:」,选了昵称/自定义署名的显示对应名字
- 多模板取消不丢草稿:选类型时点「回到刚才那份」能原样恢复
/comments分页:12 条评论能翻到第 2 页,也能翻回来,每条带”回复”按钮- 评论区自检:按会话类型分别判断(频道无讨论组 / 频道+讨论组 / 普通群)、机器人身份三种情况、并给出
setprivacy与 Discussions 两条路径 - 评论区自测闭环:布置测一测 → 群里有人说话 → 私聊回报结果 → 自动解除
- 群里回复一律算公开评论:管理员在群里回复不再被删掉或偷偷转私聊,想私下沟通用「回复投稿者」按钮
- 公开评论深链:普通用户从投稿上的「💬 评论」进私聊写内容 → 公开发到投稿下、评论入库、作者收到通知、能在收件箱里看到
- 管理员按钮不再劝退普通人:非管理员点管理员按钮会得到”点「💬 评论」或直接回复投稿”的指引
- 深链评论的边界:空内容/超长/投稿已被撤下都有对应提示,且写错一次不会丢掉”正在评论”的状态
- 作者评论收件箱:通知里进”全部评论”、逐条回复(提示里带被回复的内容)、别人的投稿看不了
- 按钮化管理面板:面板入口、待审队列、预览卡片、从面板直接通过、审核员看不到 Owner 专属项
- 管理员回复公开评论:从评论列表回复,回复挂在被回复的那条下面
- 批量通过不设 20 条上限:一次 /approve_all 能通过 22 条
- 多模板新建投稿:有草稿时也能「新建投稿」并选类型
有一个测试基建上的细节:假 Telegram 是通过 TELEGRAM_FETCH 绑定注入的,不是覆盖 globalThis.fetch,
所以同一个进程里可以同时存在多个 harness 而不互相串。
想用真 Telegram 调试本地 worker:需要把 localhost 暴露到公网(cloudflared tunnel --url http://localhost:8787),
然后 GET /setup?secret=...。
常见问题
机器人在群里没反应?
群里的普通聊天它不处理(只认 /指令),投稿走私聊。确认它已进群且是管理员。
投稿发不到群里?
检查 TARGET_CHAT_ID 是不是 -100 开头的完整 ID(用群里的 /chatid 拿最准),以及机器人是不是群管理员。
想换一个群发布 / 机器人换群了?
在新群里由 Owner 发一次 /bind 即可授权;然后用 /targets 管理启用状态。发 /settings 可以查看当前目标摘要。
论坛群(话题群)想把投稿发到指定话题?
进那个话题,由 Owner 发一次 /topic(或 /bind)即可,之后所有投稿都发到那个话题;
在群主聊天区发 /topic off 就回到主聊天。/settings 会显示当前发布话题。
如果那个话题后来被关闭了:机器人会先尝试自动重开(需要「管理话题」权限),重开不了就
只把这一条临时发到主聊天并私聊提醒管理员——绑定不会被清掉,话题重新打开后投稿会自动回到话题里。
用户上传了图片/视频,怎么还没发出去?
这是故意的。流程是「选择媒体 → 上传/处理 → 预览 → 用户确认 → 真正发布」。
机器人会把媒体、逐项摘要和「发布后的样子」一起发回私聊,只有用户点「✅ 确认发布」才会发到群里。
为什么点了两次「确认发布」只发了一条?
幂等守卫和发布租约在起作用:同一条投稿无论点几次、Telegram 怎么重投、Worker 有没有重启,都只会发一次。
如果上一次发布失败,点「🔁 重试发布」即可——已经成功的目标不会重复发送。
评论功能好像”只有管理员能评论”/ 评论收不到?
先在群里发 /diag,它按你的实际情况给出结论(不要再凭猜测改设置):
| 情况 | 结论与做法 |
|---|---|
| 发布目标是频道、且没关联讨论组 | ❗️普通用户在频道里根本不能发言,没有任何人能评论。去 频道 → 管理 → Discussions 关联一个群,并把机器人设为那个群的管理员 |
| 频道 + 已关联讨论组 | 评论回复的是 Telegram 转发的副本(不是机器人发的),所以需要机器人是讨论组管理员,或在 @BotFather 里 /setprivacy → Disable |
| 普通群 | 评论就是”回复机器人发的那条投稿”,Telegram 默认就会把这类回复推给机器人,不需要改任何设置 |
还不确定就点 /diag 下面的 🧪 测一测:布置后用另一个非管理员账号在群里回一句话,
如果你收到机器人的私聊「✅ 测试通过」,说明推送是通的;收不到就是真的没推给机器人,按上表处理。
审核模式为什么开不起来?
审核必须发到独立的审核群,否则未审核的草稿会直接出现在公开群里。请先设置 ADMIN_CHAT_ID
(npx wrangler secret put ADMIN_CHAT_ID 或填在本地 wrangler.toml 里),再执行 /review on。
审核群不能和任何一个发布目标相同(/review on 会拒绝,用户点确认时也会被拦住并给出提示);/settings 会显示当前审核群。审核卡片只发到审核群自己的主消息区,不会带上发布目标的 forum topic。
点「🗑 删除」没反应?
机器人需要”删除消息”权限:在群设置里把它升为管理员(或者在管理员权限里单独勾上 Delete Messages)。
投稿时让用户等 30 秒 / 要做验证?
这是防滥用功能,分别是 NEW_USER_COOLDOWN_SECONDS(新用户冷却)和 CAPTCHA(表情验证)。
不想用就在本地 wrangler.toml 里改成 0 再 npm run deploy。
标签太多想关掉?
把 AUTO_HASHTAGS 设成 0,就只保留 TAG 那一个标签。
改了表结构要重新初始化数据库吗?
schema.sql 可以重复执行,不会清数据:npm run db:init 就是幂等的。
(只有给已有表加字段时,需要单独跑一次 ALTER TABLE ... ADD COLUMN。)
怎么知道 webhook 有没有配好?
浏览器打开 https://<你的 worker 地址>/setup?secret=<WEBHOOK_SECRET>,返回里的 webhook_info.last_error_message 有报错原因;
没有报错就是正常的。也可以访问 /health 看服务活着没。
Telegram 报 “Wrong response from the webhook”?
多半是 Worker 抛异常了。Cloudflare 后台 → Workers → 你的脚本 → Logs(实时日志)能看到堆栈;/health 是排查路由是否正常的第一步。
.workers.dev 被墙/被 Telegram 判为不可达?
给 Worker 绑一个自己的域名(Cloudflare 后台 → Workers → 你的脚本 → Settings → Domains & Routes),
用自定义域名重新执行一次 /setup 即可。
想改机器人名字/头像?
都在 BotFather 里改,和本项目无关。
数据在哪,怎么备份?
都在 D1,例如导出全部投稿:
npx wrangler d1 execute submission-bot --remote --command "SELECT id,status,user_id,full_name,created_at FROM submissions ORDER BY id DESC LIMIT 50"
Authentication error [code: 10000] 是怎么回事?
wrangler 把请求打到了「你当前没有凭据的账号」上。本机如果同时有多个 Cloudflare 账号
(比如还登着别的项目),会出现两种坑:
node_modules/.cache/wrangler/wrangler-account.json里存着上一次部署用的账号,
即使你现在wrangler login的是另一个账号,它也会拿旧账号去发请求 → 10000;- 反过来,清掉缓存后又可能把 Worker 建到当前登录的那个账号里(而那里的 D1 数据库并不存在)。
所以本项目的配置里显式钉了 account_id(模板见 wrangler.example.toml):所有命令只认这一个账号,
凭据不对就立刻报错,不会静默跑到别的账号去。要在这个账号上部署,用它的 API Token 最省事
(不干扰本机其他项目的登录状态):
# 在「拥有本项目的那个账号」里建一个 API Token:
# My Profile → API Tokens → Create Token → Custom token
# 权限:Account · Workers Scripts:Edit、Account · D1:Edit
cd /home/emo/code/bot
export CLOUDFLARE_API_TOKEN=粘贴你的token
export CLOUDFLARE_ACCOUNT_ID=<OLD_ACCOUNT_ID> # 与 wrangler.toml 里的 account_id 一致
npm run db:migrate && npm run deploy
不想用 Token 就 npx wrangler login 登录到该账号再执行同样的命令(注意这会把本机的
wrangler 登录态切到该账号,其他项目的部署要再切回来)。
免费额度够用吗?
Workers 免费版每天 10 万次请求,D1 免费版每天几百万行读、十万行写。
一次投稿大约消耗 10 次请求、几十行读写——换句话说,日常用量基本碰不到上限
(具体额度以 Cloudflare 官网当前政策为准)。
目录结构
src/index.js Worker 入口:路由 /setup、/webhook、/submit、去重、后台处理
src/handlers.js 核心逻辑:私聊投稿流程、状态机、发布、审核、管理员操作、网页投稿落库
src/wizard.js 提问流程纯函数(可见步骤、上下一步、确认页逐项预览)
src/render.js 把投稿渲染成发到群里的文本
src/keyboards.js 内联按钮(步骤 / 确认页 / 媒体管理 / 编辑菜单 / 重试)
src/store.js D1 数据访问(会话、append-only 附件、幂等守卫、状态 CAS、网页限流)
src/telegram.js Bot API 客户端(含 429 重试、multipart 上传)
src/config.js 环境变量 → 配置,含提问模板校验与启动自检
src/texts.js 所有文案
src/web.js 网页投稿的 HTTP 层:验证题、限流、表单校验、错误响应
src/web-page.js /submit 页面(自带样式脚本,不引用外部资源)
src/client-ip.js 访客真实 IP(中继场景下只采信带 RELAY_SECRET 的转发头)
schema.sql D1 表结构(可重复执行)
migrations/ 增量迁移(给已经部署过的老库用:npm run db:migrate)
test/ 端到端测试(假 Telegram + 内存 D1)
许可证
本项目采用 Apache License 2.0 许可,版权归 emoeem 所有(见 NOTICE)。




