Merge pull request #804 from hellodigua/feat-chatlab

feat: API服务支持ChatLab新版协议
This commit is contained in:
cc
2026-04-20 23:23:39 +08:00
committed by GitHub
4 changed files with 266 additions and 64 deletions

View File

@@ -27,8 +27,8 @@ WeFlow 提供本地 HTTP API已支持GET 和 POST请求便于外部脚
- `GET|POST /api/v1/health` - `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages` - `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages` - `GET|POST /api/v1/messages`
- `GET|POST /api/v1/messages/new`
- `GET|POST /api/v1/sessions` - `GET|POST /api/v1/sessions`
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
- `GET|POST /api/v1/contacts` - `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members` - `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*` - `GET|POST /api/v1/media/*`
@@ -86,6 +86,7 @@ GET /api/v1/push/messages
- `sourceName` - `sourceName`
- `groupName`(仅群聊) - `groupName`(仅群聊)
- `content` - `content`
- `timestamp`(消息时间,秒级 Unix 时间戳)
### 示例 ### 示例
@@ -97,7 +98,7 @@ curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```text ```text
event: message.new 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}
``` ```
--- ---
@@ -116,21 +117,21 @@ GET /api/v1/messages
### 参数 ### 参数
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --------- | ------ | ---- | ----------------------------------------------------- |
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | | `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | | `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
| `offset` | number | 否 | 分页偏移,默认 `0` | | `offset` | number | 否 | 分页偏移,默认 `0` |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | | `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | | `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
| `keyword` | string | 否 | 基于消息显示文本过滤 | | `keyword` | string | 否 | 基于消息显示文本过滤 |
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | | `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
| `format` | string | 否 | `json``chatlab` | | `format` | string | 否 | `json``chatlab` |
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | | `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | | `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | | `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
| `video` | string | 否 | 在 `media=1` 时控制视频导出 | | `video` | string | 否 | 在 `media=1` 时控制视频导出 |
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | | `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
### 示例 ### 示例
@@ -253,10 +254,10 @@ GET /api/v1/sessions
### 参数 ### 参数
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --------- | ------ | ---- | -------------------------------- |
| `keyword` | string | 否 | 匹配 `username``displayName` | | `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` | | `limit` | number | 否 | 默认 `100` |
### 响应字段 ### 响应字段
@@ -288,6 +289,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 | 是 | 会话 IDPath 参数) |
| `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. 获取联系人列表 ## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json > 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
@@ -300,10 +425,10 @@ GET /api/v1/contacts
### 参数 ### 参数
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --------- | ------ | ---- | ---------------------------------------------------- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` | | `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` | | `limit` | number | 否 | 默认 `100` |
### 响应字段 ### 响应字段
@@ -353,12 +478,12 @@ GET /api/v1/group-members
### 参数 ### 参数
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | ---------------------- | ------ | ---- | ------------------------------- |
| `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 | | `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 |
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | | `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | | `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | | `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
### 响应字段 ### 响应字段
@@ -443,17 +568,17 @@ GET /api/v1/sns/timeline
参数: 参数:
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | ----------- | ------ | ---- | ------------------------------------------------------------ |
| `limit` | number | 否 | 返回数量,默认 20范围 `1~200` | | `limit` | number | 否 | 返回数量,默认 20范围 `1~200` |
| `offset` | number | 否 | 偏移量,默认 0 | | `offset` | number | 否 | 偏移量,默认 0 |
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | | `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
| `keyword` | string | 否 | 关键词过滤(正文) | | `keyword` | string | 否 | 关键词过滤(正文) |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | | `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | | `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | | `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | | `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL默认 `0` | | `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL默认 `0` |
示例: 示例:
@@ -490,9 +615,9 @@ GET /api/v1/sns/export/stats
参数: 参数:
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | ------ | ------ | ---- | ---------------------------- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | | `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
### 7.4 朋友圈媒体代理 ### 7.4 朋友圈媒体代理
@@ -502,10 +627,10 @@ GET /api/v1/sns/media/proxy
参数: 参数:
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | ----- | ------------- | ---- | ------------------------ |
| `url` | string | 是 | 媒体原始 URL | | `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 | | `key` | string/number | 否 | 解密 key部分资源需要 |
### 7.5 导出朋友圈 ### 7.5 导出朋友圈
@@ -572,15 +697,15 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
### 支持的 Content-Type ### 支持的 Content-Type
| 扩展名 | Content-Type | | 扩展名 | Content-Type |
| --- | --- | | ---------------- | ------------ |
| `.png` | `image/png` | | `.png` | `image/png` |
| `.jpg` / `.jpeg` | `image/jpeg` | | `.jpg` / `.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` | | `.gif` | `image/gif` |
| `.webp` | `image/webp` | | `.webp` | `image/webp` |
| `.wav` | `audio/wav` | | `.wav` | `audio/wav` |
| `.mp3` | `audio/mpeg` | | `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` | | `.mp4` | `video/mp4` |
常见错误响应: 常见错误响应:
@@ -626,8 +751,8 @@ headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/js
# POST 方式获取消息 # POST 方式获取消息
messages = requests.post( messages = requests.post(
f"{BASE_URL}/api/v1/messages", f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50}, json={"talker": "xxx@chatroom", "limit": 50},
headers=headers headers=headers
).json() ).json()

View File

@@ -370,6 +370,17 @@ class HttpService {
await this.handleMessages(url, res) await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') { } else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res) 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') { } else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res) await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') { } else if (pathname === '/api/v1/group-members') {
@@ -736,6 +747,7 @@ class HttpService {
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> { private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim() const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
const format = (url.searchParams.get('format') || '').trim().toLowerCase()
try { try {
const sessions = await chatService.getSessions() const sessions = await chatService.getSessions()
@@ -753,9 +765,22 @@ class HttpService {
) )
} }
// 应用 limit
const limitedSessions = filteredSessions.slice(0, 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, { this.sendJson(res, {
success: true, success: true,
count: limitedSessions.length, 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<void> {
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 * GET /api/v1/contacts?keyword=xxx&limit=100

View File

@@ -21,6 +21,7 @@ interface MessagePushPayload {
sourceName: string sourceName: string
groupName?: string groupName?: string
content: string | null content: string | null
timestamp: number
} }
const PUSH_CONFIG_KEYS = new Set([ const PUSH_CONFIG_KEYS = new Set([
@@ -313,6 +314,8 @@ class MessagePushService {
const sessionType = this.getSessionType(sessionId, session) const sessionType = this.getSessionType(sessionId, session)
const content = this.getMessageDisplayContent(message) const content = this.getMessageDisplayContent(message)
const createTime = Number(message.createTime || 0)
if (isGroup) { if (isGroup) {
const groupInfo = await chatService.getContactAvatar(sessionId) const groupInfo = await chatService.getContactAvatar(sessionId)
const groupName = session.displayName || groupInfo?.displayName || sessionId const groupName = session.displayName || groupInfo?.displayName || sessionId
@@ -326,7 +329,8 @@ class MessagePushService {
avatarUrl, avatarUrl,
groupName, groupName,
sourceName, sourceName,
content content,
timestamp: createTime
} }
} }
@@ -339,7 +343,8 @@ class MessagePushService {
messageKey, messageKey,
avatarUrl, avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId, sourceName: session.displayName || contactInfo?.displayName || sessionId,
content content,
timestamp: createTime
} }
} }

View File

@@ -4102,7 +4102,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint">SSE `message.new` `avatarUrl/sourceName/content` `groupName`</span> <span className="form-hint">SSE `message.new` `avatarUrl/sourceName/content/timestamp` `groupName` `timestamp` Unix </span>
<div className="api-docs"> <div className="api-docs">
<div className="api-item"> <div className="api-item">
<div className="api-endpoint"> <div className="api-endpoint">
@@ -4111,7 +4111,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
<p className="api-desc"> SSE `messageKey` </p> <p className="api-desc"> SSE `messageKey` </p>
<div className="api-params"> <div className="api-params">
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( {['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content', 'timestamp'].map((param) => (
<span key={param} className="param"> <span key={param} className="param">
<code>{param}</code> <code>{param}</code>
</span> </span>