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

15 KiB
Raw Permalink Blame History

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 移植,修复两个问题:

  1. 添加指数退避:出错后等待 3s → 6s → 12s → 最大 60s,避免 Telegram API 限流
  2. 添加正常间隔: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![${caption || "image"}](/upload/${filename})\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 端口。


七、实施步骤

按依赖关系排序:

第一步:基础设施(无现有代码改动)

  1. 创建 src/lib/hugo.ts — HugoManager 单例
  2. 创建 src/lib/git.ts — GitDeployQueue
  3. 更新 src/lib/config.ts — 合并 bot 环境变量
  4. 更新 .env — 合并 bot 配置项

第二步:移植 Bot 模块

  1. 创建 src/lib/bot/helpers.ts — 工具函数
  2. 创建 src/lib/bot/config.ts — bot 专用配置
  3. 创建 src/lib/bot/tg.ts — Telegram API 封装
  4. 创建 src/lib/bot/sessions.ts — 会话管理
  5. 创建 src/lib/bot/handlers.ts — 命令处理器(直接调 lib)
  6. 创建 src/lib/bot/poll.ts — 长轮询(带退避)
  7. 创建 src/lib/bot/index.ts — 启动入口

第三步:Next.js 集成

  1. 创建 src/instrumentation.ts — 服务端启动钩子
  2. 更新 next.config.ts — 确保 instrumentation 启用

第四步:适配现有 API 路由

  1. 改造 src/app/api/hugo/route.ts — 使用 HugoManager
  2. 改造 src/app/api/deploy/route.ts — 使用 GitDeployQueue

第五步:新增 Bot 管理页面

  1. 创建 src/app/api/bot/status/route.ts
  2. 创建 src/app/api/bot/restart/route.ts
  3. 创建 src/app/bot/page.tsx — Bot 管理 UI

第六步:清理

  1. 删除 bot/ 目录(或归档)
  2. 更新 start.bat — 不再需要分别启动两个进程
  3. 更新 .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 的根目录脚本依赖