# 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 { ... } /** 每 30 秒检查一次 Hugo 是否还活着,死了自动重启 */ private startHealthCheck(): void { ... } /** 用 HTTP 请求检查 Hugo 是否响应 */ private async ping(): Promise { ... } } // 全局单例 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 { ... } /** 实际执行 git add → commit → pull --rebase → push */ private async execute(message: string): Promise { ... } /** 处理队列中的下一个任务 */ private async next(): Promise { ... } } 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(method: string, body: Record): Promise> { 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): Promise { // 先尝试 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(); 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 { 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 { 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` 的根目录脚本依赖