mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Merge pull request #804 from hellodigua/feat-chatlab
feat: API服务支持ChatLab新版协议
This commit is contained in:
145
docs/HTTP-API.md
145
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/*`
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -117,7 +118,7 @@ GET /api/v1/messages
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| --------- | ------ | ---- | ----------------------------------------------------- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
@@ -254,7 +255,7 @@ GET /api/v1/sessions
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| --------- | ------ | ---- | -------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `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 | 是 | 会话 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)
|
||||
@@ -301,7 +426,7 @@ GET /api/v1/contacts
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| --------- | ------ | ---- | ---------------------------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
@@ -354,7 +479,7 @@ GET /api/v1/group-members
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| ---------------------- | ------ | ---- | ------------------------------- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
@@ -444,7 +569,7 @@ GET /api/v1/sns/timeline
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| ----------- | ------ | ---- | ------------------------------------------------------------ |
|
||||
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||
@@ -491,7 +616,7 @@ GET /api/v1/sns/export/stats
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| ------ | ------ | ---- | ---------------------------- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
|
||||
### 7.4 朋友圈媒体代理
|
||||
@@ -503,7 +628,7 @@ GET /api/v1/sns/media/proxy
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| ----- | ------------- | ---- | ------------------------ |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
|
||||
@@ -573,7 +698,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
### 支持的 Content-Type
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
| --- | --- |
|
||||
| ---------------- | ------------ |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
|
||||
@@ -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<void> {
|
||||
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<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
|
||||
|
||||
@@ -21,6 +21,7 @@ interface MessagePushPayload {
|
||||
sourceName: string
|
||||
groupName?: string
|
||||
content: string | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
@@ -313,6 +314,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 +329,8 @@ class MessagePushService {
|
||||
avatarUrl,
|
||||
groupName,
|
||||
sourceName,
|
||||
content
|
||||
content,
|
||||
timestamp: createTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +343,8 @@ class MessagePushService {
|
||||
messageKey,
|
||||
avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
content
|
||||
content,
|
||||
timestamp: createTime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4102,7 +4102,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="form-group">
|
||||
<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-item">
|
||||
<div className="api-endpoint">
|
||||
@@ -4111,7 +4111,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||
<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">
|
||||
<code>{param}</code>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user