@qihoo/tuitui-openclaw-channel 1.0.10 → 1.0.11

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.11",
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: `openclaw 配对校验信息,CHANNEL_ID: ${CHANNEL_ID}, AccountId: ${accountId}, code: <code>`,
175
157
  normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
176
158
  };
177
159
  },
@@ -201,7 +183,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
201
183
 
202
184
  const chatId = String(to || '').trim();
203
185
  // 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);
186
+ await sendTextMsg(account, chatId, guessChatType(chatId), text);
205
187
 
206
188
  return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
207
189
  },
@@ -216,11 +198,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
216
198
  }
217
199
 
218
200
  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);
201
+ await sendPageMsg(account, chatId, guessChatType(chatId), payload.page);
224
202
 
225
203
  return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
226
204
  },
@@ -231,7 +209,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
231
209
 
232
210
  const chatId = String(to || '').trim();
233
211
  // 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');
212
+ await sendMediaMsg(account, chatId, guessChatType(chatId), mediaUrl, 'tuitui.send.media');
235
213
 
236
214
  return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
237
215
  },
@@ -247,367 +225,77 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
247
225
  const account = resolveAccount(cfg, accountId);
248
226
 
249
227
  if (!isEnabled(account.enabled)) {
250
- log?.info?.(`[${CHANNEL_ID}] account ${accountId} is disabled, skipping`);
228
+ log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} is disabled, skipping`);
251
229
  return _setStatus();
252
230
  }
253
231
 
254
232
  if (!isConfigured(account)) {
255
- log?.warn?.(`[${CHANNEL_ID}] account ${accountId} not fully configured (missing appId/appSecret)`);
233
+ log?.warn?.(`[${CHANNEL_ID}] AccountId ${accountId} not fully configured (missing appId/appSecret)`);
256
234
  return _setStatus();
257
235
  }
258
236
 
259
- log?.info?.(`[${CHANNEL_ID}] Starting TuiTui channel (account: ${accountId})`);
237
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
260
238
  _setStatus({ running: true });
261
239
  let ws: any = null;
240
+ wsNumber++;
241
+ const wsId = `${wsNumber}-${Date.now()}`;
242
+ const wsEvtIds = new Set<string>();
243
+
262
244
  const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
263
245
  const defSendMsg = (msg: any) => {
264
- log?.info?.('[${CHANNEL_ID}] WebSocket.Error 环境未就绪,消息发送失败', msg);
246
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
265
247
  };
266
248
  let _sendMsg = defSendMsg;
267
249
  const startWebSocket = () => {
268
- log?.info?.(`[${CHANNEL_ID}] Registered WebSocket URL: ${wsUrl} for TuiTui`);
250
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
269
251
  ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
270
252
  ws.on('open', () => {
271
- log?.info?.(`[${CHANNEL_ID}] WebSocket connect success`);
253
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
272
254
  _setStatus({ running: true, connected: true });
273
255
  _sendMsg = (msg) => ws.send(msg);
274
256
  });
275
257
 
276
258
  // on receiving messages from tuitui websocket server
277
259
  ws.on('message', async (wsData: string) => {
278
- let json = null;
260
+ let json: any = null;
279
261
  try {
280
262
  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}`);
263
+ } catch {
264
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
285
265
  return;
286
266
  }
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
- }
267
+ const ack = json?.event_id;
268
+ if (!ack) {
269
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
315
270
  }
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;
271
+ if (wsEvtIds.has(ack)) {
272
+ // 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
273
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
423
274
  }
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
- }
275
+ // 收到任意消息则回复一下,权当“收到”
276
+ _sendMsg(JSON.stringify({ ack }));
277
+ // 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
278
+ wsEvtIds.add(ack);
279
+ // 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
280
+ if (wsEvtIds.size > 1e3) {
281
+ const firsEvtId = wsEvtIds.values().next().value;
282
+ if (firsEvtId) wsEvtIds.delete(firsEvtId);
500
283
  }
501
284
 
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
- }
285
+ const wsEvent = json?.body?.event;
286
+ if (wsEvent === 'keepalive') return;
552
287
 
553
- // Add media URLs if present
554
- if (mediaUrls?.length) msgCtx.MediaUrls = mediaUrls;
288
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Received message: ${wsData}`);
555
289
 
556
- // Add reply context if present
557
- if (replyToId) msgCtx.ReplyToId = replyToId;
290
+ if (!json?.header || !wsEvent || !json?.body?.data) {
291
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
292
+ }
558
293
 
559
- // Dispatch via the SDK's buffered block dispatcher
560
294
  if (!apiRuntime) {
561
- log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
562
- return;
295
+ return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
563
296
  }
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
- });
297
+
298
+ await handleInboundMessage({ json, account, apiRuntime, log });
611
299
  });
612
300
 
613
301
  const onErrOrClose = () => {
@@ -615,18 +303,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
615
303
  _setStatus({ running: false, connected: false });
616
304
  _sendMsg = defSendMsg;
617
305
  if (!abortSignal?.aborted) {
618
- log?.warn?.(`[${CHANNEL_ID}] WebSocket Restart`);
306
+ log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
619
307
  setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
620
308
  }
621
309
  };
622
310
  ws.on('close', () => {
623
- log?.info?.(`[${CHANNEL_ID}] WebSocket closed, ws.readyState(0:CONNECTING, 1:OPEN, 2:CLOSING, 3:CLOSED): ${ws?.readyState}`);
311
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
624
312
  onErrOrClose();
625
313
  });
626
314
 
627
315
  // on socket errors
628
316
  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}`);
317
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
630
318
  onErrOrClose();
631
319
  });
632
320
  };
package/src/command.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
+ export default function registerCommand2Api(api: OpenClawPluginApi) {
3
+ api.registerCommand({
4
+ name: "mystatus",
5
+ description: "Show my plugin status",
6
+ handler: (ctx: any) => {
7
+ console.log('xxxxx', JSON.stringify(ctx.config.channels[ctx.channel]))
8
+ return {
9
+ text: `Status of TuiTui Plugin:
10
+ Channel: ${ctx.channel},
11
+ AccountId: ${ctx.accountId},
12
+ Time: ${new Date().toLocaleString()}`,
13
+ };
14
+ },
15
+ });
16
+
17
+ // 带参数的命令
18
+ api.registerCommand({
19
+ name: "setBotName",
20
+ description: "Set bot name",
21
+ acceptsArgs: true,
22
+ requireAuth: true,
23
+ handler: async (ctx: any) => {
24
+ const mode = ctx.args?.trim() || "default";
25
+ ///await saveMode(mode);
26
+ return { text: `Mode set to: ${mode}` };
27
+ },
28
+ });
29
+ }