@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/README.md +3 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/channel.ts +1147 -0
- package/src/types.ts +123 -0
- package/src/utils.ts +344 -0
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
|
+
}
|