@qihoo/tuitui-openclaw-channel 1.0.22 → 1.0.24
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/package.json +1 -1
- package/src/deduplicator.ts +121 -0
- package/src/filespace.ts +180 -1
- package/src/inbound.ts +17 -7
- package/src/outbound.ts +11 -163
- package/src/robot_api.ts +145 -0
- package/src/tools.ts +32 -8
- package/src/types.ts +0 -9
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export class StringDeduplicator {
|
|
2
|
+
private items = new Map<string, number>(); // 存储元素和其首次插入时间(秒级时间戳)
|
|
3
|
+
private readonly maxSize: number;
|
|
4
|
+
private readonly expireTime: number | null; // 过期时间(秒),null 表示永不过期
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 字符串去重器
|
|
8
|
+
* @param maxSize - 最大缓存元素数量,默认1000
|
|
9
|
+
* @param expireTime - 过期时间(秒),默认null(永不过期)。例如:300表示5分钟
|
|
10
|
+
*/
|
|
11
|
+
constructor(maxSize: number = 1000, expireTime: number | null = null) {
|
|
12
|
+
if (maxSize <= 0) {
|
|
13
|
+
throw new Error(`[StringDeduplicator] maxSize must be positive, got ${maxSize}`);
|
|
14
|
+
}
|
|
15
|
+
if (expireTime !== null && expireTime <= 0) {
|
|
16
|
+
throw new Error(`[StringDeduplicator] expireTime must be positive or null, got ${expireTime}`);
|
|
17
|
+
}
|
|
18
|
+
this.maxSize = maxSize;
|
|
19
|
+
this.expireTime = expireTime;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 检查并记录字符串
|
|
24
|
+
* @returns true 表示在有效时间内首次出现(可处理),false 表示重复或已过期
|
|
25
|
+
*/
|
|
26
|
+
checkAndRecord(item: string): boolean {
|
|
27
|
+
if (item === undefined || item === null) {
|
|
28
|
+
throw new Error('[StringDeduplicator] item cannot be null or undefined');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const now = Date.now() / 1000; // 转换为秒级时间戳
|
|
32
|
+
|
|
33
|
+
// 检查是否已存在
|
|
34
|
+
const firstSeenTime = this.items.get(item);
|
|
35
|
+
if (firstSeenTime !== undefined) {
|
|
36
|
+
// 如果设置了过期时间,检查是否过期
|
|
37
|
+
if (this.expireTime !== null && now - firstSeenTime >= this.expireTime) {
|
|
38
|
+
// 已过期,移除旧记录,重新添加
|
|
39
|
+
this.items.delete(item);
|
|
40
|
+
} else if (this.expireTime === null || now - firstSeenTime < this.expireTime) {
|
|
41
|
+
// 未过期(或永不过期模式),重复
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 添加新记录
|
|
47
|
+
this.items.set(item, now);
|
|
48
|
+
this.enforceSizeLimit();
|
|
49
|
+
this.cleanupExpired();
|
|
50
|
+
return true; // 首次出现或已过期后重新出现
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 清理过期的条目
|
|
55
|
+
*/
|
|
56
|
+
private cleanupExpired(): void {
|
|
57
|
+
if (this.expireTime === null) return;
|
|
58
|
+
|
|
59
|
+
const now = Date.now() / 1000;
|
|
60
|
+
for (const [item, timestamp] of this.items.entries()) {
|
|
61
|
+
if (now - timestamp >= this.expireTime) {
|
|
62
|
+
this.items.delete(item);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 确保不超过最大大小,删除最老的条目
|
|
69
|
+
*/
|
|
70
|
+
private enforceSizeLimit(): void {
|
|
71
|
+
if (this.items.size <= this.maxSize) return;
|
|
72
|
+
|
|
73
|
+
// 因为每次 checkAndRecord 最多插入1个元素,所以只需删除1个最老的
|
|
74
|
+
const iterator = this.items.entries();
|
|
75
|
+
const oldestEntry = iterator.next().value;
|
|
76
|
+
if (oldestEntry) {
|
|
77
|
+
const [oldestItem] = oldestEntry;
|
|
78
|
+
this.items.delete(oldestItem);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 可选:手动清理
|
|
83
|
+
clear(): void {
|
|
84
|
+
this.items.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 可选:检查是否存在(不记录)
|
|
88
|
+
has(item: string): boolean {
|
|
89
|
+
const firstSeenTime = this.items.get(item);
|
|
90
|
+
if (firstSeenTime === undefined) return false;
|
|
91
|
+
// 如果设置了过期时间,检查是否过期
|
|
92
|
+
if (this.expireTime !== null) {
|
|
93
|
+
return (Date.now() / 1000) - firstSeenTime < this.expireTime;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 可选:获取当前大小
|
|
99
|
+
size(): number {
|
|
100
|
+
// 懒清理:每10次size调用执行一次完整清理
|
|
101
|
+
if (Math.random() < 0.1) {
|
|
102
|
+
this.cleanupExpired();
|
|
103
|
+
}
|
|
104
|
+
return this.items.size;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 可选:删除指定项
|
|
108
|
+
delete(item: string): boolean {
|
|
109
|
+
return this.items.delete(item);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 可选:获取过期时间(秒)
|
|
113
|
+
getExpireTime(): number | null {
|
|
114
|
+
return this.expireTime;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 可选:获取最大大小
|
|
118
|
+
getMaxSize(): number {
|
|
119
|
+
return this.maxSize;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/filespace.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CHANNEL_ID } from "./const";
|
|
2
|
+
import { tuituiRobotApi, downloadUrl, parseChannelIdBySessionKey, tuituiRobotUpload} from "./robot_api"
|
|
1
3
|
|
|
2
4
|
export interface FileSpaceItem {
|
|
3
5
|
filename: string;
|
|
@@ -42,4 +44,181 @@ export function flattenFileSpaceList(list: any[]): FileSpaceItem[] {
|
|
|
42
44
|
url: node.file_url,
|
|
43
45
|
filesize: node.file_size,
|
|
44
46
|
}));
|
|
45
|
-
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async function getChannelInfoBySessionKey(account: any, sessionKey: string) {
|
|
52
|
+
const channel_id = parseChannelIdBySessionKey(sessionKey);
|
|
53
|
+
if(!channel_id) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = {channel_id: channel_id};
|
|
58
|
+
const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
|
|
59
|
+
return body?.datas?.info;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function file_space_list(
|
|
63
|
+
account: any,
|
|
64
|
+
sessionKey: string
|
|
65
|
+
): Promise<FileSpaceItem[]> {
|
|
66
|
+
|
|
67
|
+
const channelInfo = await getChannelInfoBySessionKey(account, sessionKey);
|
|
68
|
+
if(!channelInfo){
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const payload = {
|
|
73
|
+
space_id: channelInfo.team_id,
|
|
74
|
+
space_type: "2",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const data = await tuituiRobotApi(account, '/file_space/node/list', payload);
|
|
78
|
+
|
|
79
|
+
const list = data?.datas?.list;
|
|
80
|
+
const flat_list = flattenFileSpaceList(list);
|
|
81
|
+
|
|
82
|
+
console.log(`[${CHANNEL_ID}] file_space_list `, flat_list);
|
|
83
|
+
return flat_list
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 确保目录路径存在,自动创建缺失的文件夹,返回最终目录的 parent_id
|
|
88
|
+
* @param account - TuiTui 账号
|
|
89
|
+
* @param spaceId - 空间 ID
|
|
90
|
+
* @param folderPath - 文件夹路径,如 "/projects/2024/q1"
|
|
91
|
+
* @returns 最终文件夹的 parent_id(用于添加文件)
|
|
92
|
+
*/
|
|
93
|
+
async function ensureFolderPath(
|
|
94
|
+
account: any,
|
|
95
|
+
spaceId: string,
|
|
96
|
+
folderPath: string
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
// 标准化路径
|
|
99
|
+
const normalizedPath = folderPath.startsWith('/') ? folderPath : '/' + folderPath;
|
|
100
|
+
|
|
101
|
+
// 如果是根目录,返回空字符串
|
|
102
|
+
if (normalizedPath === '/') {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 分割路径层级
|
|
107
|
+
const pathParts = normalizedPath.split('/').filter(part => part.trim());
|
|
108
|
+
if (pathParts.length === 0) {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 一次性获取所有节点
|
|
113
|
+
const listPayload = {
|
|
114
|
+
space_id: spaceId,
|
|
115
|
+
space_type: "2",
|
|
116
|
+
};
|
|
117
|
+
const listData = await tuituiRobotApi(account, '/file_space/node/list', listPayload);
|
|
118
|
+
const allNodes = listData?.datas?.list || [];
|
|
119
|
+
|
|
120
|
+
// 构建父目录ID映射,便于查找
|
|
121
|
+
const parentIdToNodes = new Map<string, any[]>();
|
|
122
|
+
allNodes.forEach(node => {
|
|
123
|
+
const parentId = node.parent_id || "";
|
|
124
|
+
if (!parentIdToNodes.has(parentId)) {
|
|
125
|
+
parentIdToNodes.set(parentId, []);
|
|
126
|
+
}
|
|
127
|
+
parentIdToNodes.get(parentId)!.push(node);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// 逐级查找或创建文件夹
|
|
131
|
+
let currentParentId = ""; // 从根目录开始
|
|
132
|
+
|
|
133
|
+
for (const folderName of pathParts) {
|
|
134
|
+
// 在当前层级查找文件夹
|
|
135
|
+
const siblings = parentIdToNodes.get(currentParentId) || [];
|
|
136
|
+
let folderNode = siblings.find(node =>
|
|
137
|
+
node.node_type === '1' && // 文件夹
|
|
138
|
+
node.name === folderName
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// 如果不存在,创建新文件夹
|
|
142
|
+
if (!folderNode) {
|
|
143
|
+
const createFolderPayload = {
|
|
144
|
+
space_id: spaceId,
|
|
145
|
+
space_type: "2",
|
|
146
|
+
node_type: "1", // 文件夹
|
|
147
|
+
name: folderName,
|
|
148
|
+
parent_id: currentParentId,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const createResult = await tuituiRobotApi(account, '/file_space/node/add', createFolderPayload);
|
|
152
|
+
folderNode = {
|
|
153
|
+
node_id: createResult?.datas?.node_id || createResult?.node_id,
|
|
154
|
+
name: folderName,
|
|
155
|
+
node_type: '1',
|
|
156
|
+
parent_id: currentParentId,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (!folderNode.node_id) {
|
|
160
|
+
throw new Error(`[${CHANNEL_ID}] Failed to create folder: ${folderName}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 更新缓存,避免后续重复创建
|
|
164
|
+
if (!parentIdToNodes.has(currentParentId)) {
|
|
165
|
+
parentIdToNodes.set(currentParentId, []);
|
|
166
|
+
}
|
|
167
|
+
parentIdToNodes.get(currentParentId)!.push(folderNode);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
currentParentId = folderNode.node_id;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return currentParentId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function file_space_add(
|
|
177
|
+
account: any,
|
|
178
|
+
sessionKey: string,
|
|
179
|
+
cloud_filepath: string,
|
|
180
|
+
url_or_localpath: string,
|
|
181
|
+
): Promise<any> {
|
|
182
|
+
const channelInfo = await getChannelInfoBySessionKey(account, sessionKey);
|
|
183
|
+
if (!channelInfo) {
|
|
184
|
+
throw new Error(`私聊、群聊会话不支持共享空间。仅团队频道支持。`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 1. 解析云路径,分离目录和文件名
|
|
188
|
+
const normalizedPath = cloud_filepath.startsWith('/')
|
|
189
|
+
? cloud_filepath
|
|
190
|
+
: '/' + cloud_filepath;
|
|
191
|
+
|
|
192
|
+
const pathParts = normalizedPath.split('/').filter(part => part);
|
|
193
|
+
const filename = pathParts.length > 0 ? pathParts[pathParts.length - 1] : 'unnamed';
|
|
194
|
+
const folderPath = pathParts.length > 0
|
|
195
|
+
? '/' + pathParts.slice(0, -1).join('/')
|
|
196
|
+
: '';
|
|
197
|
+
|
|
198
|
+
// 2. 确保目标目录存在,获取 parent_id
|
|
199
|
+
const parentId = await ensureFolderPath(account, channelInfo.team_id, folderPath);
|
|
200
|
+
|
|
201
|
+
// 3. 上传文件获取 fid
|
|
202
|
+
const isImage = /^data:image\//i.test(url_or_localpath) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(url_or_localpath);
|
|
203
|
+
const mediaType = isImage ? 'image' : 'file';
|
|
204
|
+
const uploadResult = await tuituiRobotUpload(url_or_localpath, account, mediaType);
|
|
205
|
+
const { fid, filesize } = uploadResult;
|
|
206
|
+
|
|
207
|
+
console.log(`[${CHANNEL_ID}] file_space_add: uploading ${normalizedPath}, parentId=${parentId}`);
|
|
208
|
+
|
|
209
|
+
// 4. 在指定目录中添加文件
|
|
210
|
+
const filePayload = {
|
|
211
|
+
space_id: channelInfo.team_id,
|
|
212
|
+
space_type: "2",
|
|
213
|
+
node_type: "2", // 文件
|
|
214
|
+
name: filename,
|
|
215
|
+
fid: fid,
|
|
216
|
+
file_size: filesize?.toString() || "0",
|
|
217
|
+
parent_id: parentId,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const body = await tuituiRobotApi(account, '/file_space/node/add', filePayload);
|
|
221
|
+
console.log(`[${CHANNEL_ID}] file_space_add: uploaded ${normalizedPath}`);
|
|
222
|
+
|
|
223
|
+
return body;
|
|
224
|
+
}
|
package/src/inbound.ts
CHANGED
|
@@ -20,13 +20,15 @@ import {
|
|
|
20
20
|
sendMediaMsg,
|
|
21
21
|
teamsBuildChatId,
|
|
22
22
|
teamsParseChatId,
|
|
23
|
+
get_announcement,
|
|
23
24
|
} from "./outbound";
|
|
24
25
|
import { parseChatMessageBody } from './inbound_body_parse';
|
|
25
26
|
import { parseAllowFroms } from './utils';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
import { addUnmentionedHistory, popUnmentionedHistories } from "./histories";
|
|
28
|
+
import { StringDeduplicator } from "./deduplicator"
|
|
29
|
+
|
|
30
|
+
// 会话上下文注入排重
|
|
31
|
+
let _session_ctx_injected = new StringDeduplicator(1000, 3600);
|
|
30
32
|
|
|
31
33
|
/** 子函数共享的可变上下文,子函数直接修改字段,外层读取结果 */
|
|
32
34
|
interface ChatPayload {
|
|
@@ -352,9 +354,17 @@ async function parse_teams_post(payload: ChatPayload, msgData: any, account: Inb
|
|
|
352
354
|
}
|
|
353
355
|
}
|
|
354
356
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
357
|
+
if(_session_ctx_injected.checkAndRecord(accountId + "_" + payload.chatId)) {
|
|
358
|
+
// 注入上下文
|
|
359
|
+
// 解决机器人不知道自己是谁的问题
|
|
360
|
+
if(payload.botName) {
|
|
361
|
+
payload.text += `\n[备忘]\n你在当前session中的名字叫: ${payload.botName}\n 如果有人@这个名字,就是在命令你`;
|
|
362
|
+
}
|
|
363
|
+
// 公告
|
|
364
|
+
const annnouncement = await get_announcement(account, channel_id, false);
|
|
365
|
+
if(annnouncement) {
|
|
366
|
+
payload.text += `\n[当前公告内容如下--有需要时可参考]\n${annnouncement}`;
|
|
367
|
+
}
|
|
358
368
|
}
|
|
359
369
|
}
|
|
360
370
|
|
package/src/outbound.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
import { basename } from 'node:path';
|
|
1
|
+
|
|
3
2
|
import { CHANNEL_ID } from "./const";
|
|
4
|
-
import {
|
|
5
|
-
import { tuituiRobotApi, downloadUrl } from "./robot_api"
|
|
3
|
+
import { tuituiRobotApi, parseChannelIdBySessionKey, tuituiRobotUpload } from "./robot_api"
|
|
6
4
|
|
|
7
5
|
import type {
|
|
8
6
|
TuiTuiMessageData,
|
|
9
|
-
TuiTuiMediaUploadResponse,
|
|
10
7
|
TuiTuiSingleEmojiReactionTarget,
|
|
11
8
|
TuiTuiGroupEmojiReactionTarget,
|
|
12
9
|
TuiTuiOutboundTextMessage,
|
|
@@ -32,120 +29,11 @@ export function guessChatType(chatId: string): ChatType {
|
|
|
32
29
|
return CHAT_TYPE_DIRECT;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
const mimeTypes: Record<string, string> = {
|
|
36
|
-
jpg: 'image/jpeg',
|
|
37
|
-
jpeg: 'image/jpeg',
|
|
38
|
-
png: 'image/png',
|
|
39
|
-
gif: 'image/gif',
|
|
40
|
-
webp: 'image/webp',
|
|
41
|
-
bmp: 'image/bmp',
|
|
42
|
-
svg: 'image/svg+xml',
|
|
43
|
-
pdf: 'application/pdf',
|
|
44
|
-
txt: 'text/plain',
|
|
45
|
-
json: 'application/json',
|
|
46
|
-
mp3: 'audio/mpeg',
|
|
47
|
-
mp4: 'video/mp4',
|
|
48
|
-
};
|
|
49
|
-
/** Get MIME type from file extension. */
|
|
50
|
-
export function getMimeType(filename: string): string {
|
|
51
|
-
const fns = filename.split('.');
|
|
52
|
-
const ext = fns.length > 1 ? fns[fns.length - 1].toLowerCase() : '';
|
|
53
|
-
return mimeTypes[ext] || 'application/octet-stream';
|
|
54
|
-
}
|
|
55
32
|
|
|
56
33
|
export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
|
|
57
34
|
await tuituiRobotApi(account, '/message/custom/send', json);
|
|
58
35
|
}
|
|
59
36
|
|
|
60
|
-
interface tuituiUploadResult {
|
|
61
|
-
fid: string;
|
|
62
|
-
filename: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Upload file to TuiTui and get media_id.
|
|
67
|
-
* Supports:
|
|
68
|
-
* - HTTP/HTTPS URLs (downloads then uploads)
|
|
69
|
-
* - Local file paths (reads then uploads)
|
|
70
|
-
* - Base64 data URLs (decodes then uploads)
|
|
71
|
-
*
|
|
72
|
-
* @param fileSrc - The URL, file path, or data URL of the media
|
|
73
|
-
* @param account - TuiTui app account id and secret
|
|
74
|
-
* @param type - Media type: 'image' or "file" (auto-detected if not specified)
|
|
75
|
-
* @returns The media_id from TuiTui
|
|
76
|
-
*/
|
|
77
|
-
export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult> {
|
|
78
|
-
let fileBuffer: ArrayBuffer;
|
|
79
|
-
let contentType: string;
|
|
80
|
-
let filename: string;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Check if it's a Base64 data URL
|
|
84
|
-
if (/^data\:/.test(fileSrc)) {
|
|
85
|
-
console.log("uploadFileToTuiTui: Base64 data");
|
|
86
|
-
|
|
87
|
-
const matches = fileSrc.match(/^data:([^;,]*)(;base64)?,(.*)$/);
|
|
88
|
-
if (!matches) {
|
|
89
|
-
throw new Error(`[${CHANNEL_ID}] Invalid data URL format`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
contentType = matches[1] || 'application/octet-stream';
|
|
93
|
-
const isBase64 = !!matches[2];
|
|
94
|
-
const data = matches[3];
|
|
95
|
-
|
|
96
|
-
if (isBase64) {
|
|
97
|
-
// Use atob if available; fallback to Buffer for Node environments
|
|
98
|
-
const binaryStr = typeof atob === 'function' ? atob(data) : Buffer.from(data, 'base64').toString('binary');
|
|
99
|
-
const bytes = new Uint8Array(binaryStr.length);
|
|
100
|
-
for (let i = 0; i < binaryStr.length; i++) {
|
|
101
|
-
bytes[i] = binaryStr.charCodeAt(i);
|
|
102
|
-
}
|
|
103
|
-
fileBuffer = bytes.buffer;
|
|
104
|
-
} else {
|
|
105
|
-
fileBuffer = new TextEncoder().encode(decodeURIComponent(data)).buffer;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const ext = contentType.split("/")[1] || "bin";
|
|
109
|
-
filename = `media_${Date.now()}.${ext}`;
|
|
110
|
-
}
|
|
111
|
-
// HTTP/HTTPS URL
|
|
112
|
-
else if (/^https?\:/.test(fileSrc)) {
|
|
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;
|
|
118
|
-
}
|
|
119
|
-
// Check if it's a local file path
|
|
120
|
-
else {
|
|
121
|
-
console.log("uploadFileToTuiTui: local", fileSrc);
|
|
122
|
-
if (!existsSync(fileSrc)) {
|
|
123
|
-
throw new Error(`[${CHANNEL_ID}] Local file not found: ${fileSrc}`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const stats = statSync(fileSrc);
|
|
127
|
-
const maxSize = 10 * 1024 * 1024;
|
|
128
|
-
if (stats.size > maxSize) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
`[${CHANNEL_ID}] File too large: ${fileSrc} (${(stats.size / 1024 / 1024).toFixed(2)}MB > 10MB limit)`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const localFileBuffer = readFileSync(fileSrc);
|
|
135
|
-
const { byteOffset, byteLength } = localFileBuffer;
|
|
136
|
-
fileBuffer = localFileBuffer.buffer.slice(byteOffset, byteOffset + byteLength);
|
|
137
|
-
|
|
138
|
-
filename = basename(fileSrc);
|
|
139
|
-
contentType = getMimeType(filename);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const body = new FormData();
|
|
143
|
-
body.append('media', new Blob([fileBuffer], { type: contentType }), filename);
|
|
144
|
-
|
|
145
|
-
const result: TuiTuiMediaUploadResponse = await tuituiRobotApi(account, '/media/upload', body);
|
|
146
|
-
return {fid: result.media_id||"", filename};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
37
|
export async function tuituiEmojiReaction(
|
|
150
38
|
account: any,
|
|
151
39
|
target: string,
|
|
@@ -331,7 +219,7 @@ export async function sendMediaMsg(
|
|
|
331
219
|
const isImage = /^data:image\//i.test(mediaUrl) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(mediaUrl);
|
|
332
220
|
const mediaType = isImage ? 'image' : 'file';
|
|
333
221
|
|
|
334
|
-
const uploadResult = await
|
|
222
|
+
const uploadResult = await tuituiRobotUpload(mediaUrl, account, mediaType);
|
|
335
223
|
const {fid, filename} = uploadResult;
|
|
336
224
|
|
|
337
225
|
const targets = getTargets(chatId, chatType);
|
|
@@ -546,60 +434,20 @@ export async function getPostChainByChatId(
|
|
|
546
434
|
return await getPostChain(account, team_id, channel_id, parent_id||"");
|
|
547
435
|
}
|
|
548
436
|
|
|
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
437
|
|
|
593
438
|
// TODO: 支持群公告
|
|
594
|
-
export async function get_announcement(account: any,
|
|
595
|
-
|
|
596
|
-
if(
|
|
597
|
-
|
|
439
|
+
export async function get_announcement(account: any, id: any, id_is_session: boolean = true): Promise<any> {
|
|
440
|
+
let channel_id = id;
|
|
441
|
+
if(id_is_session) {
|
|
442
|
+
channel_id = parseChannelIdBySessionKey(id);
|
|
443
|
+
if(!channel_id) {
|
|
444
|
+
return "";
|
|
445
|
+
}
|
|
598
446
|
}
|
|
599
447
|
|
|
600
448
|
const payload = {channel_id: channel_id};
|
|
601
449
|
const body = await tuituiRobotApi(account, '/teams/channel/info', payload);
|
|
602
450
|
//console.log("info", data);
|
|
603
451
|
const announcement = body?.datas?.info?.announcement;
|
|
604
|
-
return
|
|
452
|
+
return announcement;
|
|
605
453
|
}
|
package/src/robot_api.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
1
3
|
import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk/tlon';
|
|
2
4
|
import { CHANNEL_ID } from "./const";
|
|
3
5
|
import { TUITUI_SSRF_POLICY, getTuituiApiHost } from "./env"
|
|
@@ -121,3 +123,146 @@ export async function downloadUrl(url: string): Promise<{ buffer: ArrayBuffer; f
|
|
|
121
123
|
await release();
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
const mimeTypes: Record<string, string> = {
|
|
130
|
+
jpg: 'image/jpeg',
|
|
131
|
+
jpeg: 'image/jpeg',
|
|
132
|
+
png: 'image/png',
|
|
133
|
+
gif: 'image/gif',
|
|
134
|
+
webp: 'image/webp',
|
|
135
|
+
bmp: 'image/bmp',
|
|
136
|
+
svg: 'image/svg+xml',
|
|
137
|
+
pdf: 'application/pdf',
|
|
138
|
+
txt: 'text/plain',
|
|
139
|
+
json: 'application/json',
|
|
140
|
+
mp3: 'audio/mpeg',
|
|
141
|
+
mp4: 'video/mp4',
|
|
142
|
+
};
|
|
143
|
+
/** Get MIME type from file extension. */
|
|
144
|
+
export function getMimeType(filename: string): string {
|
|
145
|
+
const fns = filename.split('.');
|
|
146
|
+
const ext = fns.length > 1 ? fns[fns.length - 1].toLowerCase() : '';
|
|
147
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface tuituiUploadResult {
|
|
151
|
+
fid: string;
|
|
152
|
+
filename: string;
|
|
153
|
+
filesize: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface TuiTuiMediaUploadResponse {
|
|
157
|
+
errcode: number;
|
|
158
|
+
errmsg: string;
|
|
159
|
+
filename?: string;
|
|
160
|
+
media_id?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Upload file to TuiTui and get media_id.
|
|
167
|
+
* Supports:
|
|
168
|
+
* - HTTP/HTTPS URLs (downloads then uploads)
|
|
169
|
+
* - Local file paths (reads then uploads)
|
|
170
|
+
* - Base64 data URLs (decodes then uploads)
|
|
171
|
+
*
|
|
172
|
+
* @param fileSrc - The URL, file path, or data URL of the media
|
|
173
|
+
* @param account - TuiTui app account id and secret
|
|
174
|
+
* @param type - Media type: 'image' or "file" (auto-detected if not specified)
|
|
175
|
+
* @returns The media_id from TuiTui
|
|
176
|
+
*/
|
|
177
|
+
export async function tuituiRobotUpload(fileSrc: string, account: any, type: 'image' | 'file'): Promise<tuituiUploadResult> {
|
|
178
|
+
let fileBuffer: ArrayBuffer;
|
|
179
|
+
let contentType: string;
|
|
180
|
+
let filename: string;
|
|
181
|
+
let filesize = 0;
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
// Check if it's a Base64 data URL
|
|
185
|
+
if (/^data\:/.test(fileSrc)) {
|
|
186
|
+
console.log("tuituiRobotUpload: Base64 data");
|
|
187
|
+
|
|
188
|
+
const matches = fileSrc.match(/^data:([^;,]*)(;base64)?,(.*)$/);
|
|
189
|
+
if (!matches) {
|
|
190
|
+
throw new Error(`[${CHANNEL_ID}] Invalid data URL format`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
contentType = matches[1] || 'application/octet-stream';
|
|
194
|
+
const isBase64 = !!matches[2];
|
|
195
|
+
const data = matches[3];
|
|
196
|
+
|
|
197
|
+
if (isBase64) {
|
|
198
|
+
// Use atob if available; fallback to Buffer for Node environments
|
|
199
|
+
const binaryStr = typeof atob === 'function' ? atob(data) : Buffer.from(data, 'base64').toString('binary');
|
|
200
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
201
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
202
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
203
|
+
}
|
|
204
|
+
fileBuffer = bytes.buffer;
|
|
205
|
+
} else {
|
|
206
|
+
fileBuffer = new TextEncoder().encode(decodeURIComponent(data)).buffer;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ext = contentType.split("/")[1] || "bin";
|
|
210
|
+
filename = `media_${Date.now()}.${ext}`;
|
|
211
|
+
}
|
|
212
|
+
// HTTP/HTTPS URL
|
|
213
|
+
else if (/^https?\:/.test(fileSrc)) {
|
|
214
|
+
console.log("tuituiRobotUpload: url", fileSrc);
|
|
215
|
+
const { buffer, filename: downloadedFilename, contentType: downloadedContentType } = await downloadUrl(fileSrc);
|
|
216
|
+
fileBuffer = buffer;
|
|
217
|
+
filename = downloadedFilename;
|
|
218
|
+
contentType = downloadedContentType;
|
|
219
|
+
}
|
|
220
|
+
// Check if it's a local file path
|
|
221
|
+
else {
|
|
222
|
+
console.log("tuituiRobotUpload: local", fileSrc);
|
|
223
|
+
if (!existsSync(fileSrc)) {
|
|
224
|
+
throw new Error(`[${CHANNEL_ID}] Local file not found: ${fileSrc}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stats = statSync(fileSrc);
|
|
228
|
+
const maxSize = 10 * 1024 * 1024;
|
|
229
|
+
if (stats.size > maxSize) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[${CHANNEL_ID}] File too large: ${fileSrc} (${(stats.size / 1024 / 1024).toFixed(2)}MB > 10MB limit)`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const localFileBuffer = readFileSync(fileSrc);
|
|
236
|
+
const { byteOffset, byteLength } = localFileBuffer;
|
|
237
|
+
fileBuffer = localFileBuffer.buffer.slice(byteOffset, byteOffset + byteLength);
|
|
238
|
+
|
|
239
|
+
filename = basename(fileSrc);
|
|
240
|
+
contentType = getMimeType(filename);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
filesize = fileBuffer.byteLength;
|
|
244
|
+
|
|
245
|
+
const body = new FormData();
|
|
246
|
+
body.append('media', new Blob([fileBuffer], { type: contentType }), filename);
|
|
247
|
+
|
|
248
|
+
const result: TuiTuiMediaUploadResponse = await tuituiRobotApi(account, '/media/upload', body);
|
|
249
|
+
return {fid: result.media_id||"", filename, filesize};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
export function parseChannelIdBySessionKey(str: string): string {
|
|
255
|
+
// 检查是否包含必须的格式 "tuitui:channel:"
|
|
256
|
+
if (!str.includes('tuitui:channel:')) {
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const parts = str.split(':');
|
|
261
|
+
const channelIndex = parts.findIndex(part => part === 'channel');
|
|
262
|
+
|
|
263
|
+
if (channelIndex !== -1 && parts[channelIndex + 1]) {
|
|
264
|
+
return parts[channelIndex + 1];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return "";
|
|
268
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveAccount } from "./accounts"
|
|
|
5
5
|
import { Type } from "@sinclair/typebox";
|
|
6
6
|
|
|
7
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 "./
|
|
8
|
+
import {file_space_list, file_space_add} from "./filespace"
|
|
9
9
|
|
|
10
10
|
function tool_errmsg(str:string) {
|
|
11
11
|
const ret = `error: ${str}`
|
|
@@ -92,9 +92,11 @@ const tuitui_get_announcement_factory = (ctx: OpenClawPluginToolContext) => {
|
|
|
92
92
|
label: "tuitui_get_announcement",
|
|
93
93
|
description: "获取推推(tuitui) 当前会话的公告信息\n\n",
|
|
94
94
|
parameters: Type.Object({
|
|
95
|
+
// 参数不能为空,对模型兼容有问题,即使不用也要传一个
|
|
96
|
+
reason: Type.String({ description: "调用本工具的原因" }),
|
|
95
97
|
}),
|
|
96
98
|
execute: async (_toolCallId: any, params: any) => {
|
|
97
|
-
console.log(`tuitui_get_announcement(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}
|
|
99
|
+
console.log(`tuitui_get_announcement(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`);
|
|
98
100
|
const account = resolveAccount(ctx.config, ctx.agentAccountId);
|
|
99
101
|
if(!account || !account.enabled || !account.appId || !account.appSecret) {
|
|
100
102
|
return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
|
|
@@ -107,21 +109,42 @@ const tuitui_get_announcement_factory = (ctx: OpenClawPluginToolContext) => {
|
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
|
|
110
|
-
const
|
|
112
|
+
const tuitui_file_space_list_factory = (ctx: OpenClawPluginToolContext) => {
|
|
111
113
|
return {
|
|
112
114
|
name: "tuitui_file_space_list",
|
|
113
115
|
label: "tuitui_file_space_list",
|
|
114
|
-
description: "推推(tuitui)
|
|
116
|
+
description: "推推(tuitui) 在当前Session的共享文件(共享空间)文件列表\n\n",
|
|
115
117
|
parameters: Type.Object({
|
|
116
|
-
|
|
118
|
+
// 参数不能为空,对模型兼容有问题,即使不用也要传一个
|
|
119
|
+
reason: Type.String({ description: "调用本工具的原因" }),
|
|
117
120
|
}),
|
|
118
121
|
execute: async (_toolCallId: any, params: any) => {
|
|
119
|
-
console.log(`tuitui_file_space_list(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}
|
|
122
|
+
console.log(`tuitui_file_space_list(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`);
|
|
120
123
|
const account = resolveAccount(ctx.config, ctx.agentAccountId);
|
|
121
124
|
if(!account || !account.enabled || !account.appId || !account.appSecret) {
|
|
122
125
|
return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
|
|
123
126
|
}
|
|
124
|
-
return await file_space_list(account,
|
|
127
|
+
return await file_space_list(account, ctx.sessionKey||"");
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const tuitui_file_space_add_factory = (ctx: OpenClawPluginToolContext) => {
|
|
133
|
+
return {
|
|
134
|
+
name: "tuitui_file_space_add",
|
|
135
|
+
label: "tuitui_file_space_add",
|
|
136
|
+
description: "推推(tuitui) 团队频道的共享文件(共享空间)添加文件\n\n",
|
|
137
|
+
parameters: Type.Object({
|
|
138
|
+
cloud_filepath: Type.String({ description: "云端文件名路径,父目录不存在会自动创建。不含目录代表放根目录示例 1.png ,也支持带目录写法例如 /image/1.png" }),
|
|
139
|
+
url_or_localpath: Type.String({ description: "支持两种格式:以http开头的文件url;或者本地全路径文件名" }),
|
|
140
|
+
}),
|
|
141
|
+
execute: async (_toolCallId: any, params: any) => {
|
|
142
|
+
console.log(`tuitui_file_space_add(): agentAccountId: ${ctx.agentAccountId}, sessionKey: ${ctx.sessionKey}`, params);
|
|
143
|
+
const account = resolveAccount(ctx.config, ctx.agentAccountId);
|
|
144
|
+
if(!account || !account.enabled || !account.appId || !account.appSecret) {
|
|
145
|
+
return tool_errmsg(`invalid tuitui account ${ctx.agentAccountId}`);
|
|
146
|
+
}
|
|
147
|
+
return await file_space_add(account, ctx.sessionKey||"", params?.cloud_filepath, params?.url_or_localpath);
|
|
125
148
|
},
|
|
126
149
|
};
|
|
127
150
|
};
|
|
@@ -136,6 +159,7 @@ export function registerTuituiTools(api: OpenClawPluginApi) {
|
|
|
136
159
|
api.registerTool(tuitui_im_get_messages_factory);
|
|
137
160
|
api.registerTool(tuitui_send_channel_post_factory);
|
|
138
161
|
api.registerTool(tuitui_get_announcement_factory);
|
|
139
|
-
api.registerTool(
|
|
162
|
+
api.registerTool(tuitui_file_space_list_factory);
|
|
163
|
+
api.registerTool(tuitui_file_space_add_factory);
|
|
140
164
|
api.logger.info?.(`tuitui: Registered tool`);
|
|
141
165
|
}
|
package/src/types.ts
CHANGED
|
@@ -128,15 +128,6 @@ export interface TuiTuiOutboundPageMessage {
|
|
|
128
128
|
page: TuiTuiOutboundPageMessagePage;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
export interface TuiTuiMediaUploadResponse {
|
|
132
|
-
errcode: number;
|
|
133
|
-
errmsg: string;
|
|
134
|
-
filename?: string;
|
|
135
|
-
media_id?: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
131
|
export interface TuiTuiSingleEmojiReactionTarget {
|
|
141
132
|
user?: string;
|
|
142
133
|
msgid?: string;
|