@qihoo/tuitui-openclaw-channel 1.0.5 → 1.0.7

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.7",
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,57 +5,101 @@
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
- }
58
-
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);
51
+ const arrLowerCaseTrim = (arr: any[]) => arr.filter((v: any) => !!v).map((v: any) => String(v).toLowerCase().trim())
52
+ const configSchema = buildChannelConfigSchema(
53
+ z
54
+ .object({
55
+ enabled: z.boolean().optional().describe('开启或关闭'),
56
+ appId: z.string().min(1).describe('推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)'),
57
+ appSecret: z.string().min(1).describe('推推机器人密钥 Secret'),
58
+ // 私聊策略(默认 pairing)
59
+ dmPolicy: z
60
+ .enum(['pairing', 'allowlist', 'open', 'disabled'])
61
+ .optional()
62
+ .default('pairing')
63
+ .describe('私聊策略:pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊',),
64
+ // 私聊允许列表(当 dmPolicy=allowlist 生效;pairing 下也可显式允许)
65
+ allowFrom: z
66
+ .array(z.string())
67
+ .optional()
68
+ .describe('私聊白名单(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)'),
69
+
70
+ // 群组策略
71
+ groupPolicy: z
72
+ .enum(['allowlist', 'disabled'])
73
+ .default('allowlist')
74
+ .describe('群聊策略:allowlist=白名单;disabled=禁用群聊',),
75
+ // 仅在 allowlist 生效的群组 ID 列表
76
+ groupAllowFrom: z
77
+ .array(z.string())
78
+ .optional()
79
+ .describe('群组白名单(仅在 groupPolicy=allowlist 生效)'),
80
+ // 每个群组的覆盖配置
81
+ /*
82
+ groups: z
83
+ .record(
84
+ z.string(),
85
+ z
86
+ .object({
87
+ requireMention: z
88
+ .boolean()
89
+ .optional()
90
+ .describe('该群组是否需要 @ 机器人才触发 Agent(默认 true)'),
91
+ shouldReply: z
92
+ .boolean()
93
+ .optional()
94
+ .describe('该群组是否允许 Agent 回复(默认 true)'),
95
+ })
96
+ .passthrough(),
97
+ )
98
+ .optional()
99
+ .describe('群组级覆盖配置'),
100
+ */
101
+ }) as any,
102
+ );
59
103
  export function createTuiTuiChannelPlugin(apiRuntime: any) {
60
104
  return {
61
105
  id: CHANNEL_ID,
@@ -71,7 +115,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
71
115
  },
72
116
 
73
117
  capabilities: {
74
- chatTypes: ["direct" as const, "group" as const],
118
+ chatTypes: ['direct' as const, 'group' as const],
75
119
  media: true,
76
120
  threads: false,
77
121
  reactions: false,
@@ -84,73 +128,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
84
128
 
85
129
  reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
86
130
 
87
- configSchema: buildChannelConfigSchema(
88
- z
89
- .object({
90
- enabled: z.boolean().optional().describe('开启或关闭'),
91
- appId: z.string().min(1).describe('推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)'),
92
- appSecret: z.string().min(1).describe('推推机器人密钥 Secret'),
93
- // 私聊策略(默认 pairing)
94
- dmPolicy: z
95
- .enum(['pairing', 'allowlist', 'open', 'disabled'])
96
- .optional()
97
- .default('pairing')
98
- .describe('私聊策略:pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊',),
99
- // 私聊允许列表(当 dmPolicy=allowlist 生效;pairing 下也可显式允许)
100
- allowFrom: z
101
- .array(z.string())
102
- .optional()
103
- .describe('私聊白名单(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)'),
104
-
105
- // 群组策略
106
- groupPolicy: z
107
- .enum(['allowlist', 'disabled'])
108
- .default('allowlist')
109
- .describe('群聊策略:allowlist=白名单;disabled=禁用群聊',),
110
- // 仅在 allowlist 生效的群组 ID 列表
111
- groupAllowFrom: z
112
- .array(z.string())
113
- .optional()
114
- .describe('群组白名单(仅在 groupPolicy=allowlist 生效)'),
115
- // 每个群组的覆盖配置
116
- /*
117
- groups: z
118
- .record(
119
- z.string(),
120
- z
121
- .object({
122
- requireMention: z
123
- .boolean()
124
- .optional()
125
- .describe('该群组是否需要 @ 机器人才触发 Agent(默认 true)'),
126
- shouldReply: z
127
- .boolean()
128
- .optional()
129
- .describe('该群组是否允许 Agent 回复(默认 true)'),
130
- })
131
- .passthrough(),
132
- )
133
- .optional()
134
- .describe('群组级覆盖配置'),
135
- */
136
- }) as any,
137
- ),
131
+ configSchema,
138
132
 
139
133
  config: {
140
134
  listAccountIds: (cfg: any) => {
141
135
  const base = cfg?.channels?.[CHANNEL_ID];
142
136
  if (!base) return [];
143
137
  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
- }
138
+ if (base.enabled) ids.add(DEFAULT_ACCOUNT_ID);
139
+ if (base.accounts) for (const k in base.accounts) ids.add(k);
150
140
  return Array.from(ids);
151
141
  },
152
142
 
153
- resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
143
+ resolveAccount,
154
144
 
155
145
  defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
156
146
 
@@ -181,10 +171,10 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
181
171
  },
182
172
 
183
173
  deleteAccount: ({ cfg, accountId }: any) => {
184
- if (accountId === DEFAULT_ACCOUNT_ID) {
174
+ return accountId === DEFAULT_ACCOUNT_ID
185
175
  // For default account, we don't delete the entire config
186
176
  // Instead, we disable it and clear sensitive fields
187
- return {
177
+ ? {
188
178
  ...cfg,
189
179
  channels: {
190
180
  ...cfg.channels,
@@ -195,23 +185,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
195
185
  appSecret: undefined,
196
186
  },
197
187
  },
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
- });
188
+ }
189
+ // For named accounts, use the standard delete function
190
+ : deleteAccountFromConfigSection({
191
+ cfg,
192
+ sectionKey: CHANNEL_ID,
193
+ accountId,
194
+ clearBaseFields: [
195
+ 'appId',
196
+ 'appSecret',
197
+ 'dmPolicy',
198
+ 'allowFrom',
199
+ 'groupPolicy',
200
+ 'groupAllowFrom',
201
+ 'groups',
202
+ ],
203
+ });
215
204
  },
216
205
  },
217
206
 
@@ -234,12 +223,12 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
234
223
 
235
224
  security: {
236
225
  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;
226
+ account = account || resolveAccount(cfg, accountId);
227
+ const accId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
239
228
  let allowFromPath =`channels.${CHANNEL_ID}.`;
240
- if (_accountId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${_accountId}.`;
229
+ if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
241
230
 
242
- const policy = _account.dmPolicy ?? 'pairing';
231
+ const policy = account.dmPolicy ?? 'pairing';
243
232
  // dmPolicy semantics:
244
233
  // - open: always allow everyone (["*"]), ignore allowFrom values.
245
234
  // - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
@@ -247,7 +236,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
247
236
  // - disabled: block all DMs.
248
237
  return {
249
238
  policy,
250
- allowFrom: policy === 'open' ? ['*'] : (_account.allowFrom ?? []),
239
+ allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
251
240
  policyPath: `${allowFromPath}dmPolicy`,
252
241
  allowFromPath,
253
242
  approveHint: `openclaw pairing approve ${CHANNEL_ID} <code>`,
@@ -274,144 +263,45 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
274
263
  deliveryMode: 'gateway' as const,
275
264
  textChunkLimit: 2000,
276
265
 
277
- sendText: async ({ cfg, to, text, accountId, account, log }: any) => {
278
- const _account = account ?? resolveAccount(cfg, accountId);
279
- checkAccount(_account, 'send text');
266
+ sendText: async ({ cfg, to, text, accountId, account }: any) => {
267
+ account = account || resolveAccount(cfg, accountId);
268
+ checkAccount(account, 'send text');
280
269
 
281
- const toStr = String(to || '').trim();
270
+ const chatId = String(to || '').trim();
282
271
  // 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
- };
272
+ await sendTextMsg(account, chatId, isToGroup(chatId), text);
293
273
 
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
- );
304
-
305
- await postTuituiMsg(_account, payload, 'tuitui.send.text', log);
306
-
307
- return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
274
+ return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
308
275
  },
309
276
 
310
- 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
+ sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId }: any) => {
278
+ account = account || resolveAccount(cfg, accountId);
279
+ checkAccount(account, 'send custom message');
313
280
 
314
281
  // If it's a page message, we need to construct it
315
282
  if (payload?.msgtype !== 'page') {
316
283
  throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${payload?.msgtype}`);
317
284
  }
318
285
 
319
- const toStr = String(to || '').trim();
286
+ const chatId = String(to || '').trim();
320
287
  // Determine if this is a group message
321
288
  // WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
322
289
  // 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);
290
+ const isGroup = chatType === 'group' || !!groupId || (isToGroup(chatId) && !chatType);
291
+ await sendPageMsg(account, chatId, isGroup, payload.page);
347
292
 
348
- return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
293
+ return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
349
294
  },
350
295
 
351
- sendMedia: async ({ cfg, to, mediaUrl, accountId, account, log }: any) => {
352
- const _account = account ?? resolveAccount(cfg, accountId);
353
- checkAccount(_account, 'send media');
296
+ sendMedia: async ({ cfg, to, payload, mediaUrl, accountId, account }: any) => {
297
+ account = account || resolveAccount(cfg, accountId);
298
+ checkAccount(account, 'send media');
354
299
 
355
- const toStr = String(to || '').trim();
300
+ const chatId = String(to || '').trim();
356
301
  // 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
- };
302
+ await sendMediaMsg(account, chatId, isToGroup(chatId), mediaUrl, 'tuitui.send.media');
394
303
 
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 };
304
+ return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
415
305
  },
416
306
  },
417
307
 
@@ -497,8 +387,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
497
387
  const msg = json.body as TuiTuiInboundMessage;
498
388
  const msgData = msg.data;
499
389
  let userAccount: string | undefined = msg.user_account;
500
- let userUid: string | undefined = msg.uid;
501
- let userName: string | undefined = msg.user_name;
390
+ let msgUid: string | undefined = msg.uid;
391
+ let msgUname: string | undefined = msg.user_name;
502
392
  let chatType: 'direct' | 'group';
503
393
  const chatTypeIsDirect = wsEvent === 'single_chat';
504
394
  const chatTypeIsGroup = wsEvent === 'group_chat';
@@ -515,9 +405,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
515
405
  chatType = 'direct';
516
406
  text = buildMessageBody(msgData);
517
407
 
518
- log?.debug?.(
519
- `[${CHANNEL_ID}] inbound single_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)}`,
520
- );
408
+ log?.debug?.(`[${CHANNEL_ID}] inbound single_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname}`);
521
409
 
522
410
  const msgType = msgData.msg_type;
523
411
  // Extract media URLs for image/voice/file messages
@@ -534,7 +422,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
534
422
  replyToId = msgData.ref.msgid;
535
423
  }
536
424
 
537
- if (!userAccount && !userUid) {
425
+ if (!userAccount && !msgUid) {
538
426
  log?.info?.(`[${CHANNEL_ID}] Missing user_account or uid in single_chat event`);
539
427
  return;
540
428
  }
@@ -544,27 +432,16 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
544
432
  groupId = msgData.group_id;
545
433
  groupName = msgData.group_name;
546
434
 
547
- log?.debug?.(
548
- `[${CHANNEL_ID}] inbound group_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)} group_id=${String(groupId)}`,
549
- );
435
+ log?.debug?.(`[${CHANNEL_ID}] inbound group_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname} group_id=${groupId}`);
550
436
 
551
437
  // Group policy gating and @mention requirements
552
438
  const groupPolicy = String(account.groupPolicy ?? "allowlist").toLowerCase();
553
439
  const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
554
- const normalizedGroupAllowFrom = groupAllowFromRaw
555
- .filter((v: unknown) => v != null)
556
- .map((v: unknown) => String(v).trim())
557
- .filter(Boolean);
558
- log?.debug?.(
559
- `[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${String(groupId)} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
560
- );
561
-
562
- if (groupPolicy === 'disabled') {
563
- log?.info?.('Groups disabled');
564
- return;
565
- }
566
-
440
+ const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
441
+ log?.debug?.(`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${groupId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`);
567
442
 
443
+ if (groupPolicy === 'disabled') return log?.info?.('Groups disabled');
444
+
568
445
  // 群消息处理策略
569
446
  const groupCfg = account.groups?.[String(groupId)];
570
447
 
@@ -610,16 +487,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
610
487
  }
611
488
 
612
489
  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);
490
+ await sendTextMsg(account, groupId, true, `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}`, 'tuitui.groupPolicy.reply');
623
491
  return;
624
492
  }
625
493
 
@@ -635,14 +503,10 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
635
503
  // DM access gating (pairing/allowlist/open/disabled)
636
504
  const dmPolicy = String(account.dmPolicy ?? 'pairing').toLowerCase();
637
505
  const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
638
- const normalizedAllowFrom = configuredAllowFrom
639
- .filter((v: unknown) => !!v)
640
- .map((v: unknown) => String(v).toLowerCase().trim());
506
+ const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
641
507
  // 只使用 userAccount 作为匹配依据,因为用户希望 allowFrom 匹配 user_account
642
508
  const senderForPolicy = userAccount ? String(userAccount).toLowerCase().trim() : '';
643
- log?.debug?.(
644
- `[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${String(userAccount)} userUid=${String(userUid)} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
645
- );
509
+ log?.debug?.(`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${userAccount} msgUid=${msgUid} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
646
510
 
647
511
  if (chatTypeIsDirect) {
648
512
  if (dmPolicy === 'disabled') {
@@ -653,20 +517,14 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
653
517
  if (dmPolicy !== 'open') {
654
518
  // Merge pairing-store entries unless policy is allowlist-only
655
519
  let storeAllowFrom: string[] = [];
656
- try {
657
- if (dmPolicy !== 'allowlist') {
658
- const entries = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
520
+ if (dmPolicy !== 'allowlist') {
521
+ try {
522
+ const res = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
659
523
  channel: CHANNEL_ID,
660
524
  accountId: account.accountId,
661
525
  });
662
- if (Array.isArray(entries)) {
663
- storeAllowFrom = entries
664
- .filter((v: unknown) => !!v)
665
- .map((v: unknown) => String(v).toLowerCase().trim());
666
- }
667
- }
668
- } catch {
669
- storeAllowFrom = [];
526
+ if (Array.isArray(res)) storeAllowFrom = arrLowerCaseTrim(res);
527
+ } catch {}
670
528
  }
671
529
 
672
530
  // 只检查 userAccount 是否在 allowFrom 或 storeAllowFrom 中
@@ -676,9 +534,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
676
534
  if (!isAllowed) {
677
535
  if (dmPolicy === 'pairing') {
678
536
  try {
679
- log?.debug?.(
680
- `[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`,
681
- );
537
+ log?.debug?.(`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
682
538
  const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
683
539
  channel: CHANNEL_ID,
684
540
  accountId: account.accountId,
@@ -696,21 +552,11 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
696
552
  apiRuntime?.channel?.pairing?.buildPairingReply?.({
697
553
  channel: CHANNEL_ID,
698
554
  code: req.code,
699
- }) ?? 'Pairing required. Ask the bot owner to approve.';
555
+ }) ?? '需要进行配对。请让机器人所有者进行批准。';
700
556
 
701
557
  // tousers 使用 userAccount(推推用户账号)
702
558
  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);
559
+ await sendTextMsg(account, toUname, false, replyText, 'tuitui.pairing.reply');
714
560
  }
715
561
  } catch (err) {
716
562
  log?.warn?.(`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(err)}` );
@@ -721,9 +567,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
721
567
  }
722
568
 
723
569
  // dmPolicy=allowlist and sender not allowed
724
- log?.warn?.(
725
- `[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
726
- );
570
+ log?.warn?.(`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
727
571
  return;
728
572
  }
729
573
  }
@@ -733,22 +577,13 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
733
577
  await tuituiEmojiReaction(account, chatTypeIsGroup ? groupId : userAccount, chatTypeIsGroup, msgData.msgid, '收到');
734
578
 
735
579
  // Build MsgContext
736
- // 优先使用 userAccount,如果为空则降级使用 userUid
737
- const senderId = userAccount || userUid || 'unknown';
580
+ // 优先使用 userAccount,如果为空则降级使用 msgUid
581
+ const senderId = userAccount || msgUid || 'unknown';
738
582
  // 确保 accountId 有有效值,优先使用 account.accountId,其次使用 ctx 中的 accountId,最后使用 DEFAULT_ACCOUNT_ID
739
583
 
740
584
  const effectiveAccountId = String(account.accountId || accountId || DEFAULT_ACCOUNT_ID || 'default');
741
- const sessionKey = chatTypeIsGroup
742
- ? `${CHANNEL_ID}:${effectiveAccountId}:${groupId}`
743
- : `${CHANNEL_ID}:${effectiveAccountId}:${senderId}`;
744
- console.log(`[${CHANNEL_ID}] effectiveAccountId infos:
745
- account.accountId : ${account.accountId},
746
- accountId: ${accountId},
747
- DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID},
748
- chatType: ${chatType},
749
- sessionKey: ${sessionKey},
750
- effectiveAccountId: ${effectiveAccountId},
751
- `);
585
+ const sessionKey = `${CHANNEL_ID}:${effectiveAccountId}:${chatTypeIsGroup ? groupId : userAccount}`;
586
+ console.log(`[${CHANNEL_ID}] effectiveAccountId infos: account.accountId : ${account.accountId}, accountId: ${accountId}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, chatType: ${chatType}, sessionKey: ${sessionKey}, effectiveAccountId: ${effectiveAccountId}`);
752
587
 
753
588
  const msgCtx: any = {
754
589
  Body: text || ' ',
@@ -762,7 +597,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
762
597
  ChatType: chatType,
763
598
  Surface: CHANNEL_ID,
764
599
  Provider: CHANNEL_ID,
765
- SenderName: msg.user_name || String(senderId),
600
+ SenderName: msgUname || String(senderId),
766
601
  };
767
602
 
768
603
  // Add group-specific fields
@@ -778,7 +613,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
778
613
  if (replyToId) msgCtx.ReplyToId = replyToId;
779
614
 
780
615
  // Dispatch via the SDK's buffered block dispatcher
781
- let sentCount = 0;
782
616
  if (!apiRuntime) {
783
617
  log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
784
618
  return;
@@ -800,8 +634,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
800
634
  [key: string]: any;
801
635
  };
802
636
  }) => {
803
- // mark that we received a deliver call (attempt to send reply)
804
- sentCount++;
805
637
 
806
638
  // 如果设置了 suppressReply 标志,则不发送回复
807
639
  if (suppressReply) {
@@ -809,129 +641,38 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
809
641
  return;
810
642
  }
811
643
 
644
+ const chatTarget = chatTypeIsGroup ? groupId : userAccount;
812
645
  // Handle custom messages (e.g., page messages)
813
646
  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}`);
647
+ const { msgtype, page, tousers, togroups } = payload.custom;
648
+ // Handle page message type
649
+ if (msgtype === 'page' && page) {
650
+ await sendPageMsg(account, chatTarget, chatTypeIsGroup, page, 'tuitui.deliver.page', tousers, togroups);
651
+ return;
842
652
  }
653
+ log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${msgtype}`);
843
654
  return;
844
655
  }
845
-
846
656
  if (payload.mediaUrl || payload.mediaUrls?.length) {
847
657
  // Handle media messages
848
658
  const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
849
659
  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
- }
660
+ const at = chatTypeIsGroup ? [msgUname] : [];
661
+ await sendMediaMsg(account, chatTarget, chatTypeIsGroup, mediaUrl, 'tuitui.deliver.media', at);
930
662
  }
663
+ return;
664
+ }
665
+ // Handle text messages
666
+ const replyText = payload?.text || payload?.body;
667
+ if (replyText) {
668
+ // at 使用 userAccount(群聊 @ 用户)
669
+ const atList = chatTypeIsGroup && userAccount ? [userAccount] : [];
670
+ // 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
671
+ await sendTextMsg(account, chatTarget, chatTypeIsGroup, replyText, 'tuitui.deliver.text', atList);
931
672
  }
932
673
  },
933
674
  onReplyStart: () => {
934
- log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? userUid}`);
675
+ log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? msgUid}`);
935
676
  },
936
677
  },
937
678
  });
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) {
@@ -228,10 +225,8 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
228
225
  export function buildMessageBody(data: TuiTuiMessageData): string {
229
226
  const parts: string[] = [];
230
227
 
231
- const txt = data.text?.trim();
232
- const pushTxt = () => txt && parts.push(txt);
233
- const pushImgs = () => {
234
- const imgs = data.images;
228
+ const pushTxt = () => parts.push(data.text || '');
229
+ const pushImgs = (imgs: string[] | undefined, parts: string[]) => {
235
230
  if (!imgs) return;
236
231
  const imgLen = imgs?.length || 0;
237
232
  if (imgLen === 0) return;
@@ -249,10 +244,10 @@ export function buildMessageBody(data: TuiTuiMessageData): string {
249
244
  break;
250
245
  case 'mixed':
251
246
  pushTxt();
252
- pushImgs();
247
+ pushImgs(data.images, parts);
253
248
  break;
254
249
  case 'image':
255
- pushImgs();
250
+ pushImgs(data.images, parts);
256
251
  break;
257
252
  case 'voice':
258
253
  if (data.voice) parts.push(`[语音] ${data.voice}`);
@@ -265,14 +260,16 @@ export function buildMessageBody(data: TuiTuiMessageData): string {
265
260
  // Handle reference/reply
266
261
  const { ref } = data;
267
262
  if (ref && ref.is_me) {
268
- const { msg_type, text, images } = ref;
263
+ const { msg_type } = ref;
269
264
  let refContent = `[${msg_type}]`;
270
265
  switch (msg_type) {
271
266
  case 'text':
272
- refContent = text || '';
267
+ refContent = ref.text || '';
273
268
  break;
274
269
  case 'image':
275
- refContent = images?.length ? `[图片]` : '[图片]';
270
+ const refParts: string[] = [];
271
+ pushImgs(ref.images, refParts);
272
+ refContent = refParts.join("\n") || '[图片]';
276
273
  break;
277
274
  }
278
275
  parts.unshift(`[引用机器人消息]\n> ${refContent}`);
@@ -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,84 @@ 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
+ at?: string[],
371
+ ): Promise<void> {
372
+ if (!target) return console.error(`[${CHANNEL_ID}] sendMediaMsg Error ${auditCtx}: Missing "target"`);
373
+ // Check if mediaUrl looks like an image
374
+ const isImage = /^data:image\//i.test(mediaUrl) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(mediaUrl);
375
+ const mediaType = isImage ? 'image' : 'file';
376
+
377
+ const media_id = await uploadFileToTuiTui(mediaUrl, account, mediaType);
378
+ if (!media_id) {
379
+ console.error(`[${CHANNEL_ID}] uploadFileToTuiTui failed ${auditCtx}, {mediaUrl: ${mediaUrl}, mediaType: ${mediaType}}`);
380
+ return;
381
+ }
382
+
383
+ const targets = getTargets(target, isGroup);
384
+ const msg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
385
+ isImage
386
+ ? { ...targets, msgtype: 'image', image: { media_id } }
387
+ : { ...targets, msgtype: 'attachment', attachment: { media_id } };
388
+ if (at && at.length > 0) msg.at = at;
389
+ console.log(`[${CHANNEL_ID}] sendMediaMsg ${auditCtx} - `, msg);
315
390
 
316
- return '';
391
+ await postTuituiMsg(account, msg, auditCtx);
317
392
  }