@qihoo/tuitui-openclaw-channel 1.0.10 → 1.0.12

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,7 +1,7 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
2
  import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
3
- import { createTuiTuiChannelPlugin } from './src/channel.js';
4
- import { CHANNEL_ID, CHANNEL_NAME } from './src/utils';
3
+ import { createTuiTuiChannelPlugin } from './src/channel';
4
+ import { CHANNEL_ID, CHANNEL_NAME } from './src/const';
5
5
  import { id } from './openclaw.plugin.json';
6
6
 
7
7
  const plugin = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
package/src/channel.ts CHANGED
@@ -10,19 +10,19 @@ import {
10
10
  setAccountEnabledInConfigSection,
11
11
  deleteAccountFromConfigSection,
12
12
  } from 'openclaw/plugin-sdk';
13
- import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types';
13
+ import { CHANNEL_ID, CHANNEL_NAME } from "./const";
14
+
14
15
  import {
15
- CHANNEL_ID,
16
- CHANNEL_NAME,
17
- buildMessageBody,
18
- tuituiEmojiReaction,
19
16
  checkAccount,
20
17
  sendTextMsg,
21
18
  sendPageMsg,
22
19
  sendMediaMsg,
23
- } from "./utils";
20
+ guessChatType,
21
+ } from "./outbound";
22
+
23
+ import { handleInboundMessage } from './inbound';
24
24
 
25
- import { capabilities, configSchema } from './confs';
25
+ import { capabilities, configSchema, baseFildsDefault } from './confs';
26
26
 
27
27
  const isEnabled = (val: any) => val === undefined || !!val;
28
28
  const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
@@ -35,16 +35,17 @@ const resolveAccount = (cfg: any, accountId?: string | null) => {
35
35
  enabled: isEnabled(acct?.enabled),
36
36
  appId: acct?.appId as string | undefined,
37
37
  appSecret: acct?.appSecret as string | undefined,
38
- dmPolicy: acct?.dmPolicy || 'pairing',
39
- allowFrom: acct?.allowFrom || [],
38
+ dmPolicy: acct?.dmPolicy || baseFildsDefault.dmPolicy,
39
+ allowFrom: acct?.allowFrom || baseFildsDefault.allowFrom,
40
40
  // 群组策略与白名单、群组级覆盖
41
- groupPolicy: (acct?.groupPolicy as string | undefined) ?? 'allowlist',
42
- groupAllowFrom: acct?.groupAllowFrom || [],
43
- groups: (acct?.groups as Record<string, { requireMention?: boolean, shouldReply?: boolean }> | undefined) ?? {},
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,
44
45
  };
45
46
  };
46
- const isToGroup = (chatId: string) => /^\d+$/.test(chatId);
47
- const arrLowerCaseTrim = (arr: any[]) => arr.filter((v: any) => !!v).map((v: any) => String(v).toLowerCase().trim());
47
+ const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
48
+ let wsNumber = 0;
48
49
 
49
50
  export function createTuiTuiChannelPlugin(apiRuntime: any) {
50
51
  return {
@@ -91,48 +92,29 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
91
92
  ...cfg,
92
93
  channels: {
93
94
  ...cfg.channels,
94
- [CHANNEL_ID]: { ...(cfg?.channels?.[CHANNEL_ID] ?? {}), enabled },
95
+ [CHANNEL_ID]: { ...(cfg.channels?.[CHANNEL_ID] ?? {}), enabled },
95
96
  },
96
97
  } : setAccountEnabledInConfigSection({
97
98
  cfg,
98
- sectionKey: CHANNEL_ID,
99
- accountId,
100
99
  enabled,
100
+ accountId,
101
+ sectionKey: CHANNEL_ID,
101
102
  allowTopLevel: true,
102
103
  });
103
104
  },
104
105
 
105
106
  deleteAccount: ({ cfg, accountId }: any) => {
106
- return accountId === DEFAULT_ACCOUNT_ID
107
- // For default account, we don't delete the entire config
108
- // Instead, we disable it and clear sensitive fields
109
- ? {
107
+ return accountId === DEFAULT_ACCOUNT_ID ? {
110
108
  ...cfg,
111
109
  channels: {
112
110
  ...cfg.channels,
113
111
  [CHANNEL_ID]: {
114
- ...(cfg?.channels?.[CHANNEL_ID] ?? {}),
115
- enabled: true,
116
- appId: undefined,
117
- appSecret: undefined,
112
+ ...(cfg.channels?.[CHANNEL_ID] ?? {}),
113
+ enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
114
+ ...baseFildsDefault, // 重置基础配置字段,恢复默认值
118
115
  },
119
116
  },
120
- }
121
- // For named accounts, use the standard delete function
122
- : deleteAccountFromConfigSection({
123
- cfg,
124
- sectionKey: CHANNEL_ID,
125
- accountId,
126
- clearBaseFields: [
127
- 'appId',
128
- 'appSecret',
129
- 'dmPolicy',
130
- 'allowFrom',
131
- 'groupPolicy',
132
- 'groupAllowFrom',
133
- 'groups',
134
- ],
135
- });
117
+ } : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
136
118
  },
137
119
  },
138
120
 
@@ -160,7 +142,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
160
142
  let allowFromPath =`channels.${CHANNEL_ID}.`;
161
143
  if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
162
144
 
163
- const policy = account.dmPolicy ?? 'pairing';
145
+ const policy = account.dmPolicy ?? baseFildsDefault.dmPolicy; // 默认使用 baseFildsDefault 中的 dmPolicy
164
146
  // dmPolicy semantics:
165
147
  // - open: always allow everyone (["*"]), ignore allowFrom values.
166
148
  // - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
@@ -171,7 +153,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
171
153
  allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
172
154
  policyPath: `${allowFromPath}dmPolicy`,
173
155
  allowFromPath,
174
- approveHint: `openclaw pairing approve ${CHANNEL_ID} <code>`,
156
+ approveHint: `当前 ${CHANNEL_ID} openclaw(AccountId: ${accountId})需要配对校验, code: <code>`,
175
157
  normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
176
158
  };
177
159
  },
@@ -192,16 +174,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
192
174
  },
193
175
 
194
176
  outbound: {
195
- deliveryMode: 'gateway' as const,
196
- textChunkLimit: 2000,
177
+ deliveryMode: 'direct' as const,
178
+ textChunkLimit: 10000, // API上限制为50k
197
179
 
198
- sendText: async ({ cfg, to, text, accountId, account }: any) => {
199
- account = account || resolveAccount(cfg, accountId);
200
- checkAccount(account, 'send text');
180
+ sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
181
+ const account = resolveAccount(cfg, accountId);
182
+ checkAccount(account);
201
183
 
202
184
  const chatId = String(to || '').trim();
203
- // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
204
- await sendTextMsg(account, chatId, isToGroup(chatId), text);
185
+ const chatType = guessChatType(chatId);
186
+ console.log(`[${CHANNEL_ID}] AccountId ${accountId} outbound.sendText() ${chatType} to ${chatId} ${text}`);
187
+
188
+ await sendTextMsg(account, chatId, chatType, text);
205
189
 
206
190
  return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
207
191
  },
@@ -216,11 +200,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
216
200
  }
217
201
 
218
202
  const chatId = String(to || '').trim();
219
- // Determine if this is a group message
220
- // WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
221
- // If `to` is a numeric string and chatType/groupId are undefined, assume it's a group.
222
- const isGroup = chatType === 'group' || !!groupId || (isToGroup(chatId) && !chatType);
223
- await sendPageMsg(account, chatId, isGroup, payload.page);
203
+ await sendPageMsg(account, chatId, guessChatType(chatId), payload.page);
224
204
 
225
205
  return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
226
206
  },
@@ -231,7 +211,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
231
211
 
232
212
  const chatId = String(to || '').trim();
233
213
  // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
234
- await sendMediaMsg(account, chatId, isToGroup(chatId), mediaUrl, 'tuitui.send.media');
214
+ await sendMediaMsg(account, chatId, guessChatType(chatId), mediaUrl, 'tuitui.send.media');
235
215
 
236
216
  return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
237
217
  },
@@ -247,367 +227,78 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
247
227
  const account = resolveAccount(cfg, accountId);
248
228
 
249
229
  if (!isEnabled(account.enabled)) {
250
- log?.info?.(`[${CHANNEL_ID}] account ${accountId} is disabled, skipping`);
230
+ log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} is disabled, skipping`);
251
231
  return _setStatus();
252
232
  }
253
233
 
254
234
  if (!isConfigured(account)) {
255
- log?.warn?.(`[${CHANNEL_ID}] account ${accountId} not fully configured (missing appId/appSecret)`);
235
+ log?.warn?.(`[${CHANNEL_ID}] AccountId ${accountId} not fully configured (missing appId/appSecret)`);
256
236
  return _setStatus();
257
237
  }
258
238
 
259
- log?.info?.(`[${CHANNEL_ID}] Starting TuiTui channel (account: ${accountId})`);
239
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
260
240
  _setStatus({ running: true });
261
241
  let ws: any = null;
242
+ wsNumber++;
243
+ const wsId = `${wsNumber}-${Date.now()}`;
244
+ const wsEvtIds = new Set<string>();
245
+ let wsRetryTimerId = 0;
246
+
262
247
  const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
263
248
  const defSendMsg = (msg: any) => {
264
- log?.info?.('[${CHANNEL_ID}] WebSocket.Error 环境未就绪,消息发送失败', msg);
249
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
265
250
  };
266
251
  let _sendMsg = defSendMsg;
267
252
  const startWebSocket = () => {
268
- log?.info?.(`[${CHANNEL_ID}] Registered WebSocket URL: ${wsUrl} for TuiTui`);
253
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
269
254
  ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
270
255
  ws.on('open', () => {
271
- log?.info?.(`[${CHANNEL_ID}] WebSocket connect success`);
256
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
272
257
  _setStatus({ running: true, connected: true });
273
258
  _sendMsg = (msg) => ws.send(msg);
274
259
  });
275
260
 
276
261
  // on receiving messages from tuitui websocket server
277
262
  ws.on('message', async (wsData: string) => {
278
- let json = null;
263
+ let json: any = null;
279
264
  try {
280
265
  json = JSON.parse(wsData);
281
- // 收到任意消息则回复一下,权当“收到”
282
- if (json.event_id) _sendMsg(`{ "ack": "${json.event_id}" }`);
283
- } catch(err) {
284
- log?.warn?.(`[${CHANNEL_ID}] WebSocket Message Is Invalid JSON: ${wsData}`);
266
+ } catch {
267
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
285
268
  return;
286
269
  }
287
- const wsEvent = json?.body?.event;
288
- const isKeepalive = wsEvent === 'keepalive';
289
-
290
- // 心跳日志太多了,滤掉,异常信息上面 JSON.parse catch 会输出
291
- if (!isKeepalive) log?.info?.(`[${CHANNEL_ID}] Received TuiTui WebSocket message: ${wsData}`);
292
-
293
- // 忽略无效信息、心跳信息
294
- if (!json?.header || !wsEvent || !json?.body?.data|| isKeepalive) return;
295
-
296
- // Preferred TuiTui signature validation:
297
- // X-Tuitui-Robot-Checksum = sha1(app_secret + timestamp + nonce + raw_json_body)
298
- // Also verifies appid in header matches configured appId.
299
- const { appId, appSecret } = account;
300
- if (appSecret) {
301
- const hdAppId = json.header['X-Tuitui-Robot-Appid'];
302
- const hdTs = json.header['X-Tuitui-Robot-Timestamp'];
303
- const hdNonce = json.header['X-Tuitui-Robot-Nonce'];
304
- const hdChecksum = json.header['X-Tuitui-Robot-Checksum'];
305
-
306
- if (!hdAppId || !hdTs || !hdNonce || !hdChecksum) {
307
- log?.info?.(`[${CHANNEL_ID}] Missing TuiTui authentication headers`);
308
- return;
309
- }
310
-
311
- if (appId && hdAppId !== appId) {
312
- log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
313
- return;
314
- }
270
+ const ack = json?.event_id;
271
+ if (!ack) {
272
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
315
273
  }
316
-
317
- // Parse TuiTui message format
318
- // userAccount: 推推用户账号,用于 tousers(发送消息的目标用户)
319
- const msg = json.body as TuiTuiInboundMessage;
320
- const msgData = msg.data;
321
- let userAccount: string | undefined = msg.user_account;
322
- let msgUid: string | undefined = msg.uid;
323
- let msgUname: string | undefined = msg.user_name;
324
- let chatType: 'direct' | 'group';
325
- const chatTypeIsDirect = wsEvent === 'single_chat';
326
- const chatTypeIsGroup = wsEvent === 'group_chat';
327
- let text: string;
328
- let groupId: string | undefined;
329
- let groupName: string | undefined;
330
- let mediaUrls: string[] | undefined;
331
- let replyToId: string | undefined;
332
- let suppressReply = false; // 是否抑制回复(用于 shouldReply: false 且没有 @ 机器人的情况)
333
-
334
- // Handle different event types
335
- if (chatTypeIsDirect) {
336
- // Single chat message
337
- chatType = 'direct';
338
- text = buildMessageBody(msgData);
339
-
340
- log?.debug?.(`[${CHANNEL_ID}] inbound single_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname}`);
341
-
342
- const msgType = msgData.msg_type;
343
- // Extract media URLs for image/voice/file messages
344
- if ((msgType === 'image' || msgType === 'mixed') && msgData.images?.length) {
345
- mediaUrls = msgData.images;
346
- } else if (msgType === 'voice' && msgData.voice) {
347
- mediaUrls = [msgData.voice];
348
- } else if (msgType === 'file' && msgData.file?.url) {
349
- mediaUrls = [msgData.file.url];
350
- }
351
-
352
- // Handle reference/reply
353
- if (msgData.ref?.is_me && msgData.ref?.msgid) {
354
- replyToId = msgData.ref.msgid;
355
- }
356
-
357
- if (!userAccount && !msgUid) {
358
- log?.info?.(`[${CHANNEL_ID}] Missing user_account or uid in single_chat event`);
359
- return;
360
- }
361
- } else if (chatTypeIsGroup) {
362
- // Group chat message - only process if bot is mentioned
363
- chatType = 'group';
364
- groupId = msgData.group_id;
365
- groupName = msgData.group_name;
366
-
367
- log?.debug?.(`[${CHANNEL_ID}] inbound group_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname} group_id=${groupId}`);
368
-
369
- // Group policy gating and @mention requirements
370
- const groupPolicy = String(account.groupPolicy ?? "allowlist").toLowerCase();
371
- const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
372
- const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
373
- log?.debug?.(`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${groupId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`);
374
-
375
- if (groupPolicy === 'disabled') return log?.info?.('Groups disabled');
376
-
377
- // 群消息处理策略
378
- const groupCfg = account.groups?.[String(groupId)];
379
-
380
- // @机器人触发策略
381
- const requireMention = typeof groupCfg?.requireMention === 'boolean' ? groupCfg.requireMention : true;
382
-
383
- // 是否需要回复
384
- let shouldReply = typeof groupCfg?.shouldReply === 'boolean' ? groupCfg.shouldReply : true;
385
-
386
- if (!userAccount || !groupId) {
387
- log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
388
- return;
389
- }
390
- // 群消息处理逻辑:
391
- // 1. requireMention: false, shouldReply: true -> 接收所有群消息并自动回复(无论是否 @)
392
- // 2. requireMention: true, shouldReply: true -> 仅当 @ 机器人时才触发 Agent 并回复,不 @ 的消息直接忽略
393
- // 3. requireMention: true, shouldReply: false -> 接收所有群消息触发 Agent,但仅当 @ 机器人时才回复
394
-
395
- if (
396
- // 消息中 @ 了机器人,必须触发 Agent 并回复(即使 shouldReply: false)
397
- msgData.at_me ||
398
- // 消息中没有 @ 机器人,但配置为自动回复(无需 @)
399
- // 触发 Agent 并回复
400
- !requireMention ||
401
- // 消息中没有 @ 机器人,requireMention=true,shouldReply=false
402
- // 触发 Agent 但不回复(Agent 可以处理消息,但 deliver 不会发送回复)
403
- !shouldReply
404
- ) {
405
- text = buildMessageBody(msgData);
406
- if (!shouldReply) suppressReply = true; // 标记为抑制回复
407
- // 继续执行后续的 Agent 处理逻辑
408
- } else {
409
- // 默认情况:不 @ 机器人,requireMention=true,shouldReply=true
410
- // 忽略消息
411
- log?.info?.(`[${CHANNEL_ID}] ignore (not mentioned)`);
412
- return;
413
- }
414
-
415
- if (!normalizedGroupAllowFrom.includes(String(groupId))) {
416
- await sendTextMsg(account, groupId, true, `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}`, 'tuitui.groupPolicy.reply');
417
- return;
418
- }
419
-
420
- } else {
421
- log?.info?.(`[${CHANNEL_ID}] ignore unknown event ${wsEvent}`);
422
- return;
274
+ if (wsEvtIds.has(ack)) {
275
+ // 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
276
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
423
277
  }
424
-
425
- // Enforce DM allowlist at ingress to guarantee closed policy blocks unauthorized senders.
426
- // We intentionally enforce here (in addition to downstream guards) because plugin runtime
427
- // paths may vary between local/dev/prod setups.
428
-
429
- // DM access gating (pairing/allowlist/open/disabled)
430
- const dmPolicy = String(account.dmPolicy ?? 'pairing').toLowerCase();
431
- const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
432
- const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
433
- // 只使用 userAccount 作为匹配依据,因为用户希望 allowFrom 匹配 user_account
434
- const senderForPolicy = userAccount ? String(userAccount).toLowerCase().trim() : '';
435
- log?.debug?.(`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${userAccount} msgUid=${msgUid} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
436
-
437
- if (chatTypeIsDirect) {
438
- if (dmPolicy === 'disabled') {
439
- log?.warn?.(`[${CHANNEL_ID}] DM blocked (disabled) sender=${senderForPolicy}`);
440
- return;
441
- }
442
-
443
- if (dmPolicy !== 'open') {
444
- // Merge pairing-store entries unless policy is allowlist-only
445
- let storeAllowFrom: string[] = [];
446
- if (dmPolicy !== 'allowlist') {
447
- try {
448
- const res = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
449
- channel: CHANNEL_ID,
450
- accountId: account.accountId,
451
- });
452
- if (Array.isArray(res)) storeAllowFrom = arrLowerCaseTrim(res);
453
- } catch {}
454
- }
455
-
456
- // 只检查 userAccount 是否在 allowFrom 或 storeAllowFrom 中
457
- const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
458
- const isAllowed = userAccount ? allowSet.has(senderForPolicy) : false;
459
-
460
- if (!isAllowed) {
461
- if (dmPolicy === 'pairing') {
462
- try {
463
- log?.debug?.(`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
464
- const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
465
- channel: CHANNEL_ID,
466
- accountId: account.accountId,
467
- id: senderForPolicy,
468
- });
469
- log?.debug?.(
470
- `[${CHANNEL_ID}] pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`,
471
- );
472
- /***
473
- * 把 req?.created 注释掉。不然一旦发消息失败,就进入死局。
474
- * 删除这个标志,只要没配对,会每次和机器人聊,都会返回配对信息。
475
- */
476
- if (/*req?.created && */ account.appId && account.appSecret) {
477
- const replyText =
478
- apiRuntime?.channel?.pairing?.buildPairingReply?.({
479
- channel: CHANNEL_ID,
480
- code: req.code,
481
- }) ?? '需要进行配对。请让机器人所有者进行批准。';
482
-
483
- // tousers 使用 userAccount(推推用户账号)
484
- const toUname = String(userAccount || '').trim();
485
- await sendTextMsg(account, toUname, false, replyText, 'tuitui.pairing.reply');
486
- }
487
- } catch (err) {
488
- log?.warn?.(`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(err)}` );
489
- }
490
- // Drop unauthorized DM after pairing challenge
491
- log?.info?.(`[${CHANNEL_ID}] pairing required`);
492
- return;
493
- }
494
-
495
- // dmPolicy=allowlist and sender not allowed
496
- log?.warn?.(`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
497
- return;
498
- }
499
- }
278
+ // 收到任意消息则回复一下,权当“收到”
279
+ _sendMsg(JSON.stringify({ ack }));
280
+ // 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
281
+ wsEvtIds.add(ack);
282
+ // 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
283
+ if (wsEvtIds.size > 1e3) {
284
+ const firsEvtId = wsEvtIds.values().next().value;
285
+ if (firsEvtId) wsEvtIds.delete(firsEvtId);
500
286
  }
501
287
 
502
- // 表情回复。因为回复较慢,所以先回复一个表情
503
- await tuituiEmojiReaction(account, chatTypeIsGroup ? groupId : userAccount, chatTypeIsGroup, msgData.msgid, '收到');
504
-
505
- // Build MsgContext
506
- // 优先使用 userAccount,如果为空则降级使用 msgUid
507
- const senderId = userAccount || msgUid || 'unknown';
508
- // 确保 accountId 有有效值,优先使用 account.accountId,其次使用 ctx 中的 accountId,最后使用 DEFAULT_ACCOUNT_ID
509
-
510
- const effectiveAccountId = String(account.accountId || accountId || DEFAULT_ACCOUNT_ID || 'default');
511
- const chatId = chatTypeIsGroup ? String(groupId) : String(senderId);
512
-
513
- // 关于 sessionKey 格式的解释
514
- // https://docs.openclaw.ai/channels/channel-routing
515
- // 但 sessionKey 一般不要自己拼字符串,需要用系统api识别(根据 bindings)
516
- const route = apiRuntime.channel.routing.resolveAgentRoute({
517
- cfg: await apiRuntime.config.loadConfig(),
518
- channel: CHANNEL_ID,
519
- accountId: account.accountId,
520
- peer: {
521
- kind: chatTypeIsGroup ? 'group' : 'direct',
522
- id: chatId,
523
- },
524
- });
525
-
526
- const sessionKey = route.sessionKey;
527
- console.log(`[${CHANNEL_ID}] chatType: ${chatType}, chatId ${chatId}, senderId: ${senderId}, accountId: ${accountId}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, effectiveAccountId: ${effectiveAccountId}`);
528
- console.log(`[${CHANNEL_ID}] dispatching to agent session=${sessionKey}`);
529
-
530
- const msgCtx: any = {
531
- Body: text || ' ',
532
- From: String(senderId),
533
- To: CHANNEL_ID,
534
- SessionId: String(sessionKey).replace(/\//g, '_'),
535
- SessionKey: String(sessionKey).replace(/\//g, '_'),
536
- AccountId: effectiveAccountId,
537
- OriginatingChannel: CHANNEL_ID,
538
- OriginatingTo: chatId,
539
- ChatType: chatType,
540
- Surface: CHANNEL_ID,
541
- Provider: CHANNEL_ID,
542
- SenderName: msgUname || String(senderId),
543
- MsgUname: msgUname,
544
- UserAccount: userAccount,
545
- };
546
-
547
- // Add group-specific fields
548
- if (chatTypeIsGroup && groupId) {
549
- msgCtx.GroupSubject = groupName;
550
- msgCtx.GroupId = groupId;
551
- }
288
+ const wsEvent = json?.body?.event;
289
+ if (wsEvent === 'keepalive') return;
552
290
 
553
- // Add media URLs if present
554
- if (mediaUrls?.length) msgCtx.MediaUrls = mediaUrls;
291
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Received message: ${wsData}`);
555
292
 
556
- // Add reply context if present
557
- if (replyToId) msgCtx.ReplyToId = replyToId;
293
+ if (!json?.header || !wsEvent || !json?.body?.data) {
294
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
295
+ }
558
296
 
559
- // Dispatch via the SDK's buffered block dispatcher
560
297
  if (!apiRuntime) {
561
- log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
562
- return;
298
+ return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
563
299
  }
564
- await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
565
- ctx: msgCtx,
566
- cfg: await apiRuntime.config.loadConfig(),
567
- dispatcherOptions: {
568
- deliver: async (payload: TuiTuiOutboundDeliverOptions) => {
569
-
570
- // 如果设置了 suppressReply 标志,则不发送回复
571
- if (suppressReply) {
572
- log?.debug?.(`[${CHANNEL_ID}] Reply suppressed by configuration (shouldReply: false)`);
573
- return;
574
- }
575
-
576
- const chatTarget = chatTypeIsGroup ? groupId : userAccount;
577
- // Handle custom messages (e.g., page messages)
578
- if (payload.custom) {
579
- const { msgtype, page, tousers, togroups } = payload.custom;
580
- // Handle page message type
581
- if (msgtype === 'page' && page) {
582
- await sendPageMsg(account, chatTarget, chatTypeIsGroup, page, 'tuitui.deliver.page', tousers, togroups);
583
- return;
584
- }
585
- log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${msgtype}`);
586
- return;
587
- }
588
- if (payload.mediaUrl || payload.mediaUrls?.length) {
589
- // Handle media messages
590
- const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
591
- if (mediaUrl) {
592
- const at = chatTypeIsGroup && userAccount ? [userAccount] : [];
593
- await sendMediaMsg(account, chatTarget, chatTypeIsGroup, mediaUrl, 'tuitui.deliver.media', at);
594
- }
595
- return;
596
- }
597
- // Handle text messages
598
- const replyText = payload?.text || payload?.body;
599
- if (replyText) {
600
- // at 使用 userAccount(群聊 @ 用户)
601
- const at = chatTypeIsGroup && userAccount ? [userAccount] : [];
602
- // 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
603
- await sendTextMsg(account, chatTarget, chatTypeIsGroup, replyText, 'tuitui.deliver.text', at);
604
- }
605
- },
606
- onReplyStart: () => {
607
- log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? msgUid}`);
608
- },
609
- },
610
- });
300
+
301
+ await handleInboundMessage({ json, account, apiRuntime, log });
611
302
  });
612
303
 
613
304
  const onErrOrClose = () => {
@@ -615,18 +306,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
615
306
  _setStatus({ running: false, connected: false });
616
307
  _sendMsg = defSendMsg;
617
308
  if (!abortSignal?.aborted) {
618
- log?.warn?.(`[${CHANNEL_ID}] WebSocket Restart`);
619
- setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
309
+ log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
310
+ if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
311
+ wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
620
312
  }
621
313
  };
622
314
  ws.on('close', () => {
623
- log?.info?.(`[${CHANNEL_ID}] WebSocket closed, ws.readyState(0:CONNECTING, 1:OPEN, 2:CLOSING, 3:CLOSED): ${ws?.readyState}`);
315
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
624
316
  onErrOrClose();
625
317
  });
626
318
 
627
319
  // on socket errors
628
320
  ws.on('error', (err: any) => {
629
- log?.warn?.(`[${CHANNEL_ID}] WebSocket error: ${err}, ws.readyState(0:CONNECTING, 1:OPEN, 2:CLOSING, 3:CLOSED): ${ws?.readyState}`);
321
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
630
322
  onErrOrClose();
631
323
  });
632
324
  };