@qihoo/tuitui-openclaw-channel 1.0.11 → 1.0.13
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/index.ts +2 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +2 -1
- package/skills/tuitui-im-read/SKILL.md +143 -0
- package/src/accounts.ts +25 -0
- package/src/channel.ts +17 -31
- package/src/inbound.ts +323 -344
- package/src/outbound.ts +205 -35
- package/src/tools.ts +88 -0
- package/src/types.ts +5 -5
- package/src/utils.ts +5 -0
- package/src/command.ts +0 -29
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
2
|
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
|
3
3
|
import { createTuiTuiChannelPlugin } from './src/channel';
|
|
4
|
+
import { registerTuituiTools } from './src/tools';
|
|
4
5
|
import { CHANNEL_ID, CHANNEL_NAME } from './src/const';
|
|
5
6
|
import { id } from './openclaw.plugin.json';
|
|
6
7
|
|
|
@@ -12,6 +13,7 @@ const plugin = {
|
|
|
12
13
|
register(api: OpenClawPluginApi) {
|
|
13
14
|
console.log(`[${CHANNEL_ID}] Plugin.register Before.`);
|
|
14
15
|
api.registerChannel({ plugin: createTuiTuiChannelPlugin(api.runtime) });
|
|
16
|
+
registerTuituiTools(api);
|
|
15
17
|
console.log(`[${CHANNEL_ID}] Plugin.register After.`);
|
|
16
18
|
},
|
|
17
19
|
};
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qihoo/tuitui-openclaw-channel",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"maintainers": [
|
|
5
5
|
{
|
|
6
6
|
"name": "huzunjie",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"description": "TuiTui channel plugin for OpenClaw",
|
|
19
19
|
"type": "module",
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@sinclair/typebox": "^0.34.48",
|
|
21
22
|
"ws": "^8.13.0"
|
|
22
23
|
},
|
|
23
24
|
"openclaw": {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tuitui-im-read
|
|
3
|
+
description: |
|
|
4
|
+
推推(tuitui) IM 消息读取工具使用指南
|
|
5
|
+
|
|
6
|
+
**当以下情况时使用此 Skill**:
|
|
7
|
+
(1) 需要获取群聊或单聊的历史消息
|
|
8
|
+
(2) 用户提到"聊天记录"、"消息"、"群里说了什么"
|
|
9
|
+
(3) 需要按时间范围过滤消息、分页获取更多消息
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# 推推 IM 消息读取
|
|
13
|
+
|
|
14
|
+
## 执行前必读
|
|
15
|
+
|
|
16
|
+
- 该 Skill 中的所有消息读取工具均以机器人身份调用,只能读取机器人有权限的会话。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 工具:tuitui_im_get_messages
|
|
21
|
+
|
|
22
|
+
### 参数说明
|
|
23
|
+
|
|
24
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
25
|
+
|------|------|------|------|
|
|
26
|
+
| `chatId` | string | ✅ | 聊天 ID。单聊填对方的 tuitui account,群聊填群 ID |
|
|
27
|
+
| `chatType` | string | ✅ | 聊天类型:单聊填 `direct`,群聊填 `group` |
|
|
28
|
+
| `relativeTime` | string | ❌ | 相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 `startTime`/`endTime` 互斥 |
|
|
29
|
+
| `startTime` | string | ❌ | 起始时间,ISO 8601 格式,如 `2026-02-27T00:00:00+08:00`。不填默认从最早开始。与 `relativeTime` 互斥 |
|
|
30
|
+
| `endTime` | string | ❌ | 结束时间,ISO 8601 格式,如 `2026-02-27T23:59:59+08:00`。不填默认到当前时间。与 `relativeTime` 互斥 |
|
|
31
|
+
| `limit` | number | ❌ | 每页条数,范围 1~100,默认 100 |
|
|
32
|
+
| `cursor` | string | ❌ | 分页游标,首次调用填 `"0"`,翻页时传上次返回的 `cursor` |
|
|
33
|
+
| `orderAsc` | boolean | ❌ | 排序方向,`true` 正序(从旧到新),`false` 逆序(从新到旧,默认) |
|
|
34
|
+
|
|
35
|
+
### 返回结构
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"errcode": 0,
|
|
40
|
+
"errmsg": "ok",
|
|
41
|
+
"cursor": "xxx",
|
|
42
|
+
"has_more": true,
|
|
43
|
+
"current_time": "2026-03-21 10:00:00",
|
|
44
|
+
"msgs": [
|
|
45
|
+
{
|
|
46
|
+
"user_account": "zhangsan",
|
|
47
|
+
"user_name": "张三",
|
|
48
|
+
"msg_time": "2026-01-03 15:42:44",
|
|
49
|
+
"msg_type": "text",
|
|
50
|
+
"text": "消息内容",
|
|
51
|
+
"images": [],
|
|
52
|
+
"group_id": "group_abc",
|
|
53
|
+
"group_name": "某群",
|
|
54
|
+
"at_me": false,
|
|
55
|
+
"ref": null
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
字段说明:
|
|
62
|
+
|
|
63
|
+
| 字段 | 说明 |
|
|
64
|
+
|------|------|
|
|
65
|
+
| `current_time` | 当前服务器时间,格式 `YYYY-MM-DD HH:MM:SS`,辅助理解消息的相对时间 |
|
|
66
|
+
| `has_more` | `true` 时说明还有更多数据,可用返回的 `cursor` 继续翻页 |
|
|
67
|
+
| `msgs` | 消息列表,空数组表示该时间段内没有消息 |
|
|
68
|
+
| `msgs[].user_account` | 发送者账号 |
|
|
69
|
+
| `msgs[].user_name` | 发送者显示名称 |
|
|
70
|
+
| `msgs[].msg_time` | 消息时间,格式 `YYYY-MM-DD HH:MM:SS` |
|
|
71
|
+
| `msgs[].msg_type` | 消息类型:`text` / `image` / `mixed` / `voice` / `file` |
|
|
72
|
+
| `msgs[].text` | 文本内容(`msg_type` 为 `text` / `mixed` 时有值) |
|
|
73
|
+
| `msgs[].images` | 图片 URL 列表(`msg_type` 为 `image` / `mixed` 时有值) |
|
|
74
|
+
| `msgs[].file` | 文件信息 `{ name, url, file_id }`(`msg_type` 为 `file` 时有值) |
|
|
75
|
+
| `msgs[].at_me` | 是否 @ 了机器人(群聊) |
|
|
76
|
+
| `msgs[].ref` | 引用/回复的原消息,无引用时为 `null` |
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 核心约束
|
|
81
|
+
|
|
82
|
+
### 1. 时间范围:确保消息覆盖完整
|
|
83
|
+
|
|
84
|
+
- 用户说"今天"、"最近"等相对时间时,自主推算对应的 `startTime` / `endTime`(ISO 8601,+08:00 时区)
|
|
85
|
+
- 不确定范围时适当放宽,宁可多拉再过滤
|
|
86
|
+
|
|
87
|
+
### 2. 分页:根据需要翻页获取更多结果
|
|
88
|
+
|
|
89
|
+
- 返回结果中 `has_more=true` 时,将 `cursor` 传入下次调用继续获取下一页
|
|
90
|
+
- 根据用户需求判断是否需要翻页:需要完整结果时继续翻页,浏览概览时第一页通常够用
|
|
91
|
+
|
|
92
|
+
### 3. 排序方向
|
|
93
|
+
|
|
94
|
+
- 默认 `orderAsc=false`(逆序),拉取最新消息
|
|
95
|
+
- 需要按时间顺序阅读时,传 `orderAsc=true`
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 使用场景示例
|
|
100
|
+
|
|
101
|
+
### 场景 1:获取群聊最新消息
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"chatId": "4511334567",
|
|
106
|
+
"chatType": "group"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 场景 2:获取某时间段内的单聊消息
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"chatId": "zhangsan",
|
|
115
|
+
"chatType": "direct",
|
|
116
|
+
"startTime": "2026-03-01T00:00:00+08:00",
|
|
117
|
+
"endTime": "2026-03-01T23:59:59+08:00",
|
|
118
|
+
"orderAsc": true
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 场景 3:分页获取更多消息
|
|
123
|
+
|
|
124
|
+
第一次调用返回 `has_more: true` 和 `cursor: "xxx"`,继续获取:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"chatId": "4511334567",
|
|
129
|
+
"chatType": "group",
|
|
130
|
+
"cursor": "xxx"
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 常见错误与排查
|
|
137
|
+
|
|
138
|
+
| 错误现象 | 根本原因 | 解决方案 |
|
|
139
|
+
|---------|---------|---------|
|
|
140
|
+
| 消息结果为空 | 时间范围不对或 chatId 有误 | 检查 `chatId`、`chatType`,适当放宽时间范围 |
|
|
141
|
+
| 消息不完整 | 没有检查 `has_more` 并翻页 | `has_more=true` 时用返回的 `cursor` 继续翻页 |
|
|
142
|
+
| 报错 invalid tuitui account | 账号未配置或未启用 | 确认 tuitui 账号已正确配置 |
|
|
143
|
+
| 消息顺序不对 | `orderAsc` 方向有误 | 按需传 `orderAsc: true/false` |
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parseAllowFroms } from './utils';
|
|
2
|
+
import { capabilities, configSchema, baseFildsDefault } from './confs';
|
|
3
|
+
import { CHANNEL_ID,} from "./const";
|
|
4
|
+
import { DEFAULT_ACCOUNT_ID,} from 'openclaw/plugin-sdk';
|
|
5
|
+
|
|
6
|
+
const isEnabled = (val: any) => val === undefined || !!val;
|
|
7
|
+
|
|
8
|
+
export const resolveAccount = (cfg: any, accountId?: string | null) => {
|
|
9
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
|
|
10
|
+
const targetId = accountId || DEFAULT_ACCOUNT_ID;
|
|
11
|
+
const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
|
|
12
|
+
return {
|
|
13
|
+
accountId: targetId,
|
|
14
|
+
enabled: isEnabled(acct?.enabled),
|
|
15
|
+
appId: acct?.appId || '',
|
|
16
|
+
appSecret: acct?.appSecret || '',
|
|
17
|
+
dmPolicy: acct?.dmPolicy || baseFildsDefault.dmPolicy,
|
|
18
|
+
allowFrom: parseAllowFroms(acct?.allowFrom || baseFildsDefault.allowFrom),
|
|
19
|
+
// 群组策略与白名单、群组级覆盖
|
|
20
|
+
groupPolicy: acct?.groupPolic || baseFildsDefault.groupPolicy,
|
|
21
|
+
groupAllowFrom: parseAllowFroms(acct?.groupAllowFrom || baseFildsDefault.groupAllowFrom),
|
|
22
|
+
requireMention: isEnabled(acct?.requireMention ?? baseFildsDefault.requireMention),
|
|
23
|
+
channelContext: acct?.channelContext || baseFildsDefault.channelContext,
|
|
24
|
+
};
|
|
25
|
+
};
|
package/src/channel.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
deleteAccountFromConfigSection,
|
|
12
12
|
} from 'openclaw/plugin-sdk';
|
|
13
13
|
import { CHANNEL_ID, CHANNEL_NAME } from "./const";
|
|
14
|
-
|
|
15
14
|
import {
|
|
16
15
|
checkAccount,
|
|
17
16
|
sendTextMsg,
|
|
@@ -19,31 +18,14 @@ import {
|
|
|
19
18
|
sendMediaMsg,
|
|
20
19
|
guessChatType,
|
|
21
20
|
} from "./outbound";
|
|
22
|
-
|
|
23
21
|
import { handleInboundMessage } from './inbound';
|
|
24
|
-
|
|
25
22
|
import { capabilities, configSchema, baseFildsDefault } from './confs';
|
|
23
|
+
import { resolveAccount } from "./accounts"
|
|
24
|
+
|
|
26
25
|
|
|
27
26
|
const isEnabled = (val: any) => val === undefined || !!val;
|
|
28
27
|
const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
|
|
29
|
-
|
|
30
|
-
const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
|
|
31
|
-
const targetId = accountId || DEFAULT_ACCOUNT_ID;
|
|
32
|
-
const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
|
|
33
|
-
return {
|
|
34
|
-
accountId: targetId,
|
|
35
|
-
enabled: isEnabled(acct?.enabled),
|
|
36
|
-
appId: acct?.appId as string | undefined,
|
|
37
|
-
appSecret: acct?.appSecret as string | undefined,
|
|
38
|
-
dmPolicy: acct?.dmPolicy || baseFildsDefault.dmPolicy,
|
|
39
|
-
allowFrom: acct?.allowFrom || baseFildsDefault.allowFrom,
|
|
40
|
-
// 群组策略与白名单、群组级覆盖
|
|
41
|
-
groupPolicy: (acct?.groupPolicy as string | undefined) || baseFildsDefault.groupPolicy,
|
|
42
|
-
groupAllowFrom: acct?.groupAllowFrom || baseFildsDefault.groupAllowFrom,
|
|
43
|
-
requireMention: isEnabled(acct?.requireMention),
|
|
44
|
-
channelContext: acct?.channelContext || baseFildsDefault.channelContext,
|
|
45
|
-
};
|
|
46
|
-
};
|
|
28
|
+
|
|
47
29
|
const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
|
|
48
30
|
let wsNumber = 0;
|
|
49
31
|
|
|
@@ -153,7 +135,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
153
135
|
allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
|
|
154
136
|
policyPath: `${allowFromPath}dmPolicy`,
|
|
155
137
|
allowFromPath,
|
|
156
|
-
approveHint:
|
|
138
|
+
approveHint: `当前 ${CHANNEL_ID} openclaw(AccountId: ${accountId})需要配对校验, code: <code>`,
|
|
157
139
|
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
158
140
|
};
|
|
159
141
|
},
|
|
@@ -174,16 +156,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
174
156
|
},
|
|
175
157
|
|
|
176
158
|
outbound: {
|
|
177
|
-
deliveryMode: '
|
|
178
|
-
textChunkLimit:
|
|
159
|
+
deliveryMode: 'direct' as const,
|
|
160
|
+
textChunkLimit: 10000, // API上限制为50k
|
|
179
161
|
|
|
180
|
-
sendText: async ({ cfg, to, text, accountId,
|
|
181
|
-
account =
|
|
182
|
-
checkAccount(account
|
|
162
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
|
|
163
|
+
const account = resolveAccount(cfg, accountId);
|
|
164
|
+
checkAccount(account);
|
|
183
165
|
|
|
184
166
|
const chatId = String(to || '').trim();
|
|
185
|
-
|
|
186
|
-
|
|
167
|
+
const chatType = guessChatType(chatId);
|
|
168
|
+
console.log(`[${CHANNEL_ID}] AccountId ${accountId} outbound.sendText() ${chatType} to ${chatId} ${text}`);
|
|
169
|
+
|
|
170
|
+
await sendTextMsg(account, chatId, chatType, text);
|
|
187
171
|
|
|
188
172
|
return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
|
|
189
173
|
},
|
|
@@ -240,6 +224,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
240
224
|
wsNumber++;
|
|
241
225
|
const wsId = `${wsNumber}-${Date.now()}`;
|
|
242
226
|
const wsEvtIds = new Set<string>();
|
|
227
|
+
let wsRetryTimerId = 0;
|
|
243
228
|
|
|
244
229
|
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
245
230
|
const defSendMsg = (msg: any) => {
|
|
@@ -285,7 +270,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
285
270
|
const wsEvent = json?.body?.event;
|
|
286
271
|
if (wsEvent === 'keepalive') return;
|
|
287
272
|
|
|
288
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Received
|
|
273
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}], Received event ${wsEvent}, body ${JSON.stringify(json?.body, null, 2)}`);
|
|
289
274
|
|
|
290
275
|
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
291
276
|
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
@@ -304,7 +289,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
304
289
|
_sendMsg = defSendMsg;
|
|
305
290
|
if (!abortSignal?.aborted) {
|
|
306
291
|
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
307
|
-
|
|
292
|
+
if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
|
|
293
|
+
wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
308
294
|
}
|
|
309
295
|
};
|
|
310
296
|
ws.on('close', () => {
|