@53ai/53ai-openclaw 1.0.0
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/CHANGELOG.md +26 -0
- package/README.md +424 -0
- package/dist/index.cjs.js +1607 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.esm.js +1583 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/src/access-policy.d.ts +23 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/const.d.ts +44 -0
- package/dist/src/interface.d.ts +209 -0
- package/dist/src/media-handler.d.ts +23 -0
- package/dist/src/message-parser.d.ts +9 -0
- package/dist/src/message-sender.d.ts +26 -0
- package/dist/src/monitor.d.ts +2 -0
- package/dist/src/onboarding.d.ts +2 -0
- package/dist/src/reqid-store.d.ts +9 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/state-manager.d.ts +17 -0
- package/dist/src/timeout.d.ts +10 -0
- package/dist/src/utils.d.ts +48 -0
- package/openclaw.plugin.json +41 -0
- package/package.json +80 -0
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, addWildcardAllowFrom, formatPairingApproveHint, emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
|
|
6
|
+
let runtime = null;
|
|
7
|
+
function setRuntime(r) {
|
|
8
|
+
runtime = r;
|
|
9
|
+
}
|
|
10
|
+
function getRuntime() {
|
|
11
|
+
if (!runtime) {
|
|
12
|
+
throw new Error("Plugin runtime not initialized - plugin not registered");
|
|
13
|
+
}
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CHANNEL_ID = "53aihub";
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// WebSocket 配置
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/** 默认 WebSocket URL */
|
|
22
|
+
const DEFAULT_WS_URL = "ws://localhost:8080/ws";
|
|
23
|
+
/** 心跳间隔(毫秒) */
|
|
24
|
+
const WS_HEARTBEAT_INTERVAL_MS = 15000;
|
|
25
|
+
/**
|
|
26
|
+
* 最大重连次数
|
|
27
|
+
* 设为 60 次,配合指数退避(最大 30 秒),总重连时间约 30 分钟
|
|
28
|
+
*/
|
|
29
|
+
const WS_MAX_RECONNECT_ATTEMPTS = 60;
|
|
30
|
+
/** 重连基础延迟(毫秒) - 指数退避的起始值 */
|
|
31
|
+
const WS_RECONNECT_BASE_DELAY_MS = 1000;
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// 消息处理配置
|
|
34
|
+
// ============================================================================
|
|
35
|
+
/** 文本分块限制 */
|
|
36
|
+
const TEXT_CHUNK_LIMIT = 4000;
|
|
37
|
+
/** 消息处理超时(毫秒) */
|
|
38
|
+
const MESSAGE_PROCESS_TIMEOUT_MS = 120000;
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// 媒体处理配置
|
|
41
|
+
// ============================================================================
|
|
42
|
+
/** 默认媒体大小上限(MB) */
|
|
43
|
+
const DEFAULT_MEDIA_MAX_MB = 20;
|
|
44
|
+
/** 图片下载超时(毫秒) */
|
|
45
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
46
|
+
/** 文件下载超时(毫秒) */
|
|
47
|
+
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
|
|
48
|
+
/** 仅包含图片时的消息占位符 */
|
|
49
|
+
const MEDIA_IMAGE_PLACEHOLDER = "<media:image>";
|
|
50
|
+
/** 仅包含文件时的消息占位符 */
|
|
51
|
+
const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// 状态管理配置
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/** 消息状态 TTL(毫秒) */
|
|
56
|
+
const MESSAGE_STATE_TTL_MS = 5 * 60 * 1000;
|
|
57
|
+
/** 消息状态清理间隔(毫秒) */
|
|
58
|
+
const MESSAGE_STATE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
59
|
+
/** 消息状态最大条目数 */
|
|
60
|
+
const MESSAGE_STATE_MAX_SIZE = 1000;
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// 持久化配置
|
|
63
|
+
// ============================================================================
|
|
64
|
+
/** 持久化目录名称 */
|
|
65
|
+
const PERSISTENCE_DIR_NAME = ".53aihub_store";
|
|
66
|
+
/** ReqId 存储文件名 */
|
|
67
|
+
const REQID_STORE_FILENAME = "reqid_map.json";
|
|
68
|
+
/** ReqId 刷写防抖时间(毫秒) */
|
|
69
|
+
const REQID_FLUSH_DEBOUNCE_MS = 2000;
|
|
70
|
+
/** ReqId 最大条目数 */
|
|
71
|
+
const REQID_MAX_ENTRIES = 5000;
|
|
72
|
+
const THINKING_MESSAGE = "🤔 正在思考中...";
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// 错误处理
|
|
76
|
+
// ============================================================================
|
|
77
|
+
/**
|
|
78
|
+
* 错误码枚举
|
|
79
|
+
*/
|
|
80
|
+
var ErrorCode;
|
|
81
|
+
(function (ErrorCode) {
|
|
82
|
+
// 访问控制
|
|
83
|
+
ErrorCode["ACCESS_DENIED"] = "ACCESS_DENIED";
|
|
84
|
+
ErrorCode["PAIRING_REQUIRED"] = "PAIRING_REQUIRED";
|
|
85
|
+
// AI 服务错误
|
|
86
|
+
ErrorCode["RATE_LIMITED"] = "RATE_LIMITED";
|
|
87
|
+
ErrorCode["INSUFFICIENT_QUOTA"] = "INSUFFICIENT_QUOTA";
|
|
88
|
+
ErrorCode["MODEL_OVERLOADED"] = "MODEL_OVERLOADED";
|
|
89
|
+
ErrorCode["MODEL_NOT_FOUND"] = "MODEL_NOT_FOUND";
|
|
90
|
+
// 请求错误
|
|
91
|
+
ErrorCode["INVALID_REQUEST"] = "INVALID_REQUEST";
|
|
92
|
+
ErrorCode["CONTEXT_LENGTH_EXCEEDED"] = "CONTEXT_LENGTH_EXCEEDED";
|
|
93
|
+
ErrorCode["CONTENT_FILTERED"] = "CONTENT_FILTERED";
|
|
94
|
+
// 系统错误
|
|
95
|
+
ErrorCode["TIMEOUT"] = "TIMEOUT";
|
|
96
|
+
ErrorCode["INTERNAL_ERROR"] = "INTERNAL_ERROR";
|
|
97
|
+
ErrorCode["SERVICE_UNAVAILABLE"] = "SERVICE_UNAVAILABLE";
|
|
98
|
+
ErrorCode["WEBSOCKET_ERROR"] = "WEBSOCKET_ERROR";
|
|
99
|
+
})(ErrorCode || (ErrorCode = {}));
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// 错误码映射工具
|
|
102
|
+
// ============================================================================
|
|
103
|
+
/**
|
|
104
|
+
* 从错误消息中推断错误码
|
|
105
|
+
*/
|
|
106
|
+
function inferErrorCode(errorText) {
|
|
107
|
+
const text = errorText.toLowerCase();
|
|
108
|
+
if (text.includes("rate limit") || text.includes("429") || text.includes("too many requests")) {
|
|
109
|
+
return ErrorCode.RATE_LIMITED;
|
|
110
|
+
}
|
|
111
|
+
if (text.includes("quota") || text.includes("insufficient") || text.includes("balance") || text.includes("credit")) {
|
|
112
|
+
return ErrorCode.INSUFFICIENT_QUOTA;
|
|
113
|
+
}
|
|
114
|
+
if (text.includes("overload") || text.includes("capacity") || text.includes("temporarily unavailable")) {
|
|
115
|
+
return ErrorCode.MODEL_OVERLOADED;
|
|
116
|
+
}
|
|
117
|
+
if (text.includes("model not found") || text.includes("does not exist")) {
|
|
118
|
+
return ErrorCode.MODEL_NOT_FOUND;
|
|
119
|
+
}
|
|
120
|
+
if (text.includes("context length") || text.includes("token limit") || text.includes("max tokens")) {
|
|
121
|
+
return ErrorCode.CONTEXT_LENGTH_EXCEEDED;
|
|
122
|
+
}
|
|
123
|
+
if (text.includes("content filtered") || text.includes("content policy") || text.includes("safety")) {
|
|
124
|
+
return ErrorCode.CONTENT_FILTERED;
|
|
125
|
+
}
|
|
126
|
+
if (text.includes("timeout") || text.includes("timed out")) {
|
|
127
|
+
return ErrorCode.TIMEOUT;
|
|
128
|
+
}
|
|
129
|
+
if (text.includes("service unavailable") || text.includes("503")) {
|
|
130
|
+
return ErrorCode.SERVICE_UNAVAILABLE;
|
|
131
|
+
}
|
|
132
|
+
if (text.includes("invalid") || text.includes("bad request")) {
|
|
133
|
+
return ErrorCode.INVALID_REQUEST;
|
|
134
|
+
}
|
|
135
|
+
return ErrorCode.INTERNAL_ERROR;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractTextFromContent(content) {
|
|
139
|
+
if (typeof content === "string") {
|
|
140
|
+
return content;
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(content)) {
|
|
143
|
+
return content
|
|
144
|
+
.filter((item) => typeof item === "object" && item !== null && item.type === "text")
|
|
145
|
+
.map((item) => item.text || "")
|
|
146
|
+
.join("\n");
|
|
147
|
+
}
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
function extractImagesFromContent(content) {
|
|
151
|
+
const urls = [];
|
|
152
|
+
const items = [];
|
|
153
|
+
if (Array.isArray(content)) {
|
|
154
|
+
for (const item of content) {
|
|
155
|
+
if (typeof item !== "object" || item === null)
|
|
156
|
+
continue;
|
|
157
|
+
const itemRecord = item;
|
|
158
|
+
if (itemRecord.type === "image_url" &&
|
|
159
|
+
typeof itemRecord.image_url === "object" &&
|
|
160
|
+
itemRecord.image_url !== null &&
|
|
161
|
+
"url" in itemRecord.image_url) {
|
|
162
|
+
const url = String(itemRecord.image_url.url);
|
|
163
|
+
urls.push(url);
|
|
164
|
+
items.push({
|
|
165
|
+
type: "image",
|
|
166
|
+
image: { url },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (itemRecord.type === "image" && (itemRecord.url || itemRecord.base64)) {
|
|
170
|
+
if (typeof itemRecord.url === "string")
|
|
171
|
+
urls.push(itemRecord.url);
|
|
172
|
+
items.push({
|
|
173
|
+
type: "image",
|
|
174
|
+
image: {
|
|
175
|
+
url: typeof itemRecord.url === "string" ? itemRecord.url : undefined,
|
|
176
|
+
base64: typeof itemRecord.base64 === "string" ? itemRecord.base64 : undefined,
|
|
177
|
+
mimeType: typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : undefined,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { urls, items };
|
|
184
|
+
}
|
|
185
|
+
function extractFilesFromContent(content) {
|
|
186
|
+
const urls = [];
|
|
187
|
+
const items = [];
|
|
188
|
+
if (Array.isArray(content)) {
|
|
189
|
+
for (const item of content) {
|
|
190
|
+
if (typeof item !== "object" || item === null)
|
|
191
|
+
continue;
|
|
192
|
+
const itemRecord = item;
|
|
193
|
+
if (itemRecord.type === "file" && (itemRecord.url || itemRecord.base64)) {
|
|
194
|
+
if (typeof itemRecord.url === "string")
|
|
195
|
+
urls.push(itemRecord.url);
|
|
196
|
+
items.push({
|
|
197
|
+
type: "file",
|
|
198
|
+
file: {
|
|
199
|
+
url: typeof itemRecord.url === "string" ? itemRecord.url : undefined,
|
|
200
|
+
base64: typeof itemRecord.base64 === "string" ? itemRecord.base64 : undefined,
|
|
201
|
+
filename: typeof itemRecord.filename === "string" ? itemRecord.filename : undefined,
|
|
202
|
+
mimeType: typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : undefined,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { urls, items };
|
|
209
|
+
}
|
|
210
|
+
function parseIncomingMessage(rawJson) {
|
|
211
|
+
try {
|
|
212
|
+
const wsMsg = JSON.parse(rawJson);
|
|
213
|
+
if (wsMsg.action === "ping" || wsMsg.action === "pong") {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (wsMsg.action === "chat") {
|
|
217
|
+
const openAIReq = wsMsg.data;
|
|
218
|
+
if (!openAIReq || !openAIReq.messages || !Array.isArray(openAIReq.messages)) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const lastUserMsg = [...openAIReq.messages].reverse().find((m) => m.role === "user");
|
|
222
|
+
if (!lastUserMsg)
|
|
223
|
+
return null;
|
|
224
|
+
const userId = openAIReq.user || lastUserMsg.name || `user-${wsMsg.req_id}`;
|
|
225
|
+
const chatId = openAIReq.conversation_id || userId;
|
|
226
|
+
const text = extractTextFromContent(lastUserMsg.content);
|
|
227
|
+
const { urls: imageUrls, items: imageItems } = extractImagesFromContent(lastUserMsg.content);
|
|
228
|
+
const { urls: fileUrls, items: fileItems } = extractFilesFromContent(lastUserMsg.content);
|
|
229
|
+
const contentItems = [...imageItems, ...fileItems];
|
|
230
|
+
return {
|
|
231
|
+
type: "message",
|
|
232
|
+
msgId: wsMsg.req_id,
|
|
233
|
+
reqId: wsMsg.req_id,
|
|
234
|
+
chatId: chatId,
|
|
235
|
+
userId: userId,
|
|
236
|
+
text,
|
|
237
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
238
|
+
fileUrls: fileUrls.length > 0 ? fileUrls : undefined,
|
|
239
|
+
contentItems: contentItems.length > 0 ? contentItems : undefined,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// 处理非标准格式的消息 (action === "message")
|
|
243
|
+
const data = wsMsg.data;
|
|
244
|
+
const dataRecord = data;
|
|
245
|
+
const rawImages = dataRecord.images;
|
|
246
|
+
const rawFiles = dataRecord.files;
|
|
247
|
+
const imageUrls = data.imageUrls ||
|
|
248
|
+
(Array.isArray(rawImages) ? rawImages.map((img) => {
|
|
249
|
+
if (typeof img === "string")
|
|
250
|
+
return img;
|
|
251
|
+
if (typeof img === "object" && img !== null && "url" in img)
|
|
252
|
+
return String(img.url);
|
|
253
|
+
return "";
|
|
254
|
+
}).filter(Boolean) : []);
|
|
255
|
+
const fileUrls = data.fileUrls ||
|
|
256
|
+
(Array.isArray(rawFiles) ? rawFiles.map((f) => {
|
|
257
|
+
if (typeof f === "string")
|
|
258
|
+
return f;
|
|
259
|
+
if (typeof f === "object" && f !== null && "url" in f)
|
|
260
|
+
return String(f.url);
|
|
261
|
+
return "";
|
|
262
|
+
}).filter(Boolean) : []);
|
|
263
|
+
return {
|
|
264
|
+
type: dataRecord.type || "message",
|
|
265
|
+
msgId: dataRecord.msgId || dataRecord.id || `msg-${Date.now()}`,
|
|
266
|
+
chatId: dataRecord.chatId || dataRecord.userId || "default-chat",
|
|
267
|
+
userId: dataRecord.userId || dataRecord.chatId || "default-user",
|
|
268
|
+
text: dataRecord.text || dataRecord.content || "",
|
|
269
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
270
|
+
fileUrls: fileUrls.length > 0 ? fileUrls : undefined,
|
|
271
|
+
quoteContent: dataRecord.quoteContent,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function parseMessageContent(msg) {
|
|
279
|
+
const textParts = [];
|
|
280
|
+
const imageUrls = [];
|
|
281
|
+
const fileUrls = [];
|
|
282
|
+
const contentItems = [];
|
|
283
|
+
if (msg.text?.trim()) {
|
|
284
|
+
textParts.push(msg.text.trim());
|
|
285
|
+
}
|
|
286
|
+
if (msg.imageUrls?.length) {
|
|
287
|
+
imageUrls.push(...msg.imageUrls);
|
|
288
|
+
for (const url of msg.imageUrls) {
|
|
289
|
+
contentItems.push({ type: "image", image: { url } });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (msg.fileUrls?.length) {
|
|
293
|
+
fileUrls.push(...msg.fileUrls);
|
|
294
|
+
for (const url of msg.fileUrls) {
|
|
295
|
+
contentItems.push({ type: "file", file: { url } });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (msg.contentItems?.length) {
|
|
299
|
+
for (const item of msg.contentItems) {
|
|
300
|
+
if (item.type === "image" && item.image?.url && !imageUrls.includes(item.image.url)) {
|
|
301
|
+
imageUrls.push(item.image.url);
|
|
302
|
+
}
|
|
303
|
+
if (item.type === "file" && item.file?.url && !fileUrls.includes(item.file.url)) {
|
|
304
|
+
fileUrls.push(item.file.url);
|
|
305
|
+
}
|
|
306
|
+
contentItems.push(item);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { textParts, imageUrls, fileUrls, contentItems };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function sendReply(params) {
|
|
313
|
+
const { wsClient, text, toChatId, replyToMsgId, runtime, finish, streamId, isError, errorCode, errorDetails } = params;
|
|
314
|
+
const reqId = replyToMsgId || streamId;
|
|
315
|
+
runtime.log?.(`[53aihub] sendReply START: reqId=${reqId}, finish=${finish}, isError=${isError}, textLen=${text?.length || 0}, wsReadyState=${wsClient.readyState}`);
|
|
316
|
+
if (wsClient.readyState !== 1) {
|
|
317
|
+
runtime.error?.(`[53aihub] WebSocket is not open (readyState=${wsClient.readyState}). Cannot send message to ${toChatId}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (isError) {
|
|
321
|
+
runtime.error?.(`[53aihub] sendReply ERROR: reqId=${reqId}, code=${errorCode}, text=${text?.substring(0, 100)}`);
|
|
322
|
+
const errorInfo = {
|
|
323
|
+
code: errorCode || ErrorCode.INTERNAL_ERROR,
|
|
324
|
+
message: text || "Unknown error",
|
|
325
|
+
details: errorDetails,
|
|
326
|
+
};
|
|
327
|
+
const errorChunk = {
|
|
328
|
+
id: reqId,
|
|
329
|
+
object: "chat.completion.chunk",
|
|
330
|
+
created: Math.floor(Date.now() / 1000),
|
|
331
|
+
model: "openclaw-agent",
|
|
332
|
+
choices: [
|
|
333
|
+
{
|
|
334
|
+
index: 0,
|
|
335
|
+
delta: {
|
|
336
|
+
content: text,
|
|
337
|
+
role: "assistant",
|
|
338
|
+
},
|
|
339
|
+
finish_reason: "error",
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
error: errorInfo,
|
|
343
|
+
};
|
|
344
|
+
const errMsg = {
|
|
345
|
+
req_id: reqId,
|
|
346
|
+
action: "chat",
|
|
347
|
+
status: "error",
|
|
348
|
+
data: errorChunk,
|
|
349
|
+
};
|
|
350
|
+
const jsonStr = JSON.stringify(errMsg);
|
|
351
|
+
runtime.log?.(`[53aihub] sendReply ERROR SENDING: reqId=${reqId}, payloadLen=${jsonStr.length}`);
|
|
352
|
+
wsClient.send(jsonStr);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const chunk = {
|
|
356
|
+
id: reqId,
|
|
357
|
+
object: "chat.completion.chunk",
|
|
358
|
+
created: Math.floor(Date.now() / 1000),
|
|
359
|
+
model: "openclaw-agent",
|
|
360
|
+
choices: [
|
|
361
|
+
{
|
|
362
|
+
index: 0,
|
|
363
|
+
delta: {
|
|
364
|
+
content: text,
|
|
365
|
+
role: "assistant",
|
|
366
|
+
},
|
|
367
|
+
finish_reason: finish ? "stop" : null,
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
const payload = {
|
|
372
|
+
req_id: reqId,
|
|
373
|
+
action: "chat",
|
|
374
|
+
status: finish ? "done" : "streaming",
|
|
375
|
+
data: chunk,
|
|
376
|
+
};
|
|
377
|
+
const jsonStr = JSON.stringify(payload);
|
|
378
|
+
runtime.log?.(`[53aihub] sendReply SENDING: reqId=${reqId}, status=${payload.status}, textLen=${text?.length || 0}, payloadLen=${jsonStr.length}, textPreview=${text?.substring(0, 50) || "(empty)"}`);
|
|
379
|
+
try {
|
|
380
|
+
wsClient.send(jsonStr);
|
|
381
|
+
runtime.log?.(`[53aihub] sendReply SENT: reqId=${reqId}, status=${payload.status}`);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
runtime.error?.(`[53aihub] sendReply FAILED: reqId=${reqId}, error=${String(error)}`);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function sendDirectMessage(wsClient, to, content, runtime) {
|
|
389
|
+
if (wsClient.readyState !== 1) {
|
|
390
|
+
throw new Error(`[53aihub] WebSocket not connected`);
|
|
391
|
+
}
|
|
392
|
+
const payload = {
|
|
393
|
+
req_id: `msg-${Date.now()}`,
|
|
394
|
+
action: "message",
|
|
395
|
+
status: "final",
|
|
396
|
+
data: {
|
|
397
|
+
toChatId: to,
|
|
398
|
+
text: content,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
wsClient.send(JSON.stringify(payload));
|
|
402
|
+
}
|
|
403
|
+
async function sendMediaMessage(wsClient, to, media, text, runtime) {
|
|
404
|
+
if (wsClient.readyState !== 1) {
|
|
405
|
+
throw new Error(`[53aihub] WebSocket not connected`);
|
|
406
|
+
}
|
|
407
|
+
const payload = {
|
|
408
|
+
req_id: `msg-${Date.now()}`,
|
|
409
|
+
action: "message",
|
|
410
|
+
status: "final",
|
|
411
|
+
data: {
|
|
412
|
+
toChatId: to,
|
|
413
|
+
text: text || "",
|
|
414
|
+
media: {
|
|
415
|
+
type: media.type,
|
|
416
|
+
url: media.url,
|
|
417
|
+
base64: media.base64,
|
|
418
|
+
mimeType: media.mimeType,
|
|
419
|
+
filename: media.filename,
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
wsClient.send(JSON.stringify(payload));
|
|
424
|
+
}
|
|
425
|
+
async function sendThinkingMessage(wsClient, text, msgId, streamId, runtime) {
|
|
426
|
+
const wsState = wsClient.readyState;
|
|
427
|
+
runtime?.log?.(`[53aihub] sendThinkingMessage CALLED: msgId=${msgId}, streamId=${streamId}, text=${text}, wsReadyState=${wsState}`);
|
|
428
|
+
if (wsState !== 1) {
|
|
429
|
+
runtime?.error?.(`[53aihub] sendThinkingMessage SKIPPED: WebSocket not ready (state=${wsState})`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// 使用 OpenAI 兼容格式,确保 Go 后端能正确解析
|
|
433
|
+
// 关键:req_id 必须使用原始消息的 msgId,这样 Go 后端才能关联请求和响应
|
|
434
|
+
const chunk = {
|
|
435
|
+
id: streamId,
|
|
436
|
+
object: "chat.completion.chunk",
|
|
437
|
+
created: Math.floor(Date.now() / 1000),
|
|
438
|
+
model: "openclaw-agent",
|
|
439
|
+
choices: [
|
|
440
|
+
{
|
|
441
|
+
index: 0,
|
|
442
|
+
delta: {
|
|
443
|
+
content: text,
|
|
444
|
+
role: "assistant",
|
|
445
|
+
},
|
|
446
|
+
finish_reason: null,
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
const payload = {
|
|
451
|
+
req_id: msgId,
|
|
452
|
+
action: "chat",
|
|
453
|
+
status: "thinking",
|
|
454
|
+
data: chunk,
|
|
455
|
+
};
|
|
456
|
+
const jsonStr = JSON.stringify(payload);
|
|
457
|
+
runtime?.log?.(`[53aihub] sendThinkingMessage SENDING: ${jsonStr}`);
|
|
458
|
+
try {
|
|
459
|
+
wsClient.send(jsonStr);
|
|
460
|
+
runtime?.log?.(`[53aihub] sendThinkingMessage SENT SUCCESS: msgId=${msgId}`);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
runtime?.error?.(`[53aihub] sendThinkingMessage SEND FAILED: ${String(err)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 访问策略检查
|
|
469
|
+
*
|
|
470
|
+
* 策略模式:
|
|
471
|
+
* - "open": 允许所有用户(默认)
|
|
472
|
+
* - "allowlist": 仅允许 allowFrom 中的用户
|
|
473
|
+
* - "pairing": 首次使用需要管理员审批
|
|
474
|
+
*/
|
|
475
|
+
async function checkAccessPolicy(options) {
|
|
476
|
+
const { userId, account, runtime } = options;
|
|
477
|
+
const core = getRuntime();
|
|
478
|
+
const accessPolicy = account.config.accessPolicy ?? "open";
|
|
479
|
+
// 开放模式 - 所有用户都能用
|
|
480
|
+
if (accessPolicy === "open") {
|
|
481
|
+
return { allowed: true };
|
|
482
|
+
}
|
|
483
|
+
// 白名单模式
|
|
484
|
+
if (accessPolicy === "allowlist") {
|
|
485
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
486
|
+
if (allowFrom.includes(userId)) {
|
|
487
|
+
return { allowed: true };
|
|
488
|
+
}
|
|
489
|
+
runtime.log?.(`[53aihub] User ${userId} not in allowlist`);
|
|
490
|
+
return { allowed: false, reason: `User ${userId} not in allowlist` };
|
|
491
|
+
}
|
|
492
|
+
// 配对模式 - 首次使用需要审批
|
|
493
|
+
if (accessPolicy === "pairing") {
|
|
494
|
+
// 检查配置中的白名单
|
|
495
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
496
|
+
if (allowFrom.includes(userId)) {
|
|
497
|
+
return { allowed: true };
|
|
498
|
+
}
|
|
499
|
+
// 检查持久化的配对记录
|
|
500
|
+
const persistentAllowFrom = await core.channel.pairing.readAllowFromStore({
|
|
501
|
+
channel: CHANNEL_ID,
|
|
502
|
+
accountId: account.accountId,
|
|
503
|
+
});
|
|
504
|
+
if (persistentAllowFrom.includes(userId)) {
|
|
505
|
+
return { allowed: true };
|
|
506
|
+
}
|
|
507
|
+
// 记录配对请求
|
|
508
|
+
runtime.log?.(`[53aihub] User ${userId} requires pairing. Recording request.`);
|
|
509
|
+
await core.channel.pairing.upsertPairingRequest({
|
|
510
|
+
channel: CHANNEL_ID,
|
|
511
|
+
accountId: account.accountId,
|
|
512
|
+
id: userId,
|
|
513
|
+
meta: {
|
|
514
|
+
displayName: `53AIHub 用户 (${userId})`,
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
return { allowed: false, reason: "Pairing required" };
|
|
518
|
+
}
|
|
519
|
+
return { allowed: false };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function createPersistentReqIdStore(accountId) {
|
|
523
|
+
const memoryCache = new Map();
|
|
524
|
+
let flushTimer = null;
|
|
525
|
+
let isDirty = false;
|
|
526
|
+
const getStoreDir = () => {
|
|
527
|
+
const storagePath = getRuntime().state.resolveStateDir();
|
|
528
|
+
const dir = path.join(storagePath, PERSISTENCE_DIR_NAME, accountId);
|
|
529
|
+
if (!fs.existsSync(dir)) {
|
|
530
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
531
|
+
}
|
|
532
|
+
return dir;
|
|
533
|
+
};
|
|
534
|
+
const getStorePath = () => path.join(getStoreDir(), REQID_STORE_FILENAME);
|
|
535
|
+
const scheduleFlush = () => {
|
|
536
|
+
isDirty = true;
|
|
537
|
+
if (flushTimer)
|
|
538
|
+
return;
|
|
539
|
+
flushTimer = setTimeout(async () => {
|
|
540
|
+
await flush();
|
|
541
|
+
}, REQID_FLUSH_DEBOUNCE_MS);
|
|
542
|
+
};
|
|
543
|
+
const flush = async () => {
|
|
544
|
+
if (!isDirty)
|
|
545
|
+
return;
|
|
546
|
+
if (flushTimer) {
|
|
547
|
+
clearTimeout(flushTimer);
|
|
548
|
+
flushTimer = null;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const data = JSON.stringify(Object.fromEntries(memoryCache));
|
|
552
|
+
const filePath = getStorePath();
|
|
553
|
+
const tmpPath = `${filePath}.tmp`;
|
|
554
|
+
fs.writeFileSync(tmpPath, data);
|
|
555
|
+
fs.renameSync(tmpPath, filePath);
|
|
556
|
+
isDirty = false;
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
console.error(`[53aihub] Failed to flush reqId store for ${accountId}:`, error);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
set(chatId, reqId) {
|
|
564
|
+
memoryCache.set(chatId, reqId);
|
|
565
|
+
if (memoryCache.size > REQID_MAX_ENTRIES) {
|
|
566
|
+
const firstKey = memoryCache.keys().next().value;
|
|
567
|
+
if (firstKey !== undefined)
|
|
568
|
+
memoryCache.delete(firstKey);
|
|
569
|
+
}
|
|
570
|
+
scheduleFlush();
|
|
571
|
+
},
|
|
572
|
+
async get(chatId) {
|
|
573
|
+
return memoryCache.get(chatId);
|
|
574
|
+
},
|
|
575
|
+
getSync(chatId) {
|
|
576
|
+
return memoryCache.get(chatId);
|
|
577
|
+
},
|
|
578
|
+
delete(chatId) {
|
|
579
|
+
if (memoryCache.delete(chatId)) {
|
|
580
|
+
scheduleFlush();
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
async warmup(onError) {
|
|
584
|
+
try {
|
|
585
|
+
const filePath = getStorePath();
|
|
586
|
+
if (fs.existsSync(filePath)) {
|
|
587
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
588
|
+
const data = JSON.parse(content);
|
|
589
|
+
memoryCache.clear();
|
|
590
|
+
for (const [k, v] of Object.entries(data)) {
|
|
591
|
+
memoryCache.set(k, v);
|
|
592
|
+
}
|
|
593
|
+
return memoryCache.size;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
onError?.(error);
|
|
598
|
+
}
|
|
599
|
+
return 0;
|
|
600
|
+
},
|
|
601
|
+
flush,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const wsClientInstances = new Map();
|
|
606
|
+
function getWebSocket(accountId) {
|
|
607
|
+
return wsClientInstances.get(accountId) ?? null;
|
|
608
|
+
}
|
|
609
|
+
function setWebSocket(accountId, client) {
|
|
610
|
+
wsClientInstances.set(accountId, client);
|
|
611
|
+
}
|
|
612
|
+
const messageStates = new Map();
|
|
613
|
+
let cleanupTimer = null;
|
|
614
|
+
function startMessageStateCleanup() {
|
|
615
|
+
if (cleanupTimer)
|
|
616
|
+
return;
|
|
617
|
+
cleanupTimer = setInterval(() => {
|
|
618
|
+
pruneMessageStates();
|
|
619
|
+
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
|
|
620
|
+
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
621
|
+
cleanupTimer.unref();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function pruneMessageStates() {
|
|
625
|
+
const now = Date.now();
|
|
626
|
+
for (const [key, entry] of messageStates) {
|
|
627
|
+
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
628
|
+
messageStates.delete(key);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
632
|
+
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
633
|
+
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
634
|
+
for (const [key] of toRemove) {
|
|
635
|
+
messageStates.delete(key);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function setMessageState(messageId, state) {
|
|
640
|
+
messageStates.set(messageId, {
|
|
641
|
+
state,
|
|
642
|
+
createdAt: Date.now(),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
function deleteMessageState(messageId) {
|
|
646
|
+
messageStates.delete(messageId);
|
|
647
|
+
}
|
|
648
|
+
// ReqId Persistence Management
|
|
649
|
+
const reqIdStores = new Map();
|
|
650
|
+
function getOrCreateReqIdStore(accountId) {
|
|
651
|
+
let store = reqIdStores.get(accountId);
|
|
652
|
+
if (!store) {
|
|
653
|
+
store = createPersistentReqIdStore(accountId);
|
|
654
|
+
reqIdStores.set(accountId, store);
|
|
655
|
+
}
|
|
656
|
+
return store;
|
|
657
|
+
}
|
|
658
|
+
function setLastMsgIdForChat(chatId, msgId, accountId = "default") {
|
|
659
|
+
getOrCreateReqIdStore(accountId).set(chatId, msgId);
|
|
660
|
+
}
|
|
661
|
+
async function warmupReqIdStore(accountId = "default", log) {
|
|
662
|
+
const store = getOrCreateReqIdStore(accountId);
|
|
663
|
+
return store.warmup((err) => log?.(`ReqId warmup error: ${String(err)}`));
|
|
664
|
+
}
|
|
665
|
+
async function cleanupAccount(accountId) {
|
|
666
|
+
const wsClient = wsClientInstances.get(accountId);
|
|
667
|
+
if (wsClient) {
|
|
668
|
+
try {
|
|
669
|
+
wsClient.close();
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
console.error(`[53aihub] Error closing WebSocket for account ${accountId}:`, err);
|
|
673
|
+
}
|
|
674
|
+
wsClientInstances.delete(accountId);
|
|
675
|
+
}
|
|
676
|
+
const store = reqIdStores.get(accountId);
|
|
677
|
+
if (store)
|
|
678
|
+
await store.flush();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 超时处理工具模块
|
|
683
|
+
*/
|
|
684
|
+
/**
|
|
685
|
+
* 为 Promise 添加超时保护
|
|
686
|
+
* @param promise 需要包装的 Promise
|
|
687
|
+
* @param ms 超时时间(毫秒)
|
|
688
|
+
* @param message 超时错误消息
|
|
689
|
+
*/
|
|
690
|
+
async function withTimeout(promise, ms, message) {
|
|
691
|
+
return new Promise((resolve, reject) => {
|
|
692
|
+
const timer = setTimeout(() => {
|
|
693
|
+
reject(new Error(`Timeout after ${ms}ms: ${message}`));
|
|
694
|
+
}, ms);
|
|
695
|
+
promise
|
|
696
|
+
.then((result) => {
|
|
697
|
+
clearTimeout(timer);
|
|
698
|
+
resolve(result);
|
|
699
|
+
})
|
|
700
|
+
.catch((error) => {
|
|
701
|
+
clearTimeout(timer);
|
|
702
|
+
reject(error);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function detectContentType(data) {
|
|
708
|
+
const header = data.slice(0, 12);
|
|
709
|
+
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
|
|
710
|
+
return "image/jpeg";
|
|
711
|
+
}
|
|
712
|
+
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) {
|
|
713
|
+
return "image/png";
|
|
714
|
+
}
|
|
715
|
+
if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) {
|
|
716
|
+
return "image/gif";
|
|
717
|
+
}
|
|
718
|
+
if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) {
|
|
719
|
+
if (header[8] === 0x57 && header[9] === 0x45 && header[10] === 0x42 && header[11] === 0x50) {
|
|
720
|
+
return "image/webp";
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70) {
|
|
724
|
+
const ftyp = header.slice(8, 12).toString("ascii");
|
|
725
|
+
if (ftyp.startsWith("heic") || ftyp.startsWith("heix"))
|
|
726
|
+
return "image/heic";
|
|
727
|
+
if (ftyp.startsWith("avif"))
|
|
728
|
+
return "image/avif";
|
|
729
|
+
return "video/mp4";
|
|
730
|
+
}
|
|
731
|
+
if (header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46) {
|
|
732
|
+
return "application/pdf";
|
|
733
|
+
}
|
|
734
|
+
if (header.slice(0, 4).toString("ascii") === "PK\x03\x04") {
|
|
735
|
+
return "application/zip";
|
|
736
|
+
}
|
|
737
|
+
return "application/octet-stream";
|
|
738
|
+
}
|
|
739
|
+
async function downloadAndSaveImages(params) {
|
|
740
|
+
const { imageUrls, config, runtime } = params;
|
|
741
|
+
const core = getRuntime();
|
|
742
|
+
const mediaList = [];
|
|
743
|
+
for (const imageUrl of imageUrls) {
|
|
744
|
+
try {
|
|
745
|
+
runtime.log?.(`[53aihub] Downloading image from: ${imageUrl}`);
|
|
746
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
747
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
748
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
|
|
749
|
+
let imageBuffer = fetched.buffer;
|
|
750
|
+
let imageContentType = fetched.contentType ?? await detectContentType(imageBuffer);
|
|
751
|
+
runtime.log?.(`[53aihub] Image fetched: contentType=${imageContentType}, size=${imageBuffer.length}`);
|
|
752
|
+
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes);
|
|
753
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
754
|
+
runtime.log?.(`[53aihub] Image saved to ${saved.path}`);
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
runtime.error?.(`[53aihub] Failed to download image: ${String(err)}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return mediaList;
|
|
761
|
+
}
|
|
762
|
+
async function downloadAndSaveFiles(params) {
|
|
763
|
+
const { fileUrls, config, runtime } = params;
|
|
764
|
+
const core = getRuntime();
|
|
765
|
+
const mediaList = [];
|
|
766
|
+
for (const fileUrl of fileUrls) {
|
|
767
|
+
try {
|
|
768
|
+
runtime.log?.(`[53aihub] Downloading file from: ${fileUrl}`);
|
|
769
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
770
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
771
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
|
|
772
|
+
let fileBuffer = fetched.buffer;
|
|
773
|
+
let fileContentType = fetched.contentType ?? await detectContentType(fileBuffer);
|
|
774
|
+
runtime.log?.(`[53aihub] File fetched: contentType=${fileContentType}, size=${fileBuffer.length}`);
|
|
775
|
+
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes);
|
|
776
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
777
|
+
runtime.log?.(`[53aihub] File saved to ${saved.path}`);
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
runtime.error?.(`[53aihub] Failed to download file: ${String(err)}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return mediaList;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// 消息队列处理器 - 确保消息按顺序处理,避免竞态条件
|
|
787
|
+
class MessageQueue {
|
|
788
|
+
queue = [];
|
|
789
|
+
processing = false;
|
|
790
|
+
runtime;
|
|
791
|
+
constructor(runtime) {
|
|
792
|
+
this.runtime = runtime;
|
|
793
|
+
}
|
|
794
|
+
enqueue(task) {
|
|
795
|
+
this.queue.push(task);
|
|
796
|
+
this.process();
|
|
797
|
+
}
|
|
798
|
+
async process() {
|
|
799
|
+
if (this.processing || this.queue.length === 0)
|
|
800
|
+
return;
|
|
801
|
+
this.processing = true;
|
|
802
|
+
try {
|
|
803
|
+
const task = this.queue.shift();
|
|
804
|
+
if (task) {
|
|
805
|
+
await task();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
this.runtime.error?.(`[53aihub] MessageQueue error: ${String(err)}`);
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
this.processing = false;
|
|
813
|
+
// 继续处理队列中的下一条消息
|
|
814
|
+
if (this.queue.length > 0) {
|
|
815
|
+
this.process();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
clear() {
|
|
820
|
+
this.queue = [];
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function buildMessageContext(body, account, config, mediaList) {
|
|
824
|
+
const core = getRuntime();
|
|
825
|
+
const chatId = body.chatId || body.userId;
|
|
826
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
827
|
+
cfg: config,
|
|
828
|
+
channel: CHANNEL_ID,
|
|
829
|
+
accountId: account.accountId,
|
|
830
|
+
peer: {
|
|
831
|
+
kind: "direct",
|
|
832
|
+
id: chatId,
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
|
|
836
|
+
const messageBody = body.text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
|
|
837
|
+
const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
|
|
838
|
+
const mediaTypes = mediaList.length > 0
|
|
839
|
+
? mediaList.map((m) => m.contentType).filter(Boolean)
|
|
840
|
+
: undefined;
|
|
841
|
+
return core.channel.reply.finalizeInboundContext({
|
|
842
|
+
Body: messageBody,
|
|
843
|
+
RawBody: messageBody,
|
|
844
|
+
CommandBody: messageBody,
|
|
845
|
+
MessageSid: body.msgId,
|
|
846
|
+
From: `${CHANNEL_ID}:${body.userId}`,
|
|
847
|
+
To: `${CHANNEL_ID}:${chatId}`,
|
|
848
|
+
SenderId: body.userId,
|
|
849
|
+
SessionKey: route.sessionKey,
|
|
850
|
+
AccountId: account.accountId,
|
|
851
|
+
ChatType: "direct",
|
|
852
|
+
ConversationLabel: `user:${body.userId}`,
|
|
853
|
+
Timestamp: Date.now(),
|
|
854
|
+
Provider: CHANNEL_ID,
|
|
855
|
+
Surface: CHANNEL_ID,
|
|
856
|
+
OriginatingChannel: CHANNEL_ID,
|
|
857
|
+
OriginatingTo: `${CHANNEL_ID}:${chatId}`,
|
|
858
|
+
CommandAuthorized: true,
|
|
859
|
+
ReplyToBody: body.quoteContent,
|
|
860
|
+
MediaPath: mediaList[0]?.path,
|
|
861
|
+
MediaType: mediaList[0]?.contentType,
|
|
862
|
+
MediaPaths: mediaPaths,
|
|
863
|
+
MediaTypes: mediaTypes,
|
|
864
|
+
MediaUrls: mediaPaths,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
async function processMessage(params) {
|
|
868
|
+
const { rawPayload, account, config, runtime, wsClient } = params;
|
|
869
|
+
runtime.log?.(`[53aihub] processMessage: rawPayload length=${rawPayload.length}`);
|
|
870
|
+
const body = parseIncomingMessage(rawPayload);
|
|
871
|
+
if (!body) {
|
|
872
|
+
runtime.log?.(`[53aihub] processMessage: parseIncomingMessage returned null`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const parsed = parseMessageContent(body);
|
|
876
|
+
const hasMedia = parsed.imageUrls.length > 0 || parsed.fileUrls.length > 0;
|
|
877
|
+
if (!parsed.textParts.join("\n").trim() && !hasMedia) {
|
|
878
|
+
runtime.log?.(`[53aihub] processMessage: empty message, body=${JSON.stringify(body)}`);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const chatId = body.chatId || body.userId;
|
|
882
|
+
runtime.log?.(`[53aihub] processMessage: chatId=${chatId}, msgId=${body.msgId}, text=${parsed.textParts.join(" ").substring(0, 50)}... images=${parsed.imageUrls.length} files=${parsed.fileUrls.length}`);
|
|
883
|
+
const core = getRuntime();
|
|
884
|
+
const streamId = `stream-${Date.now()}`;
|
|
885
|
+
const accessResult = await checkAccessPolicy({
|
|
886
|
+
userId: body.userId,
|
|
887
|
+
account,
|
|
888
|
+
runtime,
|
|
889
|
+
});
|
|
890
|
+
if (!accessResult.allowed) {
|
|
891
|
+
await sendReply({
|
|
892
|
+
wsClient,
|
|
893
|
+
text: accessResult.reason === "Pairing required"
|
|
894
|
+
? "您尚未获得授权使用此机器人,请联系管理员进行审核。"
|
|
895
|
+
: `⚠️ 访问被拒绝: ${accessResult.reason || "未知原因"}`,
|
|
896
|
+
toChatId: chatId,
|
|
897
|
+
replyToMsgId: body.msgId,
|
|
898
|
+
runtime,
|
|
899
|
+
finish: true,
|
|
900
|
+
streamId,
|
|
901
|
+
isError: true,
|
|
902
|
+
errorCode: ErrorCode.ACCESS_DENIED,
|
|
903
|
+
errorDetails: accessResult.reason,
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
setLastMsgIdForChat(chatId, body.msgId, account.accountId);
|
|
908
|
+
const state = { accumulatedText: "", lastSentText: "", streamId };
|
|
909
|
+
setMessageState(body.msgId, state);
|
|
910
|
+
if (account.sendThinkingMessage) {
|
|
911
|
+
runtime.log?.(`[53aihub] processMessage: sendThinkingMessage=${account.sendThinkingMessage}, about to send thinking message`);
|
|
912
|
+
try {
|
|
913
|
+
await sendThinkingMessage(wsClient, THINKING_MESSAGE, body.msgId, state.streamId, runtime);
|
|
914
|
+
runtime.log?.(`[53aihub] processMessage: thinking message sent successfully`);
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
runtime.error?.(`[53aihub] Failed to send thinking message: ${String(err)}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
runtime.log?.(`[53aihub] processMessage: sendThinkingMessage=${account.sendThinkingMessage}, SKIPPING thinking message`);
|
|
922
|
+
}
|
|
923
|
+
const cleanupState = () => {
|
|
924
|
+
deleteMessageState(body.msgId);
|
|
925
|
+
};
|
|
926
|
+
const [imageMediaList, fileMediaList] = await Promise.all([
|
|
927
|
+
downloadAndSaveImages({
|
|
928
|
+
imageUrls: parsed.imageUrls,
|
|
929
|
+
config,
|
|
930
|
+
runtime}),
|
|
931
|
+
downloadAndSaveFiles({
|
|
932
|
+
fileUrls: parsed.fileUrls,
|
|
933
|
+
config,
|
|
934
|
+
runtime}),
|
|
935
|
+
]);
|
|
936
|
+
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
937
|
+
const ctxPayload = buildMessageContext(body, account, config, mediaList);
|
|
938
|
+
let cleanedUp = false;
|
|
939
|
+
const safeCleanup = () => {
|
|
940
|
+
if (!cleanedUp) {
|
|
941
|
+
cleanedUp = true;
|
|
942
|
+
cleanupState();
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
runtime.log?.(`[53aihub] processMessage: Starting dispatchReplyWithBufferedBlockDispatcher for msgId=${body.msgId}`);
|
|
946
|
+
try {
|
|
947
|
+
await withTimeout(core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
948
|
+
ctx: ctxPayload,
|
|
949
|
+
cfg: config,
|
|
950
|
+
dispatcherOptions: {
|
|
951
|
+
deliver: async (payload, info) => {
|
|
952
|
+
state.accumulatedText += payload.text;
|
|
953
|
+
runtime.log?.(`[53aihub] deliver: kind=${info.kind}, textLen=${payload.text?.length || 0}, accumulatedLen=${state.accumulatedText.length}, isError=${payload.isError}`);
|
|
954
|
+
if (payload.isError) {
|
|
955
|
+
const errorMsg = payload.text || "Unknown error";
|
|
956
|
+
const errorCode = inferErrorCode(errorMsg);
|
|
957
|
+
runtime.error?.(`[53aihub] deliver ERROR: ${errorMsg}`);
|
|
958
|
+
await sendReply({
|
|
959
|
+
wsClient,
|
|
960
|
+
text: `⚠️ ${errorMsg}`,
|
|
961
|
+
toChatId: chatId,
|
|
962
|
+
replyToMsgId: body.msgId,
|
|
963
|
+
runtime,
|
|
964
|
+
finish: true,
|
|
965
|
+
streamId: state.streamId,
|
|
966
|
+
isError: true,
|
|
967
|
+
errorCode,
|
|
968
|
+
errorDetails: errorMsg,
|
|
969
|
+
});
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (info.kind !== "final") {
|
|
973
|
+
runtime.log?.(`[53aihub] deliver STREAMING: accumulatedText preview=${state.accumulatedText.substring(0, 50)}...`);
|
|
974
|
+
await sendReply({
|
|
975
|
+
wsClient,
|
|
976
|
+
text: state.accumulatedText,
|
|
977
|
+
toChatId: chatId,
|
|
978
|
+
replyToMsgId: body.msgId,
|
|
979
|
+
runtime,
|
|
980
|
+
finish: false,
|
|
981
|
+
streamId: state.streamId,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
onError: async (err, info) => {
|
|
986
|
+
runtime.error?.(`[53aihub] onError: kind=${info.kind}, error=${String(err)}`);
|
|
987
|
+
const errorText = String(err);
|
|
988
|
+
const errorCode = inferErrorCode(errorText);
|
|
989
|
+
try {
|
|
990
|
+
await sendReply({
|
|
991
|
+
wsClient,
|
|
992
|
+
text: `⚠️ 系统错误: ${errorText}`,
|
|
993
|
+
toChatId: chatId,
|
|
994
|
+
replyToMsgId: body.msgId,
|
|
995
|
+
runtime,
|
|
996
|
+
finish: true,
|
|
997
|
+
streamId: state.streamId,
|
|
998
|
+
isError: true,
|
|
999
|
+
errorCode,
|
|
1000
|
+
errorDetails: `kind=${info.kind}, error=${errorText}`,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
catch (sendErr) {
|
|
1004
|
+
runtime.error?.(`[53aihub] Failed to send error notification: ${String(sendErr)}`);
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${body.msgId})`);
|
|
1009
|
+
runtime.log?.(`[53aihub] processMessage: dispatchReply completed, accumulatedTextLen=${state.accumulatedText.length}`);
|
|
1010
|
+
if (state.accumulatedText) {
|
|
1011
|
+
runtime.log?.(`[53aihub] processMessage: Sending final reply with accumulatedText`);
|
|
1012
|
+
await sendReply({
|
|
1013
|
+
wsClient,
|
|
1014
|
+
text: state.accumulatedText,
|
|
1015
|
+
toChatId: chatId,
|
|
1016
|
+
replyToMsgId: body.msgId,
|
|
1017
|
+
runtime,
|
|
1018
|
+
finish: true,
|
|
1019
|
+
streamId: state.streamId,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
runtime.log?.(`[53aihub] processMessage: No accumulatedText, sending empty final reply`);
|
|
1024
|
+
await sendReply({
|
|
1025
|
+
wsClient,
|
|
1026
|
+
text: "",
|
|
1027
|
+
toChatId: chatId,
|
|
1028
|
+
replyToMsgId: body.msgId,
|
|
1029
|
+
runtime,
|
|
1030
|
+
finish: true,
|
|
1031
|
+
streamId: state.streamId,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
safeCleanup();
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
runtime.error?.(`[53aihub] processMessage FAILED: ${String(err)}`);
|
|
1038
|
+
const errorText = String(err);
|
|
1039
|
+
const errorCode = inferErrorCode(errorText);
|
|
1040
|
+
if (!cleanedUp) {
|
|
1041
|
+
try {
|
|
1042
|
+
await sendReply({
|
|
1043
|
+
wsClient,
|
|
1044
|
+
text: `⚠️ 处理请求时发生异常: ${errorText}`,
|
|
1045
|
+
toChatId: chatId,
|
|
1046
|
+
replyToMsgId: body.msgId,
|
|
1047
|
+
runtime,
|
|
1048
|
+
finish: true,
|
|
1049
|
+
streamId: state.streamId,
|
|
1050
|
+
isError: true,
|
|
1051
|
+
errorCode,
|
|
1052
|
+
errorDetails: errorText,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
catch (sendErr) {
|
|
1056
|
+
runtime.error?.(`[53aihub] Failed to send final error notification: ${String(sendErr)}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
safeCleanup();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async function monitorProvider(options) {
|
|
1063
|
+
const { account, config, runtime, abortSignal } = options;
|
|
1064
|
+
runtime.log?.(`[${account.accountId}] Initializing WS connection to 53AIHub...`);
|
|
1065
|
+
startMessageStateCleanup();
|
|
1066
|
+
return new Promise((resolve, reject) => {
|
|
1067
|
+
let wsClient = null;
|
|
1068
|
+
let reconnectAttempts = 0;
|
|
1069
|
+
let pingInterval = null;
|
|
1070
|
+
let isAborted = false;
|
|
1071
|
+
let messageQueue = null;
|
|
1072
|
+
const cleanup = async () => {
|
|
1073
|
+
if (pingInterval) {
|
|
1074
|
+
clearInterval(pingInterval);
|
|
1075
|
+
pingInterval = null;
|
|
1076
|
+
}
|
|
1077
|
+
if (messageQueue) {
|
|
1078
|
+
messageQueue.clear();
|
|
1079
|
+
messageQueue = null;
|
|
1080
|
+
}
|
|
1081
|
+
await cleanupAccount(account.accountId);
|
|
1082
|
+
};
|
|
1083
|
+
const connect = () => {
|
|
1084
|
+
if (isAborted)
|
|
1085
|
+
return;
|
|
1086
|
+
// 安全: 不在 URL 中传递敏感信息,仅通过 headers 传递认证
|
|
1087
|
+
const wsUrl = account.websocketUrl;
|
|
1088
|
+
const botId = account.botId || account.config.botId;
|
|
1089
|
+
const secret = account.secret || account.token || account.config.secret || account.config.token;
|
|
1090
|
+
// 日志输出时隐藏敏感信息
|
|
1091
|
+
const safeUrl = new URL(wsUrl);
|
|
1092
|
+
const logUrl = `${safeUrl.origin}${safeUrl.pathname}`;
|
|
1093
|
+
const authBase64 = Buffer.from(`${botId}:${secret}`).toString('base64');
|
|
1094
|
+
const wsOptions = {
|
|
1095
|
+
headers: {
|
|
1096
|
+
"Authorization": `Bearer ${secret}`,
|
|
1097
|
+
"Proxy-Authorization": `Basic ${authBase64}`,
|
|
1098
|
+
"X-Bot-Id": botId || "",
|
|
1099
|
+
"X-Api-Key": secret || "",
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
runtime.log?.(`[${account.accountId}] Connecting to ${logUrl} ...`);
|
|
1103
|
+
wsClient = new WebSocket(wsUrl, wsOptions);
|
|
1104
|
+
setWebSocket(account.accountId, wsClient);
|
|
1105
|
+
// 初始化消息队列
|
|
1106
|
+
messageQueue = new MessageQueue(runtime);
|
|
1107
|
+
wsClient.on("open", () => {
|
|
1108
|
+
runtime.log?.(`[${account.accountId}] WebSocket connected successfully`);
|
|
1109
|
+
reconnectAttempts = 0;
|
|
1110
|
+
pingInterval = setInterval(() => {
|
|
1111
|
+
if (wsClient?.readyState === WebSocket.OPEN) {
|
|
1112
|
+
wsClient.ping();
|
|
1113
|
+
}
|
|
1114
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
1115
|
+
});
|
|
1116
|
+
wsClient.on("message", (data) => {
|
|
1117
|
+
const rawPayload = data.toString();
|
|
1118
|
+
runtime.log?.(`[${account.accountId}] Received WS message: ${rawPayload.substring(0, 200)}...`);
|
|
1119
|
+
// 使用消息队列确保顺序处理,避免竞态条件
|
|
1120
|
+
messageQueue?.enqueue(async () => {
|
|
1121
|
+
await processMessage({
|
|
1122
|
+
rawPayload,
|
|
1123
|
+
account,
|
|
1124
|
+
config,
|
|
1125
|
+
runtime,
|
|
1126
|
+
wsClient: wsClient,
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
wsClient.on("error", (err) => {
|
|
1131
|
+
runtime.error?.(`[${account.accountId}] WebSocket Error: ${String(err)}`);
|
|
1132
|
+
});
|
|
1133
|
+
wsClient.on("close", async (code, reason) => {
|
|
1134
|
+
runtime.log?.(`[${account.accountId}] WebSocket closed. Code: ${code}, Reason: ${reason}`);
|
|
1135
|
+
// 清理资源
|
|
1136
|
+
if (pingInterval) {
|
|
1137
|
+
clearInterval(pingInterval);
|
|
1138
|
+
pingInterval = null;
|
|
1139
|
+
}
|
|
1140
|
+
if (messageQueue) {
|
|
1141
|
+
messageQueue.clear();
|
|
1142
|
+
messageQueue = null;
|
|
1143
|
+
}
|
|
1144
|
+
if (!isAborted && reconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS) {
|
|
1145
|
+
reconnectAttempts++;
|
|
1146
|
+
const backoff = Math.min(WS_RECONNECT_BASE_DELAY_MS * Math.pow(2, reconnectAttempts), 30000);
|
|
1147
|
+
runtime.log?.(`[${account.accountId}] Reconnecting in ${backoff}ms... (attempt ${reconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})`);
|
|
1148
|
+
setTimeout(connect, backoff);
|
|
1149
|
+
}
|
|
1150
|
+
else if (!isAborted) {
|
|
1151
|
+
runtime.error?.(`[${account.accountId}] Max reconnect attempts (${WS_MAX_RECONNECT_ATTEMPTS}) reached`);
|
|
1152
|
+
await cleanup();
|
|
1153
|
+
reject(new Error(`Max reconnect attempts (${WS_MAX_RECONNECT_ATTEMPTS}) reached`));
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
};
|
|
1157
|
+
if (abortSignal) {
|
|
1158
|
+
abortSignal.addEventListener("abort", async () => {
|
|
1159
|
+
isAborted = true;
|
|
1160
|
+
await cleanup();
|
|
1161
|
+
resolve();
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
warmupReqIdStore(account.accountId, (msg) => runtime.log?.(msg))
|
|
1165
|
+
.then(() => connect())
|
|
1166
|
+
.catch(async (err) => {
|
|
1167
|
+
runtime.error?.(`[${account.accountId}] Failed to warmup ReqId store: ${String(err)}`);
|
|
1168
|
+
await cleanup();
|
|
1169
|
+
reject(err);
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ============================================================================
|
|
1175
|
+
// 配置解析函数
|
|
1176
|
+
// ============================================================================
|
|
1177
|
+
/**
|
|
1178
|
+
* 解析账户配置
|
|
1179
|
+
*/
|
|
1180
|
+
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
|
|
1181
|
+
const config = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1182
|
+
return {
|
|
1183
|
+
accountId,
|
|
1184
|
+
name: config.name ?? "53AIHub",
|
|
1185
|
+
enabled: config.enabled !== false,
|
|
1186
|
+
websocketUrl: config.websocketUrl || DEFAULT_WS_URL,
|
|
1187
|
+
botId: config.botId ?? config.userId ?? "",
|
|
1188
|
+
secret: config.secret ?? config.token ?? "",
|
|
1189
|
+
token: config.token ?? config.secret ?? "",
|
|
1190
|
+
sendThinkingMessage: config.sendThinkingMessage ?? true,
|
|
1191
|
+
config,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* 设置账户配置
|
|
1196
|
+
*/
|
|
1197
|
+
function setAccount(cfg, account) {
|
|
1198
|
+
const existing = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1199
|
+
const merged = {
|
|
1200
|
+
enabled: account.enabled ?? existing.enabled ?? true,
|
|
1201
|
+
botId: account.botId ?? existing.botId ?? "",
|
|
1202
|
+
secret: account.secret ?? existing.secret ?? "",
|
|
1203
|
+
token: account.token ?? existing.token ?? "",
|
|
1204
|
+
allowFrom: account.allowFrom ?? existing.allowFrom,
|
|
1205
|
+
accessPolicy: account.accessPolicy ?? existing.accessPolicy,
|
|
1206
|
+
sendThinkingMessage: account.sendThinkingMessage ?? existing.sendThinkingMessage,
|
|
1207
|
+
...(account.websocketUrl || existing.websocketUrl
|
|
1208
|
+
? { websocketUrl: account.websocketUrl ?? existing.websocketUrl }
|
|
1209
|
+
: {}),
|
|
1210
|
+
...(account.name || existing.name
|
|
1211
|
+
? { name: account.name ?? existing.name }
|
|
1212
|
+
: {}),
|
|
1213
|
+
};
|
|
1214
|
+
return {
|
|
1215
|
+
...cfg,
|
|
1216
|
+
channels: {
|
|
1217
|
+
...cfg.channels,
|
|
1218
|
+
[CHANNEL_ID]: merged,
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const channel = CHANNEL_ID;
|
|
1224
|
+
async function noteSetupHelp(prompter) {
|
|
1225
|
+
await prompter.note([
|
|
1226
|
+
"53AIHub 智能机器人需要以下配置信息:",
|
|
1227
|
+
"1. Bot ID: 机器人标识",
|
|
1228
|
+
"2. Secret: 机器人密钥",
|
|
1229
|
+
"3. WebSocket URL: WebSocket 连接地址",
|
|
1230
|
+
].join("\n"), "53AIHub 设置");
|
|
1231
|
+
}
|
|
1232
|
+
async function promptBotId(prompter, account) {
|
|
1233
|
+
return String(await prompter.text({
|
|
1234
|
+
message: "53AIHub 机器人 Bot ID",
|
|
1235
|
+
initialValue: account?.botId ?? "",
|
|
1236
|
+
validate: (value) => (value?.trim() ? undefined : "必填"),
|
|
1237
|
+
})).trim();
|
|
1238
|
+
}
|
|
1239
|
+
async function promptSecret(prompter, account) {
|
|
1240
|
+
return String(await prompter.text({
|
|
1241
|
+
message: "53AIHub 机器人 Secret",
|
|
1242
|
+
initialValue: account?.secret ?? "",
|
|
1243
|
+
validate: (value) => (value?.trim() ? undefined : "必填"),
|
|
1244
|
+
})).trim();
|
|
1245
|
+
}
|
|
1246
|
+
async function promptWebsocketUrl(prompter, account) {
|
|
1247
|
+
return String(await prompter.text({
|
|
1248
|
+
message: "WebSocket URL (例如: ws://localhost:8080/ws)",
|
|
1249
|
+
initialValue: account?.websocketUrl ?? "",
|
|
1250
|
+
validate: (value) => {
|
|
1251
|
+
const trimmed = value?.trim();
|
|
1252
|
+
if (!trimmed)
|
|
1253
|
+
return undefined;
|
|
1254
|
+
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
|
|
1255
|
+
return "URL 必须以 ws:// 或 wss:// 开头";
|
|
1256
|
+
}
|
|
1257
|
+
return undefined;
|
|
1258
|
+
},
|
|
1259
|
+
})).trim();
|
|
1260
|
+
}
|
|
1261
|
+
function setAccessPolicy(cfg, accessPolicy) {
|
|
1262
|
+
const account = resolveAccount(cfg);
|
|
1263
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
1264
|
+
const allowFrom = accessPolicy === "open"
|
|
1265
|
+
? addWildcardAllowFrom(existingAllowFrom.map((x) => String(x)))
|
|
1266
|
+
: existingAllowFrom.map((x) => String(x));
|
|
1267
|
+
return setAccount(cfg, {
|
|
1268
|
+
accessPolicy,
|
|
1269
|
+
allowFrom,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
const dmPolicy = {
|
|
1273
|
+
label: "53AIHub",
|
|
1274
|
+
channel,
|
|
1275
|
+
policyKey: `channels.${CHANNEL_ID}.accessPolicy`,
|
|
1276
|
+
allowFromKey: `channels.${CHANNEL_ID}.allowFrom`,
|
|
1277
|
+
getCurrent: (cfg) => {
|
|
1278
|
+
const account = resolveAccount(cfg);
|
|
1279
|
+
return account.config.accessPolicy ?? "open";
|
|
1280
|
+
},
|
|
1281
|
+
setPolicy: (cfg, policy) => {
|
|
1282
|
+
return setAccessPolicy(cfg, policy);
|
|
1283
|
+
},
|
|
1284
|
+
promptAllowFrom: async ({ cfg, prompter }) => {
|
|
1285
|
+
const account = resolveAccount(cfg);
|
|
1286
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
1287
|
+
const entry = await prompter.text({
|
|
1288
|
+
message: "允许使用的用户ID(每行一个)",
|
|
1289
|
+
placeholder: "user123",
|
|
1290
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
1291
|
+
});
|
|
1292
|
+
const allowFrom = String(entry ?? "")
|
|
1293
|
+
.split(/[\n,;]+/g)
|
|
1294
|
+
.map((s) => s.trim())
|
|
1295
|
+
.filter(Boolean);
|
|
1296
|
+
return setAccount(cfg, { allowFrom });
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
const aiHubOnboardingAdapter = {
|
|
1300
|
+
channel,
|
|
1301
|
+
getStatus: async ({ cfg }) => {
|
|
1302
|
+
const account = resolveAccount(cfg);
|
|
1303
|
+
const configured = Boolean(account.botId?.trim() &&
|
|
1304
|
+
account.secret?.trim());
|
|
1305
|
+
return {
|
|
1306
|
+
channel,
|
|
1307
|
+
configured,
|
|
1308
|
+
statusLines: [`53AIHub: ${configured ? "已配置" : "需要 Bot ID 和 Secret"}`],
|
|
1309
|
+
selectionHint: configured ? "已配置" : "需要设置",
|
|
1310
|
+
};
|
|
1311
|
+
},
|
|
1312
|
+
configure: async ({ cfg, prompter, forceAllowFrom }) => {
|
|
1313
|
+
const account = resolveAccount(cfg);
|
|
1314
|
+
if (!account.botId?.trim() || !account.secret?.trim()) {
|
|
1315
|
+
await noteSetupHelp(prompter);
|
|
1316
|
+
}
|
|
1317
|
+
const botId = await promptBotId(prompter, account);
|
|
1318
|
+
const secret = await promptSecret(prompter, account);
|
|
1319
|
+
const websocketUrl = await promptWebsocketUrl(prompter, account);
|
|
1320
|
+
const cfgWithAccount = setAccount(cfg, {
|
|
1321
|
+
botId,
|
|
1322
|
+
secret,
|
|
1323
|
+
websocketUrl: websocketUrl || undefined,
|
|
1324
|
+
enabled: true,
|
|
1325
|
+
accessPolicy: account.config.accessPolicy ?? "open",
|
|
1326
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
1327
|
+
sendThinkingMessage: account.sendThinkingMessage ?? true,
|
|
1328
|
+
});
|
|
1329
|
+
return { cfg: cfgWithAccount };
|
|
1330
|
+
},
|
|
1331
|
+
dmPolicy,
|
|
1332
|
+
disable: (cfg) => {
|
|
1333
|
+
return setAccount(cfg, { enabled: false });
|
|
1334
|
+
},
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
const meta = {
|
|
1338
|
+
id: CHANNEL_ID,
|
|
1339
|
+
label: "53AIHub",
|
|
1340
|
+
selectionLabel: "53AIHub (AgentHub)",
|
|
1341
|
+
detailLabel: "53AIHub 智能机器人",
|
|
1342
|
+
docsPath: `/channels/${CHANNEL_ID}`,
|
|
1343
|
+
docsLabel: CHANNEL_ID,
|
|
1344
|
+
blurb: "53AIHub 智能机器人接入插件",
|
|
1345
|
+
systemImage: "message.fill",
|
|
1346
|
+
};
|
|
1347
|
+
const aiHubPlugin = {
|
|
1348
|
+
id: CHANNEL_ID,
|
|
1349
|
+
meta: {
|
|
1350
|
+
...meta,
|
|
1351
|
+
quickstartAllowFrom: true,
|
|
1352
|
+
},
|
|
1353
|
+
pairing: {
|
|
1354
|
+
idLabel: "userId",
|
|
1355
|
+
normalizeAllowEntry: (entry) => entry.replace(new RegExp(`^(${CHANNEL_ID}|user):`, "i"), "").trim(),
|
|
1356
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
1357
|
+
console.log(`[53aihub] Pairing approved for user: ${id}`);
|
|
1358
|
+
},
|
|
1359
|
+
},
|
|
1360
|
+
onboarding: aiHubOnboardingAdapter,
|
|
1361
|
+
capabilities: {
|
|
1362
|
+
chatTypes: ["direct"],
|
|
1363
|
+
reactions: false,
|
|
1364
|
+
threads: false,
|
|
1365
|
+
media: true,
|
|
1366
|
+
nativeCommands: false,
|
|
1367
|
+
blockStreaming: true,
|
|
1368
|
+
},
|
|
1369
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
1370
|
+
config: {
|
|
1371
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
1372
|
+
resolveAccount: (cfg) => resolveAccount(cfg),
|
|
1373
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
1374
|
+
setAccountEnabled: ({ cfg, enabled }) => {
|
|
1375
|
+
const config = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1376
|
+
return {
|
|
1377
|
+
...cfg,
|
|
1378
|
+
channels: {
|
|
1379
|
+
...cfg.channels,
|
|
1380
|
+
[CHANNEL_ID]: { ...config, enabled },
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
1383
|
+
},
|
|
1384
|
+
deleteAccount: ({ cfg }) => {
|
|
1385
|
+
const config = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1386
|
+
const { botId, secret, token, ...rest } = config;
|
|
1387
|
+
return {
|
|
1388
|
+
...cfg,
|
|
1389
|
+
channels: {
|
|
1390
|
+
...cfg.channels,
|
|
1391
|
+
[CHANNEL_ID]: rest,
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
},
|
|
1395
|
+
isConfigured: (account) => Boolean((account.botId?.trim() || account.token?.trim()) || account.secret?.trim()),
|
|
1396
|
+
describeAccount: (account) => ({
|
|
1397
|
+
accountId: account.accountId,
|
|
1398
|
+
name: account.name,
|
|
1399
|
+
enabled: account.enabled,
|
|
1400
|
+
configured: Boolean(account.botId?.trim() || account.token?.trim()),
|
|
1401
|
+
botId: account.botId,
|
|
1402
|
+
websocketUrl: account.websocketUrl,
|
|
1403
|
+
accessPolicy: account.config.accessPolicy ?? "open",
|
|
1404
|
+
}),
|
|
1405
|
+
resolveAllowFrom: ({ cfg }) => {
|
|
1406
|
+
const account = resolveAccount(cfg);
|
|
1407
|
+
return (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
1408
|
+
},
|
|
1409
|
+
formatAllowFrom: ({ allowFrom }) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
|
|
1410
|
+
},
|
|
1411
|
+
security: {
|
|
1412
|
+
resolveDmPolicy: ({ account }) => {
|
|
1413
|
+
const basePath = `channels.${CHANNEL_ID}.`;
|
|
1414
|
+
const accessPolicy = account.config.accessPolicy ?? "open";
|
|
1415
|
+
return {
|
|
1416
|
+
policy: accessPolicy === "pairing" ? "pairing" : accessPolicy === "allowlist" ? "allowlist" : "open",
|
|
1417
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
1418
|
+
policyPath: `${basePath}accessPolicy`,
|
|
1419
|
+
allowFromPath: basePath,
|
|
1420
|
+
approveHint: formatPairingApproveHint(CHANNEL_ID),
|
|
1421
|
+
normalizeEntry: (raw) => raw.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim(),
|
|
1422
|
+
};
|
|
1423
|
+
},
|
|
1424
|
+
collectWarnings: ({ account }) => {
|
|
1425
|
+
const warnings = [];
|
|
1426
|
+
const accessPolicy = account.config.accessPolicy ?? "open";
|
|
1427
|
+
if (accessPolicy === "open") {
|
|
1428
|
+
warnings.push(`- 访问策略为 "open",所有用户都可以使用机器人`);
|
|
1429
|
+
}
|
|
1430
|
+
return warnings;
|
|
1431
|
+
},
|
|
1432
|
+
},
|
|
1433
|
+
messaging: {
|
|
1434
|
+
normalizeTarget: (target) => {
|
|
1435
|
+
const trimmed = target.trim();
|
|
1436
|
+
if (!trimmed)
|
|
1437
|
+
return undefined;
|
|
1438
|
+
return trimmed;
|
|
1439
|
+
},
|
|
1440
|
+
targetResolver: {
|
|
1441
|
+
looksLikeId: (id) => Boolean(id?.trim()),
|
|
1442
|
+
hint: "<userId|chatId>",
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
directory: {
|
|
1446
|
+
self: async () => null,
|
|
1447
|
+
listPeers: async () => [],
|
|
1448
|
+
listGroups: async () => [],
|
|
1449
|
+
},
|
|
1450
|
+
outbound: {
|
|
1451
|
+
deliveryMode: "direct",
|
|
1452
|
+
chunker: (text, limit) => getRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
1453
|
+
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
1454
|
+
sendText: async ({ to, text, accountId, ...rest }) => {
|
|
1455
|
+
const wsClient = getWebSocket(accountId ?? DEFAULT_ACCOUNT_ID);
|
|
1456
|
+
if (!wsClient) {
|
|
1457
|
+
throw new Error(`[53aihub] WS Client not connected for account ${accountId}`);
|
|
1458
|
+
}
|
|
1459
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
1460
|
+
const targetId = to.replace(channelPrefix, "");
|
|
1461
|
+
await sendDirectMessage(wsClient, targetId, text);
|
|
1462
|
+
return {
|
|
1463
|
+
channel: CHANNEL_ID,
|
|
1464
|
+
messageId: `msg-${Date.now()}`,
|
|
1465
|
+
chatId: targetId,
|
|
1466
|
+
};
|
|
1467
|
+
},
|
|
1468
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, ...rest }) => {
|
|
1469
|
+
const wsClient = getWebSocket(accountId ?? DEFAULT_ACCOUNT_ID);
|
|
1470
|
+
if (!wsClient) {
|
|
1471
|
+
throw new Error(`[53aihub] WS Client not connected for account ${accountId}`);
|
|
1472
|
+
}
|
|
1473
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
1474
|
+
const targetId = to.replace(channelPrefix, "");
|
|
1475
|
+
const restAny = rest;
|
|
1476
|
+
const mediaType = restAny.mediaType;
|
|
1477
|
+
const isImage = mediaType?.startsWith("image/") ||
|
|
1478
|
+
mediaUrl?.match(/\.(jpg|jpeg|png|gif|webp|heic|heif|avif)$/i);
|
|
1479
|
+
const mediaCategory = isImage ? "image" : "file";
|
|
1480
|
+
await sendMediaMessage(wsClient, targetId, {
|
|
1481
|
+
type: mediaCategory,
|
|
1482
|
+
url: mediaUrl,
|
|
1483
|
+
mimeType: mediaType,
|
|
1484
|
+
}, text);
|
|
1485
|
+
return { channel: CHANNEL_ID, messageId: `msg-${Date.now()}`, chatId: targetId };
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
status: {
|
|
1489
|
+
defaultRuntime: {
|
|
1490
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
1491
|
+
running: false,
|
|
1492
|
+
lastStartAt: null,
|
|
1493
|
+
lastStopAt: null,
|
|
1494
|
+
lastError: null,
|
|
1495
|
+
},
|
|
1496
|
+
collectStatusIssues: (accounts) => accounts.flatMap((entry) => {
|
|
1497
|
+
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
|
1498
|
+
if (!entry.enabled)
|
|
1499
|
+
return [];
|
|
1500
|
+
const issues = [];
|
|
1501
|
+
if (!entry.configured) {
|
|
1502
|
+
issues.push({
|
|
1503
|
+
channel: CHANNEL_ID,
|
|
1504
|
+
accountId,
|
|
1505
|
+
kind: "config",
|
|
1506
|
+
message: "53AIHub 访问令牌未配置",
|
|
1507
|
+
fix: "Run: openclaw channels add 53aihub --token <your_token>",
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
return issues;
|
|
1511
|
+
}),
|
|
1512
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
1513
|
+
configured: snapshot.configured ?? false,
|
|
1514
|
+
running: snapshot.running ?? false,
|
|
1515
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
1516
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
1517
|
+
lastError: snapshot.lastError ?? null,
|
|
1518
|
+
}),
|
|
1519
|
+
probeAccount: async () => ({ ok: true, status: 200 }),
|
|
1520
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
1521
|
+
accountId: account.accountId,
|
|
1522
|
+
name: account.name,
|
|
1523
|
+
enabled: account.enabled,
|
|
1524
|
+
configured: Boolean(account.botId?.trim() || account.token?.trim() || account.secret?.trim()),
|
|
1525
|
+
running: runtime?.running ?? false,
|
|
1526
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
1527
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
1528
|
+
lastError: runtime?.lastError ?? null,
|
|
1529
|
+
}),
|
|
1530
|
+
},
|
|
1531
|
+
gateway: {
|
|
1532
|
+
startAccount: async (ctx) => {
|
|
1533
|
+
return monitorProvider({
|
|
1534
|
+
account: ctx.account,
|
|
1535
|
+
config: ctx.cfg,
|
|
1536
|
+
runtime: ctx.runtime,
|
|
1537
|
+
abortSignal: ctx.abortSignal,
|
|
1538
|
+
});
|
|
1539
|
+
},
|
|
1540
|
+
logoutAccount: async ({ cfg }) => {
|
|
1541
|
+
const nextCfg = { ...cfg };
|
|
1542
|
+
const config = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1543
|
+
const nextConfig = { ...config };
|
|
1544
|
+
let cleared = false;
|
|
1545
|
+
let changed = false;
|
|
1546
|
+
if (nextConfig.botId || nextConfig.secret || nextConfig.token) {
|
|
1547
|
+
delete nextConfig.botId;
|
|
1548
|
+
delete nextConfig.secret;
|
|
1549
|
+
delete nextConfig.token;
|
|
1550
|
+
cleared = true;
|
|
1551
|
+
changed = true;
|
|
1552
|
+
}
|
|
1553
|
+
if (changed) {
|
|
1554
|
+
if (Object.keys(nextConfig).length > 0) {
|
|
1555
|
+
nextCfg.channels = { ...nextCfg.channels, [CHANNEL_ID]: nextConfig };
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
const nextChannels = { ...nextCfg.channels };
|
|
1559
|
+
delete nextChannels[CHANNEL_ID];
|
|
1560
|
+
nextCfg.channels = Object.keys(nextChannels).length > 0 ? nextChannels : undefined;
|
|
1561
|
+
}
|
|
1562
|
+
await getRuntime().config.writeConfigFile(nextCfg);
|
|
1563
|
+
}
|
|
1564
|
+
const resolved = resolveAccount(changed ? nextCfg : cfg);
|
|
1565
|
+
const loggedOut = !resolved.botId && !resolved.secret && !resolved.config.token;
|
|
1566
|
+
return { cleared, envToken: false, loggedOut };
|
|
1567
|
+
},
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
const plugin = {
|
|
1572
|
+
id: "53aihub-openclaw-plugin",
|
|
1573
|
+
name: "53AIHub",
|
|
1574
|
+
description: "53AIHub (AgentHub) OpenClaw 插件",
|
|
1575
|
+
configSchema: emptyPluginConfigSchema(),
|
|
1576
|
+
register(api) {
|
|
1577
|
+
setRuntime(api.runtime);
|
|
1578
|
+
api.registerChannel({ plugin: aiHubPlugin });
|
|
1579
|
+
},
|
|
1580
|
+
};
|
|
1581
|
+
|
|
1582
|
+
export { plugin as default };
|
|
1583
|
+
//# sourceMappingURL=index.esm.js.map
|