大聲公是一个 Telegram 匿名收信机器人:发送者私聊 bot,消息复制给 owner;owner 回复收到的消息,回复复制回发送者。双方不交换任何身份信息。
实现:Go + gotgbot v2(长轮询)+ modernc.org/sqlite。代码 1616 行,main.go 全部 handler,store.go 数据层,texts.go 文案。
前提与约束
以下 Bot API 行为决定实现方式:
- bot 无法主动私聊未与之交互的用户(403)。发送方向只能由对方先发起。
- 隐私模式下 bot 只能收到群内 @ 它与回复它的消息。本 bot 只处理私聊,群消息一概不转。
- 转发消息(
ForwardOrigin非空)一律拒绝并回执,防止冒用他人聊天记录。 - 复制消息用
copyMessage:不携带来源,且可被EditMessageText编辑。署名头依赖该可编辑性。
数据模型
owner 侧消息 ID 到发送者:
var fwdMap = map[int64]fwdEntry{}
type fwdEntry struct { Sender int64 // 发送者 chatID Orig int64 // 发送者原消息 id}发送者侧消息 ID 到 owner 侧转发 ID,用于续接会话线程:
var revMap = map[senderKey]int64{} // {chatID, messageID} -> owner 侧转发消息 id两张表在内存中运行,写入 SQLite 持久化,启动时加载,并清理 30 天前的记录。
收发流程
来信:
copyMessage复制到 owner 侧,得到新消息 ID;fwdMap[新ID] = {发送者, 原消息ID},同步写 SQLite;EditMessageText在消息头部插入署名行:📮 显示名 (@用户名) · 时间 · chatID。
回复:
- 由
msg.ReplyToMessage.MessageId查fwdMap;查不到回执 owner「该消息无法解析」,不静默; copyMessage发回发送者,带ReplyParameters{MessageId: ent.Orig}与AllowSendingWithoutReply: true,原消息被删也能送达;- 记录
revMap[发送者侧副本ID] = owner 侧转发 ID,发送者再次回复时线程不断。
无文本消息(贴纸、语音、图片)无法加前缀,改为在该条下方补发署名标签,标签消息 ID 同样进映射表。
实现要点
handler 分组
gotgbot 的 handler 按 group 顺序执行:同一 group 内只运行第一个命中的 handler,后续 group 一定会执行。命令注册在 group 0,收信总入口在 group 1。
- 命令 handler 必须返回
ext.EndGroups,否则命令文本会被收信入口当作普通消息再转发一次; - 非 owner 的斜杠消息不吞掉:命令包装器对非 owner 返回
ext.ContinueGroups,交给收信入口按普通消息处理; - 缺少
EndGroups曾导致/later、/done、/top、/export失效且命令文本被重复转发,现由回归测试TestCatchAllGroupSeparatedFromCommands固定。
实体转换
署名头由 EditMessageText 在原文头部插入,需保留加粗、链接、代码块。MessageEntity 的 offset/length 以 UTF-16 码元计,不是字节也不是 rune;中英文与 emoji 混排时直接按字符串切片会错位,越界时 panic。实现先把 UTF-16 码元边界映射为字节偏移,再按边界切片输出 HTML。
日志脱敏
gotgbot 的传输层错误会把完整请求 URL 拼进错误信息,其中包含 bot token;日志写入 journald,默认保留两周。处理放在日志出口:包一层 io.Writer,写出前把 token 替换为 <redacted>,slog 与标准库 log 共用该 writer。
限流与重试
copyMessage 失败即消息丢失。gotgbot 不处理 429 重试,此处实现 copyRetry:仅对 ResponseParams.RetryAfter > 0 的错误重试,最多 3 次,retry_after 超过 30 秒直接放弃并上报。最终失败向发送者与 owner 双向回执。
启动参数 DropPendingUpdates: false,重启期间的消息恢复后继续转发。
发送侧限速:60 秒窗口放行 15 条;第 16 条警告,第 17 条丢弃,第 18 条自动封禁并通知双方。自动封禁时清空该用户的限速状态,避免 /unban 后同一窗口内再次触发。消息编辑占用同一额度。
命令
| 命令 | 说明 |
|---|---|
/history <id> | 某用户最近对话(默认 10 条,上限 50) |
/search <关键词> | 跨用户全文搜索 |
/export <id> | 导出某用户全部对话为 txt |
/later /done | 回复转发消息标记待办、销号 |
/canned [编号] | 预置回复模板,一键回传 |
/away <内容> | 自动回复,同一用户 5 分钟一次 |
/msg <id> <内容> | 定向单发 |
/top /stats /broadcast | 活跃排行、统计、群发 |
/ban /unban | 封禁、解封,支持回复转发消息或直接给 id |
对方编辑已发送消息时,owner 侧转发同步更新(SetAllowEdited)。
部署
modernc.org/sqlite 为纯 Go 实现,CGO_ENABLED=0 静态编译出单个二进制,运行于 systemd:
[Service]User=dsgMemoryMax=128M
NoNewPrivileges=yesCapabilityBoundingSet=ProtectSystem=strictReadWritePaths=/srv/docker/daaiSengGungProtectHome=yesPrivateTmp=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINKUMask=0077进程不需要特权与 root;/root、/home 不可见(.env 由 systemd 读取);新建文件权限 0600,数据库内容为全部私信。常驻内存约 10 MB。
GitHub Actions 流程:
- 二进制传成
.new后由远端mv替换:直接覆盖运行中的二进制会ETXTBSY。 - 替换前保留旧版本;重启后轮询
systemctl is-active,两次间隔 1 秒,失败自动回滚。 - 主机指纹由
VPS_HOST_KEYsecret 固定,未配置时回退 TOFU 并输出警告。
测试
TestCatchAllGroupSeparatedFromCommands:handler 分组回归。entitiesToHTML属性测试,固定三条不变量:任何输入不 panic;输出的每个<属于本实现生成的标签;剥离标签并反转义后与原文一致。- store 往返测试:写入读取一致。
已知限制
- 明文不可复制的消息类型(服务消息、付费媒体、发票、quiz 投票等)无法转发,只能回执。
- 单条消息包含多张图片时,会拆成多条消息分别复制。
评论
GitHub 登录后可评论。
评论区会在滚动到这里时自动加载。