Files
blog/write/docs/integration-plan.md
T

477 lines
15 KiB
Markdown
Raw Normal View History

2026-06-02 00:23:04 +08:00
# 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![${caption || "image"}](/upload/${filename})\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` 的根目录脚本依赖