openclaw-server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/package.json +1 -1
- package/packs/default/templates.yaml +6 -6
- package/src/bookmark-digest/chat-integration.ts +49 -0
- package/src/bookmark-digest/service.test.ts +92 -0
- package/src/bookmark-digest/service.ts +573 -0
- package/src/bookmark-digest/store.ts +349 -0
- package/src/bookmark-digest/types.ts +62 -0
- package/src/bookmark-search/chat-integration.ts +56 -0
- package/src/bookmark-search/parser.test.ts +67 -0
- package/src/bookmark-search/parser.ts +235 -0
- package/src/bookmark-search/service.test.ts +330 -0
- package/src/bookmark-search/service.ts +660 -0
- package/src/bookmark-search/shuqianlan-provider.ts +334 -0
- package/src/bookmark-search/types.ts +78 -0
- package/src/config.ts +30 -2
- package/src/debug-log.ts +22 -18
- package/src/request-user.test.ts +29 -0
- package/src/request-user.ts +49 -0
- package/src/routes/admin.ts +53 -0
- package/src/routes/chat-completions.ts +42 -46
- package/src/routes/responses.ts +44 -47
- package/src/server.test.ts +336 -440
- package/src/server.ts +26 -18
- package/readme.md +0 -1219
- package/src/routes/tasks.ts +0 -138
package/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# openclaw-server
|
|
2
|
+
|
|
3
|
+
`openclaw-server` 是一个给 OpenClaw 用的 OpenAI-compatible 后端。
|
|
4
|
+
|
|
5
|
+
它不是通用大模型,也不会调用真实 AI API。当前版本的重点很明确: 把 [书签篮](https://shuqianlan.com) 的搜索、分类浏览和最近更新能力,包装成 OpenClaw 可以直接接入的聊天服务。
|
|
6
|
+
|
|
7
|
+
如果你想让 OpenClaw 在聊天里直接回答下面这类问题,这个项目就是最短路径:
|
|
8
|
+
|
|
9
|
+
- `搜 python`
|
|
10
|
+
- `搜索 cesium 教程`
|
|
11
|
+
- `看看最近更新`
|
|
12
|
+
- `列出分类`
|
|
13
|
+
- `开发工具常用链接`
|
|
14
|
+
- `每天早上 6 点推送最近更新的文章`
|
|
15
|
+
|
|
16
|
+
## 为什么要接书签篮
|
|
17
|
+
|
|
18
|
+
[书签篮](https://shuqianlan.com) 适合把常用网站、教程、工具、文章整理成一个长期可维护的知识入口。
|
|
19
|
+
把它接进 OpenClaw 之后,机器人返回的就不再只是“网上搜一下”的泛结果,而是你已经沉淀好的个人或团队导航内容。
|
|
20
|
+
|
|
21
|
+
这个项目当前内置的数据源就是 `https://shuqianlan.com`。
|
|
22
|
+
|
|
23
|
+
## 当前能力
|
|
24
|
+
|
|
25
|
+
- 关键词搜索书签篮内容
|
|
26
|
+
- 查看最近更新文章
|
|
27
|
+
- 列出一级分类
|
|
28
|
+
- 查看某个分类下的文章
|
|
29
|
+
- 查看某个分类下的常用链接
|
|
30
|
+
- 支持 `GET /healthz`
|
|
31
|
+
- 支持 `GET /v1/models`
|
|
32
|
+
- 支持 `POST /v1/chat/completions`
|
|
33
|
+
- 支持 `POST /v1/responses`
|
|
34
|
+
- 支持 SSE 流式输出
|
|
35
|
+
- 支持每天定时生成“最近更新文章”摘要
|
|
36
|
+
- 能识别 OpenClaw 包装过的用户输入,并提取其中的真实文本和 `sender_id`
|
|
37
|
+
|
|
38
|
+
## 快速开始
|
|
39
|
+
|
|
40
|
+
### 1. 环境要求
|
|
41
|
+
|
|
42
|
+
- Node.js `>= 22.12.0`
|
|
43
|
+
- `pnpm`
|
|
44
|
+
|
|
45
|
+
### 2. 安装依赖
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. 配置环境变量
|
|
52
|
+
|
|
53
|
+
最少只需要一个 API Key:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export OPENCLAW_SERVER_API_KEY='replace-me'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
常用配置如下:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export OPENCLAW_SERVER_HOST='127.0.0.1'
|
|
63
|
+
export OPENCLAW_SERVER_PORT='4318'
|
|
64
|
+
export OPENCLAW_SERVER_API_KEY='replace-me'
|
|
65
|
+
export OPENCLAW_SERVER_MODEL_ID='default-assistant'
|
|
66
|
+
export OPENCLAW_SERVER_DEBUG_LOG='1'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 4. 启动服务
|
|
70
|
+
|
|
71
|
+
开发模式:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pnpm dev
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
或直接启动:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pnpm start
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
默认监听:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
http://127.0.0.1:4318
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 5. 健康检查
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
curl http://127.0.0.1:4318/healthz
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
预期返回:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{"ok":true,"version":"0.1.0"}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 直接调用
|
|
102
|
+
|
|
103
|
+
除了 `/healthz`,其余接口都需要 Bearer Token。
|
|
104
|
+
|
|
105
|
+
### 查看模型列表
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
curl http://127.0.0.1:4318/v1/models \
|
|
109
|
+
-H 'Authorization: Bearer replace-me'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 用 Chat Completions 搜索书签
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl http://127.0.0.1:4318/v1/chat/completions \
|
|
116
|
+
-H 'Authorization: Bearer replace-me' \
|
|
117
|
+
-H 'Content-Type: application/json' \
|
|
118
|
+
-d '{
|
|
119
|
+
"model": "default-assistant",
|
|
120
|
+
"messages": [
|
|
121
|
+
{ "role": "user", "content": "搜 python" }
|
|
122
|
+
]
|
|
123
|
+
}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 用 Responses API 搜索书签
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
curl http://127.0.0.1:4318/v1/responses \
|
|
130
|
+
-H 'Authorization: Bearer replace-me' \
|
|
131
|
+
-H 'Content-Type: application/json' \
|
|
132
|
+
-d '{
|
|
133
|
+
"model": "default-assistant",
|
|
134
|
+
"input": "搜索 notion ai"
|
|
135
|
+
}'
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 使用方式
|
|
139
|
+
|
|
140
|
+
服务当前更像一个“书签篮助手”,而不是通用聊天模型。推荐直接对它说明确指令:
|
|
141
|
+
|
|
142
|
+
- 搜索: `搜 python`、`搜索 cesium 教程`、`帮我找 Notion AI`
|
|
143
|
+
- 最近更新: `看看最近更新`、`最新文章`
|
|
144
|
+
- 分类列表: `列出分类`、`有哪些一级分类`
|
|
145
|
+
- 分类文章: `开发工具分类`、`看看开发工具的文章`
|
|
146
|
+
- 分类链接: `开发工具常用链接`
|
|
147
|
+
- 每日摘要: `每天早上 6 点推送最近更新的文章`
|
|
148
|
+
|
|
149
|
+
## 接入 OpenClaw
|
|
150
|
+
|
|
151
|
+
最直接的接法,是把 `openclaw-server` 配成一个自定义 provider。
|
|
152
|
+
|
|
153
|
+
下面示例里的 `contextWindow` 和 `maxTokens` 主要是给 OpenClaw 填模型目录元数据,按你的使用习惯调整即可。
|
|
154
|
+
|
|
155
|
+
下面是一份可直接参考的 OpenClaw 配置示例:
|
|
156
|
+
|
|
157
|
+
```json5
|
|
158
|
+
{
|
|
159
|
+
env: {
|
|
160
|
+
OPENCLAW_SERVER_API_KEY: "replace-me",
|
|
161
|
+
},
|
|
162
|
+
agents: {
|
|
163
|
+
defaults: {
|
|
164
|
+
model: { primary: "openclaw-server/default-assistant" },
|
|
165
|
+
models: {
|
|
166
|
+
"openclaw-server/default-assistant": { alias: "书签篮助手" },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
models: {
|
|
171
|
+
mode: "merge",
|
|
172
|
+
providers: {
|
|
173
|
+
"openclaw-server": {
|
|
174
|
+
baseUrl: "http://127.0.0.1:4318/v1",
|
|
175
|
+
apiKey: "${OPENCLAW_SERVER_API_KEY}",
|
|
176
|
+
api: "openai-completions",
|
|
177
|
+
models: [
|
|
178
|
+
{
|
|
179
|
+
id: "default-assistant",
|
|
180
|
+
name: "Bookmark Assistant",
|
|
181
|
+
reasoning: false,
|
|
182
|
+
input: ["text"],
|
|
183
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
184
|
+
contextWindow: 32000,
|
|
185
|
+
maxTokens: 4096,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
接入时注意:
|
|
195
|
+
|
|
196
|
+
- `baseUrl` 要指向服务的 `/v1`
|
|
197
|
+
- `apiKey` 要和 `OPENCLAW_SERVER_API_KEY` 一致
|
|
198
|
+
- `model` 引用格式是 `providerId/modelId`,这里就是 `openclaw-server/default-assistant`
|
|
199
|
+
- 如果你在 OpenClaw 里统一使用 Responses API,也可以把 `api` 改成 `openai-responses`,本服务同样提供 `/v1/responses`
|
|
200
|
+
|
|
201
|
+
### 和 OpenClaw 对接时,这个服务会帮你做什么
|
|
202
|
+
|
|
203
|
+
- 自动从 OpenClaw 包装过的用户消息里提取真实用户输入
|
|
204
|
+
- 自动尝试从 `Conversation info (untrusted metadata)` 里提取 `sender_id`
|
|
205
|
+
- 书签更新订阅会按用户维度保存,便于后续做消息回推或轮询投递
|
|
206
|
+
|
|
207
|
+
这意味着,OpenClaw 把聊天请求转发过来之后,不需要你再额外清洗一层用户文本。
|
|
208
|
+
|
|
209
|
+
## 每日更新摘要怎么接
|
|
210
|
+
|
|
211
|
+
当用户说出类似 `每天早上 6 点推送最近更新的文章` 的指令后,服务会:
|
|
212
|
+
|
|
213
|
+
1. 记录订阅关系
|
|
214
|
+
2. 定时检查书签篮最近更新
|
|
215
|
+
3. 生成待发送通知
|
|
216
|
+
|
|
217
|
+
你可以用两种方式把这些通知送出去:
|
|
218
|
+
|
|
219
|
+
### 方式一: 配置 webhook 自动投递
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
export OPENCLAW_SERVER_BOOKMARK_DIGEST_WEBHOOK_URL='https://your-bridge.example.com/hooks/bookmark-digest'
|
|
223
|
+
export OPENCLAW_SERVER_BOOKMARK_DIGEST_WEBHOOK_AUTH_TOKEN='replace-me'
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
服务会定时向该地址发送 JSON:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"type": "bookmark_digest",
|
|
231
|
+
"user": "sender-1",
|
|
232
|
+
"message": "早上好,以下是 ...",
|
|
233
|
+
"payload": {
|
|
234
|
+
"type": "bookmark_digest",
|
|
235
|
+
"userId": "sender-1",
|
|
236
|
+
"slotAt": "2026-03-14T06:00:00.000Z",
|
|
237
|
+
"hasNewArticles": true,
|
|
238
|
+
"articles": [],
|
|
239
|
+
"browseUrl": "https://shuqianlan.com/static-article/page/page_1.html"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 方式二: 自己轮询管理接口
|
|
245
|
+
|
|
246
|
+
拉取待发送通知:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
curl 'http://127.0.0.1:4318/admin/bookmark-digests/pending?user=sender-1' \
|
|
250
|
+
-H 'Authorization: Bearer replace-me'
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
标记已送达:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
curl http://127.0.0.1:4318/admin/bookmark-digests/mark-delivered \
|
|
257
|
+
-H 'Authorization: Bearer replace-me' \
|
|
258
|
+
-H 'Content-Type: application/json' \
|
|
259
|
+
-d '{
|
|
260
|
+
"ids": ["notification-id"]
|
|
261
|
+
}'
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
如果你当前只想先把搜索能力接进 OpenClaw,这一部分可以先不配。
|
|
265
|
+
|
|
266
|
+
## 管理接口
|
|
267
|
+
|
|
268
|
+
- `GET /healthz`: 健康检查,无需鉴权
|
|
269
|
+
- `GET /v1/models`: 返回模型列表
|
|
270
|
+
- `POST /v1/chat/completions`: OpenAI Chat Completions 兼容接口
|
|
271
|
+
- `POST /v1/responses`: OpenAI Responses 兼容接口
|
|
272
|
+
- `GET /admin/stats`: 查看运行时统计
|
|
273
|
+
- `POST /admin/packs/reload`: 热重载 `packs/<packId>`
|
|
274
|
+
- `GET /admin/bookmark-digests/pending`: 查看待发送摘要
|
|
275
|
+
- `POST /admin/bookmark-digests/mark-delivered`: 标记摘要已送达
|
|
276
|
+
|
|
277
|
+
## 常用环境变量
|
|
278
|
+
|
|
279
|
+
- `OPENCLAW_SERVER_HOST`: 监听地址,默认 `127.0.0.1`
|
|
280
|
+
- `OPENCLAW_SERVER_PORT`: 监听端口,默认 `4318`
|
|
281
|
+
- `OPENCLAW_SERVER_API_KEY`: 鉴权令牌,默认 `openclaw-server-dev`
|
|
282
|
+
- `OPENCLAW_SERVER_MODEL_ID`: 默认模型 ID,默认 `default-assistant`
|
|
283
|
+
- `OPENCLAW_SERVER_PACK_ID`: 要加载的 pack,默认 `default`
|
|
284
|
+
- `OPENCLAW_SERVER_PACK_DIR`: 自定义 pack 目录
|
|
285
|
+
- `OPENCLAW_SERVER_SESSION_LOG`: 会话日志输出路径
|
|
286
|
+
- `OPENCLAW_SERVER_TIMEZONE`: 默认时区
|
|
287
|
+
- `OPENCLAW_SERVER_BOOKMARK_DIGEST_TIMEZONE`: 摘要时区
|
|
288
|
+
- `OPENCLAW_SERVER_BOOKMARK_DIGEST_POLL_MS`: 摘要轮询间隔
|
|
289
|
+
- `OPENCLAW_SERVER_BOOKMARK_DIGEST_LIMIT`: 每次摘要最多带多少条文章
|
|
290
|
+
- `OPENCLAW_SERVER_BOOKMARK_DIGEST_WEBHOOK_URL`: 自动投递 webhook
|
|
291
|
+
- `OPENCLAW_SERVER_BOOKMARK_DIGEST_WEBHOOK_AUTH_TOKEN`: webhook Bearer Token
|
|
292
|
+
- `OPENCLAW_SERVER_DEBUG_LOG`: 是否开启调试日志
|
|
293
|
+
|
|
294
|
+
## 自定义回复
|
|
295
|
+
|
|
296
|
+
默认 pack 在 `packs/default`,你可以直接修改这些文件来调整回复风格和兜底逻辑:
|
|
297
|
+
|
|
298
|
+
- `packs/default/pack.yaml`
|
|
299
|
+
- `packs/default/intents.yaml`
|
|
300
|
+
- `packs/default/templates.yaml`
|
|
301
|
+
- `packs/default/faq.yaml`
|
|
302
|
+
- `packs/default/scenarios.yaml`
|
|
303
|
+
|
|
304
|
+
修改后可以调用:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
curl http://127.0.0.1:4318/admin/packs/reload \
|
|
308
|
+
-X POST \
|
|
309
|
+
-H 'Authorization: Bearer replace-me'
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## 当前边界
|
|
313
|
+
|
|
314
|
+
- 它不是通用大模型服务
|
|
315
|
+
- 它当前不调用真实 AI API
|
|
316
|
+
- 它当前的核心价值是“把书签篮接到 OpenClaw 里”
|
|
317
|
+
- 它当前内置的数据源是 [书签篮](https://shuqianlan.com)
|
|
318
|
+
|
|
319
|
+
如果你的目标是让 OpenClaw 拥有一个真正可搜索、可沉淀、可长期维护的资料入口,而不是再接一个泛化聊天模型,这个项目和书签篮的组合会很合适。
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
- id: greeting.default
|
|
1
|
+
- id: greeting.default
|
|
2
2
|
variants:
|
|
3
|
-
- "你好,我是 openclaw-server
|
|
4
|
-
- "我在。当前这个服务不会调用真实 AI API
|
|
3
|
+
- "你好,我是 openclaw-server。现在我只提供书签栏里的书签搜索,你可以直接说“搜 python”“搜索 cesium 教程”或“帮我找 Notion AI”。"
|
|
4
|
+
- "我在。当前这个服务不会调用真实 AI API,只保留了书签栏搜索能力。直接给我关键词,我就返回可点击的书签结果。"
|
|
5
5
|
|
|
6
6
|
- id: capabilities.help
|
|
7
7
|
variants:
|
|
8
|
-
- "
|
|
8
|
+
- "我目前只支持书签栏里的书签搜索。你可以直接说“搜 python”“搜索 cesium”“帮我找教程”“查找 Notion AI”,我会返回可点击的标题链接。"
|
|
9
9
|
|
|
10
10
|
- id: faq.real-ai
|
|
11
11
|
variants:
|
|
12
|
-
- "不是。我不会调用真实 AI API。当前回复来自 `openclaw-server`
|
|
12
|
+
- "不是。我不会调用真实 AI API。当前回复来自 `openclaw-server` 的固定规则和书签栏搜索结果。"
|
|
13
13
|
|
|
14
14
|
- id: fallback.default
|
|
15
15
|
variants:
|
|
16
|
-
- "
|
|
16
|
+
- "我现在只保留了书签栏搜索。你可以直接说“搜 python”“搜索 cesium 教程”或“帮我找 Notion AI”,我会把对应书签结果发给你。"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { EngineResult, NormalizedTurn } from "../types.js";
|
|
2
|
+
import type { BookmarkDigestChatResult, BookmarkDigestMessageInspection } from "./types.js";
|
|
3
|
+
import type { BookmarkDigestService } from "./service.js";
|
|
4
|
+
|
|
5
|
+
export function inspectBookmarkDigestMessage(params: {
|
|
6
|
+
bookmarkDigestService: BookmarkDigestService;
|
|
7
|
+
turn: NormalizedTurn;
|
|
8
|
+
}): BookmarkDigestMessageInspection {
|
|
9
|
+
return params.bookmarkDigestService.inspectMessage({
|
|
10
|
+
text: params.turn.userText,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildBookmarkDigestEngineResult(params: {
|
|
15
|
+
turn: NormalizedTurn;
|
|
16
|
+
digestResult: BookmarkDigestChatResult;
|
|
17
|
+
}): EngineResult {
|
|
18
|
+
return {
|
|
19
|
+
model: params.turn.model,
|
|
20
|
+
sessionId: params.turn.sessionId,
|
|
21
|
+
text: params.digestResult.reply,
|
|
22
|
+
finishReason: "stop",
|
|
23
|
+
matchedIntentId: `bookmark-digest.${params.digestResult.intent}`,
|
|
24
|
+
templateId: `bookmark-digest.${params.digestResult.intent}`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function respondToBookmarkDigestMessage(params: {
|
|
29
|
+
bookmarkDigestService: BookmarkDigestService;
|
|
30
|
+
turn: NormalizedTurn;
|
|
31
|
+
userId: string;
|
|
32
|
+
now?: string;
|
|
33
|
+
}): EngineResult | undefined {
|
|
34
|
+
const inspection = inspectBookmarkDigestMessage(params);
|
|
35
|
+
if (!inspection.shouldHandle) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const digestResult = params.bookmarkDigestService.processMessage({
|
|
40
|
+
userId: params.userId,
|
|
41
|
+
text: params.turn.userText,
|
|
42
|
+
now: params.now,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return buildBookmarkDigestEngineResult({
|
|
46
|
+
turn: params.turn,
|
|
47
|
+
digestResult,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { BookmarkSearchProvider } from "../bookmark-search/types.js";
|
|
3
|
+
import { BookmarkDigestService } from "./service.js";
|
|
4
|
+
import { BookmarkDigestStore } from "./store.js";
|
|
5
|
+
|
|
6
|
+
function createProvider(): BookmarkSearchProvider {
|
|
7
|
+
return {
|
|
8
|
+
searchKeyword: vi.fn(async () => []),
|
|
9
|
+
listLatestArticles: vi.fn(async () => ({
|
|
10
|
+
items: [
|
|
11
|
+
{
|
|
12
|
+
type: "文章",
|
|
13
|
+
title: "Cesium 获取地形高度 全面深度教程",
|
|
14
|
+
desc: "覆盖地形高度采样、坐标转换和实际项目中的精度处理。",
|
|
15
|
+
firstCategory: "开发工具",
|
|
16
|
+
secondCategory: "地图",
|
|
17
|
+
updatedAt: "2026-03-13 18:20:00",
|
|
18
|
+
url: "https://shuqianlan.com/static-article/detail-html/article_cesium_height.html",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
total: 1,
|
|
22
|
+
browseUrl: "https://shuqianlan.com/static-article/page/page_1.html",
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("BookmarkDigestService", () => {
|
|
28
|
+
const stores: BookmarkDigestStore[] = [];
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const store of stores.splice(0)) {
|
|
32
|
+
store.close();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("subscribes users and schedules only one digest per day", async () => {
|
|
37
|
+
const store = new BookmarkDigestStore(":memory:");
|
|
38
|
+
stores.push(store);
|
|
39
|
+
const service = new BookmarkDigestService(store, createProvider(), {
|
|
40
|
+
pollMs: 0,
|
|
41
|
+
timeZone: "Asia/Shanghai",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const subscribed = service.processMessage({
|
|
45
|
+
userId: "u1",
|
|
46
|
+
text: "每天早晨6点将最近更新的文章推送给我",
|
|
47
|
+
now: "2026-03-13T05:30:00+08:00",
|
|
48
|
+
});
|
|
49
|
+
expect(subscribed.intent).toBe("subscribed");
|
|
50
|
+
expect(subscribed.reply).toContain("06:00");
|
|
51
|
+
|
|
52
|
+
expect(await service.dispatchDueDigests("2026-03-13T05:59:00+08:00")).toBe(0);
|
|
53
|
+
expect(await service.dispatchDueDigests("2026-03-13T06:00:00+08:00")).toBe(1);
|
|
54
|
+
expect(await service.dispatchDueDigests("2026-03-13T06:05:00+08:00")).toBe(0);
|
|
55
|
+
|
|
56
|
+
const pending = service.listPendingNotifications({ userId: "u1" });
|
|
57
|
+
expect(pending).toHaveLength(1);
|
|
58
|
+
expect(pending[0]?.message).toContain("最近更新文章");
|
|
59
|
+
expect(pending[0]?.message).toContain("article_cesium_height.html");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("retries pending notifications through the configured webhook and marks them delivered", async () => {
|
|
63
|
+
const store = new BookmarkDigestStore(":memory:");
|
|
64
|
+
stores.push(store);
|
|
65
|
+
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
66
|
+
const service = new BookmarkDigestService(store, createProvider(), {
|
|
67
|
+
pollMs: 0,
|
|
68
|
+
timeZone: "Asia/Shanghai",
|
|
69
|
+
webhookUrl: "https://example.com/hooks/bookmark-digest",
|
|
70
|
+
webhookAuthToken: "secret-token",
|
|
71
|
+
fetchImpl,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
service.processMessage({
|
|
75
|
+
userId: "u1",
|
|
76
|
+
text: "每天早上6点推送最近更新的文章",
|
|
77
|
+
now: "2026-03-13T05:30:00+08:00",
|
|
78
|
+
});
|
|
79
|
+
await service.dispatchDueDigests("2026-03-13T06:00:00+08:00");
|
|
80
|
+
|
|
81
|
+
expect(await service.deliverPendingNotifications("2026-03-13T06:00:10+08:00")).toBe(1);
|
|
82
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
Authorization: "Bearer secret-token",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
expect(service.listPendingNotifications({ userId: "u1" })).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
});
|