@qihoo/tuitui-openclaw-channel 1.0.12 → 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 +4 -22
- package/src/inbound.ts +262 -303
- package/src/outbound.ts +177 -68
- package/src/tools.ts +88 -0
- package/src/types.ts +5 -5
- package/src/utils.ts +5 -0
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
|
|
|
@@ -288,7 +270,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
288
270
|
const wsEvent = json?.body?.event;
|
|
289
271
|
if (wsEvent === 'keepalive') return;
|
|
290
272
|
|
|
291
|
-
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)}`);
|
|
292
274
|
|
|
293
275
|
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
294
276
|
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
package/src/inbound.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types';
|
|
14
14
|
import { CHANNEL_ID } from './const';
|
|
15
|
-
|
|
16
15
|
import {
|
|
17
16
|
CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType,
|
|
18
17
|
buildMessageBody,
|
|
@@ -23,10 +22,7 @@ import {
|
|
|
23
22
|
teamsBuildChatId,
|
|
24
23
|
teamsParseChatId,
|
|
25
24
|
} from "./outbound";
|
|
26
|
-
|
|
27
|
-
const arrLowerCaseTrim = (arr: any[]) =>
|
|
28
|
-
arr.filter((v: any) => !!v).map((v: any) => String(v).toLowerCase().trim());
|
|
29
|
-
|
|
25
|
+
import { parseAllowFroms } from './utils';
|
|
30
26
|
|
|
31
27
|
/** 子函数共享的可变上下文,子函数直接修改字段,外层读取结果 */
|
|
32
28
|
interface ChatPayload {
|
|
@@ -42,37 +38,32 @@ interface ChatPayload {
|
|
|
42
38
|
tuituiUserName: string | undefined;
|
|
43
39
|
}
|
|
44
40
|
|
|
41
|
+
export interface InboundAccount {
|
|
42
|
+
accountId: string;
|
|
43
|
+
appId: string;
|
|
44
|
+
appSecret: string;
|
|
45
|
+
dmPolicy: string;
|
|
46
|
+
allowFrom: string[];
|
|
47
|
+
groupPolicy: string;
|
|
48
|
+
groupAllowFrom: string[];
|
|
49
|
+
requireMention: boolean;
|
|
50
|
+
channelContext: string;
|
|
51
|
+
}
|
|
45
52
|
export interface InboundHandlerOptions {
|
|
46
53
|
json: any
|
|
47
|
-
account:
|
|
48
|
-
accountId?: string;
|
|
49
|
-
appId?: string;
|
|
50
|
-
appSecret?: string;
|
|
51
|
-
dmPolicy?: string;
|
|
52
|
-
allowFrom?: any[];
|
|
53
|
-
groupPolicy?: string;
|
|
54
|
-
groupAllowFrom?: any[];
|
|
55
|
-
requireMention?: boolean;
|
|
56
|
-
channelContext?: string;
|
|
57
|
-
};
|
|
54
|
+
account: InboundAccount;
|
|
58
55
|
apiRuntime: any;
|
|
59
56
|
log: any;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
function getSessionKey(
|
|
63
|
-
cfg: any,
|
|
64
|
-
payload: ChatPayload,
|
|
65
|
-
account: InboundHandlerOptions['account'],
|
|
66
|
-
apiRuntime: InboundHandlerOptions['apiRuntime']
|
|
67
|
-
): string | undefined {
|
|
59
|
+
function getSessionKey(cfg: any, payload: ChatPayload, account: InboundAccount, apiRuntime: any): string {
|
|
68
60
|
const { chatId, chatType } = payload;
|
|
69
|
-
if (!chatId) return undefined;
|
|
70
61
|
const { accountId, channelContext } = account;
|
|
71
62
|
// 你自己只需要拼接与定义 peer.id
|
|
72
63
|
// 关于 sessionKey 格式的解释: https://docs.openclaw.ai/channels/channel-routing#sessionkey-%E5%8F%82%E8%80%83%E6%A0%BC%E5%BC%8F
|
|
73
64
|
let id = chatId;
|
|
74
65
|
if(chatType == CHAT_TYPE_CHANNEL) {
|
|
75
|
-
const { channel_id, parent_id } = teamsParseChatId(chatId);
|
|
66
|
+
const { channel_id, parent_id } = teamsParseChatId(chatId!);
|
|
76
67
|
id = `${channel_id}`;
|
|
77
68
|
if(channelContext == 'thread' && parent_id) {
|
|
78
69
|
id += `:thread:${parent_id}`;
|
|
@@ -86,7 +77,7 @@ function getSessionKey(
|
|
|
86
77
|
channel: CHANNEL_ID,
|
|
87
78
|
peer: { kind: chatType, id: id },
|
|
88
79
|
});
|
|
89
|
-
return sessionKey;
|
|
80
|
+
return String(sessionKey).replace(/\//g, '_');
|
|
90
81
|
}
|
|
91
82
|
|
|
92
83
|
function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefined {
|
|
@@ -100,131 +91,46 @@ function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefi
|
|
|
100
91
|
return undefined;
|
|
101
92
|
}
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// Decode message
|
|
117
|
-
const msg = json.body as TuiTuiInboundMessage;
|
|
118
|
-
const msgData = msg.data;
|
|
119
|
-
const { ref } = msgData;
|
|
120
|
-
const payload: ChatPayload = {
|
|
121
|
-
chatType: CHAT_TYPE_DIRECT,
|
|
122
|
-
chatId: undefined,
|
|
123
|
-
text: undefined,
|
|
124
|
-
groupName: undefined,
|
|
125
|
-
mediaUrls: getMediaUrls(msgData),
|
|
126
|
-
replyToId: ref?.is_me && ref?.msgid ? ref.msgid : undefined,
|
|
127
|
-
tuituiAccount: msg.user_account || "",
|
|
128
|
-
tuituiUid: msg.uid || "",
|
|
129
|
-
tuituiUserName: msg.user_name || "",
|
|
130
|
-
};
|
|
131
|
-
const wsEvent = json.body.event;
|
|
132
|
-
// Event-type branching
|
|
133
|
-
if (wsEvent === 'single_chat') {
|
|
134
|
-
const valid = await parseSingleChat(payload, msgData, account, apiRuntime, log);
|
|
135
|
-
if (!valid) return;
|
|
136
|
-
} else if (wsEvent === 'group_chat') {
|
|
137
|
-
const valid = await parseGroupChat(payload, msgData, account, apiRuntime, log);
|
|
138
|
-
if (!valid) return;
|
|
139
|
-
} else if (wsEvent === 'teams_post_create') {
|
|
140
|
-
const valid = await parseTeamsPost(payload, msgData, account, apiRuntime, log);
|
|
141
|
-
if (!valid) return;
|
|
142
|
-
} else {
|
|
143
|
-
log?.info?.(`[${CHANNEL_ID}] ignore unknown event ${wsEvent}`);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (!payload.chatId || !payload.msgId) {
|
|
147
|
-
log?.info?.(`[${CHANNEL_ID}] ignore unknown mseeage, missing chatId or msgId`, payload);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// 因为回复较慢,先回复一个表情
|
|
152
|
-
await tuituiEmojiReaction(account, payload.chatId, payload.chatType, payload.msgId, '收到');
|
|
153
|
-
|
|
154
|
-
// 路由判断
|
|
155
|
-
const routeSenderFrom = payload.tuituiAccount || payload.tuituiUid || 'unknown';
|
|
156
|
-
|
|
157
|
-
const cfg = await apiRuntime.config.loadConfig();
|
|
158
|
-
const sessionKey = getSessionKey(cfg, payload, account, apiRuntime);
|
|
159
|
-
|
|
160
|
-
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}`);
|
|
161
|
-
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, dispatching to agent session=${sessionKey}`);
|
|
162
|
-
|
|
163
|
-
const ctx: any = {
|
|
164
|
-
Body: payload.text! || ' ',
|
|
165
|
-
From: String(routeSenderFrom),
|
|
166
|
-
To: CHANNEL_ID,
|
|
167
|
-
SessionId: String(sessionKey).replace(/\//g, '_'),
|
|
168
|
-
SessionKey: String(sessionKey).replace(/\//g, '_'),
|
|
169
|
-
AccountId: accountId,
|
|
170
|
-
OriginatingChannel: CHANNEL_ID,
|
|
171
|
-
OriginatingTo: payload.chatId,
|
|
172
|
-
ChatType: payload.chatType!,
|
|
173
|
-
Surface: CHANNEL_ID,
|
|
174
|
-
Provider: CHANNEL_ID,
|
|
175
|
-
SenderName: payload.tuituiUserName,
|
|
176
|
-
MsgUname: payload.tuituiAccount,
|
|
177
|
-
UserAccount: payload.tuituiAccount,
|
|
178
|
-
CommandAuthorized: true, // 允许 /new 等内置命令
|
|
179
|
-
};
|
|
180
|
-
if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
|
|
181
|
-
ctx.GroupSubject = payload.groupName;
|
|
182
|
-
ctx.GroupId = payload.chatId;
|
|
183
|
-
}
|
|
184
|
-
if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
|
|
185
|
-
if (payload.replyToId) ctx.ReplyToId = payload.replyToId;
|
|
186
|
-
|
|
187
|
-
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, handleInboundMessage payload`, payload);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
191
|
-
ctx,
|
|
192
|
-
cfg,
|
|
193
|
-
dispatcherOptions: {
|
|
194
|
-
deliver: async (outbound: TuiTuiOutboundDeliverOptions) => {
|
|
195
|
-
// Custom message (e.g. page)
|
|
196
|
-
if (outbound.custom) {
|
|
197
|
-
const { msgtype, page, tousers, togroups } = outbound.custom;
|
|
198
|
-
if (msgtype === 'page' && page) {
|
|
199
|
-
await sendPageMsg(account, payload.chatId, payload.chatType, page, 'tuitui.deliver.page', tousers, togroups);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Unsupported custom message type: ${msgtype}`);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Media message
|
|
207
|
-
const mediaUrl = outbound.mediaUrl || outbound.mediaUrls?.[0];
|
|
208
|
-
if (mediaUrl) {
|
|
209
|
-
await sendMediaMsg(account, payload.chatId, payload.chatType, mediaUrl, 'tuitui.deliver.media', [payload.tuituiAccount]);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Text message
|
|
214
|
-
const replyText = outbound?.text || outbound?.body;
|
|
215
|
-
if (replyText) {
|
|
216
|
-
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
217
|
-
await sendTextMsg(account, payload.chatId, payload.chatType, replyText, 'tuitui.deliver.text', [payload.tuituiAccount]);
|
|
94
|
+
async function dispatchReply(ctx: any, cfg: any, account: InboundAccount, payload: ChatPayload, apiRuntime: any, log: any) {
|
|
95
|
+
const { accountId } = account;
|
|
96
|
+
await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
97
|
+
ctx,
|
|
98
|
+
cfg,
|
|
99
|
+
dispatcherOptions: {
|
|
100
|
+
deliver: async (outbound: TuiTuiOutboundDeliverOptions) => {
|
|
101
|
+
// Custom message (e.g. page)
|
|
102
|
+
if (outbound.custom) {
|
|
103
|
+
const { msgtype, page, tousers, togroups } = outbound.custom;
|
|
104
|
+
if (msgtype === 'page' && page) {
|
|
105
|
+
await sendPageMsg(account, payload.chatId, payload.chatType, page, 'tuitui.deliver.page', tousers, togroups);
|
|
218
106
|
return;
|
|
219
107
|
}
|
|
220
|
-
log?.
|
|
221
|
-
|
|
108
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Unsupported custom message type: ${msgtype}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Media message
|
|
113
|
+
const mediaUrl = outbound.mediaUrl || outbound.mediaUrls?.[0];
|
|
114
|
+
if (mediaUrl) {
|
|
115
|
+
await sendMediaMsg(account, payload.chatId, payload.chatType, mediaUrl, 'tuitui.deliver.media', [payload.tuituiAccount]);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Text message
|
|
120
|
+
const replyText = outbound?.text || outbound?.body;
|
|
121
|
+
if (replyText) {
|
|
122
|
+
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
123
|
+
await sendTextMsg(account, payload.chatId, payload.chatType, replyText, 'tuitui.deliver.text', [payload.tuituiAccount]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, no_reply_content`);
|
|
127
|
+
},
|
|
222
128
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
},
|
|
129
|
+
onReplyStart: () => {
|
|
130
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Agent reply started for ${payload.tuituiAccount ?? payload.tuituiUid}`);
|
|
226
131
|
},
|
|
227
|
-
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
228
134
|
}
|
|
229
135
|
|
|
230
136
|
/*** 配对节流映射 - 用于防止短时间内大量重复配对请求消息发出
|
|
@@ -262,188 +168,241 @@ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, ap
|
|
|
262
168
|
}
|
|
263
169
|
|
|
264
170
|
/**
|
|
265
|
-
* 处理单聊(single_chat
|
|
171
|
+
* 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
|
|
266
172
|
* 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
|
|
267
173
|
*/
|
|
268
|
-
|
|
269
|
-
payload:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
174
|
+
const parseAndVerifyPayload: Record<string, Function> = {
|
|
175
|
+
single_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
176
|
+
const { accountId, dmPolicy, allowFrom } = account;
|
|
177
|
+
const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
|
|
178
|
+
if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
|
|
179
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
payload.chatType = CHAT_TYPE_DIRECT;
|
|
184
|
+
payload.chatId = chatId;
|
|
185
|
+
payload.text = buildMessageBody(msgData);
|
|
186
|
+
payload.msgId = msgData.msgid;
|
|
187
|
+
log?.debug?.(
|
|
188
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat:
|
|
189
|
+
userAccount=${chatId},
|
|
190
|
+
tuituiUid=${payload.tuituiUid},
|
|
191
|
+
tuituiUserName=${payload.tuituiUserName},
|
|
192
|
+
dmPolicy=${dmPolicy},
|
|
193
|
+
allowFrom=${JSON.stringify(allowFrom)}`);
|
|
194
|
+
|
|
195
|
+
if (!chatId && !payload.tuituiUid) {
|
|
196
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (dmPolicy === 'open') return true;
|
|
281
201
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
);
|
|
202
|
+
let storeAllowFrom: string[] = [];
|
|
203
|
+
try {
|
|
204
|
+
storeAllowFrom = parseAllowFroms(
|
|
205
|
+
await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId })
|
|
206
|
+
);
|
|
207
|
+
} catch {}
|
|
289
208
|
|
|
290
|
-
|
|
291
|
-
|
|
209
|
+
if(chatId && [...allowFrom, ...storeAllowFrom].includes(chatId)) {
|
|
210
|
+
return true; // isAllowed:单聊目标存在于白名单或配对存储中
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (dmPolicy === 'pairing') {
|
|
214
|
+
sendSingleChatPairingMsg(account, payload, log, apiRuntime);
|
|
215
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
// allowlist
|
|
219
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(allowFrom)}`);
|
|
292
220
|
return false;
|
|
293
|
-
}
|
|
221
|
+
},
|
|
222
|
+
group_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
223
|
+
const { accountId, groupPolicy, groupAllowFrom } = account;
|
|
224
|
+
payload.chatType = CHAT_TYPE_GROUP;
|
|
225
|
+
const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
|
|
226
|
+
payload.chatId = chatId;
|
|
227
|
+
payload.groupName = msgData.group_name;
|
|
228
|
+
payload.msgId = msgData.msgid;
|
|
229
|
+
log?.debug?.(
|
|
230
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
|
|
231
|
+
tuituiAccount=${payload.tuituiAccount},
|
|
232
|
+
tuituiUid=${payload.tuituiUid},
|
|
233
|
+
tuituiUserName=${payload.tuituiUserName},
|
|
234
|
+
groupId=${chatId},
|
|
235
|
+
groupPolicy=${groupPolicy},
|
|
236
|
+
groupAllowFrom=${JSON.stringify(groupAllowFrom)},
|
|
237
|
+
at_me=${JSON.stringify(msgData.at_me)},
|
|
238
|
+
at=${JSON.stringify(msgData.at)}`);
|
|
239
|
+
|
|
240
|
+
if (groupPolicy === 'disabled') {
|
|
241
|
+
log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
294
244
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
);
|
|
245
|
+
if (!payload.tuituiAccount || !chatId) {
|
|
246
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
300
249
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
250
|
+
if (!msgData.at_me && account.requireMention) {
|
|
251
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned)`);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
304
254
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
255
|
+
if (!groupAllowFrom.includes(chatId)) {
|
|
256
|
+
if (needPairingThrottle(accountId, chatId)) {
|
|
257
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
await sendTextMsg(
|
|
261
|
+
account,
|
|
262
|
+
chatId,
|
|
263
|
+
payload.chatType,
|
|
264
|
+
`当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
|
|
265
|
+
'tuitui.groupPolicy.reply',
|
|
266
|
+
);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
310
269
|
|
|
311
|
-
|
|
312
|
-
const isAllowed = chatId ? allowSet.has(chatId) : false;
|
|
313
|
-
if(isAllowed) return true;
|
|
270
|
+
payload.text = buildMessageBody(msgData);
|
|
314
271
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
272
|
+
return true;
|
|
273
|
+
},
|
|
274
|
+
teams_post_create: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
|
|
275
|
+
const { accountId, groupPolicy, groupAllowFrom } = account;
|
|
276
|
+
payload.chatType = CHAT_TYPE_CHANNEL;
|
|
277
|
+
const { team_id, channel_id, parent_id, post_id, content } = msgData;
|
|
278
|
+
const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
|
|
279
|
+
const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
|
|
280
|
+
payload.chatId = chatId;
|
|
281
|
+
payload.msgId = post_id;
|
|
282
|
+
payload.text = content;
|
|
283
|
+
payload.replyToId = "";
|
|
284
|
+
log?.debug?.(
|
|
285
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
|
|
286
|
+
tuituiAccount=${payload.tuituiAccount},
|
|
287
|
+
tuituiUid=${payload.tuituiUid},
|
|
288
|
+
tuituiUserName=${payload.tuituiUserName},
|
|
289
|
+
chatId=${chatId},
|
|
290
|
+
groupPolicy=${groupPolicy},
|
|
291
|
+
groupAllowFrom=${JSON.stringify(groupAllowFrom)},
|
|
292
|
+
at_me=${JSON.stringify(msgData.at_me)},
|
|
293
|
+
at=${JSON.stringify(msgData.at)}`,
|
|
322
294
|
);
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
295
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
async function parseGroupChat(
|
|
332
|
-
payload: ChatPayload,
|
|
333
|
-
msgData: any,
|
|
334
|
-
account: InboundHandlerOptions['account'],
|
|
335
|
-
apiRuntime: any,
|
|
336
|
-
log: any,
|
|
337
|
-
): Promise<boolean> {
|
|
338
|
-
const { accountId } = account;
|
|
339
|
-
payload.chatType = CHAT_TYPE_GROUP;
|
|
340
|
-
const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
|
|
341
|
-
payload.chatId = chatId;
|
|
342
|
-
payload.groupName = msgData.group_name;
|
|
343
|
-
payload.msgId = msgData.msgid;
|
|
344
|
-
log?.debug?.(
|
|
345
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} group_id=${payload.chatId}`,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const groupPolicy = String(account.groupPolicy ?? 'allowlist').toLowerCase();
|
|
349
|
-
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
350
|
-
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
351
|
-
log?.debug?.(
|
|
352
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
if (groupPolicy === 'disabled') {
|
|
356
|
-
log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (!payload.tuituiAccount || !chatId) {
|
|
361
|
-
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
296
|
+
if (groupPolicy === 'disabled') {
|
|
297
|
+
log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
364
300
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
301
|
+
if (!payload.tuituiAccount) {
|
|
302
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
369
305
|
|
|
370
|
-
|
|
306
|
+
if (!msgData.at_me && account.requireMention) {
|
|
307
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned)`);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
371
310
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
311
|
+
if (!groupAllowFrom.includes(String(team_id))) {
|
|
312
|
+
if (needPairingThrottle(accountId, chatId)) {
|
|
313
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
await sendTextMsg(
|
|
317
|
+
account,
|
|
318
|
+
chatId,
|
|
319
|
+
payload.chatType,
|
|
320
|
+
`当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID:\n${team_id}`,
|
|
321
|
+
'tuitui.groupPolicy.reply',
|
|
322
|
+
);
|
|
375
323
|
return false;
|
|
376
324
|
}
|
|
377
|
-
await sendTextMsg(
|
|
378
|
-
account,
|
|
379
|
-
chatId,
|
|
380
|
-
payload.chatType,
|
|
381
|
-
`当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
|
|
382
|
-
'tuitui.groupPolicy.reply',
|
|
383
|
-
);
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
325
|
|
|
387
|
-
|
|
388
|
-
}
|
|
326
|
+
return true;
|
|
327
|
+
},
|
|
328
|
+
} as const;
|
|
389
329
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const { accountId } = account;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
|
|
402
|
-
payload.chatId = chatId;
|
|
403
|
-
payload.msgId = post_id;
|
|
404
|
-
payload.text = content;
|
|
405
|
-
payload.replyToId = "";
|
|
406
|
-
log?.debug?.(
|
|
407
|
-
`[${CHANNEL_ID}] inbound teams tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} chatId=${chatId}`,
|
|
408
|
-
);
|
|
409
|
-
|
|
410
|
-
// 暂时白名单先用这个,后面拆出来
|
|
411
|
-
const groupPolicy = String(account.groupPolicy ?? 'allowlist').toLowerCase();
|
|
412
|
-
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
413
|
-
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
414
|
-
log?.debug?.(
|
|
415
|
-
`[${CHANNEL_ID}] AccountId: ${accountId} groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
if (groupPolicy === 'disabled') {
|
|
419
|
-
log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
|
|
420
|
-
return false;
|
|
330
|
+
/**
|
|
331
|
+
* 处理长链接消息
|
|
332
|
+
* account: 长连接初始化时,对应的Account配置信息
|
|
333
|
+
* accountId: 长连接启动初始化,对应的配置ID
|
|
334
|
+
*/
|
|
335
|
+
export async function handleInboundMessage({ json, account, apiRuntime, log }: InboundHandlerOptions) {
|
|
336
|
+
// Signature / AppId validation
|
|
337
|
+
const { accountId, appId, appSecret } = account;
|
|
338
|
+
if (appSecret && appId && json.header['X-Tuitui-Robot-Appid'] !== appId) {
|
|
339
|
+
log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
|
|
340
|
+
return;
|
|
421
341
|
}
|
|
422
342
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
343
|
+
// Decode message
|
|
344
|
+
const msg = json.body as TuiTuiInboundMessage;
|
|
345
|
+
const msgData = msg.data;
|
|
346
|
+
const { ref } = msgData;
|
|
347
|
+
const payload: ChatPayload = {
|
|
348
|
+
chatType: CHAT_TYPE_DIRECT,
|
|
349
|
+
chatId: undefined,
|
|
350
|
+
text: undefined,
|
|
351
|
+
groupName: undefined,
|
|
352
|
+
mediaUrls: getMediaUrls(msgData),
|
|
353
|
+
replyToId: ref?.is_me && ref?.msgid ? ref.msgid : undefined,
|
|
354
|
+
tuituiAccount: msg.user_account || "",
|
|
355
|
+
tuituiUid: msg.uid || "",
|
|
356
|
+
tuituiUserName: msg.user_name || "",
|
|
357
|
+
};
|
|
358
|
+
const wsEvent = json.body.event;
|
|
359
|
+
// 按event类型,标准化并校验基础数据
|
|
360
|
+
const parseAndVerifyPayloadFun = parseAndVerifyPayload[wsEvent] as Function | undefined;
|
|
361
|
+
if (!parseAndVerifyPayloadFun) {
|
|
362
|
+
log?.info?.(`[${CHANNEL_ID}] ignore unknown event ${wsEvent}`);
|
|
363
|
+
return;
|
|
426
364
|
}
|
|
365
|
+
if (!(await parseAndVerifyPayloadFun(payload, msgData, account, apiRuntime, log))) return;
|
|
427
366
|
|
|
428
|
-
if (!
|
|
429
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
430
|
-
return
|
|
367
|
+
if (!payload.chatId || !payload.msgId) {
|
|
368
|
+
log?.info?.(`[${CHANNEL_ID}] ignore unknown mseeage, missing chatId or msgId`, payload);
|
|
369
|
+
return;
|
|
431
370
|
}
|
|
432
371
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
372
|
+
// 因为回复较慢,先回复一个表情
|
|
373
|
+
await tuituiEmojiReaction(account, payload.chatId, payload.chatType, payload.msgId, '收到');
|
|
374
|
+
|
|
375
|
+
// 路由判断
|
|
376
|
+
const routeSenderFrom = payload.tuituiAccount || payload.tuituiUid || 'unknown';
|
|
377
|
+
|
|
378
|
+
const cfg = await apiRuntime.config.loadConfig();
|
|
379
|
+
const sessionKey = getSessionKey(cfg, payload, account, apiRuntime);
|
|
380
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, dispatching to agent session=${sessionKey}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}`);
|
|
381
|
+
|
|
382
|
+
const ctx: any = {
|
|
383
|
+
Body: payload.text! || ' ',
|
|
384
|
+
From: String(routeSenderFrom),
|
|
385
|
+
To: CHANNEL_ID,
|
|
386
|
+
SessionId: sessionKey,
|
|
387
|
+
SessionKey: sessionKey,
|
|
388
|
+
AccountId: accountId,
|
|
389
|
+
OriginatingChannel: CHANNEL_ID,
|
|
390
|
+
OriginatingTo: payload.chatId,
|
|
391
|
+
ChatType: payload.chatType!,
|
|
392
|
+
Surface: CHANNEL_ID,
|
|
393
|
+
Provider: CHANNEL_ID,
|
|
394
|
+
SenderName: payload.tuituiUserName,
|
|
395
|
+
MsgUname: payload.tuituiAccount,
|
|
396
|
+
UserAccount: payload.tuituiAccount,
|
|
397
|
+
CommandAuthorized: true, // 允许 /new 等内置命令
|
|
398
|
+
};
|
|
399
|
+
if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
|
|
400
|
+
ctx.GroupSubject = payload.groupName;
|
|
401
|
+
ctx.GroupId = payload.chatId;
|
|
446
402
|
}
|
|
403
|
+
if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
|
|
404
|
+
if (payload.replyToId) ctx.ReplyToId = payload.replyToId;
|
|
447
405
|
|
|
448
|
-
|
|
406
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, handleInboundMessage dispatchReply payload:`, JSON.stringify(payload, null, ' '));
|
|
407
|
+
dispatchReply(ctx, cfg, account, payload, apiRuntime, log);
|
|
449
408
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
CHANNEL_ID,
|
|
7
|
-
} from "./const";
|
|
4
|
+
import { CHANNEL_ID } from "./const";
|
|
8
5
|
|
|
9
6
|
import type {
|
|
10
7
|
TuiTuiMessageData,
|
|
@@ -23,7 +20,6 @@ import type {
|
|
|
23
20
|
/* 一些常量配置 */
|
|
24
21
|
export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn'] } as const;
|
|
25
22
|
|
|
26
|
-
|
|
27
23
|
// ChatType定义与SessionKey定义一致,不可随意修改
|
|
28
24
|
// https://docs.openclaw.ai/channels/channel-routing#session-key-shapes-examples
|
|
29
25
|
export const CHAT_TYPE_DIRECT = 'direct' as const;
|
|
@@ -31,14 +27,10 @@ export const CHAT_TYPE_GROUP = 'group' as const;
|
|
|
31
27
|
export const CHAT_TYPE_CHANNEL = 'channel' as const;
|
|
32
28
|
export type ChatType = typeof CHAT_TYPE_DIRECT | typeof CHAT_TYPE_GROUP | typeof CHAT_TYPE_CHANNEL;
|
|
33
29
|
|
|
34
|
-
export function guessChatType(chatId: string) {
|
|
35
|
-
if (chatId.startsWith("teams_"))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return CHAT_TYPE_GROUP;
|
|
39
|
-
} else {
|
|
40
|
-
return CHAT_TYPE_DIRECT;
|
|
41
|
-
}
|
|
30
|
+
export function guessChatType(chatId: string): ChatType {
|
|
31
|
+
if (chatId.startsWith("teams_")) return CHAT_TYPE_CHANNEL;
|
|
32
|
+
if (/^\d+$/.test(chatId)) return CHAT_TYPE_GROUP;
|
|
33
|
+
return CHAT_TYPE_DIRECT;
|
|
42
34
|
}
|
|
43
35
|
|
|
44
36
|
export function addParams2Url(urlStr: string, params: any) {
|
|
@@ -372,22 +364,14 @@ export function teamsParseChatId(chatId: string): TuiTuiTeamsTarget {
|
|
|
372
364
|
return { team_id, channel_id, parent_id} as TuiTuiTeamsTarget;
|
|
373
365
|
}
|
|
374
366
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return { togroups };
|
|
384
|
-
} else if (chatType == CHAT_TYPE_CHANNEL) {
|
|
385
|
-
const toteams: TuiTuiTeamsTarget[] = [];
|
|
386
|
-
toteams.push(teamsParseChatId(chatId));
|
|
387
|
-
return { toteams };
|
|
388
|
-
} else {
|
|
389
|
-
return {};
|
|
390
|
-
}
|
|
367
|
+
interface TuiTuiToTargets { tousers?: string[], togroups?: string[], toteams?: TuiTuiTeamsTarget[] }
|
|
368
|
+
|
|
369
|
+
function getTargets(chatId: string, chatType: ChatType): TuiTuiToTargets {
|
|
370
|
+
const ret = {} as TuiTuiToTargets;
|
|
371
|
+
if (chatType == CHAT_TYPE_DIRECT) ret.tousers = [chatId] as string[];
|
|
372
|
+
if (chatType == CHAT_TYPE_GROUP) ret.togroups = [chatId] as string[];
|
|
373
|
+
if (chatType == CHAT_TYPE_CHANNEL) ret.toteams = [teamsParseChatId(chatId)] as TuiTuiTeamsTarget[];
|
|
374
|
+
return ret;
|
|
391
375
|
}
|
|
392
376
|
|
|
393
377
|
function replaceSingleNewlines(content: string): string {
|
|
@@ -404,48 +388,31 @@ function replaceSingleNewlines(content: string): string {
|
|
|
404
388
|
输入:你好 @张三 @李四 你吃了吗
|
|
405
389
|
输出数组:["张三","李四"]
|
|
406
390
|
*/
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
// ([^\s]+) - 捕获组,匹配一个或多个非空白字符
|
|
423
|
-
const regex = /(?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])@([^\s]+)/g;
|
|
424
|
-
return regex
|
|
425
|
-
}
|
|
426
|
-
|
|
391
|
+
// 正则表达式解释:
|
|
392
|
+
// (?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])
|
|
393
|
+
// - 正向向后查找,确保@前面是:
|
|
394
|
+
// ^ - 字符串开头
|
|
395
|
+
// [\s\r\n] - 空格、回车、换行
|
|
396
|
+
// \u3000 - 中文全角空格
|
|
397
|
+
// \u3001 - 中文顿号(、)
|
|
398
|
+
// \u3002 - 中文句号(。)
|
|
399
|
+
// \uFF0C - 中文逗号(,)
|
|
400
|
+
// \uFF01 - 中文感叹号(!)
|
|
401
|
+
// \uFF1F - 中文问号(?)
|
|
402
|
+
// \u2026 - 中文省略号(…)
|
|
403
|
+
// @ - 匹配@符号
|
|
404
|
+
// ([^\s]+) - 捕获组,匹配一个或多个非空白字符
|
|
405
|
+
const mentionsRegex = /(?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])@([^\s]+)/g;
|
|
427
406
|
function extractMentions(text: string): string[] {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
while ((match = regex.exec(text)) !== null) {
|
|
433
|
-
const mention = match[1];
|
|
434
|
-
if (!mentions.includes(mention)) {
|
|
435
|
-
mentions.push(mention);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return mentions;
|
|
407
|
+
const mentionsSet = new Set<string>();
|
|
408
|
+
const arr = text.match(mentionsRegex);
|
|
409
|
+
if (arr) arr.forEach((str) => mentionsSet.add(str.substring(1)));
|
|
410
|
+
return Array.from(mentionsSet);
|
|
440
411
|
}
|
|
441
|
-
|
|
442
412
|
function replaceMentions(text: string): string {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// mention: 捕获组中的内容(例如 "username")
|
|
447
|
-
return `{{tuitui_at "${mention}"}}`;
|
|
448
|
-
});
|
|
413
|
+
// match: 完整的匹配字符串(例如 "@username")
|
|
414
|
+
// mention: 捕获组中的内容(例如 "username")
|
|
415
|
+
return text.replace(mentionsRegex, (match, mention) => `{{tuitui_at "${mention}"}}`);
|
|
449
416
|
}
|
|
450
417
|
|
|
451
418
|
export async function sendTextMsg(
|
|
@@ -565,3 +532,145 @@ export async function sendMediaMsg(
|
|
|
565
532
|
await postTuituiMsg(account, msg, auditCtx);
|
|
566
533
|
}
|
|
567
534
|
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
export interface TuiTuiChatRecordMessage {
|
|
538
|
+
msgid: string;
|
|
539
|
+
cid: string;
|
|
540
|
+
uid: string;
|
|
541
|
+
user_account: string;
|
|
542
|
+
user_name: string;
|
|
543
|
+
timestamp: string;
|
|
544
|
+
data: TuiTuiMessageData;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface TuiTuiChatRecordResponse {
|
|
548
|
+
errcode: number;
|
|
549
|
+
errmsg: string;
|
|
550
|
+
cursor: string;
|
|
551
|
+
has_more: boolean;
|
|
552
|
+
time: string;
|
|
553
|
+
msgs: TuiTuiChatRecordMessage[];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
export interface TuiTuiMessageDataClean extends TuiTuiMessageData{
|
|
558
|
+
// 对模型不需要理解的字段不进大模型上下文,避免注意力涣散
|
|
559
|
+
user_account: string;
|
|
560
|
+
user_name: string;
|
|
561
|
+
msg_time: string;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export interface TuiTuiChatRecordResponseClean {
|
|
565
|
+
errcode: number;
|
|
566
|
+
errmsg: string;
|
|
567
|
+
cursor: string;
|
|
568
|
+
has_more: boolean;
|
|
569
|
+
current_time: string; // 辅助大模型理解当前时间
|
|
570
|
+
msgs: TuiTuiMessageDataClean[];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export interface GetChatRecordOptions {
|
|
574
|
+
startTime?: string; // 格式:%Y-%m-%dT%H:%M:%S+08:00,示例:2026-03-17T15:13:48+08:00
|
|
575
|
+
endTime?: string; // 格式同 startTime
|
|
576
|
+
relativeTime?: string; // 相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 startTime/endTime 互斥,指定后 startTime/endTime 将被忽略
|
|
577
|
+
limit?: number; // 1~100,默认100
|
|
578
|
+
cursor?: string; // 游标,第一次填 "0"
|
|
579
|
+
orderAsc?: boolean; // 是否正序,默认 false(逆序)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 拉取群聊消息记录(分页)。
|
|
584
|
+
* 目前仅支持 group 类型(CHAT_TYPE_GROUP)。
|
|
585
|
+
*
|
|
586
|
+
* @param account - TuiTui 账号,含 appId / appSecret
|
|
587
|
+
* @param chatId - 群 ID
|
|
588
|
+
* @param chatType - 会话类型,当前仅支持 CHAT_TYPE_GROUP
|
|
589
|
+
* @param options - 可选参数:时间范围、分页游标、每页条数、是否正序
|
|
590
|
+
* @returns - 接口原始响应,包含 messages 列表与下一页 cursor;chatType 不支持时返回 undefined
|
|
591
|
+
*/
|
|
592
|
+
export async function getChatRecord(
|
|
593
|
+
account: any,
|
|
594
|
+
chatId: string | undefined,
|
|
595
|
+
chatType: ChatType,
|
|
596
|
+
options: GetChatRecordOptions = {},
|
|
597
|
+
): Promise<TuiTuiChatRecordResponseClean | undefined> {
|
|
598
|
+
if (!chatId) {
|
|
599
|
+
console.log(`[${CHANNEL_ID}] getChatRecord: Missing "chatId"`);
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
checkAccount(account, 'getChatRecord');
|
|
604
|
+
|
|
605
|
+
let baseurl = "";
|
|
606
|
+
if (chatType == CHAT_TYPE_DIRECT) {
|
|
607
|
+
baseurl = "https://im.live.360.cn:8282/robot/message/single/sync";
|
|
608
|
+
} else if (chatType == CHAT_TYPE_GROUP){
|
|
609
|
+
baseurl = "https://im.live.360.cn:8282/robot/message/group/sync";
|
|
610
|
+
} else {
|
|
611
|
+
console.log(`[${CHANNEL_ID}] getChatRecord: chatType "${chatType}" is not supported`);
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const body: Record<string, any> = {
|
|
616
|
+
cursor : "0",
|
|
617
|
+
};
|
|
618
|
+
if (chatType == CHAT_TYPE_DIRECT) body.user = chatId;
|
|
619
|
+
if (chatType == CHAT_TYPE_GROUP) body.group_id = chatId;
|
|
620
|
+
if (options.relativeTime) {
|
|
621
|
+
body.relative_time = options.relativeTime;
|
|
622
|
+
} else {
|
|
623
|
+
if (options.startTime) body.start_time = options.startTime;
|
|
624
|
+
if (options.endTime) body.end_time = options.endTime;
|
|
625
|
+
}
|
|
626
|
+
if (options.cursor) body.cursor = options.cursor;
|
|
627
|
+
if (options.limit) body.limit = options.limit;
|
|
628
|
+
if (typeof options.orderAsc === 'boolean') body.order_asc = options.orderAsc;
|
|
629
|
+
|
|
630
|
+
const { appId: appid, appSecret: secret } = account;
|
|
631
|
+
|
|
632
|
+
const url = addParams2Url(baseurl, { appid, secret });
|
|
633
|
+
|
|
634
|
+
console.log(`[${CHANNEL_ID}] getChatRecord request `, body);
|
|
635
|
+
|
|
636
|
+
const { response, release } = await _fetchJson(url, body, 'tuitui.chat.record');
|
|
637
|
+
try {
|
|
638
|
+
const bodyText = await response.text();
|
|
639
|
+
//console.log(`[${CHANNEL_ID}] getChatRecord response ${bodyText}`);
|
|
640
|
+
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
throw new Error(`getChatRecord failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const parsed: TuiTuiChatRecordResponse = JSON.parse(bodyText);
|
|
646
|
+
if (Number(parsed?.errcode) !== 0) {
|
|
647
|
+
throw new Error(`getChatRecord failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const clean: TuiTuiChatRecordResponseClean = {
|
|
651
|
+
errcode: parsed.errcode,
|
|
652
|
+
errmsg: parsed.errmsg,
|
|
653
|
+
cursor: parsed.cursor,
|
|
654
|
+
has_more: parsed.has_more,
|
|
655
|
+
current_time: parsed.time,
|
|
656
|
+
msgs: (parsed.msgs ?? []).map(({ user_account, user_name, timestamp, data }) => {
|
|
657
|
+
const { at, msgid, group_id, group_name, ...restData } = data; // 排除一些字段,减少大模型上下文大小
|
|
658
|
+
return {
|
|
659
|
+
...restData, // 使用排除 at 后的数据
|
|
660
|
+
user_account,
|
|
661
|
+
user_name,
|
|
662
|
+
msg_time: new Date(Number(timestamp) * 1000).toLocaleString('sv-SE', { hour12: false }).replace('T', ' '),
|
|
663
|
+
};
|
|
664
|
+
}),
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
|
|
668
|
+
|
|
669
|
+
return clean;
|
|
670
|
+
} catch (err) {
|
|
671
|
+
console.error(`[${CHANNEL_ID}] getChatRecord error:`, err);
|
|
672
|
+
return undefined;
|
|
673
|
+
} finally {
|
|
674
|
+
await release();
|
|
675
|
+
}
|
|
676
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID} from 'openclaw/plugin-sdk';
|
|
4
|
+
import { CHANNEL_ID} from "./const";
|
|
5
|
+
import { resolveAccount } from "./accounts"
|
|
6
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
7
|
+
|
|
8
|
+
import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType, getChatRecord} from "./outbound"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export const tuitui_im_get_messages_schema = Type.Object(
|
|
13
|
+
{
|
|
14
|
+
chatId: Type.String({ description: "聊天ID,单聊指tuitui用户的account,群聊是群ID" }),
|
|
15
|
+
chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP}`}),
|
|
16
|
+
relativeTime: Type.Optional(Type.String({ description: `相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 startTime/endTime 互斥`})),
|
|
17
|
+
startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000年代表。与 relativeTime 互斥`})),
|
|
18
|
+
endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认当前时间。与 relativeTime 互斥`})),
|
|
19
|
+
limit: Type.Optional(Type.Number({ description: `每页条数,1~100,默认 100`})),
|
|
20
|
+
cursor: Type.Optional(Type.String({ description: `游标首次调用填 "0",返回结果中has_more为true时代表可以获取下一页,如果你需要下一页,可以传返回结果的cursor代表继续拉取下一页`})),
|
|
21
|
+
orderAsc: Type.Optional(Type.Boolean({ description: `返回数据是否按时间正序排序,默认 false(按时间逆序,即从最新的开始拉取)`})),
|
|
22
|
+
},
|
|
23
|
+
{ additionalProperties: false },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function tool_errmsg(str:string) {
|
|
27
|
+
const ret = `tuitui_im_get_messages() error: ${str}`
|
|
28
|
+
console.log(ret);
|
|
29
|
+
return ret;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function tuitui_im_get_messages(config: any, agentAccountId:string, params:any) {
|
|
33
|
+
const account = resolveAccount(config, agentAccountId);
|
|
34
|
+
if(!account || !account.enabled || !account.appId || !account.appSecret) {
|
|
35
|
+
return tool_errmsg(`invalid tuitui account ${agentAccountId}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const chatType = params?.chatType || "";
|
|
39
|
+
const chatId = params?.chatId || "";
|
|
40
|
+
if(!chatId || !chatType) {
|
|
41
|
+
return tool_errmsg(`chatType or chatId empty`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if(chatType == CHAT_TYPE_DIRECT || chatType == CHAT_TYPE_GROUP) {
|
|
45
|
+
return await getChatRecord(account, chatId, chatType, {
|
|
46
|
+
startTime: params?.startTime,
|
|
47
|
+
endTime: params?.endTime,
|
|
48
|
+
relativeTime: params?.relativeTime,
|
|
49
|
+
limit: params?.limit,
|
|
50
|
+
cursor: params?.cursor,
|
|
51
|
+
orderAsc: params?.orderAsc,
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
return tool_errmsg(`unknown chatType: ${chatType}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tuituiToolFactory = (ctx: OpenClawPluginToolContext) => {
|
|
59
|
+
const agentAccountId = ctx.agentAccountId;
|
|
60
|
+
const messageChannel = ctx.messageChannel;
|
|
61
|
+
const sessionKey = ctx.sessionKey;
|
|
62
|
+
const config = ctx.config;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: "tuitui_im_get_messages",
|
|
66
|
+
label: "tuitui IM",
|
|
67
|
+
description: "推推(tuitui) 聊天记录获取,可查询群聊和私聊的聊天记录。\n\n",
|
|
68
|
+
parameters: tuitui_im_get_messages_schema,
|
|
69
|
+
execute: async (_toolCallId, params) => {
|
|
70
|
+
if(messageChannel != CHANNEL_ID) {
|
|
71
|
+
console.log(`tuitui_im_get_messages(): bad channel ${messageChannel}`);
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
console.log(`tuitui_im_get_messages(): agentAccountId: ${agentAccountId}, sessionKey: ${sessionKey}`, params);
|
|
75
|
+
return await tuitui_im_get_messages(config, agentAccountId, params);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function registerTuituiTools(api: OpenClawPluginApi) {
|
|
81
|
+
if (!api.config) {
|
|
82
|
+
api.logger.debug?.("tuitui: Registered tool: No config available");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
api.registerTool(tuituiToolFactory);
|
|
87
|
+
api.logger.info?.(`tuitui: Registered tool`);
|
|
88
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface TuiTuiInboundMessage {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface TuiTuiMessageData {
|
|
16
|
-
msgid
|
|
16
|
+
msgid?: string;
|
|
17
17
|
msg_type: 'text' | 'image' | 'mixed' | 'voice' | 'file';
|
|
18
18
|
text?: string;
|
|
19
19
|
images?: string[];
|
|
@@ -46,16 +46,16 @@ export interface TuiTuiMessageData {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface TuiTuiOutboundTextMessage {
|
|
49
|
-
tousers
|
|
50
|
-
togroups
|
|
49
|
+
tousers?: string[];
|
|
50
|
+
togroups?: string[];
|
|
51
51
|
at: string[];
|
|
52
52
|
msgtype: string;
|
|
53
53
|
text: { content: string; reference_msgid?: string };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface TuiTuiOutboundTeamsMarkdownMessage {
|
|
57
|
-
toteams
|
|
58
|
-
at
|
|
57
|
+
toteams?: TuiTuiTeamsTarget[];
|
|
58
|
+
at?: string[];
|
|
59
59
|
msgtype: string;
|
|
60
60
|
richtext: { markdown: string; delims_left: string; delims_right: string};
|
|
61
61
|
}
|
package/src/utils.ts
ADDED