@qihoo/tuitui-openclaw-channel 1.0.23 → 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/filespace.ts +180 -1
- package/src/outbound.ts +4 -159
- package/src/robot_api.ts +145 -0
- package/src/tools.ts +32 -8
- package/src/types.ts +0 -9
package/package.json
CHANGED
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/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,55 +434,12 @@ 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
439
|
export async function get_announcement(account: any, id: any, id_is_session: boolean = true): Promise<any> {
|
|
595
440
|
let channel_id = id;
|
|
596
441
|
if(id_is_session) {
|
|
597
|
-
channel_id =
|
|
442
|
+
channel_id = parseChannelIdBySessionKey(id);
|
|
598
443
|
if(!channel_id) {
|
|
599
444
|
return "";
|
|
600
445
|
}
|
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;
|