Files
blog/write/docs/integration-plan.md
2026-06-02 00:23:04 +08:00

478 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 的根目录脚本依赖