@qihoo/tuitui-openclaw-channel 1.0.5 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
@@ -25,16 +25,6 @@
25
25
  "extensions": [
26
26
  "./index.ts"
27
27
  ],
28
- "channel": {
29
- "id": "tuitui-openclaw-channel",
30
- "label": "TuiTui",
31
- "selectionLabel": "TuiTui",
32
- "docsPath": "/channels/tuitui",
33
- "docsLabel": "tuitui",
34
- "blurb": "TuiTui chat integration.",
35
- "aliases": [],
36
- "order": 90
37
- },
38
28
  "install": {
39
29
  "npmSpec": "@qihoo/tuitui-openclaw-channel",
40
30
  "localPath": "extensions/tuitui",
package/src/channel.ts CHANGED
@@ -5,56 +5,49 @@
5
5
  * Supports single chat (text, image, voice, file) and group chat with @mentions.
6
6
  */
7
7
  import WebSocket from 'ws';
8
- import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { z } from 'zod';
9
9
  import {
10
10
  DEFAULT_ACCOUNT_ID,
11
11
  setAccountEnabledInConfigSection,
12
12
  deleteAccountFromConfigSection,
13
- registerPluginHttpRoute,
14
13
  buildChannelConfigSchema,
15
14
  } from 'openclaw/plugin-sdk';
16
- import { z } from 'zod';
17
- import type {
18
- TuiTuiInboundMessage,
19
- TuiTuiOutboundTextMessage,
20
- TuiTuiOutboundImageMessage,
21
- TuiTuiOutboundAttachmentMessage,
22
- } from './types';
15
+ import type { TuiTuiInboundMessage } from './types';
23
16
  import {
24
17
  CHANNEL_ID,
25
18
  CHANNEL_NAME,
26
- postTuituiMsg,
27
- uploadFileToTuiTui,
28
19
  buildMessageBody,
29
20
  tuituiEmojiReaction,
30
21
  checkAccount,
22
+ sendTextMsg,
23
+ sendPageMsg,
24
+ sendMediaMsg,
31
25
  } from "./utils";
32
26
 
33
27
  function resolveAccount(cfg: any, accountId?: string | null) {
34
- const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
35
- const targetId = accountId ?? DEFAULT_ACCOUNT_ID;
28
+ const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
29
+ const targetId = accountId || DEFAULT_ACCOUNT_ID;
36
30
  const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
37
-
38
31
  return {
39
32
  accountId: targetId,
40
- enabled: acct?.enabled ?? false,
33
+ enabled: acct?.enabled || false,
41
34
  appId: acct?.appId as string | undefined,
42
35
  appSecret: acct?.appSecret as string | undefined,
43
- dmPolicy: acct?.dmPolicy ?? 'pairing',
44
- allowFrom: acct?.allowFrom ?? [],
36
+ dmPolicy: acct?.dmPolicy || 'pairing',
37
+ allowFrom: acct?.allowFrom || [],
45
38
  // 群组策略与白名单、群组级覆盖
46
39
  groupPolicy: (acct?.groupPolicy as string | undefined) ?? 'allowlist',
47
- groupAllowFrom: Array.isArray(acct?.groupAllowFrom) ? acct.groupAllowFrom : [],
40
+ groupAllowFrom: acct.groupAllowFrom || [],
48
41
  groups: (acct?.groups as Record<string, { requireMention?: boolean, shouldReply?: boolean }> | undefined) ?? {},
49
42
  };
50
43
  }
51
44
 
52
- function isConfigured(account: any) {
53
- return Boolean(
54
- String(account?.appId ?? '').trim() &&
55
- String(account?.appSecret ?? '').trim()
56
- );
57
- }
45
+ const isConfigured = (account: any)=> Boolean(
46
+ String(account?.appId ?? '').trim() &&
47
+ String(account?.appSecret ?? '').trim()
48
+ );
49
+
50
+ const isToGroup = (chatId: string) => /^\d+$/.test(chatId);
58
51
 
59
52
  export function createTuiTuiChannelPlugin(apiRuntime: any) {
60
53
  return {
@@ -71,7 +64,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
71
64
  },
72
65
 
73
66
  capabilities: {
74
- chatTypes: ["direct" as const, "group" as const],
67
+ chatTypes: ['direct' as const, 'group' as const],
75
68
  media: true,
76
69
  threads: false,
77
70
  reactions: false,
@@ -141,16 +134,12 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
141
134
  const base = cfg?.channels?.[CHANNEL_ID];
142
135
  if (!base) return [];
143
136
  const ids = new Set<string>();
144
- if (base.enabled !== undefined) {
145
- ids.add(DEFAULT_ACCOUNT_ID);
146
- }
147
- if (base.accounts) {
148
- for (const k in base.accounts) ids.add(k);
149
- }
137
+ if (base.enabled) ids.add(DEFAULT_ACCOUNT_ID);
138
+ if (base.accounts) for (const k in base.accounts) ids.add(k);
150
139
  return Array.from(ids);
151
140
  },
152
141
 
153
- resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
142
+ resolveAccount,
154
143
 
155
144
  defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
156
145
 
@@ -181,10 +170,10 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
181
170
  },
182
171
 
183
172
  deleteAccount: ({ cfg, accountId }: any) => {
184
- if (accountId === DEFAULT_ACCOUNT_ID) {
173
+ return accountId === DEFAULT_ACCOUNT_ID
185
174
  // For default account, we don't delete the entire config
186
175
  // Instead, we disable it and clear sensitive fields
187
- return {
176
+ ? {
188
177
  ...cfg,
189
178
  channels: {
190
179
  ...cfg.channels,
@@ -195,23 +184,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
195
184
  appSecret: undefined,
196
185
  },
197
186
  },
198
- };
199
- }
200
- // For named accounts, use the standard delete function
201
- return deleteAccountFromConfigSection({
202
- cfg,
203
- sectionKey: CHANNEL_ID,
204
- accountId,
205
- clearBaseFields: [
206
- 'appId',
207
- 'appSecret',
208
- 'dmPolicy',
209
- 'allowFrom',
210
- 'groupPolicy',
211
- 'groupAllowFrom',
212
- 'groups',
213
- ],
214
- });
187
+ }
188
+ // For named accounts, use the standard delete function
189
+ : deleteAccountFromConfigSection({
190
+ cfg,
191
+ sectionKey: CHANNEL_ID,
192
+ accountId,
193
+ clearBaseFields: [
194
+ 'appId',
195
+ 'appSecret',
196
+ 'dmPolicy',
197
+ 'allowFrom',
198
+ 'groupPolicy',
199
+ 'groupAllowFrom',
200
+ 'groups',
201
+ ],
202
+ });
215
203
  },
216
204
  },
217
205
 
@@ -234,12 +222,12 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
234
222
 
235
223
  security: {
236
224
  resolveDmPolicy: ({ cfg, accountId, account }: { cfg: any; accountId?: string | null; account?: any; }) => {
237
- const _account = account ?? resolveAccount(cfg, accountId);
238
- const _accountId = accountId ?? _account.accountId ?? DEFAULT_ACCOUNT_ID;
225
+ account = account || resolveAccount(cfg, accountId);
226
+ const accId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
239
227
  let allowFromPath =`channels.${CHANNEL_ID}.`;
240
- if (_accountId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${_accountId}.`;
228
+ if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
241
229
 
242
- const policy = _account.dmPolicy ?? 'pairing';
230
+ const policy = account.dmPolicy ?? 'pairing';
243
231
  // dmPolicy semantics:
244
232
  // - open: always allow everyone (["*"]), ignore allowFrom values.
245
233
  // - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
@@ -247,7 +235,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
247
235
  // - disabled: block all DMs.
248
236
  return {
249
237
  policy,
250
- allowFrom: policy === 'open' ? ['*'] : (_account.allowFrom ?? []),
238
+ allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
251
239
  policyPath: `${allowFromPath}dmPolicy`,
252
240
  allowFromPath,
253
241
  approveHint: `openclaw pairing approve ${CHANNEL_ID} <code>`,
@@ -274,144 +262,45 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
274
262
  deliveryMode: 'gateway' as const,
275
263
  textChunkLimit: 2000,
276
264
 
277
- sendText: async ({ cfg, to, text, accountId, account, log }: any) => {
278
- const _account = account ?? resolveAccount(cfg, accountId);
279
- checkAccount(_account, 'send text');
265
+ sendText: async ({ cfg, to, text, accountId, account }: any) => {
266
+ account = account || resolveAccount(cfg, accountId);
267
+ checkAccount(account, 'send text');
280
268
 
281
- const toStr = String(to || '').trim();
269
+ const chatId = String(to || '').trim();
282
270
  // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
283
- const isGroup = /^\d+$/.test(toStr);
284
-
285
- // Format message according to TuiTui's required structure
286
- const payload: TuiTuiOutboundTextMessage = {
287
- tousers: isGroup ? [] : [toStr],
288
- togroups: isGroup ? [toStr] : [],
289
- at: [],
290
- msgtype: 'text',
291
- text: { content: text },
292
- };
293
-
294
- log?.info?.(
295
- `[${CHANNEL_ID}] sendText -
296
- accountId=${accountId ?? 'default'},
297
- to=${toStr},
298
- target_users: [${payload.tousers.join(",")}],
299
- target_groups: [${payload.togroups.join(",")}],
300
- isGroup: ${isGroup},
301
- textLength=${String(text ?? '').length},
302
- text: ${text}`
303
- );
271
+ await sendTextMsg(account, chatId, isToGroup(chatId), text);
304
272
 
305
- await postTuituiMsg(_account, payload, 'tuitui.send.text', log);
306
-
307
- return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
273
+ return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
308
274
  },
309
275
 
310
276
  sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId, log }: any) => {
311
- const _account = account ?? resolveAccount(cfg, accountId);
312
- checkAccount(_account, 'send custom message');
277
+ account = account || resolveAccount(cfg, accountId);
278
+ checkAccount(account, 'send custom message');
313
279
 
314
280
  // If it's a page message, we need to construct it
315
281
  if (payload?.msgtype !== 'page') {
316
282
  throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${payload?.msgtype}`);
317
283
  }
318
284
 
319
- const toStr = String(to || '').trim();
285
+ const chatId = String(to || '').trim();
320
286
  // Determine if this is a group message
321
287
  // WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
322
288
  // If `to` is a numeric string and chatType/groupId are undefined, assume it's a group.
323
- const toIsNum = /^\d+$/.test(toStr);
324
- const isGroup = chatType === 'group' || groupId || (toIsNum && !chatType);
325
- const toGroupId = groupId || (isGroup ? toStr : undefined);
326
- const toUserId = isGroup ? undefined : toStr;
327
-
328
- const _payload: any = {
329
- tousers: payload.tousers || (toUserId ? [toUserId] : []),
330
- togroups: payload.togroups || (toGroupId ? [toGroupId] : []),
331
- msgtype: 'page',
332
- page: { ...payload.page }
333
- };
334
-
335
- log?.info?.(
336
- `[${CHANNEL_ID}] sending page to TuiTui -
337
- target_users: [${_payload.tousers.join(",")}],
338
- target_groups: [${_payload.togroups.join(",")}],
339
- chatType: ${chatType ?? 'direct'},
340
- account=${accountId ?? 'default'},
341
- chatType=${chatType ?? 'direct'},
342
- to=${toStr},
343
- groupId=${groupId ?? '-'}`,
344
- );
345
-
346
- await postTuituiMsg(_account, _payload, 'tuitui.send.page', log);
289
+ const isGroup = chatType === 'group' || !!groupId || (isToGroup(chatId) && !chatType);
290
+ await sendPageMsg(account, chatId, isGroup, payload.page);
347
291
 
348
- return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
292
+ return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
349
293
  },
350
294
 
351
295
  sendMedia: async ({ cfg, to, mediaUrl, accountId, account, log }: any) => {
352
- const _account = account ?? resolveAccount(cfg, accountId);
353
- checkAccount(_account, 'send media');
296
+ account = account || resolveAccount(cfg, accountId);
297
+ checkAccount(account, 'send media');
354
298
 
355
- const toStr = String(to || '').trim();
299
+ const chatId = String(to || '').trim();
356
300
  // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
357
- const isGroup = /^\d+$/.test(toStr);
358
- const toGroupId = isGroup ? toStr : undefined;
359
- const toUserId = isGroup ? undefined : toStr;
360
-
361
- // Detect image/file by URL pattern first; fallback to upload API type auto-detection when needed.
362
- // NOTE: relying on `mediaUrl.includes("image")` is too broad and can misclassify files.
363
- const isImageByExt = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(?:$|[?#])/i.test(mediaUrl);
364
- const isDataUrlImage = /^data:image\//i.test(mediaUrl);
365
- const isImage = isImageByExt || isDataUrlImage;
366
- const uploadType = isImage ? 'image' : 'file';
367
-
368
- log?.debug?.(
369
- `[${CHANNEL_ID}] sendMedia classify
370
- account=${accountId ?? 'default'},
371
- to=${toStr},
372
- isGroup=${isGroup},
373
- mediaUrl=${mediaUrl},
374
- isImageByExt=${isImageByExt},
375
- isDataUrlImage=${isDataUrlImage},
376
- uploadType=${uploadType}`,
377
- );
378
-
379
- const media_id = await uploadFileToTuiTui(mediaUrl, _account, uploadType);
380
- if (!media_id) {
381
- log?.error?.(`[${CHANNEL_ID}] sendMedia upload failed to get media_id from TuiTui for mediaUrl: ${mediaUrl}`);
382
- return;
383
- }
384
- log?.debug?.(`[${CHANNEL_ID}] sendMedia upload done type=${uploadType} media_id=${media_id}`);
385
-
386
- // Keep outbound msgtype consistent with upload type.
387
- // 群消息:togroups 填群ID,tousers 是空数组;私聊:tousers 填 to;均不设置 at
388
- const payload: any = {
389
- tousers: toUserId ? [toUserId] : [],
390
- togroups: toGroupId ? [toGroupId] : [],
391
- msgtype: uploadType === 'image' ? 'image' : 'attachment',
392
- image: { media_id },
393
- };
301
+ await sendMediaMsg(account, chatId, isToGroup(chatId), mediaUrl);
394
302
 
395
- log?.info?.(
396
- `[${CHANNEL_ID}] sending media to TuiTui -
397
- target_users: [${payload.tousers.join(',')}],
398
- target_groups: [${payload.togroups.join(',')}],
399
- type: ${payload.msgtype},
400
- isGroup=${isGroup},
401
- msgtype=${payload.msgtype},
402
- account=${accountId ?? 'default'},
403
- to=${toStr},
404
- isGroup=${isGroup},
405
- payload=${JSON.stringify(payload)},
406
- toGroupId=${toGroupId},
407
- toUserId=${toUserId},
408
- uploadType=${uploadType},
409
- media_id=${media_id}`
410
- );
411
-
412
- await postTuituiMsg(_account, payload, 'tuitui.send.media', log);
413
-
414
- return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
303
+ return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
415
304
  },
416
305
  },
417
306
 
@@ -610,16 +499,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
610
499
  }
611
500
 
612
501
  if (!normalizedGroupAllowFrom.includes(String(groupId))) {
613
- log?.info?.(`[${CHANNEL_ID}] Group ${groupId} not allowed`);
614
-
615
- const payload: TuiTuiOutboundTextMessage = {
616
- tousers: [],
617
- togroups: [groupId],
618
- at: [],
619
- msgtype: "text",
620
- text: { content: `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}` },
621
- };
622
- await postTuituiMsg(account, payload, 'tuitui.groupPolicy.reply', log);
502
+ await sendTextMsg(account, groupId, true, `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}`, 'tuitui.groupPolicy.reply');
623
503
  return;
624
504
  }
625
505
 
@@ -696,21 +576,11 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
696
576
  apiRuntime?.channel?.pairing?.buildPairingReply?.({
697
577
  channel: CHANNEL_ID,
698
578
  code: req.code,
699
- }) ?? 'Pairing required. Ask the bot owner to approve.';
579
+ }) ?? '需要进行配对。请让机器人所有者进行批准。';
700
580
 
701
581
  // tousers 使用 userAccount(推推用户账号)
702
582
  const toUname = String(userAccount || '').trim();
703
- log?.debug?.(
704
- `[${CHANNEL_ID}] pairing.reply target=${toUname}`,
705
- );
706
- const payload: TuiTuiOutboundTextMessage = {
707
- tousers: toUname ? [toUname] : [],
708
- togroups: [],
709
- at: [],
710
- msgtype: 'text',
711
- text: { content: replyText },
712
- };
713
- await postTuituiMsg(account, payload, 'tuitui.pairing.reply', log);
583
+ await sendTextMsg(account, toUname, false, replyText, 'tuitui.pairing.reply');
714
584
  }
715
585
  } catch (err) {
716
586
  log?.warn?.(`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(err)}` );
@@ -778,7 +648,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
778
648
  if (replyToId) msgCtx.ReplyToId = replyToId;
779
649
 
780
650
  // Dispatch via the SDK's buffered block dispatcher
781
- let sentCount = 0;
782
651
  if (!apiRuntime) {
783
652
  log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
784
653
  return;
@@ -800,8 +669,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
800
669
  [key: string]: any;
801
670
  };
802
671
  }) => {
803
- // mark that we received a deliver call (attempt to send reply)
804
- sentCount++;
805
672
 
806
673
  // 如果设置了 suppressReply 标志,则不发送回复
807
674
  if (suppressReply) {
@@ -809,125 +676,33 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
809
676
  return;
810
677
  }
811
678
 
679
+ const chatTarget = chatTypeIsGroup ? groupId : userAccount;
812
680
  // Handle custom messages (e.g., page messages)
813
681
  if (payload.custom) {
814
- try {
815
- const customPayload = payload.custom;
816
-
817
- // Handle page message type
818
- if (customPayload.msgtype === 'page' && customPayload.page) {
819
- const pagePayload: any = {
820
- tousers: customPayload.tousers || (chatTypeIsDirect && userAccount ? [userAccount] : []),
821
- togroups: customPayload.togroups || (chatTypeIsGroup && groupId ? [groupId] : []),
822
- msgtype: 'page',
823
- page: customPayload.page,
824
- };
825
-
826
- log?.info?.(
827
- `[${CHANNEL_ID}] sending page reply to TuiTui -
828
- target_users: [${pagePayload.tousers.join(',')}],
829
- target_groups: [${pagePayload.togroups.join(',')}]
830
- chatType=${chatType},
831
- tousers=${JSON.stringify(pagePayload.tousers)},
832
- togroups=${JSON.stringify(pagePayload.togroups)},
833
- title=${String(pagePayload?.page?.title ?? '')}`,
834
- );
835
-
836
- await postTuituiMsg(account, pagePayload, 'tuitui.deliver.page', log);
837
- } else {
838
- log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${customPayload.msgtype}`);
839
- }
840
- } catch (e) {
841
- log?.error?.(`[${CHANNEL_ID}] Error sending custom reply to TuiTui: ${e}`);
682
+ const { msgtype, page, tousers, togroups } = payload.custom;
683
+ // Handle page message type
684
+ if (msgtype === 'page' && page) {
685
+ await sendPageMsg(account, chatTarget, chatTypeIsGroup, page, 'tuitui.deliver.page', tousers, togroups);
686
+ return;
842
687
  }
688
+ log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${msgtype}`);
843
689
  return;
844
690
  }
845
-
846
691
  if (payload.mediaUrl || payload.mediaUrls?.length) {
847
692
  // Handle media messages
848
693
  const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
849
694
  if (mediaUrl) {
850
- try {
851
- checkAccount(account, 'send media');
852
-
853
- // Check if mediaUrl looks like an image
854
- const isImage = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(mediaUrl);
855
-
856
- const media_id = await uploadFileToTuiTui(mediaUrl, account, isImage ? 'image' : 'file');
857
- if (!media_id) {
858
- log?.error?.(`[${CHANNEL_ID}] sendMedia upload failed to get media_id from TuiTui for mediaUrl: ${mediaUrl}`);
859
- return;
860
- }
861
-
862
- // Build outbound message based on chat type
863
- // 群消息:togroups 填群ID,at 是 userAccount 的字符串值,tousers 是空数组
864
- // 私聊消息:tousers 填 userAccount,at 不存在,togroups 不存在
865
- const _baseOutboundMsg = {
866
- tousers: chatTypeIsGroup ? [] : userAccount ? [userAccount] : [],
867
- togroups: chatTypeIsGroup && groupId ? [groupId] : [],
868
- at: chatTypeIsGroup && userAccount ? userAccount : undefined,
869
- };
870
- const outboundMsg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
871
- isImage
872
- ? { ..._baseOutboundMsg, msgtype: 'image', image: { media_id } }
873
- : { ..._baseOutboundMsg, msgtype: 'attachment', attachment: { media_id } };
874
-
875
- log?.info?.(
876
- `[${CHANNEL_ID}] sending media reply to TuiTui -
877
- target_users: [${outboundMsg?.tousers?.join(",")}],
878
- target_groups: [${outboundMsg?.togroups?.join(",")}],
879
- type: ${outboundMsg.msgtype},
880
- to=${JSON.stringify(outboundMsg.tousers)},
881
- group=${JSON.stringify(outboundMsg.togroups)},
882
- msgtype=${outboundMsg.msgtype},
883
- chatType=${chatType},
884
- userAccount=${userAccount},
885
- groupId=${groupId},
886
- isImage=${isImage},
887
- outboundMsg.tousers=${JSON.stringify(outboundMsg.tousers)},
888
- outboundMsg.togroups=${JSON.stringify(outboundMsg.togroups)},
889
- outboundMsg.at=${outboundMsg.at}`
890
- );
891
-
892
- await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.media', log);
893
- } catch (e) {
894
- log?.error?.(`[${CHANNEL_ID}] Error sending media reply to TuiTui: ${e}`);
895
- }
896
- }
897
- } else if (payload.text || payload.body) {
898
- // Handle text messages
899
- const replyText = payload?.text ?? payload?.body;
900
- if (replyText) {
901
- try {
902
- // Build outbound message based on chat type
903
- // tousers 使用 userAccount(推推用户账号)
904
- // at 使用 userAccount(群聊 @ 用户)
905
- // 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
906
- const outboundMsg: TuiTuiOutboundTextMessage = {
907
- tousers: chatTypeIsDirect && userAccount ? [userAccount] : [],
908
- togroups: chatTypeIsGroup && groupId ? [groupId] : [],
909
- at: chatTypeIsGroup && userAccount ? [userAccount] : [],
910
- msgtype: 'text',
911
- text: { content: replyText },
912
- };
913
-
914
- log?.info?.(
915
- `[${CHANNEL_ID}] sending text reply to TuiTui -
916
- target_users: [${outboundMsg.tousers.join(',')}],
917
- target_groups: [${outboundMsg.togroups.join(',')}]
918
- chatType=${chatType},
919
- tousers=${JSON.stringify(outboundMsg.tousers)},
920
- togroups=${JSON.stringify(outboundMsg.togroups)},
921
- at=${JSON.stringify((outboundMsg as any).at ?? [])},
922
- userAccount=${String(userAccount)},
923
- groupId=${String(groupId)}`,
924
- );
925
-
926
- await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.text', log);
927
- } catch (e) {
928
- log?.error?.(`[${CHANNEL_ID}] Error sending reply to TuiTui: ${e}`);
929
- }
695
+ await sendMediaMsg(account, chatTarget, chatTypeIsGroup, mediaUrl, 'tuitui.deliver.media');
930
696
  }
697
+ return;
698
+ }
699
+ // Handle text messages
700
+ const replyText = payload?.text || payload?.body;
701
+ if (replyText) {
702
+ // at 使用 userAccount(群聊 @ 用户)
703
+ const atList = chatTypeIsGroup && userAccount ? [userAccount] : [];
704
+ // 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
705
+ await sendTextMsg(account, chatTarget, chatTypeIsGroup, replyText, 'tuitui.deliver.text', atList);
931
706
  }
932
707
  },
933
708
  onReplyStart: () => {
package/src/types.ts CHANGED
@@ -64,7 +64,7 @@ export interface TuiTuiOutboundLinkMessage {
64
64
  export interface TuiTuiOutboundImageMessage {
65
65
  tousers?: string[];
66
66
  togroups?: string[];
67
- at?: string;
67
+ at?: string[];
68
68
  msgtype: 'image';
69
69
  image: { media_id: string };
70
70
  }
@@ -72,37 +72,31 @@ export interface TuiTuiOutboundImageMessage {
72
72
  export interface TuiTuiOutboundAttachmentMessage {
73
73
  tousers?: string[];
74
74
  togroups?: string[];
75
- at?: string;
75
+ at?: string[];
76
76
  msgtype: 'attachment';
77
77
  attachment: { media_id: string };
78
78
  }
79
79
 
80
+ export interface TuiTuiOutboundPageMessagePage {
81
+ title: string;
82
+ summary?: string;
83
+ content: string;
84
+ image?: string;
85
+ format?: 'html';
86
+ privilege?: 'specific' | 'scope' | 'corp' | 'any';
87
+ delims_left?: string;
88
+ delims_right?: string;
89
+ kv?: Record<string, string>;
90
+ default_value?: string;
91
+ debug?: boolean;
92
+ }
80
93
  export interface TuiTuiOutboundPageMessage {
81
94
  tousers: string[];
82
95
  togroups: string[];
83
96
  msgtype: 'page';
84
- page: {
85
- title: string;
86
- summary?: string;
87
- content: string;
88
- image?: string;
89
- format?: 'html';
90
- privilege?: 'specific' | 'scope' | 'corp' | 'any';
91
- delims_left?: string;
92
- delims_right?: string;
93
- kv?: Record<string, string>;
94
- default_value?: string;
95
- debug?: boolean;
96
- };
97
+ page: TuiTuiOutboundPageMessagePage;
97
98
  }
98
99
 
99
- export type TuiTuiOutboundMessage =
100
- | TuiTuiOutboundTextMessage
101
- | TuiTuiOutboundLinkMessage
102
- | TuiTuiOutboundImageMessage
103
- | TuiTuiOutboundAttachmentMessage
104
- | TuiTuiOutboundPageMessage;
105
-
106
100
  export interface TuiTuiMediaUploadResponse {
107
101
  errcode: number;
108
102
  errmsg: string;
package/src/utils.ts CHANGED
@@ -1,9 +1,17 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
3
- import type { IncomingMessage } from 'node:http';
4
2
  import { basename } from 'node:path';
5
3
  import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk';
6
- import type { TuiTuiMessageData, TuiTuiMediaUploadResponse, TuiTuiSingleEmojiReactionTarget, TuiTuiGroupEmojiReactionTarget } from './types';
4
+ import type {
5
+ TuiTuiMessageData,
6
+ TuiTuiMediaUploadResponse,
7
+ TuiTuiSingleEmojiReactionTarget,
8
+ TuiTuiGroupEmojiReactionTarget,
9
+ TuiTuiOutboundTextMessage,
10
+ TuiTuiOutboundPageMessage,
11
+ TuiTuiOutboundPageMessagePage,
12
+ TuiTuiOutboundImageMessage,
13
+ TuiTuiOutboundAttachmentMessage
14
+ } from './types';
7
15
 
8
16
  /* 一些常量配置 */
9
17
  export const CHANNEL_ID = 'tuitui';
@@ -41,11 +49,11 @@ function _fetch(opts: any): Promise<any> {
41
49
  return fetchWithSsrFGuard({
42
50
  //url: fileSrc,
43
51
  policy: TUITUI_SSRF_POLICY,
44
- //auditContext: "tuitui.media.download",
52
+ //auditCtx: "tuitui.media.download",
45
53
  ...opts,
46
54
  })
47
55
  }
48
- function _fetchJson(url: string, json: any, auditContext: string): Promise<any> {
56
+ function _fetchJson(url: string, json: any, auditCtx: string): Promise<any> {
49
57
  return _fetch({
50
58
  url,
51
59
  init: {
@@ -53,42 +61,31 @@ function _fetchJson(url: string, json: any, auditContext: string): Promise<any>
53
61
  headers: { 'Content-Type': 'application/json' },
54
62
  body: JSON.stringify(json),
55
63
  },
56
- auditContext,
64
+ auditCtx,
57
65
  });
58
66
  }
59
- export async function postTuituiMsg(account: any, json: any, auditContext: string, log: any): Promise<any> {
67
+ export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
60
68
  const { appId: appid, appSecret: secret } = account;
61
69
  const { response, release } = await _fetchJson(
62
70
  addParams2Url('https://im.live.360.cn:8282/robot/message/custom/send', { appid, secret }),
63
71
  json,
64
- auditContext,
72
+ auditCtx,
65
73
  );
66
74
  try {
67
75
  const bodyText = await response.text();
68
- let parsed: any = null;
69
- try {
70
- parsed = bodyText ? JSON.parse(bodyText) : null;
71
- } catch(err) {
72
- parsed = null;
73
- }
76
+ const parsed = JSON.parse(bodyText);
74
77
 
75
- log?.debug?.(
76
- `[${CHANNEL_ID}] ${auditContext} response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`,
77
- );
78
+ console.debug(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`);
78
79
 
79
80
  if (!response.ok) {
80
- throw new Error(
81
- `[${CHANNEL_ID}] ${auditContext} Failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`,
82
- );
81
+ throw new Error(`postTuituiMsg Failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`);
83
82
  }
84
83
 
85
- if (parsed && typeof parsed.errcode === 'number' && parsed.errcode !== 0) {
86
- throw new Error(
87
- `[${CHANNEL_ID}] ${auditContext} Failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`,
88
- );
84
+ if (Number(parsed?.errcode) !== 0) {
85
+ throw new Error(`postTuituiMsg Failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`);
89
86
  }
90
87
  } catch(err) {
91
- console.error(`[${CHANNEL_ID}] postTuituiMsg error:`, err, `\njson: ${JSON.stringify(json)}`);
88
+ console.error(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg error:`, err, `\njson: ${JSON.stringify(json)}`);
92
89
  } finally {
93
90
  await release();
94
91
  }
@@ -148,7 +145,7 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
148
145
  }
149
146
  // HTTP/HTTPS URL
150
147
  else if (/^https?\:/.test(fileSrc)) {
151
- const { response, release } = await _fetch({ url: fileSrc, auditContext: 'tuitui.media.download' });
148
+ const { response, release } = await _fetch({ url: fileSrc, auditCtx: 'tuitui.media.download' });
152
149
  try {
153
150
  if (!response.ok) {
154
151
  throw new Error(`[${CHANNEL_ID}] Failed to download media from ${fileSrc}: ${response.status}`);
@@ -201,7 +198,7 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
201
198
  const { response, release } = await _fetch({
202
199
  url: addParams2Url('https://im.live.360.cn:8282/robot/media/upload', { appid, secret, type }),
203
200
  init: { method: "POST", body },
204
- auditContext: "tuitui.media.upload",
201
+ auditCtx: "tuitui.media.upload",
205
202
  });
206
203
  try {
207
204
  if (!response.ok) {
@@ -287,11 +284,11 @@ export async function tuituiEmojiReaction(
287
284
  targetIsGroup: boolean,
288
285
  msgid: string,
289
286
  emoji: string
290
- ): Promise<string> {
287
+ ): Promise<void> {
291
288
  const payload = {
292
289
  msgtype: 'emoji_reaction',
293
- tousers:[] as TuiTuiSingleEmojiReactionTarget[],
294
- togroups:[] as TuiTuiGroupEmojiReactionTarget[],
290
+ tousers: [] as TuiTuiSingleEmojiReactionTarget[],
291
+ togroups: [] as TuiTuiGroupEmojiReactionTarget[],
295
292
  emoji_reaction: { emoji, cancel: false},
296
293
  };
297
294
  if(targetIsGroup) {
@@ -312,6 +309,83 @@ export async function tuituiEmojiReaction(
312
309
  } finally {
313
310
  await release();
314
311
  }
312
+ }
313
+
314
+ function getTargets(chatId: string, isGroup: boolean): { tousers: string[]; togroups: string[] } {
315
+ const tousers: string[] = [];
316
+ const togroups: string[] = [];
317
+ (isGroup ? togroups : tousers).push(chatId);
318
+ return { tousers, togroups };
319
+ }
320
+
321
+ export async function sendTextMsg(
322
+ account: any,
323
+ target: string | undefined,
324
+ isGroup: boolean,
325
+ content: string,
326
+ auditCtx: string = 'tuitui.send.text',
327
+ atList?: string[],
328
+ ): Promise<void> {
329
+ if (!target) return console.error(`[${CHANNEL_ID}] sendTextMsg Error ${auditCtx}: Missing "target"`);
330
+ // Format message according to TuiTui's required structure
331
+ const msg: TuiTuiOutboundTextMessage = {
332
+ msgtype: 'text',
333
+ ...getTargets(target, isGroup),
334
+ at: atList || [],
335
+ text: { content },
336
+ };
337
+ console.log(`[${CHANNEL_ID}] sendTextMsg ${auditCtx} - `, msg);
338
+ await postTuituiMsg(account, msg, auditCtx);
339
+ }
340
+
341
+ export async function sendPageMsg(
342
+ account: any,
343
+ target: string | undefined,
344
+ isGroup: boolean,
345
+ page: TuiTuiOutboundPageMessagePage,
346
+ auditCtx: string = 'tuitui.send.page',
347
+ tousers?: string[],
348
+ togroups?: string[],
349
+ ): Promise<void> {
350
+ if (!target) return console.error(`[${CHANNEL_ID}] sendPageMsg Error ${auditCtx}: Missing "target"`);
351
+ const targets = getTargets(target, isGroup);
352
+ tousers = tousers || targets.tousers;
353
+ togroups = togroups || targets.togroups;
354
+ const msg: TuiTuiOutboundPageMessage = {
355
+ msgtype: 'page',
356
+ tousers,
357
+ togroups,
358
+ page: { ...(page || {})}
359
+ };
360
+ console.log(`[${CHANNEL_ID}] sendPageMsg ${auditCtx} - `, msg);
361
+ await postTuituiMsg(account, msg, auditCtx);
362
+ }
363
+
364
+ export async function sendMediaMsg(
365
+ account: any,
366
+ target: string | undefined,
367
+ isGroup: boolean,
368
+ mediaUrl: string,
369
+ auditCtx: string = 'tuitui.send.media',
370
+ ): Promise<void> {
371
+ if (!target) return console.error(`[${CHANNEL_ID}] sendMediaMsg Error ${auditCtx}: Missing "target"`);
372
+ // Check if mediaUrl looks like an image
373
+ const isImage = /^data:image\//i.test(mediaUrl) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(mediaUrl);
374
+ const mediaType = isImage ? 'image' : 'file';
375
+
376
+ const media_id = await uploadFileToTuiTui(mediaUrl, account, mediaType);
377
+ if (!media_id) {
378
+ console.error(`[${CHANNEL_ID}] uploadFileToTuiTui failed ${auditCtx}, {mediaUrl: ${mediaUrl}, mediaType: ${mediaType}}`);
379
+ return;
380
+ }
381
+
382
+ const targets = getTargets(target, isGroup);
383
+ const msg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
384
+ isImage
385
+ ? { ...targets, msgtype: 'image', image: { media_id } }
386
+ : { ...targets, msgtype: 'attachment', attachment: { media_id } };
387
+
388
+ console.log(`[${CHANNEL_ID}] sendMediaMsg ${auditCtx} - `, msg);
315
389
 
316
- return '';
390
+ await postTuituiMsg(account, msg, auditCtx);
317
391
  }