This commit is contained in:
Vaica committed 2026-06-02 00:23:04 +08:00
1 parent 528104fff0
commit beb498d38b
60 files changed
+3184 -2129

No files matched your search

@@ -0,0 +1,7 @@
{
"slug": "2026-06-01-深圳真的好热",
"title": "深圳真的好热",
"deletedAt": "2026-06-01T16:21:45.062Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的好热",
"expiresAt": "2026-07-01T16:21:45.062Z"
}
@@ -0,0 +1,7 @@
{
"slug": "2026-06-01-深圳真的热",
"title": "深圳真的热",
"deletedAt": "2026-06-01T14:59:57.582Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-06-01-深圳真的热",
"expiresAt": "2026-07-01T14:59:57.582Z"
}
+7
View File
@@ -0,0 +1,7 @@
{
"slug": "202605311204",
"title": "周末我把手机系统降级了一个大版本",
"deletedAt": "2026-06-01T15:06:56.071Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-31-周末我把手机系统降级了一个大版本",
"expiresAt": "2026-07-01T15:06:56.071Z"
}
@@ -0,0 +1,24 @@
---
title: 深圳真的好热
date: '2026-06-01T00:00:00.000Z'
slug: 2026-06-01-深圳真的好热
draft: false
categories:
- 技术
tags:
- 测试
author: 小赵
layout: post
---
你好
![image](1780326640500-AgACAgQA.jpg)
今天的天气不错
你觉得呢
哈哈哈哈
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,20 @@
---
title: 深圳真的热
date: '2026-06-01'
slug: 2026-06-01-深圳真的热
draft: false
categories:
- 技术
tags:
- 深圳
author: 小赵
layout: post
---
好热啊
超级热
![image](1780325877248-AgACAgQA.jpg)
哈哈哈
此文章使用 telegram 撰写
哈哈哈
@@ -0,0 +1,35 @@
---
title: 周末我把手机系统降级了一个大版本
date: '2026-05-31T00:00:00.000Z'
slug: '202605311204'
draft: false
categories:
- 生活
tags:
- OPPO;ColorOS
author: 小赵
layout: post
ai_comment: >-
从技术角度看,ColorOS 16 的 AI
功能堆砌是典型的版本迭代陷阱。厂商为追求差异化强行注入AI模块,却忽略了系统层级的功耗平衡优化。建议关闭非必要的AI服务,这就像给过度兴奋的CPU降频——有时候减法比加法更能体现工程智慧。续航劣化可能源于AI进程的常驻唤醒,等待后续热修复补丁或许是更理性的选择。
---
从 iPhone 16E 换到 OPPO Find X8s 之后,它就一直是主力机,没再变过。
说实话,OPPO 这套系统我用着挺满意,功能到位,稳定性也没得挑。
之所以彻底放弃 iPhone 当主力,理由其实很充分很现实:微信双开和通话录音。这两样,iPhone 都给不了,而我的工作又偏偏离不开。
中间也动过双持的念头,两台手机分工协作,听起来挺美。但真用起来,麻烦比便利多。光是琢磨哪个 App 装在哪台手机上,就够头疼的。用了一阵子发现,我根本没有那份“区分使用”的需求,反而更想要一个干净利落的统一。
手机到手时,预装的是 ColorOS 15。我就是被它的宣传语打动的——“流畅稳定,德芙一样丝滑”。用下来,确实名副其实,那种跟手和舒心,让我觉得自己选对了。
所以 ColorOS 16 一推送,我毫不犹豫就更了。我的手机一直是默认自动更新,总相信新的一定更好。
但很快我就发现,厂商的劲头,好像全往一个方向使了:AI。
到处都是 AI。系统里那些曾经让我安心的“本分”感,开始变了味。
带给我实际的体验就是,耗电和发热更厉害了,手机续航也来到了一天两充,看着手机里的电池健康,实际上还是百分之百
+914
View File
@@ -0,0 +1,914 @@
# WriteBot 模块设计文档
## 一、架构
Bot 作为 Next.js 的 sidecar 模块运行,通过 `instrumentation.ts` 在服务端启动时自动拉起。
```
write/src/lib/bot/
├── index.ts # 启动入口
├── config.ts # Bot 专用配置
├── tg.ts # Telegram API 封装
├── sessions.ts # 写作会话状态管理
├── handlers.ts # 全部命令处理器
├── poll.ts # 长轮询循环(带指数退避)
└── helpers.ts # 工具函数
```
Bot 不再通过 HTTP 调 write API,直接调用 `src/lib/posts.ts`、`src/lib/rssapi.ts` 等 lib 函数。
---
## 二、交互方式
### 2.1 Telegram 原生组件使用规则
| 组件 | 用途 | 使用场景 |
|------|------|---------|
| ReplyKeyboard(底部键盘) | 常驻操作按钮 | 所有文本回复都带,分主键盘和写作键盘两套 |
| InlineKeyboard(消息内按钮) | 列表操作、确认、分页 | 文章列表编辑/删除、删除确认、分页导航、打开编辑器 |
| ForceReply(强制回复) | 引导输入 | 新建标题、设置分类/标签、添加友链/订阅源 |
| Web App | 内置浏览器打开编辑器 | /start 时提供 |
**关键规则:ReplyKeyboard 和 InlineKeyboard 不能混在同一个 reply_markup 里。**
### 2.2 ReplyKeyboard 布局
主键盘(非写作状态):
```
[/new 写文章] [/list 文章列表]
[/links 友链] [/feeds 订阅]
[/stats 数据] [/help 帮助]
```
写作键盘(写作会话中):
```
[/preview 预览] [/publish 发布]
[/draft 草稿] [/cancel 取消]
[/title 标题] [/categories 分类]
```
---
## 三、命令清单
### 3.1 系统命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/start` | 无 | 启动 Hugo,显示地址,发 Web App 编辑器按钮 |
| `/stop` | 无 | 停止 Hugo |
| `/status` | 无 | 显示 Hugo 运行状态和地址 |
| `/ip` | 无 | 显示局域网 IP |
| `/help` | 无 | 显示命令帮助(根据是否在写作中显示不同内容) |
### 3.2 文章命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/new` | 标题(可选) | 新建文章,见写作流程 |
| `/edit` | slug | 编辑已有文章,见编辑流程 |
| `/delete` | slug | 删除文章(二次确认),移入回收站 |
| `/list` | 页码(可选) | 文章列表,纯文本 + 分页 |
| `/view` | slug | 显示预览链接 |
| `/preview` | 无 | 预览当前写作内容 |
| `/publish` | 无 | 保存到本地(draft: false),不触发 git push |
| `/draft` | 无 | 保存到本地(draft: true) |
| `/deploy` | 无 | Git add → commit → pull --rebase → push |
| `/sync` | 无 | Git pull --rebase,同步远程 |
| `/undo` | 无 | 撤销最后一次追加的内容,自动预览 |
| `/cancel` | 无 | 丢弃当前写作会话 |
| `/title` | 新标题 | 修改文章标题(ForceReply 引导) |
| `/categories` | 分类 | 设置分类(逗号分隔) |
| `/tags` | 标签 | 设置标签(逗号分隔) |
| `/author` | 作者名 | 修改作者 |
### 3.3 友链命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 + 命令提示 |
| `/link_add` | url 名称 | 添加友链(ForceReply 引导) |
| `/link_del` | url | 删除友链 |
| `/link_toggle` | url | 切换显示/隐藏 |
### 3.4 订阅命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 + 命令提示 |
| `/feed_add` | url | 添加订阅源(ForceReply 引导) |
| `/feed_del` | url | 删除订阅源 |
### 3.5 数据命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/stats` | 无 | 显示文章数、友链数、订阅源数 |
---
## 四、核心流程
### 4.1 新建文章 `/new`
```
用户: /new 标题
↓ (如果没有标题,ForceReply 引导输入)
Bot: 📝 开始写:《标题》
📂 设置分类(可选)
[跳过] [设置分类] ← InlineKeyboard
↓
用户: 点 [跳过] → 直接进写作
或: 点 [设置分类] → ForceReply 引导输入分类
→ 然后同样问标签 [跳过] [设置标签]
↓
Bot: 切换写作键盘
"直接发消息写正文,完成后点「发布」"
↓
用户: 发消息 → 追加到 content
用户: 发照片 → 下载到文章文件夹,追加 markdown 引用
用户: /undo → 撤销最后一次追加,自动预览
↓
用户: /publish
↓
Bot: 保存到本地文件(draft: false)
"✅ 已保存,发 /deploy 推送到线上"
↓
用户: /deploy
↓
Bot: git add → commit → pull --rebase → push
"🚀 已发布并部署!"
```
Session 初始状态:
```typescript
{
title: "标题",
slug: "2026-06-01-标题",
content: "",
categories: [],
tags: [],
author: "小赵", // DEFAULT_AUTHOR 环境变量可覆盖
draft: true,
chunks: [], // 用于 /undo,记录每次追加的内容
mode: "write", // write | edit
}
```
### 4.2 编辑文章 `/edit`(方案 B)
```
用户: /edit slug
↓
Bot: 加载文章,显示原文内容(分条,每条 ≤3800 字)
清空 session.content(保留 title、slug、categories、tags、author)
切换写作键盘
"原文已显示,现在重新写正文,写完点「发布」"
↓
用户: 发消息 → 追加到新 content(和 /new 一样)
↓
后续流程同新建
```
关键区别:`/new` 的 slug 是新生成的,`/edit` 保留原文章的 slug。保存时调用 `updatePost` 而不是 `createPost`。
### 4.3 删除文章 `/delete`
```
用户: /delete slug
↓
Bot: "确认删除《标题》?"
[确认删除] [取消] ← InlineKeyboard
↓
用户: 点 [确认删除]
→ moveToRecycle(slug, dirPath, title)
→ "✂ 已移入回收站:《标题》\n30天内可恢复。"
↓
用户: 点 [取消]
→ "已取消"
```
poll.ts callback 处理新增 `confirm_del` 前缀:
```typescript
const CALLBACK_PREFIXES = {
edit: "edit",
confirm_del: "delete", // 确认删除
cancel_del: "noop", // 取消删除
list: "list",
links: "links",
feeds: "feeds",
skip_cat: "skip_categories", // 跳过分类
skip_tag: "skip_tags", // 跳过标签
};
```
### 4.4 文章列表 `/list`
纯文本显示,无 InlineKeyboard 操作按钮:
```
📖 文章列表 共 46 篇 · 第 1/10 页
我的第一篇文章
2026-06-01-my-first-post
另一篇文章 📝(草稿)
2026-05-28-another-post
◀ 上一页 下一页 ▶ ← InlineKeyboard(仅分页)
```
用户通过 slug 操作:`/edit slug`、`/delete slug`、`/view slug`。
### 4.5 友链列表 `/links`
纯文本 + 分页 + 命令提示:
```
🔗 友链 共 46 个 · 第 1/10 页
🌐 强仔博客
🌐 七栀
🔒 若志随笔(已隐藏)
操作命令:
/link_add url 名称 — 添加
/link_del url — 删除
/link_toggle url — 显示/隐藏
◀ 上一页 下一页 ▶
```
### 4.6 订阅列表 `/feeds`
同友链,纯文本 + 分页 + 命令提示。
---
## 五、图片处理(Hugo Page Bundle)
### 5.1 存储策略
图片存到文章自己的文件夹里,符合 Hugo page bundle 规范:
```
content/post/2026/2026-06-01-文章标题/
├── index.md
├── photo1.jpg
└── photo2.jpg
```
正文引用:`![描述](photo1.jpg)`(相对路径)
### 5.2 实现逻辑
写作中收到照片时:
1. 根据 session 的 slug 计算文章文件夹路径
- slug: `2026-06-01-文章标题`
- 文件夹: `content/post/2026/2026-06-01-文章标题/`
2. 文件夹不存在则创建
3. 下载图片到文件夹内
4. content 追加 `![caption](filename.jpg)`
保存时(draft/publish):
1. index.md 写到同一个文件夹
2. 图片已经在里面,无需移动
取消时:
1. 文件夹里只有图片没有 index.md
2. Hugo 不会渲染,无害
---
## 六、撤销 `/undo`
### 6.1 实现机制
Session 新增 `chunks: string[]` 字段:
```typescript
interface Session {
// ... 其他字段
chunks: string[]; // 每次追加的内容记录
}
```
每次用户发消息追加到 content 时,同时 push 到 chunks:
```typescript
s.chunks.push(fullText + "\n");
s.content += fullText + "\n";
```
### 6.2 `/undo` 流程
```
用户: /undo
↓
如果 chunks 为空: "没有可撤销的内容"
↓
否则:
lastChunk = chunks.pop()
content = content 去掉 lastChunk
→ 自动调用 preview 逻辑,显示当前文章内容
→ "↩ 已撤销最后一段(上方为当前内容)"
```
---
## 七、发布与部署(分离设计)
### 7.1 与网页端保持一致
| 操作 | 命令 | 效果 |
|------|------|------|
| 保存草稿 | `/draft` | 保存到本地,draft: true |
| 发布 | `/publish` | 保存到本地,draft: false,不触发 git |
| 部署 | `/deploy` | git add → commit → pull --rebase → push |
| 同步 | `/sync` | git pull --rebase,拉取远程最新 |
### 7.2 Git 冲突处理
`/deploy` 执行流程:
1. `git add content/post static`
2. `git diff --cached --quiet` → 有变更则 `git commit`
3. `git pull --rebase --autostash origin main`
- 成功 → 继续
- 冲突 → `git rebase --abort` → 回复"远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy"
4. `git push`
---
## 八、配置
### 8.1 环境变量(write/.env)
```env
# Bot 配置
TG_BOT_TOKEN=xxx
TG_ALLOWED_CHAT_IDS=xxx # 留空允许所有人
PREFER_IFACE=WLAN
DEFAULT_AUTHOR=小赵 # 新增:默认作者名
```
### 8.2 config.ts 新增
```typescript
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
```
---
## 九、命令解析
### 9.1 ReplyKeyboard 按钮文字处理
按钮发出的文字如 `/new 写文章`,解析规则:
1. `split("@")[0]` 去掉 bot mention
2. `split(/\s+/)` 拆词
3. 第一个词去掉 `/` 前缀作为命令名
4. 其余词作为参数
`/new 写文章` → 命令 `new`,参数 `写文章`
但 `new` 命令的参数是标题,所以实际行为:`/new 写文章` 会创建一篇标题为"写文章"的文章。
需要特殊处理:如果参数和命令的中文描述匹配(如 `new` 的参数是 `写文章`),则忽略该参数。
或者更简单的方案:ReplyKeyboard 按钮只发命令,不带中文。
**最终方案:ReplyKeyboard 按钮文字改为纯命令:**
```
[/new] [/list]
[/links] [/feeds]
[/stats] [/help]
```
### 9.2 ForceReply 回复识别
poll.ts 通过 `reply_to_message.text` 匹配关键词来识别 ForceReply 回复:
| 关键词 | 路由到 |
|--------|--------|
| "请输入文章标题" | `handleCommand(chatId, "new", text)` |
| "请输入新标题" | `handleCommand(chatId, "title", text)` |
| "请输入友链信息" | `handleCommand(chatId, "link_add", text)` |
| "请输入订阅源 URL" | `handleCommand(chatId, "feed_add", text)` |
| "请输入分类" | `handleCommand(chatId, "categories", text)` |
| "请输入标签" | `handleCommand(chatId, "tags", text)` |
---
## 十、会话状态机
Session 有一个 `state` 字段,控制消息处理行为:
```
idle → 非写作状态,普通命令处理
writing_title → 等待 ForceReply 标题输入
writing_cat → 等待 ForceReply 分类输入(或 InlineKeyboard 跳过)
writing_tag → 等待 ForceReply 标签输入(或 InlineKeyboard 跳过)
writing → 写作中,消息追加到 content
confirm_del → 等待删除确认(InlineKeyboard)
```
状态转换:
```
idle → ( /new ) → writing_title → ( 输入标题 ) → writing_cat
→ ( 跳过/输入 ) → writing_tag → ( 跳过/输入 ) → writing → idle
idle → ( /edit slug ) → 显示原文 → 清空 content → writing → idle
idle → ( /delete slug ) → confirm_del → ( 确认 ) → idle
→ ( 取消 ) → idle
```
---
## 十一、友链模块
### 11.1 命令清单
| 命令 | 参数 | 说明 |
|------|------|------|
| `/links` | 页码(可选) | 友链列表,纯文本 + 分页 |
| `/link_add` | url 名称 | ForceReply 引导输入,添加友链 |
| `/link_edit` | url | ForceReply 引导输入新名称,通过 oldUrl 编辑 |
| `/link_del` | url | 删除友链 |
| `/link_toggle` | url | 切换显示/隐藏 |
### 11.2 列表显示格式
```
🔗 友链 共 46 个 · 第 1/10 页
🌐 吾柯 https://blog.keepke.com
🌐 强仔博客 https://loseu.cc
🔒 七栀 https://blog.qydzz.cn
操作:/link_add /link_edit /link_del /link_toggle
◀ 上一页 下一页 ▶
```
每条显示:图标(🌐 正常 / 🔒 隐藏)+ 名称 + URL。
每页 10 条。底部 InlineKeyboard 仅分页按钮。
底部文字提示可用操作命令。
### 11.3 `/link_edit` 流程
```
用户: /link_edit url
↓
Bot: 查找该友链,显示当前信息
"请输入新名称(留空保持不变):" ← ForceReply
↓
用户: 输入新名称(或发"跳过")
↓
Bot: 调用 POST /api/links { oldUrl, name: newName }
"✅ 已更新:新名称"
```
### 11.4 API 调用
- 列表:`GET /api/links?all=1`
- 添加:`POST /api/links` body: `{ name, url }`
- 编辑:`POST /api/links` body: `{ oldUrl, name, ... }`
- 删除:`DELETE /api/links?url=xxx`
- 切换:先 GET 获取 hidden 状态,再 POST `{ url, hidden: !current }`
---
## 十二、订阅模块
### 12.1 命令清单
| 命令 | 参数 | 说明 |
|------|------|------|
| `/feeds` | 页码(可选) | 订阅源列表,纯文本 + 分页 |
| `/feed_add` | url 名称 | ForceReply 引导输入 URL 和名称 |
| `/feed_edit` | url | ForceReply 引导编辑,通过 oldUrl |
| `/feed_del` | url | 删除订阅源 |
| `/feed_health` | 无 | 检测全部订阅源健康状态 |
### 12.2 列表显示格式
```
📡 订阅源 共 52 个 · 第 1/10 页
📰 吾柯 https://blog.keepke.com
📰 强仔博客 https://loseu.cc
操作:/feed_add /feed_edit /feed_del /feed_health
◀ 上一页 下一页 ▶
```
每页 10 条。
### 12.3 `/feed_health` 流程
调用 `GET /api/health`(不传 url 参数,检测全部订阅源)。
```
用户: /feed_health
↓
Bot: "⏳ 正在检测 52 个订阅源..."
↓
Bot:
📊 订阅源健康报告
✅ 存活:49 ❌ 异常:3
❌ 异常站点:
🔴 dead-blog.com — timeout
🔴 another.com — 500
🔴 third.com — 连接失败
```
只列出异常站点,正常的不逐一显示。
### 12.4 `/feed_edit` 流程
同友链编辑,通过 oldUrl 参数调用 POST /api/feeds。
### 12.5 API 调用
- 列表:`GET /api/feeds`
- 添加:`POST /api/feeds` body: `{ url, feedTitle }`
- 编辑:`POST /api/feeds` body: `{ oldUrl, url, feedTitle }`
- 删除:`DELETE /api/feeds?url=xxx`
- 健康:`GET /api/health`
---
## 十三、新增 `/read` 命令
利用 API 的 `GET /api/articles` 接口,获取友链博客的最新文章。
### 13.1 命令
| 命令 | 参数 | 说明 |
|------|------|------|
| `/read` | 数量(可选,默认 10) | 友链最新文章 |
### 13.2 显示格式
```
📰 友链最新文章(10 篇)
吾柯 — 如何在 Hugo 中实现友链
https://blog.keepke.com/xxx
强仔博客 — 2026 年度总结
https://loseu.cc/xxx
七栀 — 我的第一次旅行
https://blog.qydzz.cn/xxx
```
每条显示:博客名 — 文章标题 + 链接。
Telegram 自动将 URL 转为可点击链接。
### 13.3 API 调用
`GET /api/articles?limit=10`
响应结构:
```json
{
"total": 380,
"articles": [
{
"title": "...",
"link": "...",
"pubDate": "...",
"feedName": "吾柯",
"siteUrl": "...",
"favicon": "...",
"author": "..."
}
]
}
```
---
## 十四、完整命令注册列表
更新 `setMyCommands` 注册的命令:
```typescript
commands: [
{ command: "new", description: "写一篇文章" },
{ command: "edit", description: "编辑文章" },
{ command: "delete", description: "删除文章" },
{ command: "list", description: "文章列表" },
{ command: "publish", description: "保存发布" },
{ command: "draft", description: "保存草稿" },
{ command: "deploy", description: "推送到线上" },
{ command: "sync", description: "同步远程" },
{ command: "undo", description: "撤销最后一段" },
{ command: "preview", description: "预览" },
{ command: "links", description: "友链管理" },
{ command: "link_add", description: "添加友链" },
{ command: "link_edit", description: "编辑友链" },
{ command: "link_del", description: "删除友链" },
{ command: "link_toggle", description: "显示/隐藏友链" },
{ command: "feeds", description: "订阅管理" },
{ command: "feed_add", description: "添加订阅源" },
{ command: "feed_edit", description: "编辑订阅源" },
{ command: "feed_del", description: "删除订阅源" },
{ command: "feed_health", description: "订阅源健康检查" },
{ command: "read", description: "友链最新文章" },
{ command: "stats", description: "网站数据" },
{ command: "help", description: "帮助" },
]
```
---
## 十五、其他可考虑的功能
### 15.1 `/random` — 随机图片
利用 API 的 `GET /api/random-image` 接口,可以做一个随机壁纸/图片功能。
适合在聊天中发一张随机图片,增加趣味性。优先级低。
### 15.2 `/greeting` — 问候语
利用 API 的 `GET /api/ai/greeting` 接口,获取 AI 生成的问候语。
适合每天早上发一条问候。可以和定时任务结合。优先级低。
### 15.3 公众号同步 `/wechat`
利用 API 的 `POST /api/wechat-material` 接口,在 bot 中直接把文章同步到微信公众号草稿箱。
当前 write 已有公众号页面,bot 可以加一个 `/wechat slug` 命令。
优先级中——如果经常在手机上写文章,同步到公众号是个高频操作。
### 15.4 定时任务
利用 Cowork 的 scheduled-tasks 功能,可以设置:
- 每天早上 9 点发一条问候语 + 友链最新文章摘要
- 每周检测一次订阅源健康状态
- 每天自动部署未发布的草稿(可选)
这些功能当前不急,后续按需添加。
---
## 十六、确认的补充项
### 16.1 写作键盘更新
加入 `/undo` 和 `/deploy`:
```
[/preview] [/undo]
[/publish] [/deploy]
[/draft] [/cancel]
[/title] [/categories]
```
### 16.2 主键盘更新
按钮文字改为纯命令:
```
[/new] [/list]
[/links] [/feeds]
[/stats] [/help]
```
### 16.3 /stats 增强
显示本地数据 + API 数据:
```
📊 网站数据
📝 本地文章:128 篇
🔗 友链:46 个
📡 订阅源:52 个
📰 友链文章:380 篇(来自 52 个博客)
🌐 usj.cc
```
### 16.4 API 不可用降级
所有调用 `api.usj.cc` 的操作统一处理:
- 超时:10 秒
- 失败回复:"⚠️ API 暂时不可用,请稍后再试"
- 涉及命令:/links、/link_add、/link_edit、/link_del、/link_toggle、/feeds、/feed_add、/feed_edit、/feed_del、/feed_health、/read、/stats(API 部分)
---
## 十七、完整的 poll.ts 命令解析更新
### 17.1 ARG_COMMANDS 更新
```typescript
const ARG_COMMANDS = new Set([
"new", "title", "categories", "tags", "author",
"edit", "view", "delete",
"link_add", "link_edit", "link_del", "link_toggle",
"feed_add", "feed_edit", "feed_del",
"list", "links", "feeds", "read",
]);
```
### 17.2 CALLBACK_PREFIXES 更新
```typescript
const CALLBACK_PREFIXES: Record<string, string> = {
edit: "edit",
confirm_del: "delete",
cancel_del: "noop",
list: "list",
links: "links",
feeds: "feeds",
skip_cat: "skip_categories",
skip_tag: "skip_tags",
};
```
### 17.3 ForceReply 关键词更新
```typescript
if (original.includes("请输入文章标题")) → new
if (original.includes("请输入新标题")) → title
if (original.includes("请输入分类")) → categories
if (original.includes("请输入标签")) → tags
if (original.includes("请输入友链信息")) → link_add
if (original.includes("请输入新名称")) → link_edit
if (original.includes("请输入订阅源 URL")) → feed_add
if (original.includes("请输入新的订阅信息")) → feed_edit
```
### 17.4 InlineKeyboard callback 处理
新增 `confirm_del` 和 `cancel_del` 处理:
- `confirm_del_slug` → 执行删除
- `cancel_del_slug` → 取消,回复"已取消"
新增 `skip_cat` 和 `skip_tag` 处理:
- `skip_cat` → 跳过分类设置
- `skip_tag` → 跳过标签设置
---
## 十八、sessions.ts 更新
### 18.1 Session 接口
```typescript
interface Session {
title: string;
slug: string;
content: string;
categories: string[];
tags: string[];
author: string;
draft: boolean;
chunks: string[]; // 每次追加的内容,用于 /undo
originalSlug?: string; // /edit 时保留原 slug
}
```
### 18.2 默认作者
从 `DEFAULT_AUTHOR` 环境变量读取,默认 "小赵"。
---
## 十九、helpers.ts 更新
### 19.1 makeSlug 保持不变
`2026-06-01-标题拼音`
### 19.2 新增 getArticleDirPath
根据 slug 计算文章文件夹路径:
```typescript
function getArticleDirPath(slug: string): string {
// slug: "2026-06-01-文章标题"
// 从 slug 提取年份前四位
const year = slug.slice(0, 4);
return path.join(CONTENT_DIR, year, slug);
}
```
### 19.3 新增 truncateText
截断文本到指定长度,用于 Telegram 消息:
```typescript
function truncateText(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
}
```
---
## 二十、config.ts 更新
新增环境变量:
```typescript
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
export const UAPI_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
export const UAPI_TOKEN = process.env.RSS_API_TOKEN || "";
```
---
## 二十一、rssapi.ts 新增函数
### 21.1 getArticles
```typescript
export async function getArticles(limit = 10): Promise<Article[]> {
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
return data.articles || [];
}
```
### 21.2 checkHealth
```typescript
export async function checkHealth(): Promise<HealthResult> {
return rssFetch("/api/health");
}
```
### 21.3 updateLink 和 updateFeed 已存在
rssapi.ts 已有 `updateLink(oldUrl, data)` 函数,用于友链编辑。
需要新增 `updateFeed(oldUrl, data)` 函数用于订阅编辑。
---
## 二十二、git.ts 更新
### 22.1 GitDeployQueue 修改
`enqueue` 方法不再自动 add static 目录,改为只 add content:
```typescript
const addResult = await this.runGit(["add", "content"]);
```
### 22.2 新增 sync 方法
```typescript
async sync(): Promise<DeployResult> {
try {
const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
return { success: true, output: result.stdout + result.stderr };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (this.isRebaseConflict(msg)) {
await this.runGit(["rebase", "--abort"], true);
return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
}
return { success: false, error: msg };
}
}
```
---
## 二十三、实施清单
按依赖顺序:
### 第一步:基础设施修改
1. config.ts — 新增 DEFAULT_AUTHOR、UAPI_BASE、UAPI_TOKEN
2. git.ts — 修改 add 路径、新增 sync 方法
3. rssapi.ts — 新增 getArticles、checkHealth、updateFeed
4. helpers.ts — 新增 getArticleDirPath、truncateText
5. sessions.ts — 新增 chunks、originalSlug 字段、默认作者从 config 读取
### 第二步:poll.ts 重写
6. 更新 ARG_COMMANDS
7. 更新 CALLBACK_PREFIXES
8. 更新 ForceReply 关键词路由
9. 新增 confirm_del / cancel_del / skip_cat / skip_tag 处理
### 第三步:handlers.ts 重写
10. 系统命令(start/stop/status/ip/help)
11. 文章命令(new/edit/delete/list/view/title/categories/tags/author)
12. 写作命令(preview/publish/draft/deploy/sync/undo/cancel)
13. 友链命令(links/link_add/link_edit/link_del/link_toggle)
14. 订阅命令(feeds/feed_add/feed_edit/feed_del/feed_health)
15. 数据命令(stats/read)
### 第四步:键盘和命令注册
16. mainKeyboard — 纯命令
17. writingKeyboard — 加 /undo /deploy
18. setMyCommands — 完整命令列表
### 第五步:验证
19. TypeScript 类型检查
20. 本地 npm run dev 测试
+477
View File
@@ -0,0 +1,477 @@
# 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` 的根目录脚本依赖
+13
View File
@@ -0,0 +1,13 @@
@echo off
chcp 65001 >nul
set "STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup"
set "VBS=E:\GitHub\blog\write\startup.vbs"
REM Create shortcut in Startup folder
echo Set WshShell = CreateObject("WScript.Shell") > "%STARTUP%\WriteBot.vbs"
echo WshShell.Run Chr(34) ^& "E:\GitHub\blog\write\start-silent.bat" ^& Chr(34), 0, False >> "%STARTUP%\WriteBot.vbs"
echo Set WshShell = Nothing >> "%STARTUP%\WriteBot.vbs"
echo Done! WriteBot will start silently on Windows login.
echo Startup folder: %STARTUP%
pause
+21
View File
@@ -0,0 +1,21 @@
# Get Startup folder path
$startup = [Environment]::GetFolderPath('Startup')
$batPath = "E:\GitHub\blog\write\start-silent.bat"
$vbsPath = Join-Path $startup "WriteBot.vbs"
# Create VBS wrapper for silent execution (no cmd window)
$vbs = @"
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run Chr(34) & "$batPath" & Chr(34), 0, False
Set WshShell = Nothing
"@
$vbs | Out-File -FilePath $vbsPath -Encoding ASCII
# Verify
if (Test-Path $vbsPath) {
Write-Host "WriteBot startup script installed to: $vbsPath"
Write-Host "It will start silently on Windows login."
} else {
Write-Host "Failed to install startup script."
}
+13
View File
@@ -11,6 +11,7 @@
"date-fns": "^4.2.1",
"gray-matter": "^4.0.3",
"lucide-react": "^1.16.0",
"marked": "^18.0.4",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
@@ -5020,6 +5021,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"date-fns": "^4.2.1",
"gray-matter": "^4.0.3",
"lucide-react": "^1.16.0",
"marked": "^18.0.4",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
+33
View File
@@ -0,0 +1,33 @@
@echo off
chcp 65001 >nul
echo ========================================
echo WriteBot Auto-Start Setup
echo ========================================
echo.
REM Check for admin rights
net session >nul 2>&1
if %errorlevel% neq 0 (
echo This script requires Administrator privileges.
echo Right-click this file and select "Run as administrator".
echo.
pause
exit /b 1
)
echo Creating scheduled task...
schtasks /create /tn "WriteBot" /tr "cmd /c cd /d E:\GitHub\blog\write && npm run dev -- -p 8016" /sc onlogon /rl highest /f
if %errorlevel%==0 (
echo.
echo [OK] WriteBot scheduled task created.
echo.
echo Verify: schtasks /query /tn "WriteBot"
echo Remove: schtasks /delete /tn "WriteBot" /f
) else (
echo.
echo [FAIL] Could not create task.
)
echo.
pause
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { hugo } from "@/lib/hugo";
export async function POST() {
try {
hugo.stop();
const result = await hugo.start();
return NextResponse.json({ ok: result.ok });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { hugo } from "@/lib/hugo";
import { TG_BOT_TOKEN } from "@/lib/config";
export async function GET() {
const hugoStatus = hugo.status();
return NextResponse.json({
bot: {
enabled: !!TG_BOT_TOKEN,
polling: !!TG_BOT_TOKEN,
},
hugo: hugoStatus,
});
}
+13 -64
View File
@@ -1,39 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import { BLOG_ROOT } from "@/lib/config";
const execFileAsync = promisify(execFile);
type GitExecError = Error & {
stdout?: string;
stderr?: string;
code?: number;
};
async function runGit(args: string[], allowFailure = false) {
try {
const { stdout, stderr } = await execFileAsync("git", args, {
cwd: BLOG_ROOT,
windowsHide: true,
maxBuffer: 1024 * 1024,
});
return { stdout, stderr, code: 0 };
} catch (error) {
const err = error as GitExecError;
const result = {
stdout: err.stdout || "",
stderr: err.stderr || err.message,
code: typeof err.code === "number" ? err.code : 1,
};
if (allowFailure) return result;
throw new Error((result.stderr || result.stdout || err.message).trim());
}
}
function isRebaseConflict(message: string) {
return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message);
}
import { deployQueue } from "@/lib/git";
export async function POST(request: NextRequest) {
try {
@@ -44,40 +10,23 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Title is required" }, { status: 400 });
}
let output = "";
const result = await deployQueue.enqueue(`发布: ${title}`);
const addResult = await runGit(["add", "content/post", "static"]);
output += addResult.stdout + addResult.stderr;
const diffResult = await runGit(["diff", "--cached", "--quiet"], true);
if (diffResult.code === 1) {
const commitResult = await runGit(["commit", "-m", `发布: ${title}`]);
output += commitResult.stdout + commitResult.stderr;
} else if (diffResult.code !== 0) {
throw new Error((diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim());
if (result.success) {
return NextResponse.json({ success: true, output: result.output });
}
try {
const pullResult = await runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
output += pullResult.stdout + pullResult.stderr;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isRebaseConflict(message)) {
return NextResponse.json(
{
error: "远程仓库有新内容,自动合并失败。请在终端解决冲突后执行 git rebase --continue,再重新发布。",
detail: message.slice(0, 1000),
},
{ status: 409 }
);
}
throw error;
if (result.conflict) {
return NextResponse.json(
{
error: result.error,
detail: result.output,
},
{ status: 409 }
);
}
const pushResult = await runGit(["push"]);
output += pushResult.stdout + pushResult.stderr;
return NextResponse.json({ success: true, output: output.slice(0, 1000) });
return NextResponse.json({ error: result.error }, { status: 500 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Deploy failed" },
+17 -40
View File
@@ -1,50 +1,27 @@
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { BLOG_ROOT, HUGO_PREVIEW } from "@/lib/config";
const execAsync = promisify(exec);
async function isHugoRunning(): Promise<boolean> {
try {
const res = await fetch(HUGO_PREVIEW, { signal: AbortSignal.timeout(1500) });
return res.ok || res.status < 500;
} catch {
return false;
}
}
let starting = false;
import { hugo } from "@/lib/hugo";
import { HUGO_PREVIEW } from "@/lib/config";
export async function GET() {
try {
const running = await isHugoRunning();
if (running) {
const result = await hugo.start();
if (result.alreadyRunning || result.ok) {
return NextResponse.json({ running: true, url: HUGO_PREVIEW });
}
if (starting) {
return NextResponse.json({ running: false, starting: true, url: HUGO_PREVIEW });
}
starting = true;
// Kill existing process on port 1313, then start hugo serve
const killCmd = `for /f "tokens=5" %a in ('netstat -ano ^| findstr :1313 ^| findstr LISTENING') do taskkill /F /PID %a 2>nul`;
const startCmd = `cd "${BLOG_ROOT}" && hugo serve --port 1313 --noHTTPCache`;
try { await execAsync(killCmd); } catch { /* nothing to kill */ }
exec(startCmd, (err) => {
if (err) console.error("Hugo start error:", err.message);
});
// Wait a moment for Hugo to start
await new Promise((r) => setTimeout(r, 2000));
starting = false;
const started = await isHugoRunning();
return NextResponse.json({ running: started, url: HUGO_PREVIEW });
return NextResponse.json({ running: false, url: HUGO_PREVIEW });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
export async function DELETE() {
try {
hugo.stop();
return NextResponse.json({ running: false });
} catch (error: unknown) {
starting = false;
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import { Bot, RefreshCw, Server } from "lucide-react";
interface BotStatus {
bot: { enabled: boolean; polling: boolean };
hugo: { running: boolean; starting: boolean };
}
export default function BotPage() {
const [status, setStatus] = useState<BotStatus | null>(null);
const [restarting, setRestarting] = useState(false);
async function fetchStatus() {
try {
const res = await fetch("/api/bot/status");
const data = await res.json();
setStatus(data);
} catch {
setStatus(null);
}
}
async function restartHugo() {
setRestarting(true);
try {
await fetch("/api/bot/restart", { method: "POST" });
} catch { /* ignore */ }
setTimeout(async () => {
await fetchStatus();
setRestarting(false);
}, 3000);
}
useEffect(() => {
fetchStatus();
const timer = setInterval(fetchStatus, 10000);
return () => clearInterval(timer);
}, []);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Bot 管理</h1>
{/* Bot Status */}
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Bot size={20} /> Telegram Bot
</h2>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
status?.bot.enabled
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span className="text-sm">
{status?.bot.enabled
? "已配置,随服务自动启动"
: "未配置 TG_BOT_TOKEN"}
</span>
</div>
</div>
{/* Hugo Status */}
<div className="rounded-lg border border-[var(--border)] p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Server size={20} /> Hugo Server
</h2>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
status?.hugo.running
? "bg-green-500"
: status?.hugo.starting
? "bg-yellow-500"
: "bg-gray-400"
}`}
/>
<span className="text-sm">
{status?.hugo.running
? "运行中 — localhost:1313"
: status?.hugo.starting
? "启动中..."
: "已停止"}
</span>
</div>
<button
onClick={restartHugo}
disabled={restarting}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
<RefreshCw size={14} className={restarting ? "animate-spin" : ""} />
{restarting ? "重启中..." : "重启 Hugo"}
</button>
</div>
</div>
);
}
+2 -1
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X } from "lucide-react";
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot } from "lucide-react";
import ThemeToggle from "./ThemeToggle";
const navItems = [
@@ -15,6 +15,7 @@ const navItems = [
{ href: "/images", label: "图片", icon: Image },
{ href: "/wechat", label: "公众号", icon: MessageCircle },
{ href: "/recycle", label: "回收站", icon: Trash2 },
{ href: "/bot", label: "Bot", icon: Bot },
];
export default function Sidebar() {
+8
View File
@@ -0,0 +1,8 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startBot } = await import("@/lib/bot");
startBot().catch((e) => {
console.error("[instrumentation] bot startup failed:", e);
});
}
}
+30
View File
@@ -0,0 +1,30 @@
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const cache = new Map<string, CacheEntry<unknown>>();
export function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data as T;
}
export function setCache<T>(key: string, data: T, ttlMs: number): void {
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
export function invalidateCache(key: string): void {
cache.delete(key);
}
export function invalidateByPrefix(prefix: string): void {
for (const key of cache.keys()) {
if (key.startsWith(prefix)) cache.delete(key);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { TG_BOT_TOKEN, TG_ALLOWED_IDS } from "@/lib/config";
export const TG_TOKEN = TG_BOT_TOKEN;
export const ALLOWED_IDS = TG_ALLOWED_IDS;
export const API = TG_TOKEN ? `https://api.telegram.org/bot${TG_TOKEN}` : "";
+654
View File
@@ -0,0 +1,654 @@
import { getPost, listPosts } from "@/lib/posts";
import { getLinks, addLink, updateLink, deleteLink, getFeeds, addFeed, updateFeed, deleteFeed, getFeedsHealth, getArticles } from "@/lib/rssapi";
import { moveToRecycle } from "@/lib/recycle";
import { deployQueue } from "@/lib/git";
import { hugo } from "@/lib/hugo";
import { getLanIP, DEFAULT_AUTHOR } from "@/lib/config";
import { sendMessage, tg } from "./tg";
import { getSession, hasSession, clearSession, saveSession, undoLastChunk } from "./sessions";
import { makeSlug, sessionSummary, truncateText } from "./helpers";
import { getCached, setCache, invalidateByPrefix } from "./cache";
type Handler = (chatId: number, arg?: string) => Promise<void>;
const handlers: Record<string, Handler> = {};
// ---- Cached API wrappers ----
const CACHE_TTL_LINKS = 5 * 60 * 1000;
const CACHE_TTL_FEEDS = 5 * 60 * 1000;
const CACHE_TTL_ARTICLES = 3 * 60 * 1000;
const CACHE_TTL_HEALTH = 10 * 60 * 1000;
async function cachedLinks(all = true) {
const key = "links_" + (all ? "all" : "visible");
const hit = getCached<ReturnType<typeof getLinks> extends Promise<infer R> ? R : never>(key);
if (hit) return hit;
const data = await getLinks(all);
setCache(key, data, CACHE_TTL_LINKS);
return data;
}
async function cachedFeeds() {
const key = "feeds";
const hit = getCached<ReturnType<typeof getFeeds> extends Promise<infer R> ? R : never>(key);
if (hit) return hit;
const data = await getFeeds();
setCache(key, data, CACHE_TTL_FEEDS);
return data;
}
async function cachedArticles(limit: number) {
const key = "articles_" + limit;
const hit = getCached<ReturnType<typeof getArticles> extends Promise<infer R> ? R : never>(key);
if (hit) return hit;
const data = await getArticles(limit);
setCache(key, data, CACHE_TTL_ARTICLES);
return data;
}
async function cachedHealth() {
const key = "health";
const hit = getCached<ReturnType<typeof getFeedsHealth> extends Promise<infer R> ? R : never>(key);
if (hit) return hit;
const data = await getFeedsHealth();
setCache(key, data, CACHE_TTL_HEALTH);
return data;
}
// ---- 键盘 ----
function mainKeyboard() {
return {
keyboard: [
[{ text: "/new" }, { text: "/list" }],
[{ text: "/links" }, { text: "/feeds" }],
[{ text: "/stats" }, { text: "/help" }],
],
resize_keyboard: true,
one_time_keyboard: false,
};
}
function writingKeyboard() {
return {
keyboard: [
[{ text: "/preview" }, { text: "/undo" }],
[{ text: "/publish" }, { text: "/deploy" }],
[{ text: "/draft" }, { text: "/cancel" }],
[{ text: "/title" }, { text: "/categories" }],
],
resize_keyboard: true,
one_time_keyboard: false,
};
}
async function sendMain(chatId: number, text: string) {
await sendMessage(chatId, text, { reply_markup: mainKeyboard() });
}
async function sendWrite(chatId: number, text: string) {
await sendMessage(chatId, text, { reply_markup: writingKeyboard() });
}
// ---- System ----
handlers.start = async (chatId) => {
const ok = await hugo.ensureRunning();
const ip = getLanIP();
await sendMain(chatId, "✅ <b>已启动</b>\n\n📱 手机: http://" + ip + ":8016/\n💻 电脑: http://localhost:8016/");
await tg("sendMessage", {
chat_id: chatId,
text: "点下方按钮打开编辑器:",
reply_markup: { inline_keyboard: [[{ text: "🌐 打开编辑器", web_app: { url: "http://" + ip + ":8016/" } }]] },
});
};
handlers.stop = async (chatId) => { hugo.stop(); await sendMain(chatId, "⚡ Hugo 已停止。"); };
handlers.status = async (chatId) => {
const s = hugo.status();
const status = s.running ? "✅ 运行中" : s.starting ? "⏳ 启动中..." : "⏸ 已停止";
const lines = ["<b>Hugo:</b> " + status];
if (s.running) { const ip = getLanIP(); lines.push("📱 http://" + ip + ":8016", "💻 http://localhost:8016"); }
await sendMain(chatId, lines.join("\n"));
};
handlers.ip = async (chatId) => { await sendMain(chatId, "Phone: http://" + getLanIP() + ":8016"); };
handlers.shutdown = async (chatId) => {
await tg("sendMessage", {
chat_id: chatId,
text: "⚠️ 确认关机?\n\n电脑将在确认后 10 秒关机。",
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[
{ text: "✅ 确认关机", callback_data: "confirm_shutdown" },
{ text: "❌ 取消", callback_data: "cancel_shutdown" },
]],
},
});
};
handlers.confirm_shutdown = async (chatId) => {
await sendMessage(chatId, "⏻ 电脑将在 10 秒后关机...");
const { exec } = await import("child_process");
exec("shutdown /s /t 10", (err) => {
if (err) console.error("[shutdown]", err.message);
});
};
handlers.cancel_shutdown = async (chatId) => {
const { exec } = await import("child_process");
exec("shutdown /a", () => {});
await sendMain(chatId, "已取消关机。");
};
handlers.reboot = async (chatId) => {
await tg("sendMessage", {
chat_id: chatId,
text: "⚠️ 确认重启?\n\n电脑将在确认后 10 秒重启。",
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[
{ text: "✅ 确认重启", callback_data: "confirm_reboot" },
{ text: "❌ 取消", callback_data: "cancel_reboot" },
]],
},
});
};
handlers.confirm_reboot = async (chatId) => {
await sendMessage(chatId, "🔄 电脑将在 10 秒后重启...");
const { exec } = await import("child_process");
exec("shutdown /r /t 10", (err) => {
if (err) console.error("[reboot]", err.message);
});
};
handlers.cancel_reboot = async (chatId) => {
const { exec } = await import("child_process");
exec("shutdown /a", () => {});
await sendMain(chatId, "已取消重启。");
};
handlers.help = async (chatId) => {
if (hasSession(chatId)) {
await sendWrite(chatId,
"✍ <b>写作中</b>\n\n" +
"/publish 保存 /draft 草稿 /deploy 部署\n" +
"/preview 预览 /undo 撤销 /cancel 取消\n" +
"/title 标题 /categories 分类 /tags 标签\n\n" +
"直接发消息追加正文,发照片追加图片"
);
} else {
await sendMain(chatId,
"🤖 <b>WriteBot 命令</b>\n\n" +
"📝 <b>文章</b>\n" +
"/new 标题 — 新建\n" +
"/edit slug — 编辑\n" +
"/delete slug — 删除\n" +
"/list — 文章列表\n\n" +
"🔗 <b>友链</b>\n" +
"/links — 列表\n" +
"/link_add url 名称 — 添加\n" +
"/link_edit url — 编辑\n" +
"/link_del url — 删除\n" +
"/link_toggle url — 显示/隐藏\n\n" +
"📡 <b>订阅</b>\n" +
"/feeds — 列表\n" +
"/feed_add url — 添加\n" +
"/feed_edit url — 编辑\n" +
"/feed_del url — 删除\n" +
"/feed_health — 健康检查\n\n" +
"📰 /read — 友链最新文章\n" +
"📊 /stats — 网站数据\n\n" +
"⏻ /shutdown — 关机 🔄 /reboot — 重启"
);
}
};
// ---- Articles: New ----
handlers.new = async (chatId, title) => {
if (title) {
const s = getSession(chatId);
s.title = title;
s.slug = makeSlug(title);
s.content = "";
s.categories = [];
s.tags = [];
s.draft = true;
s.author = DEFAULT_AUTHOR;
s.chunks = [];
await tg("sendMessage", {
chat_id: chatId,
text: "📝 开始写:<b>" + title + "</b>\n\n📂 设置分类(可选)",
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[
{ text: "跳过", callback_data: "skip_cat" },
{ text: "设置分类", callback_data: "skip_cat" },
]],
},
});
await tg("sendMessage", {
chat_id: chatId,
text: "请输入分类(逗号分隔,或发「跳过」):",
reply_markup: { force_reply: true, input_field_placeholder: "技术、生活" },
});
} else {
await tg("sendMessage", { chat_id: chatId, text: "📝 请输入文章标题:", reply_markup: { force_reply: true, input_field_placeholder: "在这里输入标题..." } });
}
};
handlers.skip_categories = async (chatId) => {
const s = getSession(chatId);
if (!s.title) return;
s.categories = [];
await tg("sendMessage", {
chat_id: chatId,
text: "🏷 设置标签(可选)",
reply_markup: {
inline_keyboard: [[
{ text: "跳过", callback_data: "skip_tag" },
{ text: "设置标签", callback_data: "skip_tag" },
]],
},
});
await tg("sendMessage", {
chat_id: chatId,
text: "请输入标签(逗号分隔,或发「跳过」):",
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
});
};
handlers.skip_tags = async (chatId) => {
const s = getSession(chatId);
if (!s.title) return;
s.tags = [];
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
};
handlers.categories = async (chatId, cats) => {
const s = getSession(chatId);
s.categories = cats ? cats.split(/[,;",、]+/).map((c) => c.trim()).filter(Boolean) : [];
await tg("sendMessage", {
chat_id: chatId,
text: "📂 分类:" + (s.categories.join("、") || "无") + "\n\n🏷 设置标签(可选)",
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[
{ text: "跳过", callback_data: "skip_tag" },
{ text: "设置标签", callback_data: "skip_tag" },
]],
},
});
await tg("sendMessage", {
chat_id: chatId,
text: "请输入标签(逗号分隔,或发「跳过」):",
reply_markup: { force_reply: true, input_field_placeholder: "Hugo、博客" },
});
};
handlers.tags = async (chatId, tags) => {
const s = getSession(chatId);
s.tags = tags ? tags.split(/[,;",、]+/).map((t) => t.trim()).filter(Boolean) : [];
await sendWrite(chatId, "✅ 准备就绪,开始写正文吧!\n\n📝 <b>" + s.title + "</b>\n📂 " + (s.categories.join("、") || "无分类") + "\n🏷 " + (s.tags.join("、") || "无标签"));
};
// ---- Articles: Edit (方案 B) ----
handlers.edit = async (chatId, slug) => {
if (!slug) { await sendMain(chatId, "格式:/edit slug\n先用 /list 查看文章。"); return; }
const post = getPost(slug);
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
const originalContent = post.content || "";
if (originalContent) {
const maxLen = 3800;
for (let i = 0; i < originalContent.length; i += maxLen) {
const chunk = originalContent.slice(i, i + maxLen);
const prefix = originalContent.length > maxLen ? "(" + (Math.floor(i / maxLen) + 1) + "/" + Math.ceil(originalContent.length / maxLen) + ")\n" : "";
await sendMessage(chatId, prefix + chunk);
}
}
const s = getSession(chatId);
s.title = post.frontMatter.title;
s.slug = post.frontMatter.slug;
s.originalSlug = post.frontMatter.slug;
s.content = "";
s.chunks = [];
s.categories = (post.frontMatter.categories as string[]) || [];
s.tags = (post.frontMatter.tags as string[]) || [];
s.author = post.frontMatter.author || DEFAULT_AUTHOR;
s.draft = post.frontMatter.draft ?? true;
await sendWrite(chatId,
"✏ 编辑:<b>" + s.title + "</b>\n\n" +
"原文已显示,现在重新写正文\n" +
"📂 " + (s.categories.join("、") || "无分类") + "\n" +
"🏷 " + (s.tags.join("、") || "无标签") + "\n\n" +
"直接发消息写正文,完成后点「发布」"
);
};
// ---- Articles: Delete (二次确认) ----
handlers.delete = async (chatId, slug) => {
if (!slug) { await sendMain(chatId, "格式:/delete slug\n先用 /list 查看文章。"); return; }
const post = getPost(slug);
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
await tg("sendMessage", {
chat_id: chatId,
text: "⚠️ 确认删除《" + post.frontMatter.title + "》?\n\n删除后移入回收站,30天内可恢复。",
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[
{ text: "✅ 确认删除", callback_data: "confirm_del_" + slug },
{ text: "❌ 取消", callback_data: "cancel_del_" + slug },
]],
},
});
};
handlers.confirm_del = async (chatId, slug) => {
if (!slug) return;
const post = getPost(slug);
if (!post) { await sendMain(chatId, "❌ 未找到该文章"); return; }
moveToRecycle(slug, post.dirPath, post.frontMatter.title);
await sendMain(chatId, "✂ 已移入回收站:" + post.frontMatter.title + "\n30天内可恢复。");
};
handlers.cancel_del = async (chatId) => {
await sendMain(chatId, "已取消删除。");
};
// ---- Articles: List ----
handlers.list = async (chatId, pageStr) => {
const page = parseInt(pageStr || "1") || 1;
const perPage = 10;
try {
const { posts, total, totalPages } = listPosts({ page, perPage });
if (total === 0) { await sendMain(chatId, "📖 还没有文章,发 /new 开始写。"); return; }
const lines = ["📖 <b>文章列表</b> 共 " + total + " 篇 · 第 " + page + "/" + totalPages + " 页\n"];
for (const p of posts) {
const draft = p.draft ? " 📝" : "";
lines.push(p.title + draft + "\n <code>" + p.slug + "</code>");
}
const navRow: { text: string; callback_data: string }[] = [];
if (page > 1) navRow.push({ text: "◀ 上一页", callback_data: "list_" + (page - 1) });
if (page < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "list_" + (page + 1) });
await tg("sendMessage", {
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
});
} catch (e: unknown) { await sendMain(chatId, "❌ " + (e instanceof Error ? e.message : String(e))); }
};
// ---- Articles: View / Title / Author / Preview ----
handlers.view = async (chatId, slug) => {
if (!slug) { await sendMain(chatId, "格式:/view slug"); return; }
const ip = getLanIP();
await tg("sendMessage", {
chat_id: chatId,
text: "🔖 <b>" + slug + "</b>\n\n📱 http://" + ip + ":1313/" + slug + ".html\n💻 http://localhost:1313/" + slug + ".html",
parse_mode: "HTML", disable_web_page_preview: true,
reply_markup: { inline_keyboard: [[{ text: "🌐 浏览器打开", url: "http://localhost:1313/" + slug + ".html" }]] },
});
};
handlers.title = async (chatId, newTitle) => {
if (!newTitle) { await tg("sendMessage", { chat_id: chatId, text: "请输入新标题:", reply_markup: { force_reply: true, input_field_placeholder: "新标题..." } }); return; }
const s = getSession(chatId);
s.title = newTitle;
await sendWrite(chatId, "✏ 标题已更新:<b>" + newTitle + "</b>");
};
handlers.author = async (chatId, author) => {
getSession(chatId).author = author || DEFAULT_AUTHOR;
await sendWrite(chatId, "✍ 作者:" + getSession(chatId).author);
};
handlers.preview = async (chatId) => {
const s = getSession(chatId);
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
if (!s.content) { await sendWrite(chatId, "正文为空。"); return; }
await sendWrite(chatId, "📝 <b>" + s.title + "</b>\n\n" + truncateText(s.content, 3800));
};
// ---- Writing: Publish / Draft / Deploy / Sync / Undo / Cancel ----
handlers.publish = async (chatId) => {
const s = getSession(chatId);
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再发布。"); return; }
try {
const title = s.title;
await saveSession(s, false);
clearSession(chatId);
await sendMain(chatId, "✅ 已保存:<b>" + title + "</b>\n\n发 /deploy 推送到线上");
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
};
handlers.draft = async (chatId) => {
const s = getSession(chatId);
if (!s || !s.title) { await sendMain(chatId, "还没开始写。发 /new 开始。"); return; }
if (!s.content) { await sendWrite(chatId, "正文为空,先写点内容再保存。"); return; }
try {
const title = s.title;
await saveSession(s, true);
clearSession(chatId);
await sendMain(chatId, "📄 草稿已保存:<b>" + title + "</b>");
} catch (e: unknown) { await sendWrite(chatId, "❌ 保存失败:" + (e instanceof Error ? e.message : String(e))); }
};
handlers.deploy = async (chatId) => {
await sendMessage(chatId, "⏳ 正在部署...", { reply_markup: { remove_keyboard: true } });
const result = await deployQueue.enqueue("发布文章");
if (result.success) await sendMain(chatId, "🚀 <b>已部署!</b>\n\n🌐 https://usj.cc");
else if (result.conflict) await sendMain(chatId, "⚠️ " + (result.error || "远程冲突"));
else await sendMain(chatId, "❌ 部署失败:" + (result.error || "未知错误"));
};
handlers.sync = async (chatId) => {
await sendMessage(chatId, "⏳ 正在同步远程...");
const result = await deployQueue.sync();
if (result.success) await sendMain(chatId, "✅ 同步完成\n\n" + (result.output || "").slice(0, 500));
else await sendMain(chatId, "❌ " + (result.error || "同步失败"));
};
handlers.undo = async (chatId) => {
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
const ok = undoLastChunk(chatId);
if (!ok) { await sendWrite(chatId, "没有可撤销的内容。"); return; }
const s = getSession(chatId);
await sendWrite(chatId, "↩ 已撤销\n\n📝 <b>" + s.title + "</b>\n\n" + (s.content ? truncateText(s.content, 3800) : "(正文为空)"));
};
handlers.cancel = async (chatId) => {
if (!hasSession(chatId)) { await sendMain(chatId, "没有活动的草稿。"); return; }
clearSession(chatId);
await sendMain(chatId, "✂ 草稿已丢弃。");
};
// ---- Links ----
handlers.links = async (chatId, pageStr) => {
const page = parseInt(pageStr || "1") || 1;
const perPage = 10;
try {
const allLinks = await cachedLinks(true);
if (allLinks.length === 0) { await sendMain(chatId, "还没有友链。\n\n添加:/link_add url 名称"); return; }
const totalPages = Math.ceil(allLinks.length / perPage);
const p = Math.min(page, totalPages);
const start = (p - 1) * perPage;
const pageLinks = allLinks.slice(start, start + perPage);
const lines = ["🔗 <b>友链</b> 共 " + allLinks.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
for (const l of pageLinks) {
const icon = l.hidden ? "🔒" : "🌐";
lines.push(icon + " " + (l.name || l.url) + "\n " + l.url);
}
lines.push("\n/link_add /link_edit /link_del /link_toggle");
const navRow: { text: string; callback_data: string }[] = [];
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "links_" + (p - 1) });
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "links_" + (p + 1) });
await tg("sendMessage", {
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
});
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.link_add = async (chatId, arg) => {
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入友链信息:\n格式:url 名称", reply_markup: { force_reply: true, input_field_placeholder: "url 名称" } }); return; }
const parts = arg.split(/\s+/);
let url = parts[0];
const name = parts.slice(1).join(" ") || url;
if (!url.startsWith("http")) url = "https://" + url;
try { await addLink({ name, url }); invalidateByPrefix("links"); await sendMain(chatId, "✅ 友链已添加:" + name); }
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.link_edit = async (chatId, arg) => {
if (!arg) { await sendMain(chatId, "格式:/link_edit url\n用 /links 查看友链。"); return; }
const s = getSession(chatId);
s.originalSlug = arg;
await tg("sendMessage", {
chat_id: chatId,
text: "当前 URL:" + arg + "\n请输入新名称(空则保持不变):",
reply_markup: { force_reply: true, input_field_placeholder: "新名称" },
});
};
handlers.link_del = async (chatId, url) => {
if (!url) { await sendMain(chatId, "格式:/link_del url"); return; }
try { await deleteLink(url); invalidateByPrefix("links"); await sendMain(chatId, "✅ 已删除:" + url); }
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.link_toggle = async (chatId, url) => {
if (!url) { await sendMain(chatId, "格式:/link_toggle url"); return; }
try {
const allLinks = await cachedLinks(true);
const link = allLinks.find((l) => l.url === url);
if (!link) { await sendMain(chatId, "❌ 未找到:" + url); return; }
await updateLink(url, { hidden: !link.hidden });
invalidateByPrefix("links");
await sendMain(chatId, (!link.hidden ? "🔒 已隐藏" : "🌐 已显示") + ":" + (link.name || url));
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
// ---- Feeds ----
handlers.feeds = async (chatId, pageStr) => {
const page = parseInt(pageStr || "1") || 1;
const perPage = 10;
try {
const allFeeds = await cachedFeeds();
if (allFeeds.length === 0) { await sendMain(chatId, "还没有订阅源。\n\n添加:/feed_add url"); return; }
const totalPages = Math.ceil(allFeeds.length / perPage);
const p = Math.min(page, totalPages);
const start = (p - 1) * perPage;
const pageFeeds = allFeeds.slice(start, start + perPage);
const lines = ["📡 <b>订阅源</b> 共 " + allFeeds.length + " 个 · 第 " + p + "/" + totalPages + " 页\n"];
for (const f of pageFeeds) {
lines.push("📰 " + (f.feedTitle || f.url) + "\n " + f.url);
}
lines.push("\n/feed_add /feed_edit /feed_del /feed_health");
const navRow: { text: string; callback_data: string }[] = [];
if (p > 1) navRow.push({ text: "◀ 上一页", callback_data: "feeds_" + (p - 1) });
if (p < totalPages) navRow.push({ text: "下一页 ▶", callback_data: "feeds_" + (p + 1) });
await tg("sendMessage", {
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
reply_markup: navRow.length ? { inline_keyboard: [navRow] } : undefined,
});
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.feed_add = async (chatId, arg) => {
if (!arg) { await tg("sendMessage", { chat_id: chatId, text: "请输入订阅源 URL:", reply_markup: { force_reply: true, input_field_placeholder: "https://example.com/rss.xml" } }); return; }
const parts = arg.split(/\s+/);
const url = parts[0];
const feedTitle = parts.slice(1).join(" ") || "";
try { await addFeed(url, feedTitle); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 订阅源已添加:" + url); }
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.feed_edit = async (chatId, arg) => {
if (!arg) { await sendMain(chatId, "格式:/feed_edit url\n用 /feeds 查看。"); return; }
const s = getSession(chatId);
s.originalSlug = arg;
await tg("sendMessage", {
chat_id: chatId,
text: "当前 URL:" + arg + "\n请输入新的订阅信息:\n格式:新URL 新名称",
reply_markup: { force_reply: true, input_field_placeholder: "新URL 新名称" },
});
};
handlers.feed_del = async (chatId, url) => {
if (!url) { await sendMain(chatId, "格式:/feed_del url"); return; }
try { await deleteFeed(url); invalidateByPrefix("feeds"); await sendMain(chatId, "✅ 已删除:" + url); }
catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.feed_health = async (chatId) => {
try {
await sendMessage(chatId, "⏳ 正在检测订阅源...");
const result = await cachedHealth();
const lines = ["📊 <b>订阅源健康报告</b>\n", "✅ 存活:" + result.alive + " ❌ 异常:" + result.dead];
const dead = (result.results || []).filter((r) => !r.ok);
if (dead.length > 0) {
lines.push("\n❌ 异常站点:");
for (const d of dead.slice(0, 10)) {
lines.push("🔴 " + d.url + " — " + (d.error || String(d.status)));
}
}
await sendMain(chatId, lines.join("\n"));
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
// ---- Data ----
handlers.stats = async (chatId) => {
try {
const { total } = listPosts({ perPage: 1 });
const allLinks = await cachedLinks(true);
const allFeeds = await cachedFeeds();
const visibleLinks = allLinks.filter((l) => !l.hidden).length;
let extra = "";
try {
const healthRes = await cachedHealth();
extra = "\n\n📰 友链博客:" + healthRes.total + " 个";
} catch (e) { /* ignore */ }
await sendMain(chatId,
"📊 <b>网站数据</b>\n\n" +
"📝 本地文章:" + total + " 篇\n" +
"🔗 友链:" + visibleLinks + " 个\n" +
"📡 订阅源:" + allFeeds.length + " 个" +
extra + "\n\n🌐 usj.cc"
);
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
handlers.read = async (chatId, limitStr) => {
const limit = parseInt(limitStr || "10") || 10;
try {
const articles = await cachedArticles(Math.min(limit, 20));
if (articles.length === 0) { await sendMain(chatId, "暂无最新文章。"); return; }
const lines = ["📰 <b>友链最新文章</b>(" + articles.length + " 篇)\n"];
for (const a of articles) {
lines.push((a.feedName || "未知") + " — " + a.title + "\n " + a.link);
}
await tg("sendMessage", {
chat_id: chatId, text: lines.join("\n"), parse_mode: "HTML", disable_web_page_preview: true,
});
} catch (e) { await sendMain(chatId, "⚠️ API 暂时不可用,请稍后再试"); }
};
export default handlers;
+37
View File
@@ -0,0 +1,37 @@
import path from "path";
import { CONTENT_DIR } from "@/lib/config";
export function makeSlug(title: string): string {
const dateStr = new Date().toISOString().slice(0, 10);
const slugPart = (title || "untitled")
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "post";
return dateStr + "-" + slugPart;
}
export function sessionSummary(s: {
title: string;
categories: string[];
tags: string[];
content: string;
}): string {
const lines = s.content ? s.content.split("\n").filter(Boolean).length : 0;
const chars = s.content.length;
const parts: string[] = [];
parts.push(`\u{1f4dd} <b>${s.title || "未命名"}</b>`);
if (s.categories.length) parts.push(`\u{1f4c2} ${s.categories.join("、")}`);
if (s.tags.length) parts.push(`\u{1f3f7} ${s.tags.join("、")}`);
if (chars) parts.push(`✍ ${lines} 行 ${chars} 字`);
return parts.filter(Boolean).join(" · ");
}
export function getArticleDirPath(slug: string): string {
const year = slug.slice(0, 4);
return path.join(CONTENT_DIR, year, slug);
}
export function truncateText(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + "\n\n... 前 " + maxLen + " 字";
}
+21
View File
@@ -0,0 +1,21 @@
import { startPolling } from "./poll";
import { TG_TOKEN } from "./config";
const g = globalThis as Record<string, unknown>;
export async function startBot(): Promise<void> {
if (g.__botStarted) return;
g.__botStarted = true;
if (!TG_TOKEN) {
console.log("[bot] TG_BOT_TOKEN not configured, bot disabled");
return;
}
console.log("[bot] initializing...");
await startPolling();
}
export { TG_TOKEN } from "./config";
export { sendMessage } from "./tg";
export { getSession, hasSession, clearSession } from "./sessions";
+246
View File
@@ -0,0 +1,246 @@
import { TG_TOKEN } from "./config";
import { tg, allowed, sendMessage } from "./tg";
import { hasSession, getSession, appendContent } from "./sessions";
import handlers from "./handlers";
const ARG_COMMANDS = new Set([
"new", "title", "categories", "tags", "author",
"edit", "view", "delete",
"link_add", "link_edit", "link_del", "link_toggle",
"feed_add", "feed_edit", "feed_del",
"list", "links", "feeds", "read",
]);
const CALLBACK_PREFIXES: Record<string, string> = {
edit: "edit",
confirm_del: "delete",
cancel_del: "noop",
list: "list",
links: "links",
feeds: "feeds",
skip_cat: "skip_categories",
skip_tag: "skip_tags",
confirm_shutdown: "confirm_shutdown",
cancel_shutdown: "cancel_shutdown",
confirm_reboot: "confirm_reboot",
cancel_reboot: "cancel_reboot",
};
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function startPolling(): Promise<void> {
if (!TG_TOKEN) {
console.log("[bot] TG_BOT_TOKEN not set, skipping bot startup");
return;
}
console.log("[bot] starting Telegram polling...");
let lastOffset = 0;
let backoff = 3000;
async function poll() {
try {
const data = await tg<Record<string, unknown>[]>("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 as number;
if (update.callback_query) {
const cb = update.callback_query as {
message?: { chat?: { id: number } };
from?: { id: number };
id: string;
data?: string;
};
const cbChatId = cb.message?.chat?.id ?? cb.from?.id ?? 0;
if (cb.data && allowed(cbChatId)) {
await handleInlineCallback(cbChatId, cb.id, cb.data);
}
continue;
}
const msg = update.message as {
chat?: { id: number };
photo?: { file_id: string }[];
text?: string;
caption?: string;
reply_to_message?: { text?: string };
} | undefined;
if (!msg?.chat?.id) continue;
const chatId = msg.chat.id;
if (!allowed(chatId)) continue;
if (msg.photo && hasSession(chatId)) {
await handlePhoto(chatId, msg.photo, msg.caption);
continue;
}
if (!msg.text) continue;
const fullText = msg.text.trim();
// ForceReply routing
if (msg.reply_to_message?.text) {
const original = msg.reply_to_message.text;
if (original.includes("请输入文章标题")) { await handleCommand(chatId, "new", fullText); continue; }
if (original.includes("请输入新标题")) { await handleCommand(chatId, "title", fullText); continue; }
if (original.includes("请输入分类")) { await handleCommand(chatId, "categories", fullText); continue; }
if (original.includes("请输入标签")) { await handleCommand(chatId, "tags", fullText); continue; }
if (original.includes("请输入友链信息")) { await handleCommand(chatId, "link_add", fullText); continue; }
if (original.includes("请输入新名称")) { await handleCommand(chatId, "link_edit", fullText); continue; }
if (original.includes("请输入订阅源 URL")) { await handleCommand(chatId, "feed_add", fullText); continue; }
if (original.includes("请输入新的订阅信息")) { await handleCommand(chatId, "feed_edit", fullText); continue; }
}
const parts = fullText.split("@")[0].split(/\s+/);
const cmdRaw = parts[0].toLowerCase();
const slashCmd = cmdRaw.startsWith("/") ? cmdRaw.slice(1) : "";
const slashArg = parts.slice(1).join(" ");
// In writing session, non-slash text appends to content
if (hasSession(chatId) && !cmdRaw.startsWith("/")) {
try { appendContent(chatId, fullText); }
catch (e) { console.error("[bot:append]", e instanceof Error ? e.message : String(e)); }
continue;
}
if (cmdRaw.startsWith("/")) {
await handleCommand(chatId, slashCmd, slashArg);
}
}
}
backoff = 3000;
} catch (e) {
console.error("[bot:poll]", e instanceof Error ? e.message : String(e));
await sleep(backoff);
backoff = Math.min(backoff * 2, 60000);
}
await sleep(100);
setImmediate(poll);
}
// Register commands with Telegram
try {
await tg("setMyCommands", {
commands: [
{ command: "new", description: "写一篇文章" },
{ command: "edit", description: "编辑文章" },
{ command: "delete", description: "删除文章" },
{ command: "list", description: "文章列表" },
{ command: "publish", description: "保存发布" },
{ command: "draft", description: "保存草稿" },
{ command: "deploy", description: "推送到线上" },
{ command: "sync", description: "同步远程" },
{ command: "undo", description: "撤销最后一段" },
{ command: "preview", description: "预览" },
{ command: "links", description: "友链管理" },
{ command: "link_add", description: "添加友链" },
{ command: "link_edit", description: "编辑友链" },
{ command: "link_del", description: "删除友链" },
{ command: "link_toggle", description: "显示/隐藏友链" },
{ command: "feeds", description: "订阅管理" },
{ command: "feed_add", description: "添加订阅源" },
{ command: "feed_edit", description: "编辑订阅源" },
{ command: "feed_del", description: "删除订阅源" },
{ command: "feed_health", description: "订阅源健康检查" },
{ command: "read", description: "友链最新文章" },
{ command: "stats", description: "网站数据" },
{ command: "shutdown", description: "关机" },
{ command: "reboot", description: "重启" },
{ command: "help", description: "帮助" },
],
});
console.log("[bot] commands registered");
} catch (e) {
console.error("[bot] failed to register commands:", e instanceof Error ? e.message : String(e));
}
poll();
}
async function handlePhoto(chatId: number, photos: { file_id: string }[], caption?: string) {
try {
const fs = await import("fs");
const path = await import("path");
const { CONTENT_DIR } = await import("@/lib/config");
const { getSession } = await import("./sessions");
const { getArticleDirPath } = await import("./helpers");
const s = getSession(chatId);
const largestPhoto = photos[photos.length - 1];
const fileRes = await tg<{ file_path?: string }>("getFile", { file_id: largestPhoto.file_id });
if (!fileRes.ok || !fileRes.result?.file_path) return;
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());
// Save to article's own folder (Hugo page bundle)
const articleDir = getArticleDirPath(s.slug);
if (!fs.existsSync(articleDir)) fs.mkdirSync(articleDir, { recursive: true });
const ext = filePath.split(".").pop() || "jpg";
const filename = Date.now() + "-" + largestPhoto.file_id.slice(0, 8) + "." + ext;
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(imgBuffer));
const imgCaption = caption || "image";
appendContent(chatId, `\n![${imgCaption}](${filename})\n`);
await sendMessage(chatId, `📷 图片已保存:${filename}`);
} catch (e) {
console.error("[bot:photo]", e instanceof Error ? e.message : String(e));
await sendMessage(chatId, "❌ 图片保存失败");
}
}
async function handleCommand(chatId: number, cmd: string, arg?: string) {
try {
if (cmd === "noop") return;
if (ARG_COMMANDS.has(cmd)) {
const handler = handlers[cmd];
if (handler) await handler(chatId, arg);
else await sendMessage(chatId, "❌ 未知命令:/" + cmd);
} else {
const handler = handlers[cmd];
if (handler) await handler(chatId);
else if (cmd) await sendMessage(chatId, "❌ 未知命令:/" + cmd);
}
} catch (e) {
console.error("[bot:" + cmd + "]", e instanceof Error ? e.message : String(e));
try {
await sendMessage(chatId, "❌ " + (e instanceof Error ? e.message : "error").slice(0, 200));
} catch (e) { /* ignore */ }
}
}
async function handleInlineCallback(chatId: number, queryId: string, data: string) {
let prefix = "";
let arg = "";
// First try prefix match (e.g. "edit_slug", "list_2")
for (const p of Object.keys(CALLBACK_PREFIXES)) {
if (data.startsWith(p + "_")) {
prefix = p;
arg = data.slice(p.length + 1);
break;
}
}
// Then try exact match (e.g. "confirm_shutdown", "skip_cat")
if (!prefix && CALLBACK_PREFIXES[data]) {
prefix = data;
}
if (!prefix) {
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
return;
}
const cmd = CALLBACK_PREFIXES[prefix];
await handleCommand(chatId, cmd, arg);
try { await tg("answerCallbackQuery", { callback_query_id: queryId }); } catch (_e) {}
}
+77
View File
@@ -0,0 +1,77 @@
import { createPost, updatePost, getPost } from "@/lib/posts";
import { DEFAULT_AUTHOR } from "@/lib/config";
export interface Session {
title: string;
slug: string;
content: string;
categories: string[];
tags: string[];
author: string;
draft: boolean;
chunks: string[];
originalSlug?: string;
}
const sessions = new Map<number, Session>();
export function getSession(chatId: number): Session {
if (!sessions.has(chatId)) {
sessions.set(chatId, {
title: "",
slug: "",
content: "",
categories: [],
tags: [],
author: DEFAULT_AUTHOR,
draft: true,
chunks: [],
});
}
return sessions.get(chatId)!;
}
export function hasSession(chatId: number): boolean {
return sessions.has(chatId);
}
export function clearSession(chatId: number): void {
sessions.delete(chatId);
}
export function appendContent(chatId: number, text: string): void {
const s = getSession(chatId);
s.chunks.push(text + "\n\n");
s.content += text + "\n\n";
}
export function undoLastChunk(chatId: number): boolean {
const s = getSession(chatId);
if (s.chunks.length === 0) return false;
const lastChunk = s.chunks.pop()!;
const idx = s.content.lastIndexOf(lastChunk);
if (idx >= 0) {
s.content = s.content.slice(0, idx) + s.content.slice(idx + lastChunk.length);
}
return true;
}
export async function saveSession(s: Session, isDraft: boolean): Promise<void> {
const frontMatter = {
title: s.title,
slug: s.slug,
date: new Date().toISOString().slice(0, 10),
draft: isDraft,
categories: s.categories,
tags: s.tags,
author: s.author,
};
const existing = getPost(s.slug);
if (existing) {
const result = updatePost(s.slug, frontMatter, s.content);
if (!result.success) throw new Error("更新文章失败");
} else {
createPost(frontMatter, s.content);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { API, ALLOWED_IDS } from "./config";
interface TgResponse<T = unknown> {
ok: boolean;
result?: T;
description?: string;
}
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() as Promise<TgResponse<T>>;
}
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> {
const safeText = String(text || "");
try {
await tg("sendMessage", {
chat_id: chatId,
text: safeText,
parse_mode: "HTML",
disable_web_page_preview: true,
...opts,
});
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("parse") || msg.includes("HTML")) {
await tg("sendMessage", {
chat_id: chatId,
text: safeText,
...opts,
});
} else {
throw e;
}
}
}
+32
View File
@@ -1,7 +1,9 @@
import path from "path";
import os from "os";
export const BLOG_ROOT = path.resolve(process.cwd(), "..");
export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "post");
export const STATIC_DIR = path.join(BLOG_ROOT, "static");
export const RECYCLE_DIR = path.join(process.cwd(), ".recycle");
export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts");
export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta");
@@ -14,3 +16,33 @@ export const RSS_API_BASE = process.env.RSS_API_BASE || "https://api.usj.cc";
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
// Bot 配置
export const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || "";
export const TG_ALLOWED_IDS: number[] = (process.env.TG_ALLOWED_CHAT_IDS || "")
.split(",").map((s) => s.trim()).filter(Boolean).map(Number);
export const PREFER_IFACE = process.env.PREFER_IFACE || "WLAN";
export const DEFAULT_AUTHOR = process.env.DEFAULT_AUTHOR || "小赵";
export function getLanIP(): string {
const ifs = os.networkInterfaces();
const candidates: { name: string; address: string }[] = [];
for (const [name, addrs] of Object.entries(ifs)) {
if (!addrs) continue;
for (const a of addrs) {
if (a.family !== "IPv4" || a.internal) continue;
if (a.address.startsWith("169.254.")) continue;
candidates.push({ name, address: a.address });
}
}
const pref = candidates.find((c) =>
c.name.toLowerCase().includes(PREFER_IFACE.toLowerCase())
);
if (pref) return pref.address;
const real = candidates.find((c) => {
const n = c.name.toLowerCase();
return !n.includes("vmware") && !n.includes("virtual") && !n.includes("vethernet");
});
if (real) return real.address;
return candidates[0]?.address || "0.0.0.0";
}
+137
View File
@@ -0,0 +1,137 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { BLOG_ROOT } from "./config";
const execFileAsync = promisify(execFile);
type GitExecError = Error & {
stdout?: string;
stderr?: string;
code?: number;
};
export interface DeployResult {
success: boolean;
output?: string;
error?: string;
conflict?: boolean;
}
interface QueueItem {
message: string;
resolve: (result: DeployResult) => void;
reject: (err: Error) => void;
}
class GitDeployQueue {
private queue: QueueItem[] = [];
private running = false;
async enqueue(commitMessage: string): Promise<DeployResult> {
return new Promise<DeployResult>((resolve, reject) => {
this.queue.push({ message: commitMessage, resolve, reject });
if (!this.running) this.next();
});
}
async sync(): Promise<DeployResult> {
try {
const result = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
return { success: true, output: result.stdout + result.stderr };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (this.isRebaseConflict(msg)) {
await this.runGit(["rebase", "--abort"], true);
return { success: false, conflict: true, error: "远程冲突,已放弃合并" };
}
return { success: false, error: msg };
}
}
private async next(): Promise<void> {
const item = this.queue.shift();
if (!item) {
this.running = false;
return;
}
this.running = true;
try {
const result = await this.execute(item.message);
item.resolve(result);
} catch (e) {
item.reject(e instanceof Error ? e : new Error(String(e)));
}
this.next();
}
private async runGit(args: string[], allowFailure = false) {
try {
const { stdout, stderr } = await execFileAsync("git", args, {
cwd: BLOG_ROOT,
windowsHide: true,
maxBuffer: 1024 * 1024,
});
return { stdout, stderr, code: 0 };
} catch (error) {
const err = error as GitExecError;
const result = {
stdout: err.stdout || "",
stderr: err.stderr || err.message,
code: typeof err.code === "number" ? err.code : 1,
};
if (allowFailure) return result;
throw new Error((result.stderr || result.stdout || err.message).trim());
}
}
private isRebaseConflict(message: string): boolean {
return /conflict|CONFLICT|Resolve all conflicts|Automatic merge failed|could not apply|rebase/i.test(message);
}
private async execute(message: string): Promise<DeployResult> {
let output = "";
// git add
const addResult = await this.runGit(["add", "content"]);
output += addResult.stdout + addResult.stderr;
// git diff --cached --quiet (exit 1 = staged changes exist)
const diffResult = await this.runGit(["diff", "--cached", "--quiet"], true);
if (diffResult.code === 1) {
const commitResult = await this.runGit(["commit", "-m", message]);
output += commitResult.stdout + commitResult.stderr;
} else if (diffResult.code !== 0) {
return { success: false, error: (diffResult.stderr || diffResult.stdout || "检查暂存区失败").trim() };
}
// git pull --rebase --autostash
try {
const pullResult = await this.runGit(["pull", "--rebase", "--autostash", "origin", "main"]);
output += pullResult.stdout + pullResult.stderr;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (this.isRebaseConflict(msg)) {
await this.runGit(["rebase", "--abort"], true);
return {
success: false,
conflict: true,
error: "远程有冲突,已自动放弃合并。请先 /sync 同步,手动解决后再 deploy。",
output: msg.slice(0, 1000),
};
}
return { success: false, error: msg };
}
// git push
try {
const pushResult = await this.runGit(["push"]);
output += pushResult.stdout + pushResult.stderr;
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
return { success: true, output: output.slice(0, 1000) };
}
}
export const deployQueue = new GitDeployQueue();
+114
View File
@@ -0,0 +1,114 @@
import { spawn, execSync, type ChildProcess } from "child_process";
import { BLOG_ROOT } from "./config";
class HugoManager {
private process: ChildProcess | null = null;
private ready = false;
private starting = false;
private healthTimer: NodeJS.Timeout | null = null;
async start(): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
if (this.process && this.ready) {
return { ok: true, alreadyRunning: true };
}
if (this.starting) {
return { ok: false };
}
this.starting = true;
this.ready = false;
this.process = spawn("hugo", ["server", "--bind", "0.0.0.0", "--port", "1313", "--disableFastRender", "--noHTTPCache"], {
cwd: BLOG_ROOT,
shell: true,
stdio: "pipe",
windowsHide: true,
});
this.process.stdout?.on("data", (d: Buffer) => process.stdout.write(`[hugo] ${d}`));
this.process.stderr?.on("data", (d: Buffer) => process.stderr.write(`[hugo] ${d}`));
this.process.on("exit", (code) => {
console.log(`[hugo] exited ${code}`);
this.process = null;
this.ready = false;
this.starting = false;
});
// 等待 Hugo 就绪(最多 15 秒)
const ok = await this.waitForReady(15000);
this.starting = false;
if (ok) {
this.ready = true;
this.startHealthCheck();
console.log("[hugo] ready on 0.0.0.0:1313");
}
return { ok };
}
stop(): void {
this.stopHealthCheck();
if (!this.process) return;
try {
const pid = this.process.pid;
if (process.platform === "win32" && pid) {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
} else {
this.process.kill();
}
} catch (_e) { /* nothing to kill */ }
this.process = null;
this.ready = false;
this.starting = false;
}
status(): { running: boolean; starting: boolean } {
return { running: !!(this.process && this.ready), starting: this.starting };
}
async ensureRunning(): Promise<boolean> {
if (this.process && this.ready) return true;
const result = await this.start();
return result.ok;
}
private async waitForReady(timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await this.ping()) return true;
await new Promise((r) => setTimeout(r, 1500));
}
return false;
}
private async ping(): Promise<boolean> {
try {
const res = await fetch("http://127.0.0.1:1313", { signal: AbortSignal.timeout(1500) });
return res.ok || res.status < 500;
} catch (_e) {
return false;
}
}
private startHealthCheck(): void {
this.stopHealthCheck();
this.healthTimer = setInterval(async () => {
if (!this.process) return;
const alive = await this.ping();
if (!alive) {
console.warn("[hugo] health check failed, marking as not ready");
this.ready = false;
this.stopHealthCheck();
}
}, 30_000);
}
private stopHealthCheck(): void {
if (this.healthTimer) {
clearInterval(this.healthTimer);
this.healthTimer = null;
}
}
}
export const hugo = new HugoManager();
+1 -1
View File
@@ -164,7 +164,7 @@ export function createPost(frontMatter: PostFrontMatter, content: string): strin
const dirName = `${dateStr}-${titlePart}`;
const dirPath = path.join(CONTENT_DIR, year, dirName);
if (fs.existsSync(dirPath)) {
if (fs.existsSync(path.join(dirPath, "index.md"))) {
throw new Error(`Post already exists at ${dirPath}`);
}
+25 -4
View File
@@ -48,6 +48,23 @@ export interface LinkHealth {
error?: string;
}
export interface Article {
title: string;
link: string;
pubDate: string;
feedName: string;
siteUrl: string;
favicon: string;
author: string;
}
export interface HealthResult {
total: number;
alive: number;
dead: number;
results: FeedHealth[];
}
// ---- Feeds API ----
export async function getFeeds(): Promise<Feed[]> {
@@ -80,13 +97,17 @@ export async function deleteFeed(url: string): Promise<void> {
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
}
export async function getFeedsHealth(): Promise<{
total: number; alive: number; dead: number;
results: FeedHealth[];
}> {
export async function getFeedsHealth(): Promise<HealthResult> {
return rssFetch("/api/health");
}
// ---- Articles API ----
export async function getArticles(limit = 10): Promise<Article[]> {
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
return data.articles || [];
}
// ---- Links API ----
export async function getLinks(all = false): Promise<Link[]> {
+4
View File
@@ -0,0 +1,4 @@
declare module "marked" {
export function marked(src: string): string;
export function parse(src: string): string;
}
+4
View File
@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0write"
start /b npm run dev -- -p 8016
+3
View File
@@ -0,0 +1,3 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run Chr(34) & "E:\GitHub\blog\write\start-silent.bat" & Chr(34), 0, False
Set WshShell = Nothing