From 0ba10671235e3c7ff75f7960bc09fc51ac73cf61 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 19 Apr 2026 14:50:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20API=E6=9C=8D=E5=8A=A1=E6=94=AF?= =?UTF-8?q?=E6=8C=81ChatLab=E6=96=B0=E7=89=88=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HTTP-API.md | 240 ++++++++++++++++++------ electron/services/httpService.ts | 74 +++++++- electron/services/messagePushService.ts | 12 +- 3 files changed, 265 insertions(+), 61 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index fb2c636..91ad769 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -27,8 +27,8 @@ WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚 - `GET|POST /api/v1/health` - `GET|POST /api/v1/push/messages` - `GET|POST /api/v1/messages` -- `GET|POST /api/v1/messages/new` - `GET|POST /api/v1/sessions` +- `GET /api/v1/sessions/:id/messages` (ChatLab Pull) - `GET|POST /api/v1/contacts` - `GET|POST /api/v1/group-members` - `GET|POST /api/v1/media/*` @@ -116,21 +116,21 @@ GET /api/v1/messages ### 参数 -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | -| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | -| `offset` | number | 否 | 分页偏移,默认 `0` | -| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | -| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | -| `keyword` | string | 否 | 基于消息显示文本过滤 | -| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | -| `format` | string | 否 | `json` 或 `chatlab` | -| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | -| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | -| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | -| `video` | string | 否 | 在 `media=1` 时控制视频导出 | -| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ----------------------------------------------------- | +| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | +| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | +| `offset` | number | 否 | 分页偏移,默认 `0` | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | +| `keyword` | string | 否 | 基于消息显示文本过滤 | +| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | +| `format` | string | 否 | `json` 或 `chatlab` | +| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | +| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | +| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | +| `video` | string | 否 | 在 `media=1` 时控制视频导出 | +| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | ### 示例 @@ -253,10 +253,10 @@ GET /api/v1/sessions ### 参数 -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | -| `limit` | number | 否 | 默认 `100` | +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | -------------------------------- | +| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | +| `limit` | number | 否 | 默认 `100` | ### 响应字段 @@ -288,6 +288,130 @@ GET /api/v1/sessions --- +## 4.1 获取会话列表(ChatLab 格式) + +当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。 + +**请求** + +```http +GET /api/v1/sessions?format=chatlab +``` + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | -------------------------------- | +| `format` | string | 是 | 设为 `chatlab` | +| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | +| `limit` | number | 否 | 默认 `100` | + +### 响应 + +```json +{ + "sessions": [ + { + "id": "xxx@chatroom", + "name": "项目群", + "platform": "wechat", + "type": "group", + "messageCount": 58000, + "lastMessageAt": 1738713600 + } + ] +} +``` + +| 字段 | 说明 | +| --------------- | ----------------------------------- | +| `id` | 会话 ID(微信 username) | +| `name` | 会话显示名称 | +| `platform` | 固定 `wechat` | +| `type` | `group`(群聊)或 `private`(私聊) | +| `messageCount` | 消息数量(估算值,可能不精确) | +| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 | + +--- + +## 4.2 拉取会话消息(ChatLab Pull) + +返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。 + +**请求** + +```http +GET /api/v1/sessions/:id/messages +``` + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +| -------- | ------ | ---- | ---------------------------------------- | +| `:id` | string | 是 | 会话 ID(Path 参数) | +| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 | +| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 | +| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` | +| `offset` | number | 否 | 分页偏移,默认 `0` | + +### 响应 + +返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块: + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1738713600, + "generator": "WeFlow" + }, + "meta": { + "name": "项目群", + "platform": "wechat", + "type": "group", + "groupId": "xxx@chatroom", + "ownerId": "wxid_xxx" + }, + "members": [ + { + "platformId": "wxid_a", + "accountName": "张三", + "groupNickname": "产品", + "avatar": "https://example.com/avatar.jpg" + } + ], + "messages": [ + { + "sender": "wxid_a", + "accountName": "张三", + "timestamp": 1738713600, + "type": 0, + "content": "你好", + "platformMessageId": "123456" + } + ], + "sync": { + "hasMore": true, + "nextSince": 1738713600, + "nextOffset": 5000, + "watermark": 1738714000 + } +} +``` + +### sync 块 + +| 字段 | 说明 | +| ------------ | -------------------------------- | +| `hasMore` | 是否还有更多数据 | +| `nextSince` | 下次请求的 `since` 值 | +| `nextOffset` | 下次请求的 `offset` 值 | +| `watermark` | 本次拉取的时间上界(秒级时间戳) | + +**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。 + +--- + ## 5. 获取联系人列表 > 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) @@ -300,10 +424,10 @@ GET /api/v1/contacts ### 参数 -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | -| `limit` | number | 否 | 默认 `100` | +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ---------------------------------------------------- | +| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | +| `limit` | number | 否 | 默认 `100` | ### 响应字段 @@ -353,12 +477,12 @@ GET /api/v1/group-members ### 参数 -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | -| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | -| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | -| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | +| 参数 | 类型 | 必填 | 说明 | +| ---------------------- | ------ | ---- | ------------------------------- | +| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | +| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | +| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | +| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | ### 响应字段 @@ -443,17 +567,17 @@ GET /api/v1/sns/timeline 参数: -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | -| `offset` | number | 否 | 偏移量,默认 0 | -| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | -| `keyword` | string | 否 | 关键词过滤(正文) | -| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | -| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | -| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | -| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | -| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | +| 参数 | 类型 | 必填 | 说明 | +| ----------- | ------ | ---- | ------------------------------------------------------------ | +| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | +| `offset` | number | 否 | 偏移量,默认 0 | +| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | +| `keyword` | string | 否 | 关键词过滤(正文) | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | +| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | +| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | 示例: @@ -490,9 +614,9 @@ GET /api/v1/sns/export/stats 参数: -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | +| 参数 | 类型 | 必填 | 说明 | +| ------ | ------ | ---- | ---------------------------- | +| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | ### 7.4 朋友圈媒体代理 @@ -502,10 +626,10 @@ GET /api/v1/sns/media/proxy 参数: -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `url` | string | 是 | 媒体原始 URL | -| `key` | string/number | 否 | 解密 key(部分资源需要) | +| 参数 | 类型 | 必填 | 说明 | +| ----- | ------------- | ---- | ------------------------ | +| `url` | string | 是 | 媒体原始 URL | +| `key` | string/number | 否 | 解密 key(部分资源需要) | ### 7.5 导出朋友圈 @@ -572,15 +696,15 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" ### 支持的 Content-Type -| 扩展名 | Content-Type | -| --- | --- | -| `.png` | `image/png` | +| 扩展名 | Content-Type | +| ---------------- | ------------ | +| `.png` | `image/png` | | `.jpg` / `.jpeg` | `image/jpeg` | -| `.gif` | `image/gif` | -| `.webp` | `image/webp` | -| `.wav` | `audio/wav` | -| `.mp3` | `audio/mpeg` | -| `.mp4` | `video/mp4` | +| `.gif` | `image/gif` | +| `.webp` | `image/webp` | +| `.wav` | `audio/wav` | +| `.mp3` | `audio/mpeg` | +| `.mp4` | `video/mp4` | 常见错误响应: @@ -626,8 +750,8 @@ headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/js # POST 方式获取消息 messages = requests.post( - f"{BASE_URL}/api/v1/messages", - json={"talker": "xxx@chatroom", "limit": 50}, + f"{BASE_URL}/api/v1/messages", + json={"talker": "xxx@chatroom", "limit": 50}, headers=headers ).json() diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index f353434..195d263 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -370,6 +370,17 @@ class HttpService { await this.handleMessages(url, res) } else if (pathname === '/api/v1/sessions') { await this.handleSessions(url, res) + } else if ( + pathname.startsWith('/api/v1/sessions/') && + pathname.endsWith('/messages') + ) { + const parts = pathname.split('/') + const sessionId = decodeURIComponent(parts[4] || '') + if (!sessionId) { + this.sendError(res, 400, 'Missing session ID') + } else { + await this.handlePullMessages(sessionId, url, res) + } } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) } else if (pathname === '/api/v1/group-members') { @@ -736,6 +747,7 @@ class HttpService { private async handleSessions(url: URL, res: http.ServerResponse): Promise { const keyword = (url.searchParams.get('keyword') || '').trim() const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) + const format = (url.searchParams.get('format') || '').trim().toLowerCase() try { const sessions = await chatService.getSessions() @@ -753,9 +765,22 @@ class HttpService { ) } - // 应用 limit const limitedSessions = filteredSessions.slice(0, limit) + if (format === 'chatlab') { + this.sendJson(res, { + sessions: limitedSessions.map(s => ({ + id: s.username, + name: s.displayName || s.username, + platform: 'wechat', + type: s.username.endsWith('@chatroom') ? 'group' : 'private', + messageCount: s.messageCountHint || undefined, + lastMessageAt: s.lastTimestamp + })) + }) + return + } + this.sendJson(res, { success: true, count: limitedSessions.length, @@ -772,6 +797,53 @@ class HttpService { } } + /** + * ChatLab Pull: GET /api/v1/sessions/:id/messages?since=&limit=&offset=&end= + * 返回 ChatLab 标准格式 + sync 分页块 + */ + private async handlePullMessages(sessionId: string, url: URL, res: http.ServerResponse): Promise { + const PULL_MAX_LIMIT = 5000 + const limit = this.parseIntParam(url.searchParams.get('limit'), PULL_MAX_LIMIT, 1, PULL_MAX_LIMIT) + const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) + const sinceParam = url.searchParams.get('since') + const endParam = url.searchParams.get('end') + + const startTime = sinceParam ? this.parseTimeParam(sinceParam) : 0 + const endTime = endParam ? this.parseTimeParam(endParam, true) : 0 + + try { + const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true) + if (!result.success || !result.messages) { + this.sendError(res, 500, result.error || 'Failed to get messages') + return + } + + const messages = result.messages + const hasMore = result.hasMore === true + + const displayNames = await this.getDisplayNames([sessionId]) + const talkerName = displayNames[sessionId] || sessionId + const chatLabData = await this.convertToChatLab(messages, sessionId, talkerName) + + const lastTimestamp = messages.length > 0 + ? messages[messages.length - 1].createTime + : undefined + + this.sendJson(res, { + ...chatLabData, + sync: { + hasMore, + nextSince: hasMore && lastTimestamp ? lastTimestamp : undefined, + nextOffset: hasMore ? offset + messages.length : undefined, + watermark: Math.floor(Date.now() / 1000) + } + }) + } catch (error) { + console.error('[HttpService] handlePullMessages error:', error) + this.sendError(res, 500, String(error)) + } + } + /** * 处理联系人查询 * GET /api/v1/contacts?keyword=xxx&limit=100 diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index fa8f3ad..86d5e9d 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -21,6 +21,8 @@ interface MessagePushPayload { sourceName: string groupName?: string content: string | null + eventId: string + timestamp: number } const PUSH_CONFIG_KEYS = new Set([ @@ -313,6 +315,8 @@ class MessagePushService { const sessionType = this.getSessionType(sessionId, session) const content = this.getMessageDisplayContent(message) + const createTime = Number(message.createTime || 0) + if (isGroup) { const groupInfo = await chatService.getContactAvatar(sessionId) const groupName = session.displayName || groupInfo?.displayName || sessionId @@ -326,7 +330,9 @@ class MessagePushService { avatarUrl, groupName, sourceName, - content + content, + eventId: messageKey, + timestamp: createTime } } @@ -339,7 +345,9 @@ class MessagePushService { messageKey, avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, - content + content, + eventId: messageKey, + timestamp: createTime } } From 898d2c7f29fb8ea7f0415c9fbd79014e9d4ddd31 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Mon, 20 Apr 2026 23:12:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HTTP-API.md | 3 ++- electron/services/messagePushService.ts | 3 --- src/pages/SettingsPage.tsx | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 91ad769..0bec407 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -86,6 +86,7 @@ GET /api/v1/push/messages - `sourceName` - `groupName`(仅群聊) - `content` +- `timestamp`(消息时间,秒级 Unix 时间戳) ### 示例 @@ -97,7 +98,7 @@ curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN ```text event: message.new -data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"} +data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123} ``` --- diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 86d5e9d..6617526 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -21,7 +21,6 @@ interface MessagePushPayload { sourceName: string groupName?: string content: string | null - eventId: string timestamp: number } @@ -331,7 +330,6 @@ class MessagePushService { groupName, sourceName, content, - eventId: messageKey, timestamp: createTime } } @@ -346,7 +344,6 @@ class MessagePushService { avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, content, - eventId: messageKey, timestamp: createTime } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 851d8d1..753f2db 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -4049,7 +4049,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName` + SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content/timestamp`,群聊额外附带 `groupName`,其中 `timestamp` 为秒级 Unix 时间戳
@@ -4058,7 +4058,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。

- {['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( + {['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content', 'timestamp'].map((param) => ( {param}