@qihoo/tuitui-openclaw-channel 1.0.11 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
package/src/channel.ts CHANGED
@@ -153,7 +153,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
153
153
  allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
154
154
  policyPath: `${allowFromPath}dmPolicy`,
155
155
  allowFromPath,
156
- approveHint: `openclaw 配对校验信息,CHANNEL_ID: ${CHANNEL_ID}, AccountId: ${accountId}, code: <code>`,
156
+ approveHint: `当前 ${CHANNEL_ID} openclaw(AccountId: ${accountId})需要配对校验, code: <code>`,
157
157
  normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
158
158
  };
159
159
  },
@@ -174,16 +174,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
174
174
  },
175
175
 
176
176
  outbound: {
177
- deliveryMode: 'gateway' as const,
178
- textChunkLimit: 2000,
177
+ deliveryMode: 'direct' as const,
178
+ textChunkLimit: 10000, // API上限制为50k
179
179
 
180
- sendText: async ({ cfg, to, text, accountId, account }: any) => {
181
- account = account || resolveAccount(cfg, accountId);
182
- checkAccount(account, 'send text');
180
+ sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
181
+ const account = resolveAccount(cfg, accountId);
182
+ checkAccount(account);
183
183
 
184
184
  const chatId = String(to || '').trim();
185
- // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
186
- await sendTextMsg(account, chatId, guessChatType(chatId), text);
185
+ const chatType = guessChatType(chatId);
186
+ console.log(`[${CHANNEL_ID}] AccountId ${accountId} outbound.sendText() ${chatType} to ${chatId} ${text}`);
187
+
188
+ await sendTextMsg(account, chatId, chatType, text);
187
189
 
188
190
  return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
189
191
  },
@@ -240,6 +242,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
240
242
  wsNumber++;
241
243
  const wsId = `${wsNumber}-${Date.now()}`;
242
244
  const wsEvtIds = new Set<string>();
245
+ let wsRetryTimerId = 0;
243
246
 
244
247
  const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
245
248
  const defSendMsg = (msg: any) => {
@@ -304,7 +307,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
304
307
  _sendMsg = defSendMsg;
305
308
  if (!abortSignal?.aborted) {
306
309
  log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
307
- setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
310
+ if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
311
+ wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
308
312
  }
309
313
  };
310
314
  ws.on('close', () => {
package/src/inbound.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  * await handleInboundMessage(json, { account, accountId, apiRuntime, log });
11
11
  * });
12
12
  */
13
- import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
14
13
  import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types';
15
14
  import { CHANNEL_ID } from './const';
16
15
 
@@ -60,6 +59,47 @@ export interface InboundHandlerOptions {
60
59
  log: any;
61
60
  }
62
61
 
62
+ function getSessionKey(
63
+ cfg: any,
64
+ payload: ChatPayload,
65
+ account: InboundHandlerOptions['account'],
66
+ apiRuntime: InboundHandlerOptions['apiRuntime']
67
+ ): string | undefined {
68
+ const { chatId, chatType } = payload;
69
+ if (!chatId) return undefined;
70
+ const { accountId, channelContext } = account;
71
+ // 你自己只需要拼接与定义 peer.id
72
+ // 关于 sessionKey 格式的解释: https://docs.openclaw.ai/channels/channel-routing#sessionkey-%E5%8F%82%E8%80%83%E6%A0%BC%E5%BC%8F
73
+ let id = chatId;
74
+ if(chatType == CHAT_TYPE_CHANNEL) {
75
+ const { channel_id, parent_id } = teamsParseChatId(chatId);
76
+ id = `${channel_id}`;
77
+ if(channelContext == 'thread' && parent_id) {
78
+ id += `:thread:${parent_id}`;
79
+ }
80
+ }
81
+
82
+ // sessionKey 不能自己拼字符串,需要用系统api识别,他会根据 bindings 路由规则处理
83
+ const { sessionKey } = apiRuntime.channel.routing.resolveAgentRoute({
84
+ cfg,
85
+ accountId,
86
+ channel: CHANNEL_ID,
87
+ peer: { kind: chatType, id: id },
88
+ });
89
+ return sessionKey;
90
+ }
91
+
92
+ function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefined {
93
+ if (msg_type === 'image' || msg_type === 'mixed') {
94
+ if (images?.length) return images;
95
+ } else if (msg_type === 'voice') {
96
+ if (voice) return [voice];
97
+ } else if (msg_type === 'file') {
98
+ if (file?.url) return [file.url];
99
+ }
100
+ return undefined;
101
+ }
102
+
63
103
  /**
64
104
  * 处理长链接消息
65
105
  * account: 长连接初始化时,对应的Account配置信息
@@ -68,31 +108,27 @@ export interface InboundHandlerOptions {
68
108
  export async function handleInboundMessage({ json, account, apiRuntime, log }: InboundHandlerOptions) {
69
109
  // Signature / AppId validation
70
110
  const { accountId, appId, appSecret } = account;
71
- if (appSecret) {
72
- const hdAppId = json.header['X-Tuitui-Robot-Appid'];
73
- if (appId && hdAppId !== appId) {
74
- log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
75
- return;
76
- }
111
+ if (appSecret && appId && json.header['X-Tuitui-Robot-Appid'] !== appId) {
112
+ log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
113
+ return;
77
114
  }
78
115
 
79
116
  // Decode message
80
- const wsEvent = json.body.event;
81
- const msg = json.body as TuiTuiInboundMessage;
117
+ const msg = json.body as TuiTuiInboundMessage;
82
118
  const msgData = msg.data;
83
-
119
+ const { ref } = msgData;
84
120
  const payload: ChatPayload = {
85
121
  chatType: CHAT_TYPE_DIRECT,
86
122
  chatId: undefined,
87
123
  text: undefined,
88
124
  groupName: undefined,
89
- mediaUrls: undefined,
90
- replyToId: undefined,
125
+ mediaUrls: getMediaUrls(msgData),
126
+ replyToId: ref?.is_me && ref?.msgid ? ref.msgid : undefined,
91
127
  tuituiAccount: msg.user_account || "",
92
128
  tuituiUid: msg.uid || "",
93
- tuituiUserName:msg.user_name || "",
129
+ tuituiUserName: msg.user_name || "",
94
130
  };
95
-
131
+ const wsEvent = json.body.event;
96
132
  // Event-type branching
97
133
  if (wsEvent === 'single_chat') {
98
134
  const valid = await parseSingleChat(payload, msgData, account, apiRuntime, log);
@@ -117,44 +153,20 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
117
153
 
118
154
  // 路由判断
119
155
  const routeSenderFrom = payload.tuituiAccount || payload.tuituiUid || 'unknown';
120
- const routeAccountId = String(accountId || DEFAULT_ACCOUNT_ID || 'default');
121
156
 
122
157
  const cfg = await apiRuntime.config.loadConfig();
123
-
124
- // 关于 sessionKey 格式的解释
125
- // https://docs.openclaw.ai/channels/channel-routing
126
- var peer_id = payload.chatId
127
- if(payload.chatType == CHAT_TYPE_CHANNEL) {
128
- const target = teamsParseChatId(payload.chatId)
129
- if(account?.channelContext == "thread" && target.parent_id) {
130
- peer_id = `${target.channel_id}:thread:${target.parent_id}`;
131
- } else {
132
- peer_id = `${target.channel_id}`;
133
- }
134
- }
158
+ const sessionKey = getSessionKey(cfg, payload, account, apiRuntime);
135
159
 
136
- // 你自己只需要拼接与定义peer_id
137
- // sessionKey 不能自己拼字符串,需要用系统api识别,他会根据 bindings 路由规则处理
138
- const { sessionKey } = apiRuntime.channel.routing.resolveAgentRoute({
139
- cfg,
140
- channel: CHANNEL_ID,
141
- accountId,
142
- peer: {
143
- kind: payload.chatType,
144
- id: peer_id,
145
- },
146
- });
147
-
148
- console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, routeAccountId: ${routeAccountId}`);
160
+ console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}`);
149
161
  console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, dispatching to agent session=${sessionKey}`);
150
162
 
151
- const msgCtx: any = {
163
+ const ctx: any = {
152
164
  Body: payload.text! || ' ',
153
165
  From: String(routeSenderFrom),
154
166
  To: CHANNEL_ID,
155
167
  SessionId: String(sessionKey).replace(/\//g, '_'),
156
168
  SessionKey: String(sessionKey).replace(/\//g, '_'),
157
- AccountId: routeAccountId,
169
+ AccountId: accountId,
158
170
  OriginatingChannel: CHANNEL_ID,
159
171
  OriginatingTo: payload.chatId,
160
172
  ChatType: payload.chatType!,
@@ -165,19 +177,18 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
165
177
  UserAccount: payload.tuituiAccount,
166
178
  CommandAuthorized: true, // 允许 /new 等内置命令
167
179
  };
168
-
169
180
  if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
170
- msgCtx.GroupSubject = payload.groupName;
171
- msgCtx.GroupId = payload.chatId;
181
+ ctx.GroupSubject = payload.groupName;
182
+ ctx.GroupId = payload.chatId;
172
183
  }
173
- if (payload.mediaUrls?.length) msgCtx.MediaUrls = payload.mediaUrls;
174
- if (payload.replyToId) msgCtx.ReplyToId = payload.replyToId;
184
+ if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
185
+ if (payload.replyToId) ctx.ReplyToId = payload.replyToId;
175
186
 
176
187
  console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, handleInboundMessage payload`, payload);
177
188
 
178
189
 
179
190
  await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
180
- ctx: msgCtx,
191
+ ctx,
181
192
  cfg,
182
193
  dispatcherOptions: {
183
194
  deliver: async (outbound: TuiTuiOutboundDeliverOptions) => {
@@ -216,6 +227,40 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
216
227
  });
217
228
  }
218
229
 
230
+ /*** 配对节流映射 - 用于防止短时间内大量重复配对请求消息发出
231
+ * key: `${accountId}-${chatId}`
232
+ * val: timestamp */
233
+ const pairingThrottleMap = new Map<string, number>();
234
+ // 暂时控制为 2 分钟内同一个用户只能触发一次配对请求
235
+ const needPairingThrottle = (accountId: string | undefined, chatId: string, throttleMs = 2 * 60 * 1000): boolean => {
236
+ const key = `${accountId}-${chatId}`;
237
+ const now = Date.now();
238
+ const lastTime = pairingThrottleMap.get(key) || 0;
239
+ if (now - lastTime < throttleMs) {
240
+ return true;
241
+ }
242
+ pairingThrottleMap.set(key, now);
243
+ return false;
244
+ }
245
+
246
+ async function sendSingleChatPairingMsg(account: any, payload: any, log: any, apiRuntime: any) {
247
+ const { accountId } = account;
248
+ const { chatId, chatType } = payload;
249
+ if (needPairingThrottle(accountId, chatId)) {
250
+ return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing throttled for sender=${chatId}`);
251
+ }
252
+ try {
253
+ log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: checking if pairing request exists for sender=${chatId}`);
254
+ const req = await apiRuntime.channel.pairing.upsertPairingRequest({ channel: CHANNEL_ID, accountId, id: chatId });
255
+ log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`);
256
+ // 只要没配对,会每次和机器人聊,都会返回配对信息。
257
+ const replyText = apiRuntime.channel.pairing.buildPairingReply({ channel: CHANNEL_ID, code: req.code });
258
+ await sendTextMsg(account, chatId, chatType, replyText, 'tuitui.pairing.reply');
259
+ } catch (err) {
260
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing failed for ${chatId}: ${String(err)}`);
261
+ }
262
+ }
263
+
219
264
  /**
220
265
  * 处理单聊(single_chat)分支,直接修改 payload。
221
266
  * 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
@@ -227,46 +272,32 @@ async function parseSingleChat(
227
272
  apiRuntime: any,
228
273
  log: any,
229
274
  ): Promise<boolean> {
230
- const { accountId } = account;
275
+ const { accountId, dmPolicy } = account;
276
+ const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
277
+ if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
278
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
279
+ return false;
280
+ }
281
+
231
282
  payload.chatType = CHAT_TYPE_DIRECT;
232
- payload.chatId = payload.tuituiAccount;
283
+ payload.chatId = chatId;
233
284
  payload.text = buildMessageBody(msgData);
234
- payload.msgId = msgData.msgid;
285
+ payload.msgId = msgData.msgid;
235
286
  log?.debug?.(
236
- `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat user_account=${payload.tuituiAccount} uid=${payload.tuituiUid} user_name=${payload.tuituiUserName}`,
287
+ `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat user_account=${chatId} uid=${payload.tuituiUid} user_name=${payload.tuituiUserName}`,
237
288
  );
238
289
 
239
- const msgType = msgData.msg_type;
240
- if ((msgType === 'image' || msgType === 'mixed') && msgData.images?.length) {
241
- payload.mediaUrls = msgData.images;
242
- } else if (msgType === 'voice' && msgData.voice) {
243
- payload.mediaUrls = [msgData.voice];
244
- } else if (msgType === 'file' && msgData.file?.url) {
245
- payload.mediaUrls = [msgData.file.url];
246
- }
247
-
248
- if (msgData.ref?.is_me && msgData.ref?.msgid) {
249
- payload.replyToId = msgData.ref.msgid;
250
- }
251
-
252
- if (!payload.tuituiAccount && !payload.tuituiUid) {
290
+ if (!chatId && !payload.tuituiUid) {
253
291
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
254
292
  return false;
255
293
  }
256
294
 
257
- const dmPolicy = String(account.dmPolicy ?? 'pairing').toLowerCase();
258
295
  const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
259
296
  const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
260
- const senderForPolicy = payload.tuituiAccount ? String(payload.tuituiAccount).toLowerCase().trim() : '';
261
297
  log?.debug?.(
262
- `[${CHANNEL_ID}] AccountId: ${accountId}, dmPolicy=${dmPolicy} userAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
298
+ `[${CHANNEL_ID}] AccountId: ${accountId}, dmPolicy=${dmPolicy} userAccount=${chatId} tuituiUid=${payload.tuituiUid} chatId=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
263
299
  );
264
300
 
265
- if (dmPolicy === 'disabled') {
266
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${senderForPolicy}`);
267
- return false;
268
- }
269
-
270
301
  if (dmPolicy === 'open') {
271
302
  return true;
272
303
  }
@@ -278,36 +309,16 @@ async function parseSingleChat(
278
309
  } catch {}
279
310
 
280
311
  const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
281
- const isAllowed = payload.tuituiAccount ? allowSet.has(senderForPolicy) : false;
312
+ const isAllowed = chatId ? allowSet.has(chatId) : false;
282
313
  if(isAllowed) return true;
283
314
 
284
315
  if (dmPolicy === 'pairing') {
285
- try {
286
- log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
287
- const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
288
- channel: CHANNEL_ID,
289
- accountId,
290
- id: senderForPolicy,
291
- });
292
- log?.debug?.(
293
- `[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`,
294
- );
295
- // 只要没配对,会每次和机器人聊,都会返回配对信息。
296
- const replyText =
297
- apiRuntime?.channel?.pairing?.buildPairingReply?.({
298
- channel: CHANNEL_ID,
299
- code: req.code,
300
- }) ?? '需要进行配对。请让机器人所有者进行批准。';
301
-
302
- await sendTextMsg(account, payload.tuituiAccount, payload.chatType, replyText, 'tuitui.pairing.reply');
303
- } catch (err) {
304
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing failed for ${senderForPolicy}: ${String(err)}`);
305
- }
316
+ sendSingleChatPairingMsg(account, payload, log, apiRuntime);
306
317
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
307
318
  return false;
308
319
  } else { // allowlist
309
320
  log?.warn?.(
310
- `[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
321
+ `[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
311
322
  );
312
323
  return false;
313
324
  }
@@ -326,7 +337,8 @@ async function parseGroupChat(
326
337
  ): Promise<boolean> {
327
338
  const { accountId } = account;
328
339
  payload.chatType = CHAT_TYPE_GROUP;
329
- payload.chatId = msgData.group_id;
340
+ const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
341
+ payload.chatId = chatId;
330
342
  payload.groupName = msgData.group_name;
331
343
  payload.msgId = msgData.msgid;
332
344
  log?.debug?.(
@@ -337,7 +349,7 @@ async function parseGroupChat(
337
349
  const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
338
350
  const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
339
351
  log?.debug?.(
340
- `[${CHANNEL_ID}] AccountId: ${accountId}, groupPolicy=${groupPolicy} groupId=${payload.chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
352
+ `[${CHANNEL_ID}] AccountId: ${accountId}, groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
341
353
  );
342
354
 
343
355
  if (groupPolicy === 'disabled') {
@@ -345,7 +357,7 @@ async function parseGroupChat(
345
357
  return false;
346
358
  }
347
359
 
348
- if (!payload.tuituiAccount || !payload.chatId) {
360
+ if (!payload.tuituiAccount || !chatId) {
349
361
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
350
362
  return false;
351
363
  }
@@ -357,13 +369,16 @@ async function parseGroupChat(
357
369
 
358
370
  payload.text = buildMessageBody(msgData);
359
371
 
360
- const whiteId = payload.chatId;
361
- if (!normalizedGroupAllowFrom.includes(String(whiteId))) {
372
+ if (!normalizedGroupAllowFrom.includes(String(chatId))) {
373
+ if (needPairingThrottle(accountId, chatId)) {
374
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
375
+ return false;
376
+ }
362
377
  await sendTextMsg(
363
378
  account,
364
- payload.chatId,
379
+ chatId,
365
380
  payload.chatType,
366
- `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${whiteId}`,
381
+ `当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
367
382
  'tuitui.groupPolicy.reply',
368
383
  );
369
384
  return false;
@@ -381,13 +396,15 @@ async function parseTeamsPost(
381
396
  ): Promise<boolean> {
382
397
  const { accountId } = account;
383
398
  payload.chatType = CHAT_TYPE_CHANNEL;
384
- const thread_id = (msgData.parent_id && msgData.parent_id != "0")?msgData.parent_id: msgData.post_id;
385
- payload.chatId = teamsBuildChatId(msgData.team_id, msgData.channel_id, thread_id);
386
- payload.msgId = msgData.post_id;
387
- payload.text = msgData.content;
399
+ const { team_id, channel_id, parent_id, post_id, content } = msgData;
400
+ const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
401
+ const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
402
+ payload.chatId = chatId;
403
+ payload.msgId = post_id;
404
+ payload.text = content;
388
405
  payload.replyToId = "";
389
406
  log?.debug?.(
390
- `[${CHANNEL_ID}] inbound teams tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} chatId=${payload.chatId}`,
407
+ `[${CHANNEL_ID}] inbound teams tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} chatId=${chatId}`,
391
408
  );
392
409
 
393
410
  // 暂时白名单先用这个,后面拆出来
@@ -395,7 +412,7 @@ async function parseTeamsPost(
395
412
  const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
396
413
  const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
397
414
  log?.debug?.(
398
- `[${CHANNEL_ID}] AccountId: ${accountId} groupPolicy=${groupPolicy} groupId=${payload.chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
415
+ `[${CHANNEL_ID}] AccountId: ${accountId} groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
399
416
  );
400
417
 
401
418
  if (groupPolicy === 'disabled') {
@@ -403,7 +420,7 @@ async function parseTeamsPost(
403
420
  return false;
404
421
  }
405
422
 
406
- if (!payload.tuituiAccount || !payload.chatId) {
423
+ if (!payload.tuituiAccount || !chatId) {
407
424
  log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
408
425
  return false;
409
426
  }
@@ -413,13 +430,16 @@ async function parseTeamsPost(
413
430
  return false;
414
431
  }
415
432
 
416
- const whiteId = msgData.team_id;
417
- if (!normalizedGroupAllowFrom.includes(String(whiteId))) {
433
+ if (!normalizedGroupAllowFrom.includes(String(team_id))) {
434
+ if (needPairingThrottle(accountId, chatId)) {
435
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
436
+ return false;
437
+ }
418
438
  await sendTextMsg(
419
439
  account,
420
- payload.chatId,
440
+ chatId,
421
441
  payload.chatType,
422
- `当前openclaw群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID:\n${whiteId}`,
442
+ `当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID:\n${team_id}`,
423
443
  'tuitui.groupPolicy.reply',
424
444
  );
425
445
  return false;
package/src/outbound.ts CHANGED
@@ -115,7 +115,7 @@ export async function postTuituiMsg(account: any, json: any, auditCtx: string):
115
115
  }
116
116
 
117
117
  export function checkAccount(account: any, ctxTips: string = 'send text') {
118
- if (!account.appId || !account.appSecret) {
118
+ if (!account || !account.appId || !account.appSecret) {
119
119
  throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for ${ctxTips}`);
120
120
  }
121
121
  }
@@ -395,6 +395,59 @@ function replaceSingleNewlines(content: string): string {
395
395
  return content.replace(/([^\n])\n([^\n])/g, '$1\n\n$2');
396
396
  }
397
397
 
398
+ /*
399
+ 从一个字符串中提取出来 @ 列表,匹配规则是
400
+ @之前的字符,只能是空格、回车、换行
401
+ @之后匹配到空格为止或者文本末尾
402
+
403
+ 例子:
404
+ 输入:你好 @张三 @李四 你吃了吗
405
+ 输出数组:["张三","李四"]
406
+ */
407
+
408
+ function getMentionsRegex(): RegExp {
409
+ // 正则表达式解释:
410
+ // (?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])
411
+ // - 正向向后查找,确保@前面是:
412
+ // ^ - 字符串开头
413
+ // [\s\r\n] - 空格、回车、换行
414
+ // \u3000 - 中文全角空格
415
+ // \u3001 - 中文顿号(、)
416
+ // \u3002 - 中文句号(。)
417
+ // \uFF0C - 中文逗号(,)
418
+ // \uFF01 - 中文感叹号(!)
419
+ // \uFF1F - 中文问号(?)
420
+ // \u2026 - 中文省略号(…)
421
+ // @ - 匹配@符号
422
+ // ([^\s]+) - 捕获组,匹配一个或多个非空白字符
423
+ const regex = /(?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])@([^\s]+)/g;
424
+ return regex
425
+ }
426
+
427
+ function extractMentions(text: string): string[] {
428
+ const mentions: string[] = [];
429
+
430
+ const regex = getMentionsRegex();
431
+ let match;
432
+ while ((match = regex.exec(text)) !== null) {
433
+ const mention = match[1];
434
+ if (!mentions.includes(mention)) {
435
+ mentions.push(mention);
436
+ }
437
+ }
438
+
439
+ return mentions;
440
+ }
441
+
442
+ function replaceMentions(text: string): string {
443
+ const regex = getMentionsRegex();
444
+ return text.replace(regex, (match, mention) => {
445
+ // match: 完整的匹配字符串(例如 "@username")
446
+ // mention: 捕获组中的内容(例如 "username")
447
+ return `{{tuitui_at "${mention}"}}`;
448
+ });
449
+ }
450
+
398
451
  export async function sendTextMsg(
399
452
  account: any,
400
453
  chatId: string | undefined,
@@ -404,22 +457,30 @@ export async function sendTextMsg(
404
457
  atList?: string[],
405
458
  ): Promise<void> {
406
459
  if (!chatId) return console.error(`[${CHANNEL_ID}] sendTextMsg Error ${auditCtx}: Missing "target"`);
407
- // Format message according to TuiTui's required structure
408
- const realAtList = chatType == CHAT_TYPE_GROUP? atList : [];
460
+
461
+ // 澄清:不再使用 atList 参数;统一从文本中提取 at 目标;
462
+ // 因为 atList 参数上层固定写死的填写为 发消息人,如果是机器人这样可能导致机器人之间at产生死循环。
409
463
  if (chatType == CHAT_TYPE_CHANNEL) {
464
+ const content_with_newline = replaceSingleNewlines(content);
465
+ const content_with_mention = replaceMentions(content_with_newline);
466
+ const has_at = (content_with_mention != content_with_newline);
410
467
  const msg: TuiTuiOutboundTeamsMarkdownMessage = {
411
468
  ...getTargets(chatId, chatType),
412
469
  msgtype: 'richtext/markdown',
413
- at: realAtList || [],
414
- richtext: { markdown: replaceSingleNewlines(content) },
470
+ richtext: {
471
+ markdown: content_with_mention,
472
+ delims_left: has_at?"{{":"",
473
+ delims_right: has_at?"}}":"",
474
+ },
415
475
  };
416
476
  console.log(`[${CHANNEL_ID}] sendTeamsPost to ${chatId} ${auditCtx} - `, msg);
417
477
  await postTuituiMsg(account, msg, auditCtx);
418
478
  } else {
479
+ const at_from_text = chatType == CHAT_TYPE_GROUP?extractMentions(content):[];
419
480
  const msg: TuiTuiOutboundTextMessage = {
420
481
  ...getTargets(chatId, chatType),
421
482
  msgtype: 'text',
422
- at: realAtList || [],
483
+ at: at_from_text,
423
484
  text: { content },
424
485
  };
425
486
  console.log(`[${CHANNEL_ID}] sendTextMsg to ${chatId} ${auditCtx} - `, msg);
package/src/command.ts DELETED
@@ -1,29 +0,0 @@
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
- }