@qihoo/tuitui-openclaw-channel 1.0.20 → 1.0.22

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.22",
4
4
  "maintainers": [
5
5
  {
6
6
  "name": "huzunjie",
package/src/channel.ts CHANGED
@@ -8,7 +8,6 @@ import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from
8
8
  import { CHANNEL_ID, CHANNEL_NAME } from "./const";
9
9
  import { handleInboundMessage } from './inbound';
10
10
  import {
11
- checkAccount,
12
11
  sendTextMsg,
13
12
  sendPageMsg,
14
13
  sendMediaMsg,
@@ -189,7 +188,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
189
188
 
190
189
  sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
191
190
  const account = resolveAccount(cfg, accountId);
192
- checkAccount(account);
193
191
 
194
192
  const chatId = String(to || '').trim();
195
193
  const chatType = guessChatType(chatId);
@@ -202,8 +200,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
202
200
 
203
201
  sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId }: any) => {
204
202
  account = account || resolveAccount(cfg, accountId);
205
- checkAccount(account, 'send custom message');
206
-
207
203
  // If it's a page message, we need to construct it
208
204
  if (payload?.msgtype !== 'page') {
209
205
  throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${payload?.msgtype}`);
@@ -217,7 +213,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
217
213
 
218
214
  sendMedia: async ({ cfg, to, mediaUrl, accountId, account }: any) => {
219
215
  account = account || resolveAccount(cfg, accountId);
220
- checkAccount(account, 'send media');
221
216
 
222
217
  const chatId = String(to || '').trim();
223
218
  // Determine if this is a group message based on 'to' being all digits (group) or not (direct)
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,
@@ -413,7 +451,11 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
413
451
 
414
452
  if (account.emojiReaction) {
415
453
  // 因为回复较慢,先回复一个表情
416
- await tuituiEmojiReaction(account, payload.chatId, payload.chatType, payload.msgId, '收到');
454
+ try{
455
+ await tuituiEmojiReaction(account, payload.chatId, payload.chatType, payload.msgId, '收到');
456
+ } catch (err) {
457
+ // 出错不影响后续逻辑
458
+ }
417
459
  }
418
460
 
419
461
  // 路由判断
@@ -451,12 +493,16 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
451
493
  CommandAuthorized: true, // 允许 /new 等内置命令
452
494
  };
453
495
  if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
496
+ ctx.WasMentioned = payload.WasMentioned;
454
497
  ctx.GroupSubject = payload.groupName;
455
498
  ctx.GroupId = payload.chatId;
456
499
  ctx.InboundHistory = popUnmentionedHistories(accountId, payload.chatId);
457
500
  }
458
501
  if (payload.chatType == CHAT_TYPE_CHANNEL) {
459
- ctx.GroupChannel = payload.channelName;
502
+ ctx.WasMentioned = payload.WasMentioned;
503
+ ctx.IsForum = true;
504
+ ctx.GroupSubject = payload.channelName;
505
+ ctx.GroupChannel = payload.chatId;
460
506
  ctx.InboundHistory = popUnmentionedHistories(accountId, payload.chatId);
461
507
  }
462
508
  if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
package/src/outbound.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
- import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
4
3
  import { CHANNEL_ID } from "./const";
5
- import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
4
+ import { flattenFileSpaceList, FileSpaceItem } from "./filespace";
5
+ import { tuituiRobotApi, downloadUrl } from "./robot_api"
6
6
 
7
7
  import type {
8
8
  TuiTuiMessageData,
@@ -32,12 +32,6 @@ export function guessChatType(chatId: string): ChatType {
32
32
  return CHAT_TYPE_DIRECT;
33
33
  }
34
34
 
35
- export function addTuituiParams2Url(urlStr: string, params: any) {
36
- const url = new URL(getTuituiApiHost() + urlStr);
37
- for (let k in params) url.searchParams.set(k, params[k]);
38
- return url.toString();
39
- }
40
-
41
35
  const mimeTypes: Record<string, string> = {
42
36
  jpg: 'image/jpeg',
43
37
  jpeg: 'image/jpeg',
@@ -59,61 +53,10 @@ export function getMimeType(filename: string): string {
59
53
  return mimeTypes[ext] || 'application/octet-stream';
60
54
  }
61
55
 
62
- function _fetch(opts: any): Promise<any> {
63
- return fetchWithSsrFGuard({
64
- //url: fileSrc,
65
- policy: TUITUI_SSRF_POLICY,
66
- //auditCtx: "tuitui.media.download",
67
- ...opts,
68
- })
69
- }
70
- function _fetchJson(url: string, json: any, auditCtx: string): Promise<any> {
71
- return _fetch({
72
- url,
73
- init: {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify(json),
77
- },
78
- auditCtx,
79
- });
80
- }
81
56
  export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
82
- const { appId: appid, appSecret: secret } = account;
83
- const { response, release } = await _fetchJson(
84
- addTuituiParams2Url('/message/custom/send', { appid, secret }),
85
- json,
86
- auditCtx,
87
- );
88
- try {
89
- const bodyText = await response.text();
90
- const parsed = JSON.parse(bodyText);
91
-
92
- console.debug(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`);
93
-
94
- if (!response.ok) {
95
- throw new Error(`postTuituiMsg Failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`);
96
- }
97
-
98
- if (Number(parsed?.errcode) !== 0) {
99
- throw new Error(`postTuituiMsg Failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`);
100
- }
101
- } catch(err) {
102
- console.error(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg error:`, err, `\njson: ${JSON.stringify(json)}`);
103
- // 必须抛出错误,否则上层无法知道失败了(channel机器人问答、agent调用tool的场景)
104
- throw err;
105
- } finally {
106
- await release();
107
- }
57
+ await tuituiRobotApi(account, '/message/custom/send', json);
108
58
  }
109
59
 
110
- export function checkAccount(account: any, ctxTips: string = 'send text') {
111
- if (!account || !account.appId || !account.appSecret) {
112
- throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for ${ctxTips}`);
113
- }
114
- }
115
-
116
-
117
60
  interface tuituiUploadResult {
118
61
  fid: string;
119
62
  filename: string;
@@ -132,15 +75,15 @@ interface tuituiUploadResult {
132
75
  * @returns The media_id from TuiTui
133
76
  */
134
77
  export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult> {
135
- checkAccount(account, 'uploadFileToTuiTui');
136
-
137
- const { appId: appid, appSecret: secret } = account;
138
78
  let fileBuffer: ArrayBuffer;
139
79
  let contentType: string;
140
80
  let filename: string;
141
81
 
82
+
142
83
  // Check if it's a Base64 data URL
143
84
  if (/^data\:/.test(fileSrc)) {
85
+ console.log("uploadFileToTuiTui: Base64 data");
86
+
144
87
  const matches = fileSrc.match(/^data:([^;,]*)(;base64)?,(.*)$/);
145
88
  if (!matches) {
146
89
  throw new Error(`[${CHANNEL_ID}] Invalid data URL format`);
@@ -167,33 +110,15 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
167
110
  }
168
111
  // HTTP/HTTPS URL
169
112
  else if (/^https?\:/.test(fileSrc)) {
170
- const { response, release } = await _fetch({ url: fileSrc, auditCtx: 'tuitui.media.download' });
171
- try {
172
- if (!response.ok) {
173
- throw new Error(`[${CHANNEL_ID}] Failed to download media from ${fileSrc}: ${response.status}`);
174
- }
175
-
176
- fileBuffer = await response.arrayBuffer();
177
- contentType = response.headers.get('content-type') || 'application/octet-stream';
178
-
179
- filename = 'media';
180
- const urlPath = new URL(fileSrc).pathname;
181
- const pathParts = urlPath.split('/');
182
- const lastPart = pathParts[pathParts.length - 1];
183
- if (lastPart) filename = lastPart;
184
- const contentDisposition = response.headers.get('content-disposition');
185
- if (contentDisposition) {
186
- const match = contentDisposition.match(/filename\*?=(?:UTF-8''|")?([^";\r\n]+)"?/i);
187
- if (match) {
188
- filename = decodeURIComponent(match[1]);
189
- }
190
- }
191
- } finally {
192
- await release();
193
- }
113
+ console.log("uploadFileToTuiTui: url", fileSrc);
114
+ const { buffer, filename: downloadedFilename, contentType: downloadedContentType } = await downloadUrl(fileSrc);
115
+ fileBuffer = buffer;
116
+ filename = downloadedFilename;
117
+ contentType = downloadedContentType;
194
118
  }
195
119
  // Check if it's a local file path
196
120
  else {
121
+ console.log("uploadFileToTuiTui: local", fileSrc);
197
122
  if (!existsSync(fileSrc)) {
198
123
  throw new Error(`[${CHANNEL_ID}] Local file not found: ${fileSrc}`);
199
124
  }
@@ -217,27 +142,8 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
217
142
  const body = new FormData();
218
143
  body.append('media', new Blob([fileBuffer], { type: contentType }), filename);
219
144
 
220
- const { response, release } = await _fetch({
221
- url: addTuituiParams2Url('/media/upload', { appid, secret, type }),
222
- init: { method: "POST", body },
223
- auditCtx: "tuitui.media.upload",
224
- });
225
- try {
226
- if (!response.ok) {
227
- throw new Error(`[${CHANNEL_ID}] Failed to upload file to TuiTui: HTTP ${response.status}`);
228
- }
229
-
230
- const result: TuiTuiMediaUploadResponse = await response.json();
231
- if (result.errcode !== 0 || !result.media_id) {
232
- throw new Error(`[${CHANNEL_ID}] file upload failed: ${result.errmsg || "Unknown error"}`);
233
- }
234
-
235
- return {fid: result.media_id, filename};
236
- } catch(err) {
237
- throw err;
238
- } finally {
239
- await release();
240
- }
145
+ const result: TuiTuiMediaUploadResponse = await tuituiRobotApi(account, '/media/upload', body);
146
+ return {fid: result.media_id||"", filename};
241
147
  }
242
148
 
243
149
  export async function tuituiEmojiReaction(
@@ -259,21 +165,7 @@ export async function tuituiEmojiReaction(
259
165
  payload.toteams = [{ ...teamsParseChatId(target), parent_id: '', post_id: msgid }] as TuiTuiTeamsTarget[];
260
166
  }
261
167
 
262
- const { appId: appid, appSecret: secret } = account;
263
- const toTarget = (payload.togroups || payload.tousers || payload.toteams)[0];
264
- const _logTxt =`[${CHANNEL_ID}] emoji_reaction "${emoji}"`;
265
- console.log(`${_logTxt} request`, toTarget);
266
- const sendUrl = addTuituiParams2Url('/message/custom/modify', { appid, secret });
267
- const { response, release } = await _fetchJson(sendUrl, payload, 'tuitui.emoji_reaction');
268
-
269
- try {
270
- const body = JSON.parse(await response.text().catch(() => "{}"));
271
- console.log(`${_logTxt} response errcode=${body.errcode} errmsg=${body.errmsg}`, toTarget);
272
- } catch (err) {
273
- console.error(`${_logTxt} Caught exception:`, err)
274
- } finally {
275
- await release();
276
- }
168
+ await tuituiRobotApi(account, '/message/custom/modify', payload);
277
169
  }
278
170
 
279
171
  export function teamsBuildChatId(team_id: string, channel_id:string, thread_id:string) : string{
@@ -358,20 +250,36 @@ export async function sendTextMsg(
358
250
  // 澄清:不再使用 atList 参数;统一从文本中提取 at 目标;
359
251
  // 因为 atList 参数上层固定写死的填写为 发消息人,如果是机器人这样可能导致机器人之间at产生死循环。
360
252
  if (chatType == CHAT_TYPE_CHANNEL) {
253
+
361
254
  const content_with_newline = replaceSingleNewlines(content);
362
255
  const content_with_mention = replaceMentions(content_with_newline);
363
256
  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);
257
+ try {
258
+ if (has_at && content.indexOf("禁止@") != -1) throw Error("触发魔法词");
259
+ const msg: TuiTuiOutboundTeamsMarkdownMessage = {
260
+ ...getTargets(chatId, chatType),
261
+ msgtype: 'richtext/markdown',
262
+ richtext: {
263
+ markdown: content_with_mention,
264
+ delims_left: has_at?"{{":"",
265
+ delims_right: has_at?"}}":"",
266
+ },
267
+ };
268
+ await postTuituiMsg(account, msg, auditCtx);
269
+ } catch(err) {
270
+ if(!has_at)throw err;
271
+ // replaceMentions 问题:如果用户不存在帖子发不出去,所以做一次重试去掉伪渲染
272
+ const msg: TuiTuiOutboundTeamsMarkdownMessage = {
273
+ ...getTargets(chatId, chatType),
274
+ msgtype: 'richtext/markdown',
275
+ richtext: {
276
+ markdown: content_with_newline,
277
+ delims_left: "",
278
+ delims_right: ""
279
+ },
280
+ };
281
+ await postTuituiMsg(account, msg, auditCtx);
282
+ }
375
283
  } else {
376
284
  const at_from_text = chatType == CHAT_TYPE_GROUP?extractMentions(content):[];
377
285
  const msg: TuiTuiOutboundTextMessage = {
@@ -380,7 +288,6 @@ export async function sendTextMsg(
380
288
  at: at_from_text,
381
289
  text: { content },
382
290
  };
383
- console.log(`[${CHANNEL_ID}] sendTextMsg to ${chatId} ${auditCtx} - `, msg);
384
291
  await postTuituiMsg(account, msg, auditCtx);
385
292
  }
386
293
  }
@@ -396,7 +303,7 @@ export async function sendPageMsg(
396
303
  ): Promise<void> {
397
304
  if (!chatId) return console.error(`[${CHANNEL_ID}] sendPageMsg Error ${auditCtx}: Missing "target"`);
398
305
  if(chatType == CHAT_TYPE_CHANNEL) {
399
- console.log(`[${CHANNEL_ID}] sendPageMsg to teams notNOT supported ${auditCtx} - `, page);
306
+ console.log(`[${CHANNEL_ID}] sendPageMsg to teams NOT supported ${auditCtx} - `, page);
400
307
  return
401
308
  }
402
309
  const targets = getTargets(chatId, chatType);
@@ -408,7 +315,6 @@ export async function sendPageMsg(
408
315
  togroups,
409
316
  page: { ...(page || {})}
410
317
  };
411
- console.log(`[${CHANNEL_ID}] sendPageMsg ${auditCtx} - `, msg);
412
318
  await postTuituiMsg(account, msg, auditCtx);
413
319
  }
414
320
 
@@ -421,7 +327,6 @@ export async function sendMediaMsg(
421
327
  atList?: string[],
422
328
  ): Promise<void> {
423
329
  if (!chatId) return console.error(`[${CHANNEL_ID}] sendMediaMsg Error ${auditCtx}: Missing "target"`);
424
- console.log(`[${CHANNEL_ID}] sendMediaMsg ${chatType} ${chatId} ${auditCtx} uploading`);
425
330
  // Check if mediaUrl looks like an image
426
331
  const isImage = /^data:image\//i.test(mediaUrl) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(mediaUrl);
427
332
  const mediaType = isImage ? 'image' : 'file';
@@ -443,7 +348,6 @@ export async function sendMediaMsg(
443
348
  at: atList || [],
444
349
  richtext: { markdown: content, delims_left: "{{", delims_right: "}}"},
445
350
  }
446
- console.log(`[${CHANNEL_ID}] sendMediaMsg ${chatType} ${chatId} ${auditCtx} - `, msg);
447
351
  await postTuituiMsg(account, msg, auditCtx);
448
352
  } else {
449
353
  const msg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
@@ -453,7 +357,6 @@ export async function sendMediaMsg(
453
357
 
454
358
  const realAtList = chatType == CHAT_TYPE_GROUP? atList : [];
455
359
  msg.at = realAtList;
456
- console.log(`[${CHANNEL_ID}] sendMediaMsg ${chatType} ${chatId} ${auditCtx} - `, msg);
457
360
 
458
361
  await postTuituiMsg(account, msg, auditCtx);
459
362
  }
@@ -526,8 +429,6 @@ export async function getChatRecord(
526
429
  return undefined;
527
430
  }
528
431
 
529
- checkAccount(account, 'getChatRecord');
530
-
531
432
  let baseurl = "";
532
433
  if (chatType == CHAT_TYPE_DIRECT) {
533
434
  baseurl = "/message/single/sync";
@@ -553,50 +454,152 @@ export async function getChatRecord(
553
454
  if (options.limit) body.limit = options.limit;
554
455
  if (typeof options.orderAsc === 'boolean') body.order_asc = options.orderAsc;
555
456
 
556
- const { appId: appid, appSecret: secret } = account;
457
+ const parsed: TuiTuiChatRecordResponse = await tuituiRobotApi(account, baseurl, body);
458
+
459
+ const clean: TuiTuiChatRecordResponseClean = {
460
+ errcode: parsed.errcode,
461
+ errmsg: parsed.errmsg,
462
+ cursor: parsed.cursor,
463
+ has_more: parsed.has_more,
464
+ current_time: parsed.time,
465
+ msgs: (parsed.msgs ?? []).map(({ user_account, user_name, timestamp, data }) => {
466
+ const { at, msgid, group_id, group_name, ...restData } = data; // 排除一些字段,减少大模型上下文大小
467
+ return {
468
+ ...restData, // 使用排除 at 后的数据
469
+ user_account,
470
+ user_name,
471
+ msg_time: new Date(Number(timestamp) * 1000).toLocaleString('sv-SE', { hour12: false }).replace('T', ' '),
472
+ };
473
+ }),
474
+ };
557
475
 
558
- const url = addTuituiParams2Url(baseurl, { appid, secret });
476
+ console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
559
477
 
560
- console.log(`[${CHANNEL_ID}] getChatRecord request `, body);
478
+ return clean;
479
+ }
561
480
 
562
- const { response, release } = await _fetchJson(url, body, 'tuitui.chat.record');
563
- try {
564
- const bodyText = await response.text();
565
- //console.log(`[${CHANNEL_ID}] getChatRecord response ${bodyText}`);
566
481
 
567
- if (!response.ok) {
568
- throw new Error(`getChatRecord failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText}`);
569
- }
482
+ export interface TeamsPostChainItem {
483
+ post_id: string;
484
+ time: string;
485
+ name: string;
486
+ content: string;
487
+ properties: any;
488
+ }
570
489
 
571
- const parsed: TuiTuiChatRecordResponse = JSON.parse(bodyText);
572
- if (Number(parsed?.errcode) !== 0) {
573
- throw new Error(`getChatRecord failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`);
574
- }
490
+ /**
491
+ * 获取 Teams channel 帖子的完整消息链(主贴 + 回复列表)。
492
+ *
493
+ * @param account - TuiTui 账号,含 appId / appSecret
494
+ * @param teamId - Teams team ID
495
+ * @param channelId - Teams channel ID
496
+ * @param threadId - 帖子/主贴 ID(post_id)
497
+ * @returns - 主贴在前、回复按时间正序排列的消息数组
498
+ */
499
+ export async function getPostChain(
500
+ account: any,
501
+ teamId: string,
502
+ channelId: string,
503
+ threadId: string,
504
+ ): Promise<TeamsPostChainItem[]> {
575
505
 
576
- const clean: TuiTuiChatRecordResponseClean = {
577
- errcode: parsed.errcode,
578
- errmsg: parsed.errmsg,
579
- cursor: parsed.cursor,
580
- has_more: parsed.has_more,
581
- current_time: parsed.time,
582
- msgs: (parsed.msgs ?? []).map(({ user_account, user_name, timestamp, data }) => {
583
- const { at, msgid, group_id, group_name, ...restData } = data; // 排除一些字段,减少大模型上下文大小
584
- return {
585
- ...restData, // 使用排除 at 后的数据
586
- user_account,
587
- user_name,
588
- msg_time: new Date(Number(timestamp) * 1000).toLocaleString('sv-SE', { hour12: false }).replace('T', ' '),
589
- };
590
- }),
591
- };
506
+ const payload = {
507
+ team_id: teamId,
508
+ channel_id: channelId,
509
+ post_id: threadId,
510
+ };
511
+ const data = await tuituiRobotApi(account, '/teams/post/chain', payload);
592
512
 
593
- console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
513
+ const datas = data.datas ?? {};
514
+ const topic = datas.topic ?? {};
515
+ const replyList: any[] = datas.reply_list ?? [];
594
516
 
595
- return clean;
596
- } catch (err) {
597
- console.error(`[${CHANNEL_ID}] getChatRecord error:`, err);
598
- return undefined;
599
- } finally {
600
- await release();
517
+ const posts: TeamsPostChainItem[] = [];
518
+
519
+ posts.push({
520
+ post_id: topic.post_id ?? '',
521
+ time: topic.create_time ?? '',
522
+ name: topic.from_name ?? '',
523
+ content: topic.content ?? '',
524
+ properties: topic.properties ?? '',
525
+ });
526
+
527
+ for (const post of [...replyList].reverse()) {
528
+ posts.push({
529
+ post_id: post.post_id ?? '',
530
+ time: post.create_time ?? '',
531
+ name: post.from_name ?? '',
532
+ content: post.content ?? '',
533
+ properties: post.properties ?? '',
534
+ });
601
535
  }
602
- }
536
+
537
+ console.log(`[${CHANNEL_ID}] getPostChain result: ${posts.length} posts`, posts);
538
+ return posts;
539
+ }
540
+
541
+ export async function getPostChainByChatId(
542
+ account: any,
543
+ chatId: string,
544
+ ): Promise<TeamsPostChainItem[]> {
545
+ const { team_id, channel_id, parent_id } = teamsParseChatId(chatId!);
546
+ return await getPostChain(account, team_id, channel_id, parent_id||"");
547
+ }
548
+
549
+ export async function file_space_list(
550
+ account: any,
551
+ spaceId: string = "",
552
+ spaceType: string = "2"
553
+ ): Promise<FileSpaceItem[]> {
554
+ const guessType = guessChatType(spaceId);
555
+ let space_final_id = "";
556
+ if(guessType == CHAT_TYPE_CHANNEL){
557
+ const { team_id, channel_id, parent_id } = teamsParseChatId(spaceId);
558
+ space_final_id = team_id;
559
+ } else {
560
+ space_final_id = spaceId;
561
+ }
562
+
563
+ const payload = {
564
+ space_id: space_final_id,
565
+ space_type: spaceType,
566
+ };
567
+
568
+ const data = await tuituiRobotApi(account, '/file_space/node/list', payload);
569
+
570
+ const list = data?.datas?.list;
571
+ const flat_list = flattenFileSpaceList(list);
572
+
573
+ console.log(`[${CHANNEL_ID}] file_space_list `, flat_list);
574
+ return flat_list
575
+ }
576
+
577
+ function parseSessionKey(str: string): string {
578
+ // 检查是否包含必须的格式 "tuitui:channel:"
579
+ if (!str.includes('tuitui:channel:')) {
580
+ return "";
581
+ }
582
+
583
+ const parts = str.split(':');
584
+ const channelIndex = parts.findIndex(part => part === 'channel');
585
+
586
+ if (channelIndex !== -1 && parts[channelIndex + 1]) {
587
+ return parts[channelIndex + 1];
588
+ }
589
+
590
+ return "";
591
+ }
592
+
593
+ // TODO: 支持群公告
594
+ export async function get_announcement(account: any, sessionKey: string = ""): Promise<any> {
595
+ const channel_id = parseSessionKey(sessionKey);
596
+ if(!channel_id) {
597
+ return {"announcement": ""};
598
+ }
599
+
600
+ const payload = {channel_id: channel_id};
601
+ const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
602
+ //console.log("info", data);
603
+ const announcement = body?.datas?.info?.announcement;
604
+ return {"announcement": announcement};
605
+ }
@@ -0,0 +1,123 @@
1
+ import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
2
+ import { CHANNEL_ID } from "./const";
3
+ import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
4
+
5
+ function addTuituiParams2Url(urlStr: string, params: any) {
6
+ const url = new URL(getTuituiApiHost() + urlStr);
7
+ for (let k in params) url.searchParams.set(k, params[k]);
8
+ return url.toString();
9
+ }
10
+
11
+ export function checkAccount(account: any, ctxTips: string = 'send text') {
12
+ if (!account || !account.appId || !account.appSecret) {
13
+ throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for ${ctxTips}`);
14
+ }
15
+ }
16
+
17
+ export async function tuituiRobotApi(account: any, api: string, payload: any) {
18
+ checkAccount(account);
19
+
20
+ console.log(`[${CHANNEL_ID}] ${api} request`, payload);
21
+
22
+ const { appId: appid, appSecret: secret } = account;
23
+ const url = addTuituiParams2Url(api, { appid, secret });
24
+
25
+
26
+ let fetch_fun = _fetchJson;
27
+ if (payload instanceof FormData) {
28
+ fetch_fun = _fetchForm;
29
+ }
30
+
31
+ const { response, release } = await fetch_fun(url, payload);
32
+ try {
33
+ const bodyText = await response.text();
34
+
35
+ if (!response.ok) {
36
+ throw new Error(`${api} failed : ${response.status} ${response.statusText}; body=${bodyText}`);
37
+ }
38
+
39
+ const data = JSON.parse(bodyText);
40
+ if (Number(data?.errcode) !== 0) {
41
+ throw new Error(`${api} failed : ${bodyText}`);
42
+ }
43
+ return data;
44
+ } catch (err) {
45
+ // 必须抛出错误,否则上层无法知道失败了
46
+ console.error(`[${CHANNEL_ID}] ${api} error:`, err);
47
+ throw err;
48
+ } finally {
49
+ await release();
50
+ }
51
+ }
52
+
53
+ function _fetch(opts: any): Promise<any> {
54
+ return fetchWithSsrFGuard({
55
+ policy: TUITUI_SSRF_POLICY,
56
+ ...opts,
57
+ })
58
+ }
59
+ function _fetchJson(url: string, json: any, auditCtx: string = "tuitui.api.call"): Promise<any> {
60
+ return _fetch({
61
+ url,
62
+ init: {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify(json),
66
+ },
67
+ auditCtx
68
+ });
69
+ }
70
+
71
+ function _fetchForm(url: string, form: any, auditCtx: string = "tuitui.api.call"): Promise<any> {
72
+ return _fetch({
73
+ url,
74
+ init: {
75
+ method: 'POST',
76
+ body: form,
77
+ },
78
+ auditCtx
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Download content from a URL and extract filename and content type
84
+ * @param url - The URL to download from
85
+ * @returns Object with buffer, filename and content type
86
+ */
87
+ export async function downloadUrl(url: string): Promise<{ buffer: ArrayBuffer; filename: string; contentType: string }> {
88
+ const { response, release } = await _fetch({
89
+ url,
90
+ init: { method: 'GET' },
91
+ auditCtx: "tuitui.download"
92
+ });
93
+
94
+ try {
95
+ if (!response.ok) {
96
+ throw new Error(`[${CHANNEL_ID}] Failed to download from ${url}: ${response.status}`);
97
+ }
98
+
99
+ const buffer = await response.arrayBuffer();
100
+ const contentType = response.headers.get('content-type') || 'application/octet-stream';
101
+
102
+ // Extract filename from URL or Content-Disposition header
103
+ let filename = 'media';
104
+ try {
105
+ const urlPath = new URL(url).pathname;
106
+ const pathParts = urlPath.split('/');
107
+ const lastPart = pathParts[pathParts.length - 1];
108
+ if (lastPart) filename = lastPart;
109
+ } catch (e) {
110
+ // Ignore URL parsing errors
111
+ }
112
+
113
+ const contentDisposition = response.headers.get('content-disposition');
114
+ if (contentDisposition) {
115
+ const match = contentDisposition.match(/filename\*?=(?:UTF-8''|")?([^";\r\n]+)"?/i);
116
+ if (match) filename = decodeURIComponent(match[1]);
117
+ }
118
+
119
+ return { buffer, filename, contentType };
120
+ } finally {
121
+ await release();
122
+ }
123
+ }
package/src/tools.ts CHANGED
@@ -4,7 +4,8 @@ import { CHANNEL_ID} from "./const";
4
4
  import { resolveAccount } from "./accounts"
5
5
  import { Type } from "@sinclair/typebox";
6
6
 
7
- import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,ChatType, getChatRecord, sendTextMsg, teamsBuildChatId} from "./outbound"
7
+ import {CHAT_TYPE_DIRECT,CHAT_TYPE_GROUP,CHAT_TYPE_CHANNEL,guessChatType, getChatRecord, getPostChainByChatId, sendTextMsg, teamsBuildChatId, get_announcement} from "./outbound"
8
+ import {file_space_list} from "./outbound"
8
9
 
9
10
  function tool_errmsg(str:string) {
10
11
  const ret = `error: ${str}`
@@ -16,10 +17,10 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
16
17
  return {
17
18
  name: "tuitui_im_get_messages",
18
19
  label: "tuitui_im_get_messages",
19
- description: "推推(tuitui) 聊天记录获取,可查询私聊和群聊的聊天记录。\n\n",
20
+ description: "推推(tuitui) 聊天记录获取,可查询私聊、群聊、频道的聊天记录。频道不支持时间范围过滤只能查询当前帖子讨论串\n\n",
20
21
  parameters: Type.Object({
21
22
  chatId: Type.String({ description: "聊天ID,单聊指tuitui用户的account,群聊是群ID" }),
22
- chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP}`}),
23
+ chatType: Type.String({ description: `聊天类型。 单聊:${CHAT_TYPE_DIRECT} 群聊:${CHAT_TYPE_GROUP} 频道:${CHAT_TYPE_CHANNEL}`}),
23
24
  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
25
  startTime: Type.Optional(Type.String({ description: `起始时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认指2000年代表。与 relativeTime 互斥`})),
25
26
  endTime: Type.Optional(Type.String({ description: `结束时间(ISO 8601 格式,如 2026-02-27T00:00:00+08:00),非必填,默认当前时间。与 relativeTime 互斥`})),
@@ -39,19 +40,23 @@ const tuitui_im_get_messages_factory = (ctx: OpenClawPluginToolContext) => {
39
40
  if(!chatId || !chatType) {
40
41
  return tool_errmsg(`chatType or chatId empty`);
41
42
  }
42
-
43
- if(chatType != CHAT_TYPE_DIRECT && chatType != CHAT_TYPE_GROUP) {
43
+
44
+ const guessType = guessChatType(chatId);
45
+ if(chatType == CHAT_TYPE_CHANNEL || guessType == CHAT_TYPE_CHANNEL) {
46
+ // 模型有时候搞不清楚频道和群的区别
47
+ return await getPostChainByChatId(account, chatId);
48
+ } else if(chatType == CHAT_TYPE_DIRECT || chatType == CHAT_TYPE_GROUP) {
49
+ return await getChatRecord(account, chatId, chatType, {
50
+ startTime: params?.startTime,
51
+ endTime: params?.endTime,
52
+ relativeTime: params?.relativeTime,
53
+ limit: params?.limit,
54
+ cursor: params?.cursor,
55
+ orderAsc: params?.orderAsc,
56
+ });
57
+ } else {
44
58
  return tool_errmsg(`unknown chatType: ${chatType}`);
45
59
  }
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
60
  },
56
61
  };
57
62
  };
@@ -80,6 +85,48 @@ const tuitui_send_channel_post_factory = (ctx: OpenClawPluginToolContext) => {
80
85
  };
81
86
  };
82
87
 
88
+
89
+ const tuitui_get_announcement_factory = (ctx: OpenClawPluginToolContext) => {
90
+ return {
91
+ name: "tuitui_get_announcement",
92
+ label: "tuitui_get_announcement",
93
+ description: "获取推推(tuitui) 当前会话的公告信息\n\n",
94
+ parameters: Type.Object({
95
+ }),
96
+ execute: async (_toolCallId: any, params: any) => {
97
+ console.log(`tuitui_get_announcement(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`, params);
98
+ const account = resolveAccount(ctx.config, ctx.agentAccountId);
99
+ if(!account || !account.enabled || !account.appId || !account.appSecret) {
100
+ return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
101
+ }
102
+ return await get_announcement(account, ctx.sessionKey);
103
+ },
104
+ };
105
+ };
106
+
107
+
108
+
109
+
110
+ const tuitui_file_space_factory = (ctx: OpenClawPluginToolContext) => {
111
+ return {
112
+ name: "tuitui_file_space_list",
113
+ label: "tuitui_file_space_list",
114
+ description: "推推(tuitui) 团队频道的共享文件(共享空间)的文件列表\n\n",
115
+ parameters: Type.Object({
116
+ space_id: Type.String({ description: "空间ID。请传入 GroupChannel 或者 GroupId 中非空的值" }),
117
+ }),
118
+ execute: async (_toolCallId: any, params: any) => {
119
+ console.log(`tuitui_file_space_list(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`, params);
120
+ const account = resolveAccount(ctx.config, ctx.agentAccountId);
121
+ if(!account || !account.enabled || !account.appId || !account.appSecret) {
122
+ return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
123
+ }
124
+ return await file_space_list(account, params?.space_id);
125
+ },
126
+ };
127
+ };
128
+
129
+
83
130
  export function registerTuituiTools(api: OpenClawPluginApi) {
84
131
  if (!api.config) {
85
132
  api.logger.debug?.("tuitui: Registered tool: No config available");
@@ -88,5 +135,7 @@ export function registerTuituiTools(api: OpenClawPluginApi) {
88
135
 
89
136
  api.registerTool(tuitui_im_get_messages_factory);
90
137
  api.registerTool(tuitui_send_channel_post_factory);
138
+ api.registerTool(tuitui_get_announcement_factory);
139
+ api.registerTool(tuitui_file_space_factory);
91
140
  api.logger.info?.(`tuitui: Registered tool`);
92
141
  }