@qihoo/tuitui-openclaw-channel 1.0.19 → 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/accounts.ts +4 -4
- package/src/channel.ts +9 -0
- package/src/env.ts +15 -0
- package/src/filespace.ts +45 -0
- package/src/inbound.ts +181 -139
- package/src/outbound.ts +191 -31
- package/src/tools.ts +44 -14
- package/src/websocket.ts +4 -8
|
@@ -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/accounts.ts
CHANGED
|
@@ -19,10 +19,10 @@ export const getDefaultAccountConfig = (acct?: any) => ({
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
export const resolveAccount = (cfg: any, accountId?: string | null) => {
|
|
22
|
-
|
|
22
|
+
const accId = accountId || DEFAULT_ACCOUNT_ID;
|
|
23
23
|
let currAccount = cfg?.channels?.[CHANNEL_ID] || {};
|
|
24
|
-
if (
|
|
25
|
-
currAccount = currAccount.accounts?.[
|
|
24
|
+
if (accId !== DEFAULT_ACCOUNT_ID) {
|
|
25
|
+
currAccount = currAccount.accounts?.[accId];
|
|
26
26
|
}
|
|
27
|
-
return { accountId, ...getDefaultAccountConfig(currAccount) };
|
|
27
|
+
return { accountId: accId, ...getDefaultAccountConfig(currAccount) };
|
|
28
28
|
};
|
package/src/channel.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id';
|
|
7
7
|
import { setAccountEnabledInConfigSection, deleteAccountFromConfigSection } from 'openclaw/plugin-sdk/core';
|
|
8
8
|
import { CHANNEL_ID, CHANNEL_NAME } from "./const";
|
|
9
|
+
import { handleInboundMessage } from './inbound';
|
|
9
10
|
import {
|
|
10
11
|
checkAccount,
|
|
11
12
|
sendTextMsg,
|
|
@@ -256,6 +257,14 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
256
257
|
apiRuntime,
|
|
257
258
|
onConnected: () => _setStatus({ running: true, connected: true }),
|
|
258
259
|
onDisconnected: () => _setStatus({ running: false, connected: false }),
|
|
260
|
+
onInbounMessage: (json: any) => {
|
|
261
|
+
_setStatus({ running: true, connected: true, lastInboundAt: Date.now() });
|
|
262
|
+
if (apiRuntime) {
|
|
263
|
+
handleInboundMessage({ json, account, apiRuntime, log });
|
|
264
|
+
} else {
|
|
265
|
+
log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket.onInbounMessage TuiTuiRuntime error`);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
259
268
|
});
|
|
260
269
|
|
|
261
270
|
// 保持账户运行状态,直至触发“abortSignal”信号为止。
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const DEFAULT_ONLINE = true;
|
|
2
|
+
|
|
3
|
+
export const getTuituiHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
4
|
+
return online ? "im.live.360.cn" : "im.qihoo.net";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const getTuituiWebsocketHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
8
|
+
return `wss://${getTuituiHost(online)}:8282/robot`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getTuituiApiHost = (online: boolean = DEFAULT_ONLINE): string => {
|
|
12
|
+
return `https://${getTuituiHost(online)}:8282/robot`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn', 'im.qihoo.net'] } as const;
|
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
|
@@ -2,6 +2,8 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
|
|
4
4
|
import { CHANNEL_ID } from "./const";
|
|
5
|
+
import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
|
|
6
|
+
import { flattenFileSpaceList, FileSpaceItem } from "./filespace";
|
|
5
7
|
|
|
6
8
|
import type {
|
|
7
9
|
TuiTuiMessageData,
|
|
@@ -17,8 +19,6 @@ import type {
|
|
|
17
19
|
TuiTuiTeamsTarget
|
|
18
20
|
} from './types';
|
|
19
21
|
|
|
20
|
-
/* 一些常量配置 */
|
|
21
|
-
export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn'] } as const;
|
|
22
22
|
|
|
23
23
|
// ChatType定义与SessionKey定义一致,不可随意修改
|
|
24
24
|
// https://docs.openclaw.ai/channels/channel-routing#session-key-shapes-examples
|
|
@@ -33,8 +33,8 @@ export function guessChatType(chatId: string): ChatType {
|
|
|
33
33
|
return CHAT_TYPE_DIRECT;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function
|
|
37
|
-
const url = new URL(urlStr);
|
|
36
|
+
export function addTuituiParams2Url(urlStr: string, params: any) {
|
|
37
|
+
const url = new URL(getTuituiApiHost() + urlStr);
|
|
38
38
|
for (let k in params) url.searchParams.set(k, params[k]);
|
|
39
39
|
return url.toString();
|
|
40
40
|
}
|
|
@@ -82,7 +82,7 @@ function _fetchJson(url: string, json: any, auditCtx: string): Promise<any> {
|
|
|
82
82
|
export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
|
|
83
83
|
const { appId: appid, appSecret: secret } = account;
|
|
84
84
|
const { response, release } = await _fetchJson(
|
|
85
|
-
|
|
85
|
+
addTuituiParams2Url('/message/custom/send', { appid, secret }),
|
|
86
86
|
json,
|
|
87
87
|
auditCtx,
|
|
88
88
|
);
|
|
@@ -132,7 +132,7 @@ interface tuituiUploadResult {
|
|
|
132
132
|
* @param type - Media type: 'image' or "file" (auto-detected if not specified)
|
|
133
133
|
* @returns The media_id from TuiTui
|
|
134
134
|
*/
|
|
135
|
-
export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult
|
|
135
|
+
export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult> {
|
|
136
136
|
checkAccount(account, 'uploadFileToTuiTui');
|
|
137
137
|
|
|
138
138
|
const { appId: appid, appSecret: secret } = account;
|
|
@@ -219,23 +219,23 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
|
|
|
219
219
|
body.append('media', new Blob([fileBuffer], { type: contentType }), filename);
|
|
220
220
|
|
|
221
221
|
const { response, release } = await _fetch({
|
|
222
|
-
url:
|
|
222
|
+
url: addTuituiParams2Url('/media/upload', { appid, secret, type }),
|
|
223
223
|
init: { method: "POST", body },
|
|
224
224
|
auditCtx: "tuitui.media.upload",
|
|
225
225
|
});
|
|
226
226
|
try {
|
|
227
227
|
if (!response.ok) {
|
|
228
|
-
throw new Error(`[${CHANNEL_ID}] Failed to upload
|
|
228
|
+
throw new Error(`[${CHANNEL_ID}] Failed to upload file to TuiTui: HTTP ${response.status}`);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
const result: TuiTuiMediaUploadResponse = await response.json();
|
|
232
232
|
if (result.errcode !== 0 || !result.media_id) {
|
|
233
|
-
throw new Error(`[${CHANNEL_ID}]
|
|
233
|
+
throw new Error(`[${CHANNEL_ID}] file upload failed: ${result.errmsg || "Unknown error"}`);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
return {fid: result.media_id, filename};
|
|
237
237
|
} catch(err) {
|
|
238
|
-
|
|
238
|
+
throw err;
|
|
239
239
|
} finally {
|
|
240
240
|
await release();
|
|
241
241
|
}
|
|
@@ -264,7 +264,7 @@ export async function tuituiEmojiReaction(
|
|
|
264
264
|
const toTarget = (payload.togroups || payload.tousers || payload.toteams)[0];
|
|
265
265
|
const _logTxt =`[${CHANNEL_ID}] emoji_reaction "${emoji}"`;
|
|
266
266
|
console.log(`${_logTxt} request`, toTarget);
|
|
267
|
-
const sendUrl =
|
|
267
|
+
const sendUrl = addTuituiParams2Url('/message/custom/modify', { appid, secret });
|
|
268
268
|
const { response, release } = await _fetchJson(sendUrl, payload, 'tuitui.emoji_reaction');
|
|
269
269
|
|
|
270
270
|
try {
|
|
@@ -359,20 +359,38 @@ export async function sendTextMsg(
|
|
|
359
359
|
// 澄清:不再使用 atList 参数;统一从文本中提取 at 目标;
|
|
360
360
|
// 因为 atList 参数上层固定写死的填写为 发消息人,如果是机器人这样可能导致机器人之间at产生死循环。
|
|
361
361
|
if (chatType == CHAT_TYPE_CHANNEL) {
|
|
362
|
+
|
|
362
363
|
const content_with_newline = replaceSingleNewlines(content);
|
|
363
364
|
const content_with_mention = replaceMentions(content_with_newline);
|
|
364
365
|
const has_at = (content_with_mention != content_with_newline);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
}
|
|
376
394
|
} else {
|
|
377
395
|
const at_from_text = chatType == CHAT_TYPE_GROUP?extractMentions(content):[];
|
|
378
396
|
const msg: TuiTuiOutboundTextMessage = {
|
|
@@ -428,11 +446,6 @@ export async function sendMediaMsg(
|
|
|
428
446
|
const mediaType = isImage ? 'image' : 'file';
|
|
429
447
|
|
|
430
448
|
const uploadResult = await uploadFileToTuiTui(mediaUrl, account, mediaType);
|
|
431
|
-
if (!uploadResult || !uploadResult?.fid) {
|
|
432
|
-
console.error(`[${CHANNEL_ID}] uploadFileToTuiTui failed ${auditCtx}, {mediaUrl: ${mediaUrl}, mediaType: ${mediaType}}`);
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
449
|
const {fid, filename} = uploadResult;
|
|
437
450
|
|
|
438
451
|
const targets = getTargets(chatId, chatType);
|
|
@@ -536,9 +549,9 @@ export async function getChatRecord(
|
|
|
536
549
|
|
|
537
550
|
let baseurl = "";
|
|
538
551
|
if (chatType == CHAT_TYPE_DIRECT) {
|
|
539
|
-
baseurl = "
|
|
552
|
+
baseurl = "/message/single/sync";
|
|
540
553
|
} else if (chatType == CHAT_TYPE_GROUP){
|
|
541
|
-
baseurl = "
|
|
554
|
+
baseurl = "/message/group/sync";
|
|
542
555
|
} else {
|
|
543
556
|
console.log(`[${CHANNEL_ID}] getChatRecord: chatType "${chatType}" is not supported`);
|
|
544
557
|
return undefined;
|
|
@@ -561,7 +574,7 @@ export async function getChatRecord(
|
|
|
561
574
|
|
|
562
575
|
const { appId: appid, appSecret: secret } = account;
|
|
563
576
|
|
|
564
|
-
const url =
|
|
577
|
+
const url = addTuituiParams2Url(baseurl, { appid, secret });
|
|
565
578
|
|
|
566
579
|
console.log(`[${CHANNEL_ID}] getChatRecord request `, body);
|
|
567
580
|
|
|
@@ -605,4 +618,151 @@ export async function getChatRecord(
|
|
|
605
618
|
} finally {
|
|
606
619
|
await release();
|
|
607
620
|
}
|
|
608
|
-
}
|
|
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
|
}
|
package/src/websocket.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { CHANNEL_ID } from "./const";
|
|
3
|
-
import {
|
|
3
|
+
import { getTuituiWebsocketHost } from "./env"
|
|
4
4
|
|
|
5
5
|
const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
|
|
6
6
|
let wsNum = 0;
|
|
7
7
|
|
|
8
|
-
export default function createWebSocket({ account, log, abortSignal, onConnected, onDisconnected,
|
|
8
|
+
export default function createWebSocket({ account, log, abortSignal, onConnected, onDisconnected, onInbounMessage }: any): () => void {
|
|
9
9
|
const { accountId, appId, appSecret } = account;
|
|
10
10
|
let ws: any = null;
|
|
11
11
|
const _closeWS = () => ws && ws.readyState !== WebSocket.CLOSED && ws.close();
|
|
@@ -20,7 +20,7 @@ export default function createWebSocket({ account, log, abortSignal, onConnected
|
|
|
20
20
|
const _clearTimeoutTimer = () => _timeoutId && clearTimeout(_timeoutId);
|
|
21
21
|
|
|
22
22
|
const wsEvtIds = new Set<string>();
|
|
23
|
-
const wsUrl =
|
|
23
|
+
const wsUrl = getTuituiWebsocketHost() + `/callback/ws?auth=${appId}.${appSecret}`;
|
|
24
24
|
const _startWS = () => {
|
|
25
25
|
_closeWS(); // 启动新链接前先关闭旧链接,防止产生空指针链接
|
|
26
26
|
|
|
@@ -81,11 +81,7 @@ export default function createWebSocket({ account, log, abortSignal, onConnected
|
|
|
81
81
|
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
82
82
|
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
handleInboundMessage({ json, account, apiRuntime, log });
|
|
84
|
+
onInbounMessage(json);
|
|
89
85
|
});
|
|
90
86
|
|
|
91
87
|
const onErrOrClose = (errStr: string) => {
|