@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 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
  };
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "id": "tuitui-openclaw-channel",
3
3
  "channels": ["tuitui"],
4
+ "skills": ["./skills"],
4
5
  "configSchema": {
5
6
  "type": "object",
6
7
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.12",
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` |
@@ -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
- const resolveAccount = (cfg: any, accountId?: string | null) => {
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 message: ${wsData}`);
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
- * account: 长连接初始化时,对应的Account配置信息
106
- * accountId: 长连接启动初始化,对应的配置ID
107
- */
108
- export async function handleInboundMessage({ json, account, apiRuntime, log }: InboundHandlerOptions) {
109
- // Signature / AppId validation
110
- const { accountId, appId, appSecret } = account;
111
- if (appSecret && appId && json.header['X-Tuitui-Robot-Appid'] !== appId) {
112
- log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
113
- return;
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?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, no_reply_content`);
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
- onReplyStart: () => {
224
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Agent reply started for ${payload.tuituiAccount ?? payload.tuituiUid}`);
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)分支,直接修改 payload。
171
+ * 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
266
172
  * 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
267
173
  */
268
- async function parseSingleChat(
269
- payload: ChatPayload,
270
- msgData: any,
271
- account: InboundHandlerOptions['account'],
272
- apiRuntime: any,
273
- log: any,
274
- ): Promise<boolean> {
275
- const { accountId, dmPolicy } = account;
276
- const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
277
- if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
278
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
279
- return false;
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
- payload.chatType = CHAT_TYPE_DIRECT;
283
- payload.chatId = chatId;
284
- payload.text = buildMessageBody(msgData);
285
- payload.msgId = msgData.msgid;
286
- log?.debug?.(
287
- `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat user_account=${chatId} uid=${payload.tuituiUid} user_name=${payload.tuituiUserName}`,
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
- if (!chatId && !payload.tuituiUid) {
291
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
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
- const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
296
- const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
297
- log?.debug?.(
298
- `[${CHANNEL_ID}] AccountId: ${accountId}, dmPolicy=${dmPolicy} userAccount=${chatId} tuituiUid=${payload.tuituiUid} chatId=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
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
- if (dmPolicy === 'open') {
302
- return true;
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
- let storeAllowFrom: string[] = [];
306
- try {
307
- const res = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({ channel: CHANNEL_ID, accountId });
308
- if (Array.isArray(res)) storeAllowFrom = arrLowerCaseTrim(res);
309
- } catch {}
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
- const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
312
- const isAllowed = chatId ? allowSet.has(chatId) : false;
313
- if(isAllowed) return true;
270
+ payload.text = buildMessageBody(msgData);
314
271
 
315
- if (dmPolicy === 'pairing') {
316
- sendSingleChatPairingMsg(account, payload, log, apiRuntime);
317
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
318
- return false;
319
- } else { // allowlist
320
- log?.warn?.(
321
- `[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
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
- * 处理群聊(group_chat)分支
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
- if (!msgData.at_me && account.requireMention) {
366
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore (not mentioned)`);
367
- return false;
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
- payload.text = buildMessageBody(msgData);
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
- if (!normalizedGroupAllowFrom.includes(String(chatId))) {
373
- if (needPairingThrottle(accountId, chatId)) {
374
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
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
- return true;
388
- }
326
+ return true;
327
+ },
328
+ } as const;
389
329
 
390
- async function parseTeamsPost(
391
- payload: ChatPayload,
392
- msgData: any,
393
- account: InboundHandlerOptions['account'],
394
- apiRuntime: any,
395
- log: any,
396
- ): Promise<boolean> {
397
- const { accountId } = account;
398
- payload.chatType = CHAT_TYPE_CHANNEL;
399
- const { team_id, channel_id, parent_id, post_id, content } = msgData;
400
- const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
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
- if (!payload.tuituiAccount || !chatId) {
424
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
425
- return false;
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 (!msgData.at_me && account.requireMention) {
429
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore (not mentioned)`);
430
- return false;
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
- if (!normalizedGroupAllowFrom.includes(String(team_id))) {
434
- if (needPairingThrottle(accountId, chatId)) {
435
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
436
- return false;
437
- }
438
- await sendTextMsg(
439
- account,
440
- chatId,
441
- payload.chatType,
442
- `当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID:\n${team_id}`,
443
- 'tuitui.groupPolicy.reply',
444
- );
445
- return false;
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
- return true;
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
- return CHAT_TYPE_CHANNEL;
37
- } else if (/^\d+$/.test(chatId)) {
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
- function getTargets(chatId: string, chatType: ChatType): any {
376
- if (chatType == CHAT_TYPE_DIRECT) {
377
- const tousers: string[] = [];
378
- tousers.push(chatId);
379
- return { tousers };
380
- } else if (chatType == CHAT_TYPE_GROUP) {
381
- const togroups: string[] = [];
382
- togroups.push(chatId);
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
- function getMentionsRegex(): RegExp {
409
- // 正则表达式解释:
410
- // (?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])
411
- // - 正向向后查找,确保@前面是:
412
- // ^ - 字符串开头
413
- // [\s\r\n] - 空格、回车、换行
414
- // \u3000 - 中文全角空格
415
- // \u3001 - 中文顿号(、)
416
- // \u3002 - 中文句号(。)
417
- // \uFF0C - 中文逗号(,)
418
- // \uFF01 - 中文感叹号(!)
419
- // \uFF1F - 中文问号(?)
420
- // \u2026 - 中文省略号(…)
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 mentions: string[] = [];
429
-
430
- const regex = getMentionsRegex();
431
- let match;
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
- const regex = getMentionsRegex();
444
- return text.replace(regex, (match, mention) => {
445
- // match: 完整的匹配字符串(例如 "@username"
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: string;
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: string[];
50
- togroups: string[];
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: TuiTuiTeamsTarget[];
58
- at: string[];
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
@@ -0,0 +1,5 @@
1
+ /* 格式化allowFrom配置项,确保其为小写字符串数组 */
2
+ export const parseAllowFroms = (allowFrom: any) : string[] => {
3
+ const arr = Array.isArray(allowFrom) ? allowFrom : [];
4
+ return arr.filter((v: any) => !!v).map((v: any) => String(v).toLowerCase().trim());
5
+ }