大聲公是一个 Telegram 匿名收信机器人:发送者私聊 bot,消息复制给 owner;owner 回复收到的消息,回复复制回发送者。双方不交换任何身份信息。

ownerbot发送者ownerbot发送者fwdMap[转发 ID] = {发送者, 原消息 ID}revMap[发送者侧副本 ID] = 转发 ID私聊消息copyMessage,加署名头回复该条转发消息copyMessage + ReplyParameters

实现:Go + gotgbot v2(长轮询)+ modernc.org/sqlite。代码 1616 行,main.go 全部 handler,store.go 数据层,texts.go 文案。

前提与约束

以下 Bot API 行为决定实现方式:

  1. bot 无法主动私聊未与之交互的用户(403)。发送方向只能由对方先发起。
  2. 隐私模式下 bot 只能收到群内 @ 它与回复它的消息。本 bot 只处理私聊,群消息一概不转。
  3. 转发消息(ForwardOrigin 非空)一律拒绝并回执,防止冒用他人聊天记录。
  4. 复制消息用 copyMessage:不携带来源,且可被 EditMessageText 编辑。署名头依赖该可编辑性。

数据模型

owner 侧消息 ID 到发送者:

go
var fwdMap = map[int64]fwdEntry{}
type fwdEntry struct {
Sender int64 // 发送者 chatID
Orig int64 // 发送者原消息 id
}

发送者侧消息 ID 到 owner 侧转发 ID,用于续接会话线程:

go
var revMap = map[senderKey]int64{} // {chatID, messageID} -> owner 侧转发消息 id

两张表在内存中运行,写入 SQLite 持久化,启动时加载,并清理 30 天前的记录。

收发流程

来信:

  1. copyMessage 复制到 owner 侧,得到新消息 ID;
  2. fwdMap[新ID] = {发送者, 原消息ID},同步写 SQLite;
  3. EditMessageText 在消息头部插入署名行:📮 显示名 (@用户名) · 时间 · chatID

回复:

  1. msg.ReplyToMessage.MessageIdfwdMap;查不到回执 owner「该消息无法解析」,不静默;
  2. copyMessage 发回发送者,带 ReplyParameters{MessageId: ent.Orig}AllowSendingWithoutReply: true,原消息被删也能送达;
  3. 记录 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 在原文头部插入,需保留加粗、链接、代码块。MessageEntityoffset/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:

ini
[Service]
User=dsg
MemoryMax=128M
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ReadWritePaths=/srv/docker/daaiSengGung
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
UMask=0077

进程不需要特权与 root;/root/home 不可见(.env 由 systemd 读取);新建文件权限 0600,数据库内容为全部私信。常驻内存约 10 MB。

GitHub Actions 流程:

产物 VPS · systemd不一致失败存活

scp .new

校验哈希

原子替换

重启

健康检查

❌ 中止

❌ 回滚

✅ 上线

CI · GitHub Actions

gofmt -l .

go vet ./...

go test -race ./...

静态编译

SHA-256

  • 二进制传成 .new 后由远端 mv 替换:直接覆盖运行中的二进制会 ETXTBSY
  • 替换前保留旧版本;重启后轮询 systemctl is-active,两次间隔 1 秒,失败自动回滚。
  • 主机指纹由 VPS_HOST_KEY secret 固定,未配置时回退 TOFU 并输出警告。

测试

  • TestCatchAllGroupSeparatedFromCommands:handler 分组回归。
  • entitiesToHTML 属性测试,固定三条不变量:任何输入不 panic;输出的每个 < 属于本实现生成的标签;剥离标签并反转义后与原文一致。
  • store 往返测试:写入读取一致。

已知限制

  • 明文不可复制的消息类型(服务消息、付费媒体、发票、quiz 投票等)无法转发,只能回执。
  • 单条消息包含多张图片时,会拆成多条消息分别复制。