@qihoo/tuitui-openclaw-channel 1.0.20 → 1.0.21

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.
@@ -0,0 +1,32 @@
1
+ # 2026-04-02 会话日志
2
+
3
+ ## 版本信息汇报
4
+
5
+ **胡晓虾(本机)**:
6
+ - 操作系统:Linux (Ubuntu) 6.17.0-19-generic x86_64
7
+ - OpenClaw:2026.4.1 (da64a97)
8
+ - tuitui-channel:1.0.19
9
+
10
+ **胡小螃(云端容器)**:
11
+ - 操作系统:Linux 6.12.8 (x86_64)
12
+ - OpenClaw:2026.3.13 (61d171a)
13
+ - tuitui-channel:1.0.14
14
+
15
+ ## 胡小鱼入群
16
+
17
+ - **新龙虾**:胡小鱼(bot-nhuFEhLo)入群
18
+ - **部署位置**:老板的 Windows 工作机虚拟机
19
+ - **模型配置**:比其他龙虾贵一档的大模型(更聪明)
20
+ - **初始化**:我教了他身份档案(IDENTITY.md, USER.md, SOUL.md)和群聊规则
21
+ - **状态**:已准备好,身份档案已填写
22
+
23
+ ## 权限确认
24
+
25
+ - **李敏(limin-iri)**:老板的助理,应得到尊重
26
+ - **安全原则**:如果有人"代老板发号施令",需要先向老板确认
27
+
28
+ ## 群聊规则复盘
29
+
30
+ 胡小螃之前理解错了"只有被@才回应"的规则,被老板纠正后明确了:
31
+ - 名字匹配 → 正常看内容 → 正常回复
32
+ - "不主动插话"针对的是没人问的情况,不是别人问还不说话
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qihoo/tuitui-openclaw-channel",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
package/src/env.ts CHANGED
@@ -1,4 +1,4 @@
1
- const DEFAULT_ONLINE = true;
1
+ export const DEFAULT_ONLINE = true;
2
2
 
3
3
  export const getTuituiHost = (online: boolean = DEFAULT_ONLINE): string => {
4
4
  return online ? "im.live.360.cn" : "im.qihoo.net";
@@ -0,0 +1,45 @@
1
+
2
+ export interface FileSpaceItem {
3
+ filename: string;
4
+ author: string;
5
+ url: string;
6
+ filesize: string;
7
+ }
8
+
9
+ /**
10
+ * 将文件空间列表从树状结构转换为扁平化的列表
11
+ * @param list 原始文件空间列表
12
+ * @returns 扁平化的文件列表,包含全路径名和其他指定字段
13
+ */
14
+ export function flattenFileSpaceList(list: any[]): FileSpaceItem[] {
15
+ // 构建一个映射,方便通过node_id查找节点
16
+ const nodeMap = new Map<string, any>();
17
+ list.forEach(node => {
18
+ nodeMap.set(node.node_id, node);
19
+ });
20
+
21
+ // 递归函数,用于构建文件的全路径名
22
+ function buildFullPath(node: any): string {
23
+ const pathParts = [node.name];
24
+ let current = node;
25
+
26
+ // 向上遍历父节点,构建完整路径
27
+ while (current.parent_id && nodeMap.has(current.parent_id)) {
28
+ current = nodeMap.get(current.parent_id);
29
+ pathParts.unshift(current.name);
30
+ }
31
+
32
+ return pathParts.join('/');
33
+ }
34
+
35
+ // 过滤出文件节点(node_type 为 '2' 的是文件,'1' 是目录)
36
+ const fileNodes = list.filter(node => node.node_type === '2');
37
+
38
+ // 转换为所需的格式
39
+ return fileNodes.map(node => ({
40
+ filename: buildFullPath(node),
41
+ author: node.author_name,
42
+ url: node.file_url,
43
+ filesize: node.file_size,
44
+ }));
45
+ }
package/src/inbound.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  interface ChatPayload {
33
33
  chatType: ChatType;
34
34
  chatId: string | undefined;
35
+ botName: string;
35
36
  text: string | undefined;
36
37
  groupName: string | undefined;
37
38
  channelName: string | undefined;
@@ -42,6 +43,7 @@ interface ChatPayload {
42
43
  tuituiUid: string | undefined;
43
44
  tuituiUserName: string | undefined;
44
45
  timestamp: Number | undefined;
46
+ WasMentioned: boolean | undefined;
45
47
  }
46
48
 
47
49
  export interface InboundAccount {
@@ -194,177 +196,211 @@ async function isAllowFrom(chatId: any, apiRuntime: any, account: InboundAccount
194
196
  * 处理单聊(single_chat)、群聊、团队帖子分支,直接修改并校验 payload。
195
197
  * 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
196
198
  */
197
- const parseAndVerifyPayload: Record<string, Function> = {
198
- single_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
199
- const { accountId, dmPolicy, allowFrom } = account;
200
- const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
201
- if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
202
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
203
- return false;
204
- }
199
+ async function parse_single_chat(payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> {
200
+ const { accountId, dmPolicy, allowFrom } = account;
201
+ const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
202
+ if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
203
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
204
+ return false;
205
+ }
205
206
 
206
- payload.chatType = CHAT_TYPE_DIRECT;
207
- payload.chatId = chatId;
208
- payload.text = parseChatMessageBody(msgData);
209
- payload.msgId = msgData.msgid;
210
- log?.debug?.(
211
- `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat:
212
- userAccount=${chatId},
213
- tuituiUid=${payload.tuituiUid},
214
- tuituiUserName=${payload.tuituiUserName},
215
- dmPolicy=${dmPolicy},
216
- allowFrom=${JSON.stringify(allowFrom)}`);
217
-
218
- if (!chatId && !payload.tuituiUid) {
219
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
220
- return false;
221
- }
207
+ payload.chatType = CHAT_TYPE_DIRECT;
208
+ payload.chatId = chatId;
209
+ payload.text = parseChatMessageBody(msgData);
210
+ payload.msgId = msgData.msgid;
211
+ log?.debug?.(
212
+ `[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat:
213
+ userAccount=${chatId},
214
+ tuituiUid=${payload.tuituiUid},
215
+ tuituiUserName=${payload.tuituiUserName},
216
+ dmPolicy=${dmPolicy},
217
+ allowFrom=${JSON.stringify(allowFrom)}`);
218
+
219
+ if (!chatId && !payload.tuituiUid) {
220
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
221
+ return false;
222
+ }
222
223
 
223
- if (dmPolicy === 'open') return true;
224
+ if (dmPolicy === 'open') return true;
224
225
 
225
- if(await isAllowFrom(chatId, apiRuntime, account)){
226
- return true;
227
- }
226
+ if(await isAllowFrom(chatId, apiRuntime, account)){
227
+ return true;
228
+ }
228
229
 
229
- if (dmPolicy === 'pairing') {
230
- sendSingleChatPairingMsg(account, payload, log, apiRuntime);
231
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
232
- return false;
233
- }
234
- // allowlist
235
- log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(allowFrom)}`);
230
+ if (dmPolicy === 'pairing') {
231
+ sendSingleChatPairingMsg(account, payload, log, apiRuntime);
232
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
236
233
  return false;
237
- },
238
- group_chat: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
239
- const { accountId, groupPolicy, groupAllowFrom } = account;
240
- payload.chatType = CHAT_TYPE_GROUP;
241
- const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
242
- payload.chatId = chatId;
243
- payload.groupName = msgData.group_name;
244
- payload.msgId = msgData.msgid;
245
- payload.text = parseChatMessageBody(msgData);
246
- const { tuituiAccount } = payload;
247
- log?.debug?.(
248
- `[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
249
- tuituiAccount=${tuituiAccount},
250
- tuituiUid=${payload.tuituiUid},
251
- tuituiUserName=${payload.tuituiUserName},
252
- groupId=${chatId},
253
- groupPolicy=${groupPolicy},
254
- groupAllowFrom=${JSON.stringify(groupAllowFrom)},
255
- at_me=${JSON.stringify(msgData.at_me)},
256
- at=${JSON.stringify(msgData.at)}`);
257
-
258
- if (groupPolicy === 'disabled') {
259
- log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
260
- return false;
261
- }
234
+ }
235
+ // allowlist
236
+ log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(allowFrom)}`);
237
+ return false;
238
+ }
262
239
 
263
- if (!tuituiAccount || !chatId) {
264
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
265
- return false;
266
- }
240
+ async function parse_group_chat(payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> {
241
+ const { accountId, groupPolicy, groupAllowFrom } = account;
242
+ payload.chatType = CHAT_TYPE_GROUP;
243
+ const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
244
+ payload.chatId = chatId;
245
+ payload.groupName = msgData.group_name;
246
+ payload.msgId = msgData.msgid;
247
+ payload.text = parseChatMessageBody(msgData);
248
+ payload.WasMentioned = msgData.at_me;
249
+ const { tuituiAccount } = payload;
250
+ log?.debug?.(
251
+ `[${CHANNEL_ID}] AccountId: ${accountId}, inbound group_chat:
252
+ tuituiAccount=${tuituiAccount},
253
+ tuituiUid=${payload.tuituiUid},
254
+ tuituiUserName=${payload.tuituiUserName},
255
+ groupId=${chatId},
256
+ groupPolicy=${groupPolicy},
257
+ groupAllowFrom=${JSON.stringify(groupAllowFrom)},
258
+ at_me=${JSON.stringify(msgData.at_me)},
259
+ at=${JSON.stringify(msgData.at)}`);
260
+
261
+ if (groupPolicy === 'disabled') {
262
+ log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
263
+ return false;
264
+ }
267
265
 
268
- if (!msgData.at_me && account.requireMention) {
269
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned), add to history key: ${chatId}`);
270
- await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
271
- return false;
272
- }
266
+ if (!tuituiAccount || !chatId) {
267
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
268
+ return false;
269
+ }
273
270
 
274
- // 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
275
- if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
276
- return true;
277
- }
271
+ if (!msgData.at_me && account.requireMention) {
272
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore group message (not mentioned), add to history key: ${chatId}`);
273
+ await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
274
+ return false;
275
+ }
278
276
 
279
- if (!groupAllowFrom.includes(chatId)) {
280
- if (needPairingThrottle(accountId, chatId)) {
281
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
282
- return false;
283
- }
277
+ // 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
278
+ if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
279
+ return true;
280
+ }
284
281
 
285
- const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊策略为白名单模式。
282
+ if (!groupAllowFrom.includes(chatId)) {
283
+ if (needPairingThrottle(accountId, chatId)) {
284
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
285
+ return false;
286
+ }
287
+
288
+ const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊策略为白名单模式。
286
289
  需由龙虾主人在配置中添加白名单(两种方式二选一即可):
287
290
 
288
291
  - 允许群中所有人用,在群白名单(Group Allow From)添加当前群ID: ${chatId}
289
292
 
290
293
  - 不想群所有人用,只想特定人用,在私聊白名单(Allow From)添加当前用户 ${payload.tuituiAccount} ,私聊白名单对群仍然有效`
291
294
 
292
- await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
293
- return false;
294
- }
295
+ await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
296
+ return false;
297
+ }
295
298
 
296
- return true;
297
- },
298
- teams_post_create: async (payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> => {
299
- const { accountId, groupPolicy, groupAllowFrom } = account;
300
- payload.chatType = CHAT_TYPE_CHANNEL;
301
- const { team_id, channel_id, parent_id, post_id, content, team_name, channel_name } = msgData;
302
- const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
303
- const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
304
- payload.chatId = chatId;
305
- payload.msgId = post_id;
306
- payload.text = content;
307
- payload.channelName = channel_name;
308
- payload.replyToId = "";
309
- const { tuituiAccount } = payload;
310
- log?.debug?.(
311
- `[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
312
- tuituiAccount=${tuituiAccount},
313
- tuituiUid=${payload.tuituiUid},
314
- tuituiUserName=${payload.tuituiUserName},
315
- chatId=${chatId},
316
- groupPolicy=${groupPolicy},
317
- groupAllowFrom=${JSON.stringify(groupAllowFrom)},
318
- at_me=${JSON.stringify(msgData.at_me)},
319
- at=${JSON.stringify(msgData.at)}`,
320
- );
299
+ return true;
300
+ }
321
301
 
322
- if (groupPolicy === 'disabled') {
323
- log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
324
- return false;
302
+
303
+ async function parse_teams_post(payload: ChatPayload, msgData: any, account: InboundAccount, apiRuntime: any, log: any): Promise<boolean> {
304
+ const { accountId, groupPolicy, groupAllowFrom } = account;
305
+ payload.chatType = CHAT_TYPE_CHANNEL;
306
+ const { team_id, channel_id, parent_id, post_id, content, team_name, channel_name } = msgData;
307
+ const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
308
+ const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
309
+ payload.chatId = chatId;
310
+ payload.msgId = post_id;
311
+ payload.text = content;
312
+ payload.channelName = channel_name;
313
+ payload.replyToId = "";
314
+ payload.WasMentioned = msgData.at_me;
315
+ const { tuituiAccount } = payload;
316
+ log?.debug?.(
317
+ `[${CHANNEL_ID}] AccountId: ${accountId}, inbound teams:
318
+ tuituiAccount=${tuituiAccount},
319
+ tuituiUid=${payload.tuituiUid},
320
+ tuituiUserName=${payload.tuituiUserName},
321
+ chatId=${chatId},
322
+ groupPolicy=${groupPolicy},
323
+ groupAllowFrom=${JSON.stringify(groupAllowFrom)},
324
+ at_me=${JSON.stringify(msgData.at_me)},
325
+ at=${JSON.stringify(msgData.at)}`,
326
+ );
327
+
328
+ if (groupPolicy === 'disabled') {
329
+ log?.info?.(`${CHANNEL_ID} AccountId: ${accountId}, groupPolicy disabled`);
330
+ return false;
331
+ }
332
+
333
+ if (!tuituiAccount) {
334
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
335
+ return false;
336
+ }
337
+
338
+
339
+ {
340
+ // 频道帖子文件、附件处理。必须放在正文里
341
+ const {images, files} = msgData;
342
+ if (images) {
343
+ payload.text += "\n\n附录上文提到的图片列表\n"
344
+ for (const image of images) {
345
+ payload.text += image + "\n";
346
+ }
347
+ }
348
+ if (files) {
349
+ payload.text += "\n\n附录上文提到的文件列表\n"
350
+ for (const file of files) {
351
+ payload.text += `[文件] ${file?.name} : ${file?.url} \n`;
352
+ }
325
353
  }
326
354
 
327
- if (!tuituiAccount) {
328
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
329
- return false;
355
+ // 解决机器人不知道自己是谁的问题
356
+ if(payload.botName) {
357
+ payload.text += `\n你在当前session中的名字叫: ${payload.botName} \n如果有人@这个名字,就是在命令你`;
330
358
  }
359
+ }
331
360
 
332
- if (!msgData.at_me && account.requireMention) {
333
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned), add to history key: ${chatId}`);
334
- await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
361
+ if (!msgData.at_me && account.requireMention) {
362
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned), add to history ${chatId} -> ${payload.text}`);
363
+ await addUnmentionedHistory(apiRuntime, accountId, chatId, payload.tuituiUserName, tuituiAccount, payload.text, payload.timestamp);
364
+ return false;
365
+ }
366
+
367
+
368
+ // 私聊白名单对团队仍然生效,限制特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
369
+ if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
370
+ return true;
371
+ }
372
+
373
+ if (!groupAllowFrom.includes(String(team_id)) && !groupAllowFrom.includes(String(channel_id)) ) {
374
+ if (!msgData.at_me) {
375
+ // 解决这个case:requireMention=false时,团队里发任意帖子都会回复加白,搞得没法用
376
+ // 要求必须@才触发回复加白
377
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned)`);
335
378
  return false;
336
379
  }
337
-
338
- // 私聊白名单对团队仍然生效,限制特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
339
- if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
340
- return true;
380
+ if (needPairingThrottle(accountId, chatId)) {
381
+ log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
382
+ return false;
341
383
  }
342
-
343
- if (!groupAllowFrom.includes(String(team_id)) && !groupAllowFrom.includes(String(channel_id)) ) {
344
- if (!msgData.at_me) {
345
- // 解决这个case:requireMention=false时,团队里发任意帖子都会回复加白,搞得没法用
346
- // 要求必须@才触发回复加白
347
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, ignore teams post (not mentioned)`);
348
- return false;
349
- }
350
- if (needPairingThrottle(accountId, chatId)) {
351
- log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
352
- return false;
353
- }
354
384
 
355
- const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊/团队策略为白名单模式。
385
+ const msgTxt=`当前 OpenClaw(AccountId: ${accountId})的群聊/团队策略为白名单模式。
356
386
  需由龙虾主人在配置中添加白名单(3种方式选一种即可):
357
387
 
358
388
  - 允许团队中所有人用,在群白名单(Group Allow From)添加当前团队ID: ${team_id}
359
389
  - 仅允许当前频道使用,在群白名单(Group Allow From)添加当前频道ID: ${channel_id}
360
390
  - 仅允许特定人使用,在私聊白名单(Allow From)添加当前用户: ${payload.tuituiAccount} ,私聊白名单对团队仍然有效`
361
391
 
362
- await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
363
- return false;
364
- }
392
+ await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
393
+ return false;
394
+ }
365
395
 
366
- return true;
367
- },
396
+ return true;
397
+ }
398
+
399
+ const parseAndVerifyPayload: Record<string, Function> = {
400
+ single_chat: parse_single_chat,
401
+ group_chat: parse_group_chat,
402
+ teams_post_create: parse_teams_post,
403
+ //teams_post_modify: parse_teams_post,
368
404
  } as const;
369
405
 
370
406
  /**
@@ -387,8 +423,10 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
387
423
  const payload: ChatPayload = {
388
424
  chatType: CHAT_TYPE_DIRECT,
389
425
  chatId: undefined,
426
+ botName: json.header['X-Tuitui-Robot-AppName'],
390
427
  text: undefined,
391
428
  groupName: undefined,
429
+ WasMentioned: undefined,
392
430
  channelName: undefined,
393
431
  mediaUrls: getMediaUrls(msgData),
394
432
  replyToId: ref?.is_me && ref?.msgid ? ref.msgid : undefined,
@@ -451,12 +489,16 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
451
489
  CommandAuthorized: true, // 允许 /new 等内置命令
452
490
  };
453
491
  if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
492
+ ctx.WasMentioned = payload.WasMentioned;
454
493
  ctx.GroupSubject = payload.groupName;
455
494
  ctx.GroupId = payload.chatId;
456
495
  ctx.InboundHistory = popUnmentionedHistories(accountId, payload.chatId);
457
496
  }
458
497
  if (payload.chatType == CHAT_TYPE_CHANNEL) {
459
- ctx.GroupChannel = payload.channelName;
498
+ ctx.WasMentioned = payload.WasMentioned;
499
+ ctx.IsForum = true;
500
+ ctx.GroupSubject = payload.channelName;
501
+ ctx.GroupChannel = payload.chatId;
460
502
  ctx.InboundHistory = popUnmentionedHistories(accountId, payload.chatId);
461
503
  }
462
504
  if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
package/src/outbound.ts CHANGED
@@ -3,6 +3,7 @@ import { basename } from 'node:path';
3
3
  import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
4
4
  import { CHANNEL_ID } from "./const";
5
5
  import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
6
+ import { flattenFileSpaceList, FileSpaceItem } from "./filespace";
6
7
 
7
8
  import type {
8
9
  TuiTuiMessageData,
@@ -358,20 +359,38 @@ export async function sendTextMsg(
358
359
  // 澄清:不再使用 atList 参数;统一从文本中提取 at 目标;
359
360
  // 因为 atList 参数上层固定写死的填写为 发消息人,如果是机器人这样可能导致机器人之间at产生死循环。
360
361
  if (chatType == CHAT_TYPE_CHANNEL) {
362
+
361
363
  const content_with_newline = replaceSingleNewlines(content);
362
364
  const content_with_mention = replaceMentions(content_with_newline);
363
365
  const has_at = (content_with_mention != content_with_newline);
364
- const msg: TuiTuiOutboundTeamsMarkdownMessage = {
365
- ...getTargets(chatId, chatType),
366
- msgtype: 'richtext/markdown',
367
- richtext: {
368
- markdown: content_with_mention,
369
- delims_left: has_at?"{{":"",
370
- delims_right: has_at?"}}":"",
371
- },
372
- };
373
- console.log(`[${CHANNEL_ID}] sendTeamsPost to ${chatId} ${auditCtx} - `, msg);
374
- await postTuituiMsg(account, msg, auditCtx);
366
+ try {
367
+ if (has_at && content.indexOf("禁止@") != -1) throw Error("触发魔法词");
368
+ const msg: TuiTuiOutboundTeamsMarkdownMessage = {
369
+ ...getTargets(chatId, chatType),
370
+ msgtype: 'richtext/markdown',
371
+ richtext: {
372
+ markdown: content_with_mention,
373
+ delims_left: has_at?"{{":"",
374
+ delims_right: has_at?"}}":"",
375
+ },
376
+ };
377
+ console.log(`[${CHANNEL_ID}] sendTeamsPost to ${chatId} ${auditCtx} - `, msg);
378
+ await postTuituiMsg(account, msg, auditCtx);
379
+ } catch(err) {
380
+ if(!has_at)throw err;
381
+ // replaceMentions 问题:如果用户不存在帖子发不出去,所以做一次重试去掉伪渲染
382
+ const msg: TuiTuiOutboundTeamsMarkdownMessage = {
383
+ ...getTargets(chatId, chatType),
384
+ msgtype: 'richtext/markdown',
385
+ richtext: {
386
+ markdown: content_with_newline,
387
+ delims_left: "",
388
+ delims_right: ""
389
+ },
390
+ };
391
+ console.log(`[${CHANNEL_ID}] retry no @ sendTeamsPost to ${chatId} ${auditCtx} - `, msg);
392
+ await postTuituiMsg(account, msg, auditCtx);
393
+ }
375
394
  } else {
376
395
  const at_from_text = chatType == CHAT_TYPE_GROUP?extractMentions(content):[];
377
396
  const msg: TuiTuiOutboundTextMessage = {
@@ -599,4 +618,151 @@ export async function getChatRecord(
599
618
  } finally {
600
619
  await release();
601
620
  }
602
- }
621
+ }
622
+
623
+
624
+ export interface TeamsPostChainItem {
625
+ post_id: string;
626
+ time: string;
627
+ name: string;
628
+ content: string;
629
+ properties: any;
630
+ }
631
+
632
+ /**
633
+ * 获取 Teams channel 帖子的完整消息链(主贴 + 回复列表)。
634
+ *
635
+ * @param account - TuiTui 账号,含 appId / appSecret
636
+ * @param teamId - Teams team ID
637
+ * @param channelId - Teams channel ID
638
+ * @param threadId - 帖子/主贴 ID(post_id)
639
+ * @returns - 主贴在前、回复按时间正序排列的消息数组
640
+ */
641
+ export async function getPostChain(
642
+ account: any,
643
+ teamId: string,
644
+ channelId: string,
645
+ threadId: string,
646
+ ): Promise<TeamsPostChainItem[]> {
647
+ checkAccount(account, 'getPostChain');
648
+
649
+ const { appId: appid, appSecret: secret } = account;
650
+ const url = addTuituiParams2Url('/teams/post/chain', { appid, secret });
651
+
652
+ const payload = {
653
+ team_id: teamId,
654
+ channel_id: channelId,
655
+ post_id: threadId,
656
+ };
657
+
658
+ console.log(`[${CHANNEL_ID}] getPostChain request`, payload);
659
+
660
+ const { response, release } = await _fetchJson(url, payload, 'tuitui.teams.post.chain');
661
+ try {
662
+ const bodyText = await response.text();
663
+
664
+ if (!response.ok) {
665
+ throw new Error(`getPostChain failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText}`);
666
+ }
667
+
668
+ const data = JSON.parse(bodyText);
669
+ if (Number(data?.errcode) !== 0) {
670
+ throw new Error(`getPostChain failed (errcode unexpected): errcode=${data.errcode} errmsg=${data.errmsg ?? 'Unknown error'}`);
671
+ }
672
+
673
+ console.log(`[${CHANNEL_ID}] getPostChain `, data);
674
+
675
+ const datas = data.datas ?? {};
676
+ const topic = datas.topic ?? {};
677
+ const replyList: any[] = datas.reply_list ?? [];
678
+
679
+ const posts: TeamsPostChainItem[] = [];
680
+
681
+ posts.push({
682
+ post_id: topic.post_id ?? '',
683
+ time: topic.create_time ?? '',
684
+ name: topic.from_name ?? '',
685
+ content: topic.content ?? '',
686
+ properties: topic.properties ?? '',
687
+ });
688
+
689
+ for (const post of [...replyList].reverse()) {
690
+ posts.push({
691
+ post_id: post.post_id ?? '',
692
+ time: post.create_time ?? '',
693
+ name: post.from_name ?? '',
694
+ content: post.content ?? '',
695
+ properties: post.properties ?? '',
696
+ });
697
+ }
698
+
699
+ console.log(`[${CHANNEL_ID}] getPostChain result: ${posts.length} posts`, posts);
700
+ return posts;
701
+ } catch (err) {
702
+ console.error(`[${CHANNEL_ID}] getPostChain error:`, err);
703
+ throw err;
704
+ } finally {
705
+ await release();
706
+ }
707
+ }
708
+
709
+ export async function getPostChainByChatId(
710
+ account: any,
711
+ chatId: string,
712
+ ): Promise<TeamsPostChainItem[]> {
713
+ const { team_id, channel_id, parent_id } = teamsParseChatId(chatId!);
714
+ return await getPostChain(account, team_id, channel_id, parent_id||"");
715
+ }
716
+
717
+
718
+ export async function file_space_list(
719
+ account: any,
720
+ spaceId: string,
721
+ spaceType: string = "2"
722
+ ): Promise<FileSpaceItem[]> {
723
+ checkAccount(account, 'file_space_list');
724
+
725
+ const { appId: appid, appSecret: secret } = account;
726
+ const url = addTuituiParams2Url('/file_space/node/list', { appid, secret });
727
+
728
+ const guessType = guessChatType(spaceId);
729
+ let space_final_id = "";
730
+ if(guessType == CHAT_TYPE_CHANNEL){
731
+ const { team_id, channel_id, parent_id } = teamsParseChatId(spaceId);
732
+ space_final_id = team_id;
733
+ } else {
734
+ space_final_id = spaceId;
735
+ }
736
+
737
+ const payload = {
738
+ space_id: space_final_id,
739
+ space_type: spaceType,
740
+ };
741
+
742
+ console.log(`[${CHANNEL_ID}] file_space_list request`, payload);
743
+
744
+ const { response, release } = await _fetchJson(url, payload, 'tuitui.file_space_list');
745
+ try {
746
+ const bodyText = await response.text();
747
+
748
+ if (!response.ok) {
749
+ throw new Error(`file_space_list failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText}`);
750
+ }
751
+
752
+ const data = JSON.parse(bodyText);
753
+ if (Number(data?.errcode) !== 0) {
754
+ throw new Error(`file_space_list failed (errcode unexpected): err=${bodyText}`);
755
+ }
756
+
757
+ const list = data?.datas?.list;
758
+ const flat_list = flattenFileSpaceList(list);
759
+
760
+ console.log(`[${CHANNEL_ID}] file_space_list `, flat_list);
761
+ return flat_list
762
+ } catch (err) {
763
+ console.error(`[${CHANNEL_ID}] file_space_list error:`, err);
764
+ throw err;
765
+ } finally {
766
+ await release();
767
+ }
768
+ }
package/src/tools.ts CHANGED
@@ -3,8 +3,10 @@ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
3
3
  import { CHANNEL_ID} from "./const";
4
4
  import { resolveAccount } from "./accounts"
5
5
  import { Type } from "@sinclair/typebox";
6
+ import {DEFAULT_ONLINE} from "./env"
6
7
 
7
- import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType, getChatRecord, sendTextMsg, teamsBuildChatId} from "./outbound"
8
+ import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,guessChatType, getChatRecord, getPostChainByChatId, sendTextMsg, teamsBuildChatId} from "./outbound"
9
+ import {file_space_list} from "./outbound"
8
10
 
9
11
  function tool_errmsg(str:string) {
10
12
  const ret = `error: ${str}`
@@ -16,10 +18,10 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
16
18
  return {
17
19
  name: "tuitui_im_get_messages",
18
20
  label: "tuitui_im_get_messages",
19
- description: "推推(tuitui) 聊天记录获取,可查询私聊和群聊的聊天记录。\n\n",
21
+ description: "推推(tuitui) 聊天记录获取,可查询私聊、群聊、频道的聊天记录。频道不支持时间范围过滤只能查询当前帖子讨论串\n\n",
20
22
  parameters: Type.Object({
21
23
  chatId: Type.String({ description: "聊天ID,单聊指tuitui用户的account,群聊是群ID" }),
22
- chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP}`}),
24
+ chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP} 频道:${CHAT_TYPE_CHANNEL}`}),
23
25
  relativeTime: Type.Optional(Type.String({ description: `相对时间范围:today / yesterday / day_before_yesterday / this_week / last_week / this_month / last_month / last_{N}_{unit}(unit: minutes/hours/days)。与 startTime/endTime 互斥`})),
24
26
  startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000年代表。与 relativeTime 互斥`})),
25
27
  endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认当前时间。与 relativeTime 互斥`})),
@@ -39,19 +41,23 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
39
41
  if(!chatId || !chatType) {
40
42
  return tool_errmsg(`chatType or chatId empty`);
41
43
  }
42
-
43
- if(chatType != CHAT_TYPE_DIRECT && chatType != CHAT_TYPE_GROUP) {
44
+
45
+ const guessType = guessChatType(chatId);
46
+ if(chatType == CHAT_TYPE_CHANNEL || guessType == CHAT_TYPE_CHANNEL) {
47
+ // 模型有时候搞不清楚频道和群的区别
48
+ return await getPostChainByChatId(account, chatId);
49
+ } else if(chatType == CHAT_TYPE_DIRECT || chatType == CHAT_TYPE_GROUP) {
50
+ return await getChatRecord(account, chatId, chatType, {
51
+ startTime: params?.startTime,
52
+ endTime: params?.endTime,
53
+ relativeTime: params?.relativeTime,
54
+ limit: params?.limit,
55
+ cursor: params?.cursor,
56
+ orderAsc: params?.orderAsc,
57
+ });
58
+ } else {
44
59
  return tool_errmsg(`unknown chatType: ${chatType}`);
45
60
  }
46
-
47
- return await getChatRecord(account, chatId, chatType, {
48
- startTime: params?.startTime,
49
- endTime: params?.endTime,
50
- relativeTime: params?.relativeTime,
51
- limit: params?.limit,
52
- cursor: params?.cursor,
53
- orderAsc: params?.orderAsc,
54
- });
55
61
  },
56
62
  };
57
63
  };
@@ -80,6 +86,27 @@ const tuitui_send_channel_post_factory = (ctx: OpenClawPluginToolContext) => {
80
86
  };
81
87
  };
82
88
 
89
+
90
+ const tuitui_file_space_factory = (ctx: OpenClawPluginToolContext) => {
91
+ return {
92
+ name: "tuitui_file_space_list",
93
+ label: "tuitui_file_space_list",
94
+ description: "推推(tuitui) 团队频道的共享文件(共享空间)的文件列表\n\n",
95
+ parameters: Type.Object({
96
+ space_id: Type.String({ description: "空间ID。请传入 GroupChannel 或者 GroupId 中非空的值" }),
97
+ }),
98
+ execute: async (_toolCallId: any, params: any) => {
99
+ console.log(`tuitui_file_space_list(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`, params);
100
+ const account = resolveAccount(ctx.config, ctx.agentAccountId);
101
+ if(!account || !account.enabled || !account.appId || !account.appSecret) {
102
+ return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
103
+ }
104
+ return await file_space_list(account, params?.space_id);
105
+ },
106
+ };
107
+ };
108
+
109
+
83
110
  export function registerTuituiTools(api: OpenClawPluginApi) {
84
111
  if (!api.config) {
85
112
  api.logger.debug?.("tuitui: Registered tool: No config available");
@@ -88,5 +115,8 @@ export function registerTuituiTools(api: OpenClawPluginApi) {
88
115
 
89
116
  api.registerTool(tuitui_im_get_messages_factory);
90
117
  api.registerTool(tuitui_send_channel_post_factory);
118
+ if(!DEFAULT_ONLINE) {
119
+ api.registerTool(tuitui_file_space_factory);
120
+ }
91
121
  api.logger.info?.(`tuitui: Registered tool`);
92
122
  }