@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.
- package/memory/2026-04-02.md +32 -0
- package/package.json +1 -1
- package/src/env.ts +1 -1
- package/src/filespace.ts +45 -0
- package/src/inbound.ts +181 -139
- package/src/outbound.ts +178 -12
- package/src/tools.ts +44 -14
|
@@ -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
package/src/env.ts
CHANGED
package/src/filespace.ts
ADDED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
+
if (dmPolicy === 'open') return true;
|
|
224
225
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
if(await isAllowFrom(chatId, apiRuntime, account)){
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
228
229
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
277
|
+
// 私聊白名单对群聊仍然生效,当群里只想特定人@时,可以不配置groupAllowFrom,而是配置 allowFrom
|
|
278
|
+
if(await isAllowFrom(tuituiAccount, apiRuntime, account)){
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
284
281
|
|
|
285
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
+
await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
295
298
|
|
|
296
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
// 解决机器人不知道自己是谁的问题
|
|
356
|
+
if(payload.botName) {
|
|
357
|
+
payload.text += `\n你在当前session中的名字叫: ${payload.botName} \n如果有人@这个名字,就是在命令你`;
|
|
330
358
|
}
|
|
359
|
+
}
|
|
331
360
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
392
|
+
await sendTextMsg(account, chatId, payload.chatType, msgTxt, 'tuitui.groupPolicy.reply');
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
365
395
|
|
|
366
|
-
|
|
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.
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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,
|
|
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)
|
|
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}
|
|
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
|
-
|
|
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
|
}
|