6.2
This commit is contained in:
1 parent
528104fff0
commit
beb498d38b
60 files changed
+3184
-2129
No files matched your search
@@ -0,0 +1,914 @@
|
||||
# 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 初始状态:
|
||||
```typescript
|
||||
{
|
||||
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` 前缀:
|
||||
```typescript
|
||||
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`。
|
||||
|
||||
### 4.5 友链列表 `/links`
|
||||
|
||||
纯文本 + 分页 + 命令提示:
|
||||
```
|
||||
🔗 友链 共 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
|
||||
```
|
||||
|
||||
正文引用:``(相对路径)
|
||||
|
||||
### 5.2 实现逻辑
|
||||
|
||||
写作中收到照片时:
|
||||
1. 根据 session 的 slug 计算文章文件夹路径
|
||||
- slug: `2026-06-01-文章标题`
|
||||
- 文件夹: `content/post/2026/2026-06-01-文章标题/`
|
||||
2. 文件夹不存在则创建
|
||||
3. 下载图片到文件夹内
|
||||
4. content 追加 ``
|
||||
|
||||
保存时(draft/publish):
|
||||
1. index.md 写到同一个文件夹
|
||||
2. 图片已经在里面,无需移动
|
||||
|
||||
取消时:
|
||||
1. 文件夹里只有图片没有 index.md
|
||||
2. Hugo 不会渲染,无害
|
||||
|
||||
---
|
||||
|
||||
## 六、撤销 `/undo`
|
||||
|
||||
### 6.1 实现机制
|
||||
|
||||
Session 新增 `chunks: string[]` 字段:
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
// ... 其他字段
|
||||
chunks: string[]; // 每次追加的内容记录
|
||||
}
|
||||
```
|
||||
|
||||
每次用户发消息追加到 content 时,同时 push 到 chunks:
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```env
|
||||
# Bot 配置
|
||||
TG_BOT_TOKEN=xxx
|
||||
TG_ALLOWED_CHAT_IDS=xxx # 留空允许所有人
|
||||
PREFER_IFACE=WLAN
|
||||
DEFAULT_AUTHOR=小赵 # 新增:默认作者名
|
||||
```
|
||||
|
||||
### 8.2 config.ts 新增
|
||||
|
||||
```typescript
|
||||
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 仅分页按钮。
|
||||
底部文字提示可用操作命令。
|
||||
|
||||
### 11.3 `/link_edit` 流程
|
||||
|
||||
```
|
||||
用户: /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`
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"total": 380,
|
||||
"articles": [
|
||||
{
|
||||
"title": "...",
|
||||
"link": "...",
|
||||
"pubDate": "...",
|
||||
"feedName": "吾柯",
|
||||
"siteUrl": "...",
|
||||
"favicon": "...",
|
||||
"author": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、完整命令注册列表
|
||||
|
||||
更新 `setMyCommands` 注册的命令:
|
||||
|
||||
```typescript
|
||||
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 更新
|
||||
|
||||
```typescript
|
||||
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 更新
|
||||
|
||||
```typescript
|
||||
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 关键词更新
|
||||
|
||||
```typescript
|
||||
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 接口
|
||||
|
||||
```typescript
|
||||
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 计算文章文件夹路径:
|
||||
|
||||
```typescript
|
||||
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 消息:
|
||||
|
||||
```typescript
|
||||
function truncateText(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十、config.ts 更新
|
||||
|
||||
新增环境变量:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
export async function getArticles(limit = 10): Promise<Article[]> {
|
||||
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
|
||||
return data.articles || [];
|
||||
}
|
||||
```
|
||||
|
||||
### 21.2 checkHealth
|
||||
|
||||
```typescript
|
||||
export async function checkHealth(): Promise<HealthResult> {
|
||||
return rssFetch("/api/health");
|
||||
}
|
||||
```
|
||||
|
||||
### 21.3 updateLink 和 updateFeed 已存在
|
||||
|
||||
rssapi.ts 已有 `updateLink(oldUrl, data)` 函数,用于友链编辑。
|
||||
需要新增 `updateFeed(oldUrl, data)` 函数用于订阅编辑。
|
||||
|
||||
---
|
||||
|
||||
## 二十二、git.ts 更新
|
||||
|
||||
### 22.1 GitDeployQueue 修改
|
||||
|
||||
`enqueue` 方法不再自动 add static 目录,改为只 add content:
|
||||
|
||||
```typescript
|
||||
const addResult = await this.runGit(["add", "content"]);
|
||||
```
|
||||
|
||||
### 22.2 新增 sync 方法
|
||||
|
||||
```typescript
|
||||
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 重写
|
||||
6. 更新 ARG_COMMANDS
|
||||
7. 更新 CALLBACK_PREFIXES
|
||||
8. 更新 ForceReply 关键词路由
|
||||
9. 新增 confirm_del / cancel_del / skip_cat / skip_tag 处理
|
||||
|
||||
### 第三步:handlers.ts 重写
|
||||
10. 系统命令(start/stop/status/ip/help)
|
||||
11. 文章命令(new/edit/delete/list/view/title/categories/tags/author)
|
||||
12. 写作命令(preview/publish/draft/deploy/sync/undo/cancel)
|
||||
13. 友链命令(links/link_add/link_edit/link_del/link_toggle)
|
||||
14. 订阅命令(feeds/feed_add/feed_edit/feed_del/feed_health)
|
||||
15. 数据命令(stats/read)
|
||||
|
||||
### 第四步:键盘和命令注册
|
||||
16. mainKeyboard — 纯命令
|
||||
17. writingKeyboard — 加 /undo /deploy
|
||||
18. setMyCommands — 完整命令列表
|
||||
|
||||
### 第五步:验证
|
||||
19. TypeScript 类型检查
|
||||
20. 本地 npm run dev 测试
|
||||
@@ -0,0 +1,477 @@
|
||||
# Bot + Write 整合架构方案
|
||||
|
||||
## 目标
|
||||
|
||||
将 `bot/`(Telegram Bot)的全部功能合并进 `write/`(Next.js 16)项目,消除双进程问题,统一文件操作、Hugo 管理、Git 部署。
|
||||
|
||||
---
|
||||
|
||||
## 一、整合后的目录结构
|
||||
|
||||
```
|
||||
write/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js 页面 + API 路由(现有)
|
||||
│ ├── components/ # React 组件(现有)
|
||||
│ ├── lib/
|
||||
│ │ ├── config.ts # 统一配置(合并 bot 的 .env 字段)
|
||||
│ │ ├── posts.ts # 文章 CRUD(现有,改异步)
|
||||
│ │ ├── ai.ts # AI 功能(现有)
|
||||
│ │ ├── artalk.ts # 评论管理(现有)
|
||||
│ │ ├── recycle.ts # 回收站(现有)
|
||||
│ │ ├── rssapi.ts # 友链/订阅 API(现有)
|
||||
│ │ ├── hugo.ts # ★ 新增:Hugo 进程单例管理器
|
||||
│ │ ├── git.ts # ★ 新增:Git 操作队列
|
||||
│ │ └── bot/ # ★ 新增:Telegram Bot 模块
|
||||
│ │ ├── index.ts # bot 启动入口,导出 startBot()
|
||||
│ │ ├── config.ts # bot 专用配置(TG_TOKEN, ALLOWED_IDS)
|
||||
│ │ ├── tg.ts # Telegram API 封装(tg(), sendMessage(), handleCallback())
|
||||
│ │ ├── sessions.ts # 写作会话状态管理
|
||||
│ │ ├── handlers.ts # 全部 /command 处理器
|
||||
│ │ ├── poll.ts # 长轮询循环(带指数退避)
|
||||
│ │ └── helpers.ts # 工具函数(getLanIP, makeSlug, sessionSummary)
|
||||
│ └── instrumentation.ts # ★ 新增:Next.js 服务端启动钩子,启动 bot
|
||||
├── .env # 合并后的环境变量
|
||||
├── start.bat # 启动脚本(不变)
|
||||
└── ...(其余不变)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、三个核心共享模块
|
||||
|
||||
### 2.1 Hugo 进程管理器 — `src/lib/hugo.ts`
|
||||
|
||||
**解决的问题:** 当前 bot 的 `write-server.js` 和 write 的 `hugo/route.ts` 各自管理 Hugo 进程,互不知道对方状态,可能互相杀进程。
|
||||
|
||||
```typescript
|
||||
// src/lib/hugo.ts
|
||||
|
||||
class HugoManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private ready = false;
|
||||
private starting = false;
|
||||
private healthTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** 启动 Hugo server,如果已在运行则跳过 */
|
||||
async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> { ... }
|
||||
|
||||
/** 优雅停止 Hugo */
|
||||
stop(): void { ... }
|
||||
|
||||
/** 当前状态 */
|
||||
status(): { running: boolean; starting: boolean } { ... }
|
||||
|
||||
/** 确保 Hugo 在运行,不在则启动并等待就绪 */
|
||||
async ensureRunning(): Promise<boolean> { ... }
|
||||
|
||||
/** 每 30 秒检查一次 Hugo 是否还活着,死了自动重启 */
|
||||
private startHealthCheck(): void { ... }
|
||||
|
||||
/** 用 HTTP 请求检查 Hugo 是否响应 */
|
||||
private async ping(): Promise<boolean> { ... }
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
export const hugo = new HugoManager();
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- `start()` 内部有互斥锁,两个并发调用不会重复启动
|
||||
- 健康检查每 30 秒 ping 一次 Hugo 的 localhost:1313,失败则标记 ready=false
|
||||
- Web 端的 `/api/hugo/route.ts` 改为调用 `hugo.start()` / `hugo.stop()` / `hugo.status()`
|
||||
- Bot 的 `/start` 命令改为调用 `hugo.ensureRunning()`
|
||||
- 启动参数统一为 `hugo server --bind 0.0.0.0 --port 1313 --disableFastRender --noHTTPCache`
|
||||
|
||||
### 2.2 Git 部署队列 — `src/lib/git.ts`
|
||||
|
||||
**解决的问题:** 当前 deploy route 没有并发控制,两个同时触发的 deploy 会导致 rebase 冲突或仓库损坏。
|
||||
|
||||
```typescript
|
||||
// src/lib/git.ts
|
||||
|
||||
class GitDeployQueue {
|
||||
private queue: Array<{
|
||||
message: string;
|
||||
resolve: (result: DeployResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}> = [];
|
||||
private running = false;
|
||||
|
||||
/** 入队一次部署任务,返回 Promise 等待结果 */
|
||||
async enqueue(commitMessage: string): Promise<DeployResult> { ... }
|
||||
|
||||
/** 实际执行 git add → commit → pull --rebase → push */
|
||||
private async execute(message: string): Promise<DeployResult> { ... }
|
||||
|
||||
/** 处理队列中的下一个任务 */
|
||||
private async next(): Promise<void> { ... }
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
export const deployQueue = new GitDeployQueue();
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
- 同一时间只有一个 git 操作在执行,其余排队等待
|
||||
- Web 端的 `/api/deploy/route.ts` 改为调用 `deployQueue.enqueue(title)`
|
||||
- Bot 的 `/publish` 保存文章后也调用 `deployQueue.enqueue()` 触发自动部署
|
||||
- 返回 `{ conflict: true }` 时前端提示用户手动解决
|
||||
|
||||
### 2.3 Bot 模块 — `src/lib/bot/`
|
||||
|
||||
**解决的问题:** bot 作为独立进程通过 HTTP 调 write API,引入了不必要的网络依赖和状态不一致。
|
||||
|
||||
整合后 bot 是 Next.js 进程内的一个模块,handler 直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等函数,不再走 HTTP。
|
||||
|
||||
---
|
||||
|
||||
## 三、Bot 模块详细设计
|
||||
|
||||
### 3.1 启动方式 — `src/instrumentation.ts`
|
||||
|
||||
Next.js 13+ 支持 `instrumentation.ts`,在服务端进程启动时执行一次:
|
||||
|
||||
```typescript
|
||||
// src/instrumentation.ts
|
||||
export async function register() {
|
||||
// 仅在 Node.js 运行时执行(不是 Edge Runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startBot } = await import("@/lib/bot");
|
||||
startBot();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需要在 `next.config.ts` 中启用:
|
||||
|
||||
```typescript
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
experimental: {
|
||||
instrumentationHook: true, // Next.js 15+ 已默认开启,16 可能不需要
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Telegram API 封装 — `src/lib/bot/tg.ts`
|
||||
|
||||
从 bot 的 `tg-core.js` 移植,改为 TypeScript:
|
||||
|
||||
```typescript
|
||||
const API = `https://api.telegram.org/bot${TG_TOKEN}`;
|
||||
|
||||
export async function tg<T = unknown>(method: string, body: Record<string, unknown>): Promise<TgResponse<T>> {
|
||||
const res = await fetch(`${API}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function allowed(chatId: number): boolean {
|
||||
if (ALLOWED_IDS.length === 0) return true;
|
||||
return ALLOWED_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
export async function sendMessage(chatId: number, text: string, opts?: Record<string, unknown>): Promise<void> {
|
||||
// 先尝试 HTML 解析,失败则降级纯文本
|
||||
try {
|
||||
await tg("sendMessage", { chat_id: chatId, text, parse_mode: "HTML", disable_web_page_preview: true, ...opts });
|
||||
} catch {
|
||||
await tg("sendMessage", { chat_id: chatId, text, ...opts });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 写作会话 — `src/lib/bot/sessions.ts`
|
||||
|
||||
从 bot 的 `sessions.js` 移植,改为直接调用 `src/lib/posts.ts`:
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
const sessions = new Map<number, Session>();
|
||||
|
||||
export function getSession(chatId: number): Session { ... }
|
||||
export function clearSession(chatId: number): void { ... }
|
||||
|
||||
// 关键变化:直接调用 lib/posts.ts,不再走 HTTP
|
||||
export async function saveSession(s: Session, isDraft: boolean): Promise<void> {
|
||||
const { createPost, updatePost, getPost } = await import("@/lib/posts");
|
||||
const existing = getPost(s.slug);
|
||||
if (existing) {
|
||||
updatePost(s.slug, {
|
||||
title: s.title,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
}, s.content);
|
||||
} else {
|
||||
createPost({
|
||||
title: s.title,
|
||||
slug: s.slug,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
draft: isDraft,
|
||||
categories: s.categories,
|
||||
tags: s.tags,
|
||||
author: s.author,
|
||||
}, s.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 命令处理器 — `src/lib/bot/handlers.ts`
|
||||
|
||||
从 bot 的 `tg-handlers.js` 移植,核心变化:
|
||||
|
||||
| 原来(HTTP 调 write API) | 现在(直接调 lib) |
|
||||
|---|---|
|
||||
| `fetch("http://127.0.0.1:8016/api/posts/" + slug)` | `getPost(slug)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/posts", { method: "POST" })` | `createPost(frontMatter, content)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/posts/" + slug, { method: "DELETE" })` | `moveToRecycle(slug, dirPath, title)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/rss/links")` | `getLinks(true)` |
|
||||
| `fetch("http://127.0.0.1:8016/api/rss/feeds")` | `getFeeds()` |
|
||||
| `fetch("http://127.0.0.1:8016/api/deploy", ...)` | `deployQueue.enqueue(title)` |
|
||||
|
||||
另外,`/publish` 命令现在会自动触发 git 部署:
|
||||
|
||||
```typescript
|
||||
// /publish handler
|
||||
async function handlePublish(chatId: number) {
|
||||
const s = sessions.get(chatId);
|
||||
await saveSession(s, false); // 保存文章
|
||||
const result = await deployQueue.enqueue(`发布: ${s.title}`); // 自动部署
|
||||
if (result.success) {
|
||||
sendMessage(chatId, `✅ 已发布并部署: ${s.title}`);
|
||||
} else if (result.conflict) {
|
||||
sendMessage(chatId, `⚠️ 已保存但部署冲突,请手动解决`);
|
||||
} else {
|
||||
sendMessage(chatId, `❌ 部署失败: ${result.error}`);
|
||||
}
|
||||
clearSession(chatId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 长轮询 — `src/lib/bot/poll.ts`
|
||||
|
||||
从 bot 的 `tg-poll.js` 移植,修复两个问题:
|
||||
|
||||
1. **添加指数退避**:出错后等待 3s → 6s → 12s → 最大 60s,避免 Telegram API 限流
|
||||
2. **添加正常间隔**:`getUpdates` 返回后等待 100ms 再发起下一次,避免空转
|
||||
|
||||
```typescript
|
||||
export async function startPolling(): Promise<void> {
|
||||
let lastOffset = 0;
|
||||
let backoff = 3000;
|
||||
|
||||
async function poll() {
|
||||
if (!TG_TOKEN) return;
|
||||
try {
|
||||
const data = await tg("getUpdates", {
|
||||
offset: lastOffset + 1,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
if (data.ok && data.result) {
|
||||
for (const update of data.result) {
|
||||
lastOffset = update.update_id;
|
||||
// ... 处理消息(逻辑不变)
|
||||
}
|
||||
}
|
||||
backoff = 3000; // 成功则重置退避
|
||||
} catch (e) {
|
||||
console.error("[bot:poll]", e.message);
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, 60000);
|
||||
}
|
||||
await sleep(100); // 正常间隔
|
||||
setImmediate(poll);
|
||||
}
|
||||
|
||||
poll();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 图片处理改进
|
||||
|
||||
当前 bot 直接引用 Telegram 临时 URL,改为下载到本地:
|
||||
|
||||
```typescript
|
||||
// 在 poll.ts 中处理 photo 消息
|
||||
if (msg.photo && sessions.has(chatId)) {
|
||||
const s = getSession(chatId);
|
||||
const largestPhoto = msg.photo[msg.photo.length - 1];
|
||||
const fileRes = await tg("getFile", { file_id: largestPhoto.file_id });
|
||||
if (fileRes.ok) {
|
||||
const filePath = fileRes.result.file_path;
|
||||
const fileUrl = `https://api.telegram.org/file/bot${TG_TOKEN}/${filePath}`;
|
||||
const imgBuffer = await fetch(fileUrl).then(r => r.arrayBuffer());
|
||||
|
||||
// 保存到 static/upload/ 目录
|
||||
const filename = `${Date.now()}-${largestPhoto.file_id.slice(0, 8)}.jpg`;
|
||||
const savePath = path.join(STATIC_DIR, "upload", filename);
|
||||
fs.writeFileSync(savePath, Buffer.from(imgBuffer));
|
||||
|
||||
// 引用本地路径
|
||||
const caption = msg.caption || "";
|
||||
s.content += `\n\n`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 端 API 路由适配
|
||||
|
||||
### 4.1 `/api/hugo/route.ts` — 改用 HugoManager
|
||||
|
||||
```typescript
|
||||
import { hugo } from "@/lib/hugo";
|
||||
|
||||
export async function GET() {
|
||||
const result = await hugo.ensureRunning();
|
||||
return NextResponse.json({ running: result, url: "http://localhost:1313" });
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `/api/deploy/route.ts` — 改用 GitDeployQueue
|
||||
|
||||
```typescript
|
||||
import { deployQueue } from "@/lib/git";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { title } = await request.json();
|
||||
const result = await deployQueue.enqueue(`发布: ${title}`);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、配置合并
|
||||
|
||||
将 bot 的 `.env` 字段合入 write 的 `.env`:
|
||||
|
||||
```env
|
||||
# ---- Write 原有 ----
|
||||
RSS_API_BASE=https://api.usj.cc
|
||||
RSS_API_TOKEN=xxx
|
||||
WECHAT_APP_ID=xxx
|
||||
WECHAT_APP_SECRET=xxx
|
||||
DEEPSEEK_API_KEY=xxx
|
||||
ARTALK_SERVER=https://artalk.usj.cc
|
||||
|
||||
# ---- Bot 合并过来 ----
|
||||
TG_BOT_TOKEN=xxx
|
||||
TG_ALLOWED_CHAT_IDS=xxx
|
||||
PREFER_IFACE=WLAN
|
||||
```
|
||||
|
||||
`src/lib/config.ts` 新增:
|
||||
|
||||
```typescript
|
||||
export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || "";
|
||||
export const TG_ALLOWED_IDS = (process.env.TG_ALLOWED_CHAT_IDS || "")
|
||||
.split(",").map(s => s.trim()).filter(Boolean).map(Number);
|
||||
export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、Bot 管理页面(替代 admin-panel)
|
||||
|
||||
将 bot 的 `admin-panel.js`(独立 HTTP 服务器)改为 Next.js 页面 `/bot/page.tsx`:
|
||||
|
||||
- 显示 Telegram Bot 连接状态(是否在轮询、最后收到消息的时间)
|
||||
- 显示 Hugo 进程状态
|
||||
- 显示最近的 bot 操作日志(最近 10 条)
|
||||
- 提供重启 bot 的按钮
|
||||
|
||||
对应 API 路由 `/api/bot/status/route.ts` 和 `/api/bot/restart/route.ts`。
|
||||
|
||||
这样 bot 的管理界面和 write 的其他页面共用同一个 Next.js 服务器,不再需要单独的 8017 端口。
|
||||
|
||||
---
|
||||
|
||||
## 七、实施步骤
|
||||
|
||||
按依赖关系排序:
|
||||
|
||||
### 第一步:基础设施(无现有代码改动)
|
||||
1. 创建 `src/lib/hugo.ts` — HugoManager 单例
|
||||
2. 创建 `src/lib/git.ts` — GitDeployQueue
|
||||
3. 更新 `src/lib/config.ts` — 合并 bot 环境变量
|
||||
4. 更新 `.env` — 合并 bot 配置项
|
||||
|
||||
### 第二步:移植 Bot 模块
|
||||
5. 创建 `src/lib/bot/helpers.ts` — 工具函数
|
||||
6. 创建 `src/lib/bot/config.ts` — bot 专用配置
|
||||
7. 创建 `src/lib/bot/tg.ts` — Telegram API 封装
|
||||
8. 创建 `src/lib/bot/sessions.ts` — 会话管理
|
||||
9. 创建 `src/lib/bot/handlers.ts` — 命令处理器(直接调 lib)
|
||||
10. 创建 `src/lib/bot/poll.ts` — 长轮询(带退避)
|
||||
11. 创建 `src/lib/bot/index.ts` — 启动入口
|
||||
|
||||
### 第三步:Next.js 集成
|
||||
12. 创建 `src/instrumentation.ts` — 服务端启动钩子
|
||||
13. 更新 `next.config.ts` — 确保 instrumentation 启用
|
||||
|
||||
### 第四步:适配现有 API 路由
|
||||
14. 改造 `src/app/api/hugo/route.ts` — 使用 HugoManager
|
||||
15. 改造 `src/app/api/deploy/route.ts` — 使用 GitDeployQueue
|
||||
|
||||
### 第五步:新增 Bot 管理页面
|
||||
16. 创建 `src/app/api/bot/status/route.ts`
|
||||
17. 创建 `src/app/api/bot/restart/route.ts`
|
||||
18. 创建 `src/app/bot/page.tsx` — Bot 管理 UI
|
||||
|
||||
### 第六步:清理
|
||||
19. 删除 `bot/` 目录(或归档)
|
||||
20. 更新 `start.bat` — 不再需要分别启动两个进程
|
||||
21. 更新 `.gitignore` — 移除 bot 相关条目
|
||||
|
||||
---
|
||||
|
||||
## 八、解决的问题清单
|
||||
|
||||
| 原问题 | 解决方式 |
|
||||
|---|---|
|
||||
| 两个进程各自操作同一份文件系统 | 合并为一个进程,所有文件操作走同一层 lib |
|
||||
| Hugo 两边各管各的 | HugoManager 单例,一个进程只启动一个 Hugo |
|
||||
| Git deploy 无并发控制 | GitDeployQueue 串行执行 |
|
||||
| bot 调 write API 走 HTTP 网络 | 直接调 lib 函数,零网络开销 |
|
||||
| bot 的 writeReady 不会自动恢复 | HugoManager 带健康检查 + 自动重启 |
|
||||
| 长轮询无退避策略 | 指数退避 3s→60s |
|
||||
| Telegram 图片用临时 URL | 下载到 static/upload/ 本地引用 |
|
||||
| bot 的 admin-panel 单独占一个端口 | 改为 Next.js 内页面 |
|
||||
|
||||
---
|
||||
|
||||
## 九、不变的部分
|
||||
|
||||
以下保持不变,不做改动:
|
||||
- `src/app/` 下所有页面(前端 UI)
|
||||
- `src/components/` 下所有组件
|
||||
- `src/lib/posts.ts` 的核心逻辑(仅改为在 handler 中直接调用)
|
||||
- `src/lib/ai.ts`、`src/lib/artalk.ts`、`src/lib/recycle.ts`、`src/lib/rssapi.ts`
|
||||
- `hugo.toml`、`content/`、`themes/`、`static/`、`data/`
|
||||
- GitHub Actions workflows 和 `scripts/` 目录
|
||||
- `package.json` 的根目录脚本依赖
|
||||
Reference in new issue
Block a user