@mocrane/wecom 2026.3.9 → 2026.3.12
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 +25 -22
- package/clawdbot.plugin.json +1 -0
- package/index.ts +38 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +7 -4
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/skills/wecom-doc-manager/SKILL.md +64 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +249 -0
- package/skills/wecom-get-todo-detail/SKILL.md +143 -0
- package/skills/wecom-get-todo-list/SKILL.md +127 -0
- package/skills/wecom-meeting-create/SKILL.md +158 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +136 -0
- package/skills/wecom-meeting-query/SKILL.md +330 -0
- package/skills/wecom-preflight/SKILL.md +141 -0
- package/skills/wecom-schedule/SKILL.md +159 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +71 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/src/agent/handler.ts +105 -14
- package/src/channel.ts +7 -4
- package/src/compat/plugin-sdk-shim.ts +152 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/schema.ts +108 -0
- package/src/mcp/tool.ts +247 -0
- package/src/mcp/transport.ts +583 -0
- package/src/mcp-config.ts +182 -0
- package/src/media/const.ts +24 -0
- package/src/media/index.ts +15 -0
- package/src/media/uploader.ts +240 -0
- package/src/monitor.ts +362 -40
- package/src/onboarding.ts +45 -6
- package/src/outbound.ts +116 -46
- package/src/timeout.ts +45 -0
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +10 -1
- package/src/ws-adapter.ts +22 -0
package/src/outbound.ts
CHANGED
|
@@ -4,9 +4,63 @@ import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } f
|
|
|
4
4
|
import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
|
|
5
5
|
import { getWecomRuntime } from "./runtime.js";
|
|
6
6
|
import { getWsClient, waitForWsConnection } from "./ws-adapter.js";
|
|
7
|
+
import { uploadAndSendMediaBuffer } from "./media/index.js";
|
|
7
8
|
|
|
8
9
|
import { resolveWecomTarget } from "./target.js";
|
|
9
10
|
|
|
11
|
+
// ─── MIME 类型映射表(扩展名 → Content-Type)──────────────────────────
|
|
12
|
+
|
|
13
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
14
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
15
|
+
webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
|
|
16
|
+
amr: "audio/amr", mp4: "video/mp4", mov: "video/quicktime",
|
|
17
|
+
pdf: "application/pdf", doc: "application/msword",
|
|
18
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
19
|
+
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
20
|
+
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
21
|
+
txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
|
|
22
|
+
xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
|
|
23
|
+
zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
|
|
24
|
+
tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
|
|
25
|
+
rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── 共享的媒体加载逻辑 ────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 从 URL 或本地文件路径加载媒体文件,返回 buffer、contentType、filename。
|
|
32
|
+
* 供 Bot WS 和 Agent 两种发送模式共用。
|
|
33
|
+
*/
|
|
34
|
+
async function loadMediaBuffer(mediaUrl: string): Promise<{
|
|
35
|
+
buffer: Buffer;
|
|
36
|
+
contentType: string;
|
|
37
|
+
filename: string;
|
|
38
|
+
}> {
|
|
39
|
+
const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
|
|
40
|
+
|
|
41
|
+
if (isRemoteUrl) {
|
|
42
|
+
const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30_000) });
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`Failed to download media: ${res.status}`);
|
|
45
|
+
}
|
|
46
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
47
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
48
|
+
const urlPath = new URL(mediaUrl).pathname;
|
|
49
|
+
const filename = urlPath.split("/").pop() || "media";
|
|
50
|
+
return { buffer, contentType, filename };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 本地文件路径
|
|
54
|
+
const fs = await import("node:fs/promises");
|
|
55
|
+
const path = await import("node:path");
|
|
56
|
+
const buffer = await fs.readFile(mediaUrl);
|
|
57
|
+
const filename = path.basename(mediaUrl);
|
|
58
|
+
const ext = path.extname(mediaUrl).slice(1).toLowerCase();
|
|
59
|
+
const contentType = MIME_BY_EXT[ext] || "application/octet-stream";
|
|
60
|
+
console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
|
|
61
|
+
return { buffer, contentType, filename };
|
|
62
|
+
}
|
|
63
|
+
|
|
10
64
|
function resolveAgentConfigOrThrow(params: {
|
|
11
65
|
cfg: ChannelOutboundContext["cfg"];
|
|
12
66
|
accountId?: string | null;
|
|
@@ -166,8 +220,68 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
166
220
|
};
|
|
167
221
|
},
|
|
168
222
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
|
|
169
|
-
|
|
223
|
+
if (!mediaUrl) {
|
|
224
|
+
throw new Error("WeCom outbound requires mediaUrl.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Bot WebSocket 模式 ──
|
|
228
|
+
const resolvedAccount = resolveWecomAccount({ cfg, accountId });
|
|
229
|
+
const botAccount = resolvedAccount.bot;
|
|
230
|
+
if (botAccount?.connectionMode === "websocket" && botAccount.configured) {
|
|
231
|
+
const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
|
|
232
|
+
// 如果目标是 Agent 会话(wecom-agent:),跳过 WS Bot,走 Agent outbound
|
|
233
|
+
if (!rawTo.startsWith("wecom-agent:")) {
|
|
234
|
+
const wsTarget = resolveWecomTarget(to);
|
|
235
|
+
const chatId = wsTarget?.touser || wsTarget?.chatid;
|
|
236
|
+
if (!chatId) {
|
|
237
|
+
throw new Error(`[wecom-outbound] Bot WS sendMedia 无法解析目标 chatId (to=${String(to)})`);
|
|
238
|
+
}
|
|
170
239
|
|
|
240
|
+
// 确保 WS 连接可用
|
|
241
|
+
let wsClient = getWsClient(botAccount.accountId);
|
|
242
|
+
if (!wsClient?.isConnected) {
|
|
243
|
+
console.log(`[wecom-outbound] Bot WS 未连接,等待重连... (accountId=${botAccount.accountId})`);
|
|
244
|
+
const reconnected = await waitForWsConnection(botAccount.accountId, 10_000);
|
|
245
|
+
if (!reconnected) {
|
|
246
|
+
throw new Error(`[wecom-outbound] Bot WS 等待重连超时,无法发送媒体 (accountId=${botAccount.accountId})`);
|
|
247
|
+
}
|
|
248
|
+
wsClient = getWsClient(botAccount.accountId);
|
|
249
|
+
}
|
|
250
|
+
if (!wsClient?.isConnected) {
|
|
251
|
+
throw new Error(`[wecom-outbound] Bot WS 重连后仍不可用 (accountId=${botAccount.accountId})`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 加载媒体并通过 WSClient 上传发送
|
|
255
|
+
const { buffer, contentType, filename } = await loadMediaBuffer(mediaUrl);
|
|
256
|
+
console.log(`[wecom-outbound] Bot WS sendMedia: chatId=${chatId} filename=${filename} contentType=${contentType} size=${buffer.length}`);
|
|
257
|
+
|
|
258
|
+
const result = await uploadAndSendMediaBuffer({
|
|
259
|
+
wsClient,
|
|
260
|
+
buffer,
|
|
261
|
+
contentType,
|
|
262
|
+
fileName: filename,
|
|
263
|
+
chatId,
|
|
264
|
+
log: (msg) => console.log(`[wecom-outbound] ${msg}`),
|
|
265
|
+
errorLog: (msg) => console.error(`[wecom-outbound] ${msg}`),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (result.rejected) {
|
|
269
|
+
throw new Error(`WeCom Bot WS 媒体被拒绝: ${result.rejectReason}`);
|
|
270
|
+
}
|
|
271
|
+
if (!result.ok) {
|
|
272
|
+
throw new Error(`WeCom Bot WS 媒体发送失败: ${result.error}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
console.log(`[wecom-outbound] Bot WS sendMedia 成功: type=${result.finalType}${result.downgraded ? ` (降级: ${result.downgradeNote})` : ""}`);
|
|
276
|
+
return {
|
|
277
|
+
channel: "wecom",
|
|
278
|
+
messageId: `ws-bot-media-${Date.now()}`,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Agent 模式 ──
|
|
171
285
|
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
172
286
|
const target = resolveWecomTarget(to);
|
|
173
287
|
if (!target) {
|
|
@@ -180,52 +294,8 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
180
294
|
`请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
|
|
181
295
|
);
|
|
182
296
|
}
|
|
183
|
-
if (!mediaUrl) {
|
|
184
|
-
throw new Error("WeCom outbound requires mediaUrl.");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let buffer: Buffer;
|
|
188
|
-
let contentType: string;
|
|
189
|
-
let filename: string;
|
|
190
|
-
|
|
191
|
-
// 判断是 URL 还是本地文件路径
|
|
192
|
-
const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
|
|
193
297
|
|
|
194
|
-
|
|
195
|
-
const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
|
|
196
|
-
if (!res.ok) {
|
|
197
|
-
throw new Error(`Failed to download media: ${res.status}`);
|
|
198
|
-
}
|
|
199
|
-
buffer = Buffer.from(await res.arrayBuffer());
|
|
200
|
-
contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
201
|
-
const urlPath = new URL(mediaUrl).pathname;
|
|
202
|
-
filename = urlPath.split("/").pop() || "media";
|
|
203
|
-
} else {
|
|
204
|
-
// 本地文件路径
|
|
205
|
-
const fs = await import("node:fs/promises");
|
|
206
|
-
const path = await import("node:path");
|
|
207
|
-
|
|
208
|
-
buffer = await fs.readFile(mediaUrl);
|
|
209
|
-
filename = path.basename(mediaUrl);
|
|
210
|
-
|
|
211
|
-
// 根据扩展名推断 content-type
|
|
212
|
-
const ext = path.extname(mediaUrl).slice(1).toLowerCase();
|
|
213
|
-
const mimeTypes: Record<string, string> = {
|
|
214
|
-
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
215
|
-
webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
|
|
216
|
-
amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
|
|
217
|
-
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
218
|
-
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
219
|
-
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
220
|
-
txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
|
|
221
|
-
xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
|
|
222
|
-
zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
|
|
223
|
-
tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
|
|
224
|
-
rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
|
|
225
|
-
};
|
|
226
|
-
contentType = mimeTypes[ext] || "application/octet-stream";
|
|
227
|
-
console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
|
|
228
|
-
}
|
|
298
|
+
const { buffer, contentType, filename } = await loadMediaBuffer(mediaUrl);
|
|
229
299
|
|
|
230
300
|
let mediaType: "image" | "voice" | "video" | "file" = "file";
|
|
231
301
|
if (contentType.startsWith("image/")) mediaType = "image";
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 超时控制工具模块
|
|
3
|
+
*
|
|
4
|
+
* 为异步操作提供统一的超时保护机制
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 为 Promise 添加超时保护
|
|
9
|
+
*
|
|
10
|
+
* @param promise - 原始 Promise
|
|
11
|
+
* @param timeoutMs - 超时时间(毫秒)
|
|
12
|
+
* @param message - 超时错误消息
|
|
13
|
+
* @returns 带超时保护的 Promise
|
|
14
|
+
*/
|
|
15
|
+
export function withTimeout<T>(
|
|
16
|
+
promise: Promise<T>,
|
|
17
|
+
timeoutMs: number,
|
|
18
|
+
message?: string,
|
|
19
|
+
): Promise<T> {
|
|
20
|
+
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
|
|
21
|
+
return promise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
25
|
+
|
|
26
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
27
|
+
timeoutId = setTimeout(() => {
|
|
28
|
+
reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
33
|
+
clearTimeout(timeoutId);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 超时错误类型
|
|
39
|
+
*/
|
|
40
|
+
export class TimeoutError extends Error {
|
|
41
|
+
constructor(message: string) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "TimeoutError";
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/types/message.ts
CHANGED
|
@@ -41,6 +41,12 @@ export type WecomBotInboundVoice = WecomBotInboundBase & {
|
|
|
41
41
|
quote?: WecomInboundQuote;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export type WecomBotInboundVideo = WecomBotInboundBase & {
|
|
45
|
+
msgtype: "video";
|
|
46
|
+
video?: { url?: string; aeskey?: string };
|
|
47
|
+
quote?: WecomInboundQuote;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
export type WecomBotInboundStreamRefresh = WecomBotInboundBase & {
|
|
45
51
|
msgtype: "stream";
|
|
46
52
|
stream?: { id?: string };
|
|
@@ -62,7 +68,7 @@ export type WecomBotInboundEvent = WecomBotInboundBase & {
|
|
|
62
68
|
* 支持引用文本、图片、混合类型、语音、文件等。
|
|
63
69
|
*/
|
|
64
70
|
export type WecomInboundQuote = {
|
|
65
|
-
msgtype?: "text" | "image" | "mixed" | "voice" | "file";
|
|
71
|
+
msgtype?: "text" | "image" | "mixed" | "voice" | "file" | "video";
|
|
66
72
|
/** 引用文本内容 */
|
|
67
73
|
text?: { content?: string };
|
|
68
74
|
/** 引用图片 URL */
|
|
@@ -79,11 +85,14 @@ export type WecomInboundQuote = {
|
|
|
79
85
|
voice?: { content?: string };
|
|
80
86
|
/** 引用文件 */
|
|
81
87
|
file?: { url?: string };
|
|
88
|
+
/** 引用视频 */
|
|
89
|
+
video?: { url?: string };
|
|
82
90
|
};
|
|
83
91
|
|
|
84
92
|
export type WecomBotInboundMessage =
|
|
85
93
|
| WecomBotInboundText
|
|
86
94
|
| WecomBotInboundVoice
|
|
95
|
+
| WecomBotInboundVideo
|
|
87
96
|
| WecomBotInboundStreamRefresh
|
|
88
97
|
| WecomBotInboundEvent
|
|
89
98
|
| (WecomBotInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
|
package/src/ws-adapter.ts
CHANGED
|
@@ -32,6 +32,12 @@ import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState } from "./monitor
|
|
|
32
32
|
import { shouldProcessBotInboundMessage, buildInboundBody } from "./monitor.js";
|
|
33
33
|
import { monitorState } from "./monitor/state.js";
|
|
34
34
|
import { getWecomRuntime } from "./runtime.js";
|
|
35
|
+
import { fetchAndSaveMcpConfig } from "./mcp-config.js";
|
|
36
|
+
|
|
37
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** "思考中" 占位消息,让用户立即看到机器人正在响应 */
|
|
40
|
+
const THINKING_MESSAGE = "<think></think>";
|
|
35
41
|
|
|
36
42
|
// ─── WSClient Instance Registry ────────────────────────────────────────
|
|
37
43
|
|
|
@@ -190,6 +196,10 @@ function convertSdkMessageToInbound(body: BaseMessage): WecomBotInboundMessage {
|
|
|
190
196
|
const fileBody = body as FileMessage;
|
|
191
197
|
return { ...base, msgtype: "file" as any, file: fileBody.file, quote: fileBody.quote as any } as any;
|
|
192
198
|
}
|
|
199
|
+
if (msgtype === "video") {
|
|
200
|
+
// SDK 没有导出 VideoMessage 类型,直接从 BaseMessage 取 video 字段
|
|
201
|
+
return { ...base, msgtype: "video" as any, video: (body as any).video, quote: (body as any).quote } as any;
|
|
202
|
+
}
|
|
193
203
|
if (msgtype === "mixed") {
|
|
194
204
|
const mixedBody = body as MixedMessage;
|
|
195
205
|
return { ...base, msgtype: "mixed" as any, mixed: mixedBody.mixed, quote: mixedBody.quote as any } as any;
|
|
@@ -265,6 +275,16 @@ function setupMessageHandler(params: {
|
|
|
265
275
|
s.wsMode = true;
|
|
266
276
|
});
|
|
267
277
|
|
|
278
|
+
// 立即发送"思考中"占位消息,让用户看到即时反馈
|
|
279
|
+
const sendThinking = (target.account.config as any).sendThinkingMessage ?? true;
|
|
280
|
+
if (sendThinking) {
|
|
281
|
+
wsClient.replyStream(frame, streamId, THINKING_MESSAGE, false).catch((err) => {
|
|
282
|
+
target.runtime.error?.(
|
|
283
|
+
`[${accountId}] ws-thinking: failed to send thinking message: ${String(err)}`,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
268
288
|
// 注册流式回复监听器
|
|
269
289
|
watchStreamReply({
|
|
270
290
|
wsClient,
|
|
@@ -448,6 +468,8 @@ export function startWsClient(params: StartWsClientParams): () => void {
|
|
|
448
468
|
});
|
|
449
469
|
wsClient.on("authenticated", () => {
|
|
450
470
|
runtime.log?.(`[${accountId}] ws: authenticated successfully`);
|
|
471
|
+
// 认证成功后拉取 MCP 配置(非阻塞,失败仅记日志)
|
|
472
|
+
void fetchAndSaveMcpConfig(wsClient, accountId, runtime);
|
|
451
473
|
});
|
|
452
474
|
wsClient.on("disconnected", (reason: string) => {
|
|
453
475
|
runtime.log?.(`[${accountId}] ws: disconnected - ${reason}`);
|