@qihoo/tuitui-openclaw-channel 2026.3.1-3.13

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/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared TypeScript types for the TuiTui channel plugin.
3
+ */
4
+
5
+ export interface TuiTuiInboundMessage {
6
+ cid: string;
7
+ uid: string;
8
+ user_account: string;
9
+ user_name: string;
10
+ timestamp: string;
11
+ event: 'single_chat' | 'group_chat';
12
+ data: TuiTuiMessageData;
13
+ }
14
+
15
+ export interface TuiTuiMessageData {
16
+ msgid: string;
17
+ msg_type: 'text' | 'image' | 'mixed' | 'voice' | 'file';
18
+ text?: string;
19
+ images?: string[];
20
+ image_ids?: string[];
21
+ voice?: string;
22
+ voice_id?: string;
23
+ file?: { name: string; url: string; file_id: string };
24
+ // Group chat fields
25
+ group_id?: string;
26
+ group_name?: string;
27
+ at_me?: boolean;
28
+ at?: Array<{ is_at_all: boolean; cid?: string; uid?: string; name?: string }>;
29
+ // Reference/reply fields
30
+ ref?: {
31
+ cid: string;
32
+ uid: string;
33
+ user_account?: string;
34
+ user_name: string;
35
+ is_me?: boolean;
36
+ msgid: string;
37
+ msg_type: 'text' | 'image' | 'mixed' | 'file' | 'voice';
38
+ text?: string;
39
+ images?: string[];
40
+ image_ids?: string[];
41
+ };
42
+ }
43
+
44
+ export interface TuiTuiOutboundTextMessage {
45
+ tousers: string[];
46
+ togroups: string[];
47
+ at: string[];
48
+ msgtype: 'text';
49
+ text: { content: string; reference_msgid?: string };
50
+ }
51
+
52
+ export interface TuiTuiOutboundLinkMessage {
53
+ tousers: string[];
54
+ togroups: string[];
55
+ msgtype: 'link';
56
+ link: {
57
+ url: string;
58
+ title: string;
59
+ content?: string;
60
+ image?: string;
61
+ };
62
+ }
63
+
64
+ export interface TuiTuiOutboundImageMessage {
65
+ tousers?: string[];
66
+ togroups?: string[];
67
+ at?: string;
68
+ msgtype: 'image';
69
+ image: { media_id: string };
70
+ }
71
+
72
+ export interface TuiTuiOutboundAttachmentMessage {
73
+ tousers?: string[];
74
+ togroups?: string[];
75
+ at?: string;
76
+ msgtype: 'attachment';
77
+ attachment: { media_id: string };
78
+ }
79
+
80
+ export interface TuiTuiOutboundPageMessage {
81
+ tousers: string[];
82
+ togroups: string[];
83
+ msgtype: 'page';
84
+ page: {
85
+ title: string;
86
+ summary?: string;
87
+ content: string;
88
+ image?: string;
89
+ format?: 'html';
90
+ privilege?: 'specific' | 'scope' | 'corp' | 'any';
91
+ delims_left?: string;
92
+ delims_right?: string;
93
+ kv?: Record<string, string>;
94
+ default_value?: string;
95
+ debug?: boolean;
96
+ };
97
+ }
98
+
99
+ export type TuiTuiOutboundMessage =
100
+ | TuiTuiOutboundTextMessage
101
+ | TuiTuiOutboundLinkMessage
102
+ | TuiTuiOutboundImageMessage
103
+ | TuiTuiOutboundAttachmentMessage
104
+ | TuiTuiOutboundPageMessage;
105
+
106
+ export interface TuiTuiMediaUploadResponse {
107
+ errcode: number;
108
+ errmsg: string;
109
+ filename?: string;
110
+ media_id?: string;
111
+ }
112
+
113
+
114
+
115
+ export interface TuiTuiSingleEmojiReactionTarget {
116
+ user?: string;
117
+ msgid?: string;
118
+ }
119
+
120
+ export interface TuiTuiGroupEmojiReactionTarget {
121
+ group?: string;
122
+ msgid?: string;
123
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,344 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, existsSync, statSync } from 'node:fs';
3
+ import type { IncomingMessage } from 'node:http';
4
+ import { basename } from 'node:path';
5
+ import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk';
6
+ import type { TuiTuiMessageData, TuiTuiMediaUploadResponse, TuiTuiSingleEmojiReactionTarget, TuiTuiGroupEmojiReactionTarget } from './types';
7
+
8
+ /* 一些常量配置 */
9
+ export const CHANNEL_ID = 'tuitui';
10
+ export const CHANNEL_NAME = 'TuiTui';
11
+ export const TUITUI_SSRF_POLICY = { allowedHostnames: ['im.live.360.cn'] } as const;
12
+
13
+ export function addParams2Url(urlStr: string, params: any) {
14
+ const url = new URL(urlStr);
15
+ for (let k in params) url.searchParams.set(k, params[k]);
16
+ return url.toString();
17
+ }
18
+
19
+ /** Detect media type from filename or content type. */
20
+ export function detectMediaType(filename: string, contentType?: string): 'image' | 'file' {
21
+ return /^image\//i.test(contentType) || /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(filename) ? 'image' : 'file';
22
+ }
23
+
24
+ export function getExt(filename: string) : string {
25
+ const fns = filename.split('.');
26
+ return fns.length > 1 ? fns[fns.length - 1].toLowerCase() : '';
27
+ }
28
+
29
+ const mimeTypes: Record<string, string> = {
30
+ jpg: 'image/jpeg',
31
+ jpeg: 'image/jpeg',
32
+ png: 'image/png',
33
+ gif: 'image/gif',
34
+ webp: 'image/webp',
35
+ bmp: 'image/bmp',
36
+ svg: 'image/svg+xml',
37
+ pdf: 'application/pdf',
38
+ txt: 'text/plain',
39
+ json: 'application/json',
40
+ mp3: 'audio/mpeg',
41
+ mp4: 'video/mp4',
42
+ };
43
+ /** Get MIME type from file extension. */
44
+ export function getMimeType(filename: string): string {
45
+ return mimeTypes[getExt(filename)] || 'application/octet-stream';
46
+ }
47
+
48
+ function _fetch(opts: any): Promise<any> {
49
+ return fetchWithSsrFGuard({
50
+ //url: mediaSrc,
51
+ policy: TUITUI_SSRF_POLICY,
52
+ //auditContext: "tuitui.media.download",
53
+ ...opts,
54
+ })
55
+ }
56
+ function _fetchJson(url: string, json: any, auditContext: string): Promise<any> {
57
+ return _fetch({
58
+ url,
59
+ init: {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(json),
63
+ },
64
+ auditContext,
65
+ });
66
+ }
67
+ export async function postTuituiMsg(account: any, json: any, auditContext: string, log: any): Promise<any> {
68
+ const { appId: appid, appSecret: secret } = account;
69
+ const { response, release } = await _fetchJson(
70
+ addParams2Url('https://im.live.360.cn:8282/robot/message/custom/send', { appid, secret }),
71
+ json,
72
+ auditContext,
73
+ );
74
+ try {
75
+ const bodyText = await response.text();
76
+ let parsed: any = null;
77
+ try {
78
+ parsed = bodyText ? JSON.parse(bodyText) : null;
79
+ } catch(err) {
80
+ parsed = null;
81
+ }
82
+
83
+ log?.debug?.(
84
+ `[${CHANNEL_ID}] ${auditContext} response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`,
85
+ );
86
+
87
+ if (!response.ok) {
88
+ throw new Error(
89
+ `[${CHANNEL_ID}] ${auditContext} Failed: ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`,
90
+ );
91
+ }
92
+
93
+ if (parsed && typeof parsed.errcode === 'number' && parsed.errcode !== 0) {
94
+ throw new Error(
95
+ `[${CHANNEL_ID}] ${auditContext} Failed: errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`,
96
+ );
97
+ }
98
+ } finally {
99
+ await release();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Upload media to TuiTui and get media_id.
105
+ * Supports:
106
+ * - HTTP/HTTPS URLs (downloads then uploads)
107
+ * - Local file paths (reads then uploads)
108
+ * - Base64 data URLs (decodes then uploads)
109
+ *
110
+ * @param mediaSrc - The URL, file path, or data URL of the media
111
+ * @param appid - TuiTui app ID
112
+ * @param secret - TuiTui app secret
113
+ * @param type - Media type: 'image' or "file" (auto-detected if not specified)
114
+ * @returns The media_id from TuiTui
115
+ */
116
+ export async function uploadMediaToTuiTui(mediaSrc: string, appid: string, secret: string, type?: 'image' | 'file'): Promise<string> {
117
+ let mediaBuffer: ArrayBuffer;
118
+ let contentType: string;
119
+ let filename: string;
120
+
121
+ // Check if it's a Base64 data URL
122
+ if (/^data\:/.test(mediaSrc)) {
123
+ const matches = mediaSrc.match(/^data:([^;,]*)(;base64)?,(.*)$/);
124
+ if (!matches) {
125
+ throw new Error(`[${CHANNEL_ID}] Invalid data URL format`);
126
+ }
127
+
128
+ contentType = matches[1] || "application/octet-stream";
129
+ const isBase64 = !!matches[2];
130
+ const data = matches[3];
131
+
132
+ if (isBase64) {
133
+ // Use atob if available; fallback to Buffer for Node environments
134
+ let binaryString: string;
135
+ if (typeof atob === "function") {
136
+ binaryString = atob(data);
137
+ } else {
138
+ binaryString = Buffer.from(data, "base64").toString("binary");
139
+ }
140
+ const bytes = new Uint8Array(binaryString.length);
141
+ for (let i = 0; i < binaryString.length; i++) {
142
+ bytes[i] = binaryString.charCodeAt(i);
143
+ }
144
+ mediaBuffer = bytes.buffer;
145
+ } else {
146
+ mediaBuffer = new TextEncoder().encode(decodeURIComponent(data)).buffer;
147
+ }
148
+
149
+ const ext = contentType.split("/")[1] || "bin";
150
+ filename = `media_${Date.now()}.${ext}`;
151
+ }
152
+ // HTTP/HTTPS URL
153
+ else if (/^https?\:/.test(mediaSrc)) {
154
+ const { response, release } = await _fetch({
155
+ url: mediaSrc,
156
+ auditContext: 'tuitui.media.download',
157
+ });
158
+ try {
159
+ if (!response.ok) {
160
+ throw new Error(`[${CHANNEL_ID}] Failed to download media from ${mediaSrc}: ${response.status}`);
161
+ }
162
+
163
+ mediaBuffer = await response.arrayBuffer();
164
+ contentType = response.headers.get("content-type") || "application/octet-stream";
165
+
166
+ filename = "media";
167
+ const urlPath = new URL(mediaSrc).pathname;
168
+ const pathParts = urlPath.split("/");
169
+ if (pathParts.length > 0 && pathParts[pathParts.length - 1]) {
170
+ filename = pathParts[pathParts.length - 1];
171
+ }
172
+ const contentDisposition = response.headers.get("content-disposition");
173
+ if (contentDisposition) {
174
+ const match = contentDisposition.match(/filename\*?=(?:UTF-8''|")?([^";\r\n]+)"?/i);
175
+ if (match) {
176
+ filename = decodeURIComponent(match[1]);
177
+ }
178
+ }
179
+ } finally {
180
+ await release();
181
+ }
182
+ }
183
+ // Check if it's a local file path
184
+ else {
185
+ if (!existsSync(mediaSrc)) {
186
+ throw new Error(`[${CHANNEL_ID}] Local file not found: ${mediaSrc}`);
187
+ }
188
+
189
+ const stats = statSync(mediaSrc);
190
+ const maxSize = 10 * 1024 * 1024;
191
+ if (stats.size > maxSize) {
192
+ throw new Error(
193
+ `[${CHANNEL_ID}] File too large: ${mediaSrc} (${(stats.size / 1024 / 1024).toFixed(2)}MB > 10MB limit)`,
194
+ );
195
+ }
196
+
197
+ const fileBuffer = readFileSync(mediaSrc);
198
+ mediaBuffer = fileBuffer.buffer.slice(
199
+ fileBuffer.byteOffset,
200
+ fileBuffer.byteOffset + fileBuffer.byteLength,
201
+ );
202
+
203
+ filename = basename(mediaSrc);
204
+ contentType = getMimeType(filename);
205
+ }
206
+
207
+ const _type = type || detectMediaType(filename, contentType); // Auto-detect type if not specified
208
+ const uploadUrl = addParams2Url('https://im.live.360.cn:8282/robot/media/upload', { appid, secret, type: _type });
209
+
210
+ const formData = new FormData();
211
+ formData.append("media", new Blob([mediaBuffer], { type: contentType }), filename);
212
+
213
+ const { response, release } = await _fetch({
214
+ url: uploadUrl,
215
+ init: {
216
+ method: "POST",
217
+ body: formData,
218
+ },
219
+ auditContext: "tuitui.media.upload",
220
+ });
221
+ try {
222
+ if (!response.ok) {
223
+ throw new Error(`[${CHANNEL_ID}] Failed to upload media to TuiTui: ${response.status}`);
224
+ }
225
+
226
+ const result: TuiTuiMediaUploadResponse = await response.json();
227
+
228
+ if (result.errcode !== 0 || !result.media_id) {
229
+ throw new Error(`[${CHANNEL_ID}] TuiTui media upload failed: ${result.errmsg || "Unknown error"}`);
230
+ }
231
+
232
+ return result.media_id;
233
+ } finally {
234
+ await release();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Build message body text from TuiTui inbound message data.
240
+ * Handles text, image, voice, file, and reference messages.
241
+ */
242
+ export function buildMessageBody(data: TuiTuiMessageData): string {
243
+ const parts: string[] = [];
244
+
245
+ switch (data.msg_type) {
246
+ case "text":
247
+ if (data.text?.trim()) {
248
+ parts.push(data.text.trim());
249
+ }
250
+ break;
251
+
252
+ case "mixed":
253
+ if (data.text?.trim()) {
254
+ parts.push(data.text.trim());
255
+ }
256
+ if (data.images && data.images.length > 0) {
257
+ if (data.images.length === 1) {
258
+ parts.push(`[图片] ${data.images[0]}`);
259
+ } else {
260
+ parts.push(`[图片] 共 ${data.images.length} 张图片:`);
261
+ data.images.forEach((url, i) => parts.push(` ${i + 1}. ${url}`));
262
+ }
263
+ }
264
+ break;
265
+
266
+ case "image":
267
+ if (data.images && data.images.length > 0) {
268
+ if (data.images.length === 1) {
269
+ parts.push(`[图片] ${data.images[0]}`);
270
+ } else {
271
+ parts.push(`[图片] 共 ${data.images.length} 张图片:`);
272
+ data.images.forEach((url, i) => parts.push(` ${i + 1}. ${url}`));
273
+ }
274
+ }
275
+ break;
276
+
277
+ case "voice":
278
+ if (data.voice) {
279
+ parts.push(`[语音] ${data.voice}`);
280
+ }
281
+ break;
282
+
283
+ case "file":
284
+ if (data.file) {
285
+ parts.push(`[文件] ${data.file.name}: ${data.file.url}`);
286
+ }
287
+ break;
288
+ }
289
+
290
+ // Handle reference/reply
291
+ if (data.ref && data.ref.is_me) {
292
+ const refPrefix = `[引用机器人消息]`;
293
+ let refContent = "";
294
+ switch (data.ref.msg_type) {
295
+ case "text":
296
+ refContent = data.ref.text || "";
297
+ break;
298
+ case "image":
299
+ refContent = data.ref.images?.length ? `[图片]` : "[图片]";
300
+ break;
301
+ default:
302
+ refContent = `[${data.ref.msg_type}]`;
303
+ }
304
+ parts.unshift(`${refPrefix}\n> ${refContent}`);
305
+ }
306
+
307
+ return parts.join("\n");
308
+ }
309
+
310
+ export async function tuituiEmojiReaction(
311
+ appid:string,
312
+ secret: string,
313
+ target: string,
314
+ targetIsGroup: boolean,
315
+ msgid: string,
316
+ emoji: string
317
+ ): Promise<string> {
318
+
319
+ const payload = {
320
+ msgtype: "emoji_reaction",
321
+ tousers:[] as TuiTuiSingleEmojiReactionTarget[],
322
+ togroups:[] as TuiTuiGroupEmojiReactionTarget[],
323
+ emoji_reaction: { emoji: emoji, cancel: false},
324
+ };
325
+ if(targetIsGroup) {
326
+ payload.togroups.push({group: target, msgid: msgid});
327
+ } else {
328
+ payload.tousers.push({user: target, msgid: msgid});
329
+ }
330
+
331
+ const sendUrl = addParams2Url('https://im.live.360.cn:8282/robot/message/custom/modify', { appid, secret });
332
+ const { response, release } = await _fetchJson(sendUrl, payload, 'tuitui.emoji_reaction');
333
+
334
+ try {
335
+ const body = await response.text().catch(() => "");
336
+ //console.log('Response body:', body)
337
+ } catch (error) {
338
+ console.error('Caught exception:', error)
339
+ } finally {
340
+ await release();
341
+ }
342
+
343
+ return '';
344
+ }