Files
blog/write/docs/bot_design/design.md
T
2026-06-02 00:23:04 +08:00

24 KiB
Raw Blame History

WriteBot 模块设计文档

一、架构

Bot 作为 Next.js 的 sidecar 模块运行,通过 instrumentation.ts 在服务端启动时自动拉起。

write/src/lib/bot/
├── index.ts        # 启动入口
├── config.ts       # Bot 专用配置
├── tg.ts           # Telegram API 封装
├── sessions.ts     # 写作会话状态管理
├── handlers.ts     # 全部命令处理器
├── poll.ts         # 长轮询循环(带指数退避)
└── helpers.ts      # 工具函数

Bot 不再通过 HTTP 调 write API,直接调用 src/lib/posts.ts、src/lib/rssapi.ts 等 lib 函数。


二、交互方式

2.1 Telegram 原生组件使用规则

组件 用途 使用场景
ReplyKeyboard(底部键盘) 常驻操作按钮 所有文本回复都带,分主键盘和写作键盘两套
InlineKeyboard(消息内按钮) 列表操作、确认、分页 文章列表编辑/删除、删除确认、分页导航、打开编辑器
ForceReply(强制回复) 引导输入 新建标题、设置分类/标签、添加友链/订阅源
Web App 内置浏览器打开编辑器 /start 时提供

关键规则:ReplyKeyboard 和 InlineKeyboard 不能混在同一个 reply_markup 里。

2.2 ReplyKeyboard 布局

主键盘(非写作状态):

[/new 写文章] [/list 文章列表]
[/links 友链]  [/feeds 订阅]
[/stats 数据]  [/help 帮助]

写作键盘(写作会话中):

[/preview 预览] [/publish 发布]
[/draft 草稿]   [/cancel 取消]
[/title 标题]   [/categories 分类]

三、命令清单

3.1 系统命令

命令 参数 说明
/start 无 启动 Hugo,显示地址,发 Web App 编辑器按钮
/stop 无 停止 Hugo
/status 无 显示 Hugo 运行状态和地址
/ip 无 显示局域网 IP
/help 无 显示命令帮助(根据是否在写作中显示不同内容)

3.2 文章命令

命令 参数 说明
/new 标题(可选) 新建文章,见写作流程
/edit slug 编辑已有文章,见编辑流程
/delete slug 删除文章(二次确认),移入回收站
/list 页码(可选) 文章列表,纯文本 + 分页
/view slug 显示预览链接
/preview 无 预览当前写作内容
/publish 无 保存到本地(draft: false),不触发 git push
/draft 无 保存到本地(draft: true)
/deploy 无 Git add → commit → pull --rebase → push
/sync 无 Git pull --rebase,同步远程
/undo 无 撤销最后一次追加的内容,自动预览
/cancel 无 丢弃当前写作会话
/title 新标题 修改文章标题(ForceReply 引导)
/categories 分类 设置分类(逗号分隔)
/tags 标签 设置标签(逗号分隔)
/author 作者名 修改作者

3.3 友链命令

命令 参数 说明
/links 页码(可选) 友链列表,纯文本 + 分页 + 命令提示
/link_add url 名称 添加友链(ForceReply 引导)
/link_del url 删除友链
/link_toggle url 切换显示/隐藏

3.4 订阅命令

命令 参数 说明
/feeds 页码(可选) 订阅源列表,纯文本 + 分页 + 命令提示
/feed_add url 添加订阅源(ForceReply 引导)
/feed_del url 删除订阅源

3.5 数据命令

命令 参数 说明
/stats 无 显示文章数、友链数、订阅源数

四、核心流程

4.1 新建文章 /new

用户: /new 标题
  ↓ (如果没有标题,ForceReply 引导输入)
Bot: 📝 开始写:《标题》
     📂 设置分类(可选)
     [跳过] [设置分类]  ← InlineKeyboard
  ↓
用户: 点 [跳过] → 直接进写作
  或: 点 [设置分类] → ForceReply 引导输入分类
     → 然后同样问标签 [跳过] [设置标签]
  ↓
Bot: 切换写作键盘
     "直接发消息写正文,完成后点「发布」"
  ↓
用户: 发消息 → 追加到 content
用户: 发照片 → 下载到文章文件夹,追加 markdown 引用
用户: /undo → 撤销最后一次追加,自动预览
  ↓
用户: /publish
  ↓
Bot: 保存到本地文件(draft: false)
     "✅ 已保存,发 /deploy 推送到线上"
  ↓
用户: /deploy
  ↓
Bot: git add → commit → pull --rebase → push
     "🚀 已发布并部署!"

Session 初始状态:

{
  title: "标题",
  slug: "2026-06-01-标题",
  content: "",
  categories: [],
  tags: [],
  author: "小赵",  // DEFAULT_AUTHOR 环境变量可覆盖
  draft: true,
  chunks: [],       // 用于 /undo,记录每次追加的内容
  mode: "write",    // write | edit
}

4.2 编辑文章 /edit(方案 B)

用户: /edit slug
  ↓
Bot: 加载文章,显示原文内容(分条,每条 ≤3800 字)
     清空 session.content(保留 title、slug、categories、tags、author)
     切换写作键盘
     "原文已显示,现在重新写正文,写完点「发布」"
  ↓
用户: 发消息 → 追加到新 content(和 /new 一样)
  ↓
后续流程同新建

关键区别:/new 的 slug 是新生成的,/edit 保留原文章的 slug。保存时调用 updatePost 而不是 createPost。

4.3 删除文章 /delete

用户: /delete slug
  ↓
Bot: "确认删除《标题》?"
     [确认删除] [取消]  ← InlineKeyboard
  ↓
用户: 点 [确认删除]
  → moveToRecycle(slug, dirPath, title)
  → "✂ 已移入回收站:《标题》\n30天内可恢复。"
  ↓
用户: 点 [取消]
  → "已取消"

poll.ts callback 处理新增 confirm_del 前缀:

const CALLBACK_PREFIXES = {
  edit: "edit",
  confirm_del: "delete",   // 确认删除
  cancel_del: "noop",      // 取消删除
  list: "list",
  links: "links",
  feeds: "feeds",
  skip_cat: "skip_categories",   // 跳过分类
  skip_tag: "skip_tags",         // 跳过标签
};

4.4 文章列表 /list

纯文本显示,无 InlineKeyboard 操作按钮:

📖 文章列表  共 46 篇 · 第 1/10 页

我的第一篇文章
  2026-06-01-my-first-post
另一篇文章 📝(草稿)
  2026-05-28-another-post

◀ 上一页  下一页 ▶  ← InlineKeyboard(仅分页)

用户通过 slug 操作:/edit slug、/delete slug、/view slug。

纯文本 + 分页 + 命令提示:

🔗 友链  共 46 个 · 第 1/10 页

🌐 强仔博客
🌐 七栀
🔒 若志随笔(已隐藏)

操作命令:
/link_add url 名称 — 添加
/link_del url — 删除
/link_toggle url — 显示/隐藏

◀ 上一页  下一页 ▶

4.6 订阅列表 /feeds

同友链,纯文本 + 分页 + 命令提示。


五、图片处理(Hugo Page Bundle)

5.1 存储策略

图片存到文章自己的文件夹里,符合 Hugo page bundle 规范:

content/post/2026/2026-06-01-文章标题/
├── index.md
├── photo1.jpg
└── photo2.jpg

正文引用:![描述](photo1.jpg)(相对路径)

5.2 实现逻辑

写作中收到照片时:

  1. 根据 session 的 slug 计算文章文件夹路径
    • slug: 2026-06-01-文章标题
    • 文件夹: content/post/2026/2026-06-01-文章标题/
  2. 文件夹不存在则创建
  3. 下载图片到文件夹内
  4. content 追加 ![caption](filename.jpg)

保存时(draft/publish):

  1. index.md 写到同一个文件夹
  2. 图片已经在里面,无需移动

取消时:

  1. 文件夹里只有图片没有 index.md
  2. Hugo 不会渲染,无害

六、撤销 /undo

6.1 实现机制

Session 新增 chunks: string[] 字段:

interface Session {
  // ... 其他字段
  chunks: string[];  // 每次追加的内容记录
}

每次用户发消息追加到 content 时,同时 push 到 chunks:

s.chunks.push(fullText + "\n");
s.content += fullText + "\n";

6.2 /undo 流程

用户: /undo
  ↓
如果 chunks 为空: "没有可撤销的内容"
  ↓
否则:
  lastChunk = chunks.pop()
  content = content 去掉 lastChunk
  → 自动调用 preview 逻辑,显示当前文章内容
  → "↩ 已撤销最后一段(上方为当前内容)"

七、发布与部署(分离设计)

7.1 与网页端保持一致

操作 命令 效果
保存草稿 /draft 保存到本地,draft: true
发布 /publish 保存到本地,draft: false,不触发 git
部署 /deploy git add → commit → pull --rebase → push
同步 /sync git pull --rebase,拉取远程最新

7.2 Git 冲突处理

/deploy 执行流程:

  1. git add content/post static
  2. git diff --cached --quiet → 有变更则 git commit
  3. git pull --rebase --autostash origin main
    • 成功 → 继续
    • 冲突 → git rebase --abort → 回复"远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy"
  4. git push

八、配置

8.1 环境变量(write/.env)

# Bot 配置
TG_BOT_TOKEN=xxx
TG_ALLOWED_CHAT_IDS=xxx       # 留空允许所有人
PREFER_IFACE=WLAN
DEFAULT_AUTHOR=小赵            # 新增:默认作者名

8.2 config.ts 新增

export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";

九、命令解析

9.1 ReplyKeyboard 按钮文字处理

按钮发出的文字如 /new 写文章,解析规则:

  1. split("@")[0] 去掉 bot mention
  2. split(/\s+/) 拆词
  3. 第一个词去掉 / 前缀作为命令名
  4. 其余词作为参数

/new 写文章 → 命令 new,参数 写文章 但 new 命令的参数是标题,所以实际行为:/new 写文章 会创建一篇标题为"写文章"的文章。

需要特殊处理:如果参数和命令的中文描述匹配(如 new 的参数是 写文章),则忽略该参数。 或者更简单的方案:ReplyKeyboard 按钮只发命令,不带中文。

最终方案:ReplyKeyboard 按钮文字改为纯命令:

[/new] [/list]
[/links] [/feeds]
[/stats] [/help]

9.2 ForceReply 回复识别

poll.ts 通过 reply_to_message.text 匹配关键词来识别 ForceReply 回复:

关键词 路由到
"请输入文章标题" handleCommand(chatId, "new", text)
"请输入新标题" handleCommand(chatId, "title", text)
"请输入友链信息" handleCommand(chatId, "link_add", text)
"请输入订阅源 URL" handleCommand(chatId, "feed_add", text)
"请输入分类" handleCommand(chatId, "categories", text)
"请输入标签" handleCommand(chatId, "tags", text)

十、会话状态机

Session 有一个 state 字段,控制消息处理行为:

idle          → 非写作状态,普通命令处理
writing_title → 等待 ForceReply 标题输入
writing_cat   → 等待 ForceReply 分类输入(或 InlineKeyboard 跳过)
writing_tag   → 等待 ForceReply 标签输入(或 InlineKeyboard 跳过)
writing       → 写作中,消息追加到 content
confirm_del   → 等待删除确认(InlineKeyboard)

状态转换:

idle → ( /new ) → writing_title → ( 输入标题 ) → writing_cat
  → ( 跳过/输入 ) → writing_tag → ( 跳过/输入 ) → writing → idle

idle → ( /edit slug ) → 显示原文 → 清空 content → writing → idle

idle → ( /delete slug ) → confirm_del → ( 确认 ) → idle
                        → ( 取消 ) → idle

十一、友链模块

11.1 命令清单

命令 参数 说明
/links 页码(可选) 友链列表,纯文本 + 分页
/link_add url 名称 ForceReply 引导输入,添加友链
/link_edit url ForceReply 引导输入新名称,通过 oldUrl 编辑
/link_del url 删除友链
/link_toggle url 切换显示/隐藏

11.2 列表显示格式

🔗 友链  共 46 个 · 第 1/10 页

🌐 吾柯  https://blog.keepke.com
🌐 强仔博客  https://loseu.cc
🔒 七栀  https://blog.qydzz.cn

操作:/link_add /link_edit /link_del /link_toggle

◀ 上一页  下一页 ▶

每条显示:图标(🌐 正常 / 🔒 隐藏)+ 名称 + URL。 每页 10 条。底部 InlineKeyboard 仅分页按钮。 底部文字提示可用操作命令。

用户: /link_edit url
  ↓
Bot: 查找该友链,显示当前信息
     "请输入新名称(留空保持不变):"  ← ForceReply
  ↓
用户: 输入新名称(或发"跳过")
  ↓
Bot: 调用 POST /api/links { oldUrl, name: newName }
     "✅ 已更新:新名称"

11.4 API 调用

  • 列表:GET /api/links?all=1
  • 添加:POST /api/links body: { name, url }
  • 编辑:POST /api/links body: { oldUrl, name, ... }
  • 删除:DELETE /api/links?url=xxx
  • 切换:先 GET 获取 hidden 状态,再 POST { url, hidden: !current }

十二、订阅模块

12.1 命令清单

命令 参数 说明
/feeds 页码(可选) 订阅源列表,纯文本 + 分页
/feed_add url 名称 ForceReply 引导输入 URL 和名称
/feed_edit url ForceReply 引导编辑,通过 oldUrl
/feed_del url 删除订阅源
/feed_health 无 检测全部订阅源健康状态

12.2 列表显示格式

📡 订阅源  共 52 个 · 第 1/10 页

📰 吾柯  https://blog.keepke.com
📰 强仔博客  https://loseu.cc

操作:/feed_add /feed_edit /feed_del /feed_health

◀ 上一页  下一页 ▶

每页 10 条。

12.3 /feed_health 流程

调用 GET /api/health(不传 url 参数,检测全部订阅源)。

用户: /feed_health
  ↓
Bot: "⏳ 正在检测 52 个订阅源..."
  ↓
Bot:
  📊 订阅源健康报告
  ✅ 存活:49  ❌ 异常:3

  ❌ 异常站点:
  🔴 dead-blog.com — timeout
  🔴 another.com — 500
  🔴 third.com — 连接失败

只列出异常站点,正常的不逐一显示。

12.4 /feed_edit 流程

同友链编辑,通过 oldUrl 参数调用 POST /api/feeds。

12.5 API 调用

  • 列表:GET /api/feeds
  • 添加:POST /api/feeds body: { url, feedTitle }
  • 编辑:POST /api/feeds body: { oldUrl, url, feedTitle }
  • 删除:DELETE /api/feeds?url=xxx
  • 健康:GET /api/health

十三、新增 /read 命令

利用 API 的 GET /api/articles 接口,获取友链博客的最新文章。

13.1 命令

命令 参数 说明
/read 数量(可选,默认 10) 友链最新文章

13.2 显示格式

📰 友链最新文章(10 篇)

吾柯 — 如何在 Hugo 中实现友链
  https://blog.keepke.com/xxx

强仔博客 — 2026 年度总结
  https://loseu.cc/xxx

七栀 — 我的第一次旅行
  https://blog.qydzz.cn/xxx

每条显示:博客名 — 文章标题 + 链接。 Telegram 自动将 URL 转为可点击链接。

13.3 API 调用

GET /api/articles?limit=10

响应结构:

{
  "total": 380,
  "articles": [
    {
      "title": "...",
      "link": "...",
      "pubDate": "...",
      "feedName": "吾柯",
      "siteUrl": "...",
      "favicon": "...",
      "author": "..."
    }
  ]
}

十四、完整命令注册列表

更新 setMyCommands 注册的命令:

commands: [
  { command: "new", description: "写一篇文章" },
  { command: "edit", description: "编辑文章" },
  { command: "delete", description: "删除文章" },
  { command: "list", description: "文章列表" },
  { command: "publish", description: "保存发布" },
  { command: "draft", description: "保存草稿" },
  { command: "deploy", description: "推送到线上" },
  { command: "sync", description: "同步远程" },
  { command: "undo", description: "撤销最后一段" },
  { command: "preview", description: "预览" },
  { command: "links", description: "友链管理" },
  { command: "link_add", description: "添加友链" },
  { command: "link_edit", description: "编辑友链" },
  { command: "link_del", description: "删除友链" },
  { command: "link_toggle", description: "显示/隐藏友链" },
  { command: "feeds", description: "订阅管理" },
  { command: "feed_add", description: "添加订阅源" },
  { command: "feed_edit", description: "编辑订阅源" },
  { command: "feed_del", description: "删除订阅源" },
  { command: "feed_health", description: "订阅源健康检查" },
  { command: "read", description: "友链最新文章" },
  { command: "stats", description: "网站数据" },
  { command: "help", description: "帮助" },
]

十五、其他可考虑的功能

15.1 /random — 随机图片

利用 API 的 GET /api/random-image 接口,可以做一个随机壁纸/图片功能。 适合在聊天中发一张随机图片,增加趣味性。优先级低。

15.2 /greeting — 问候语

利用 API 的 GET /api/ai/greeting 接口,获取 AI 生成的问候语。 适合每天早上发一条问候。可以和定时任务结合。优先级低。

15.3 公众号同步 /wechat

利用 API 的 POST /api/wechat-material 接口,在 bot 中直接把文章同步到微信公众号草稿箱。 当前 write 已有公众号页面,bot 可以加一个 /wechat slug 命令。 优先级中——如果经常在手机上写文章,同步到公众号是个高频操作。

15.4 定时任务

利用 Cowork 的 scheduled-tasks 功能,可以设置:

  • 每天早上 9 点发一条问候语 + 友链最新文章摘要
  • 每周检测一次订阅源健康状态
  • 每天自动部署未发布的草稿(可选)

这些功能当前不急,后续按需添加。


十六、确认的补充项

16.1 写作键盘更新

加入 /undo 和 /deploy:

[/preview]   [/undo]
[/publish]   [/deploy]
[/draft]     [/cancel]
[/title]     [/categories]

16.2 主键盘更新

按钮文字改为纯命令:

[/new]     [/list]
[/links]   [/feeds]
[/stats]   [/help]

16.3 /stats 增强

显示本地数据 + API 数据:

📊 网站数据

📝 本地文章:128 篇
🔗 友链:46 个
📡 订阅源:52 个

📰 友链文章:380 篇(来自 52 个博客)
🌐 usj.cc

16.4 API 不可用降级

所有调用 api.usj.cc 的操作统一处理:

  • 超时:10 秒
  • 失败回复:"⚠️ API 暂时不可用,请稍后再试"
  • 涉及命令:/links、/link_add、/link_edit、/link_del、/link_toggle、/feeds、/feed_add、/feed_edit、/feed_del、/feed_health、/read、/stats(API 部分)

十七、完整的 poll.ts 命令解析更新

17.1 ARG_COMMANDS 更新

const ARG_COMMANDS = new Set([
  "new", "title", "categories", "tags", "author",
  "edit", "view", "delete",
  "link_add", "link_edit", "link_del", "link_toggle",
  "feed_add", "feed_edit", "feed_del",
  "list", "links", "feeds", "read",
]);

17.2 CALLBACK_PREFIXES 更新

const CALLBACK_PREFIXES: Record<string, string> = {
  edit: "edit",
  confirm_del: "delete",
  cancel_del: "noop",
  list: "list",
  links: "links",
  feeds: "feeds",
  skip_cat: "skip_categories",
  skip_tag: "skip_tags",
};

17.3 ForceReply 关键词更新

if (original.includes("请输入文章标题")) → new
if (original.includes("请输入新标题")) → title
if (original.includes("请输入分类")) → categories
if (original.includes("请输入标签")) → tags
if (original.includes("请输入友链信息")) → link_add
if (original.includes("请输入新名称")) → link_edit
if (original.includes("请输入订阅源 URL")) → feed_add
if (original.includes("请输入新的订阅信息")) → feed_edit

17.4 InlineKeyboard callback 处理

新增 confirm_del 和 cancel_del 处理:

  • confirm_del_slug → 执行删除
  • cancel_del_slug → 取消,回复"已取消"

新增 skip_cat 和 skip_tag 处理:

  • skip_cat → 跳过分类设置
  • skip_tag → 跳过标签设置

十八、sessions.ts 更新

18.1 Session 接口

interface Session {
  title: string;
  slug: string;
  content: string;
  categories: string[];
  tags: string[];
  author: string;
  draft: boolean;
  chunks: string[];      // 每次追加的内容,用于 /undo
  originalSlug?: string;  // /edit 时保留原 slug
}

18.2 默认作者

从 DEFAULT_AUTHOR 环境变量读取,默认 "小赵"。


十九、helpers.ts 更新

19.1 makeSlug 保持不变

2026-06-01-标题拼音

19.2 新增 getArticleDirPath

根据 slug 计算文章文件夹路径:

function getArticleDirPath(slug: string): string {
  // slug: "2026-06-01-文章标题"
  // 从 slug 提取年份前四位
  const year = slug.slice(0, 4);
  return path.join(CONTENT_DIR, year, slug);
}

19.3 新增 truncateText

截断文本到指定长度,用于 Telegram 消息:

function truncateText(text: string, maxLen: number): string {
  if (text.length <= maxLen) return text;
  return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
}

二十、config.ts 更新

新增环境变量:

export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
export const UAPI_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
export const UAPI_TOKEN = process.env.RSS_API_TOKEN || "";

二十一、rssapi.ts 新增函数

21.1 getArticles

export async function getArticles(limit = 10): Promise<Article[]> {
  const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
  return data.articles || [];
}

21.2 checkHealth

export async function checkHealth(): Promise<HealthResult> {
  return rssFetch("/api/health");
}

rssapi.ts 已有 updateLink(oldUrl, data) 函数,用于友链编辑。 需要新增 updateFeed(oldUrl, data) 函数用于订阅编辑。


二十二、git.ts 更新

22.1 GitDeployQueue 修改

enqueue 方法不再自动 add static 目录,改为只 add content:

const addResult = await this.runGit(["add", "content"]);

22.2 新增 sync 方法

async sync(): Promise<DeployResult> {
  try {
    const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
    return { success: true, output: result.stdout + result.stderr };
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    if (this.isRebaseConflict(msg)) {
      await this.runGit(["rebase", "--abort"], true);
      return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
    }
    return { success: false, error: msg };
  }
}

二十三、实施清单

按依赖顺序:

第一步:基础设施修改

  1. config.ts — 新增 DEFAULT_AUTHOR、UAPI_BASE、UAPI_TOKEN
  2. git.ts — 修改 add 路径、新增 sync 方法
  3. rssapi.ts — 新增 getArticles、checkHealth、updateFeed
  4. helpers.ts — 新增 getArticleDirPath、truncateText
  5. sessions.ts — 新增 chunks、originalSlug 字段、默认作者从 config 读取

第二步:poll.ts 重写

  1. 更新 ARG_COMMANDS
  2. 更新 CALLBACK_PREFIXES
  3. 更新 ForceReply 关键词路由
  4. 新增 confirm_del / cancel_del / skip_cat / skip_tag 处理

第三步:handlers.ts 重写

  1. 系统命令(start/stop/status/ip/help)
  2. 文章命令(new/edit/delete/list/view/title/categories/tags/author)
  3. 写作命令(preview/publish/draft/deploy/sync/undo/cancel)
  4. 友链命令(links/link_add/link_edit/link_del/link_toggle)
  5. 订阅命令(feeds/feed_add/feed_edit/feed_del/feed_health)
  6. 数据命令(stats/read)

第四步:键盘和命令注册

  1. mainKeyboard — 纯命令
  2. writingKeyboard — 加 /undo /deploy
  3. setMyCommands — 完整命令列表

第五步:验证

  1. TypeScript 类型检查
  2. 本地 npm run dev 测试