15 KiB
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 进程,互不知道对方状态,可能互相杀进程。
// 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 冲突或仓库损坏。
// 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,在服务端进程启动时执行一次:
// 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 中启用:
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:
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:
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 部署:
// /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 移植,修复两个问题:
- 添加指数退避:出错后等待 3s → 6s → 12s → 最大 60s,避免 Telegram API 限流
- 添加正常间隔:
getUpdates返回后等待 100ms 再发起下一次,避免空转
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,改为下载到本地:
// 在 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
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
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:
# ---- 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 新增:
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 端口。
七、实施步骤
按依赖关系排序:
第一步:基础设施(无现有代码改动)
- 创建
src/lib/hugo.ts— HugoManager 单例 - 创建
src/lib/git.ts— GitDeployQueue - 更新
src/lib/config.ts— 合并 bot 环境变量 - 更新
.env— 合并 bot 配置项
第二步:移植 Bot 模块
- 创建
src/lib/bot/helpers.ts— 工具函数 - 创建
src/lib/bot/config.ts— bot 专用配置 - 创建
src/lib/bot/tg.ts— Telegram API 封装 - 创建
src/lib/bot/sessions.ts— 会话管理 - 创建
src/lib/bot/handlers.ts— 命令处理器(直接调 lib) - 创建
src/lib/bot/poll.ts— 长轮询(带退避) - 创建
src/lib/bot/index.ts— 启动入口
第三步:Next.js 集成
- 创建
src/instrumentation.ts— 服务端启动钩子 - 更新
next.config.ts— 确保 instrumentation 启用
第四步:适配现有 API 路由
- 改造
src/app/api/hugo/route.ts— 使用 HugoManager - 改造
src/app/api/deploy/route.ts— 使用 GitDeployQueue
第五步:新增 Bot 管理页面
- 创建
src/app/api/bot/status/route.ts - 创建
src/app/api/bot/restart/route.ts - 创建
src/app/bot/page.tsx— Bot 管理 UI
第六步:清理
- 删除
bot/目录(或归档) - 更新
start.bat— 不再需要分别启动两个进程 - 更新
.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.tshugo.toml、content/、themes/、static/、data/- GitHub Actions workflows 和
scripts/目录 package.json的根目录脚本依赖