@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.
- package/memory/2026-04-02.md +32 -0
- package/package.json +1 -1
- package/src/channel.ts +0 -5
- package/src/env.ts +1 -1
- package/src/filespace.ts +45 -0
- package/src/inbound.ts +186 -140
- package/src/outbound.ts +183 -180
- package/src/robot_api.ts +123 -0
- package/src/tools.ts +63 -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/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
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,
|
|
@@ -413,7 +451,11 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
|
|
|
413
451
|
|
|
414
452
|
if (account.emojiReaction) {
|
|
415
453
|
// 因为回复较慢,先回复一个表情
|
|
416
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
476
|
+
console.log(`[${CHANNEL_ID}] getChatRecord result(cleaned)`, JSON.stringify(clean, null, 2));
|
|
559
477
|
|
|
560
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
513
|
+
const datas = data.datas ?? {};
|
|
514
|
+
const topic = datas.topic ?? {};
|
|
515
|
+
const replyList: any[] = datas.reply_list ?? [];
|
|
594
516
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
+
}
|
package/src/robot_api.ts
ADDED
|
@@ -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,
|
|
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)
|
|
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}
|
|
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
|
-
|
|
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
|
}
|