@fontdo/5g-message 1.0.4

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 ADDED
@@ -0,0 +1,88 @@
1
+ # Fontdo 5G Message Channel Plugin
2
+
3
+ OpenClaw 通道插件,用于连接 Fontdo 5G 消息平台。
4
+
5
+ ## 协议支持
6
+
7
+ - **传输协议**: WebSocket over TLS (wss://)
8
+ - **消息协议**: JSON-RPC 2.0
9
+ - **认证方式**: Headers 签名认证
10
+
11
+ ## 安装
12
+
13
+ ### 方法 1: 本地安装
14
+
15
+ ```bash
16
+ # 将插件复制到 OpenClaw 扩展目录
17
+ cp -r . ~/.openclaw/extensions/fontdo-5g-message
18
+
19
+ # 或者使用链接方式(开发模式)
20
+ openclaw plugins install -l /path/to/fontdo-5g-message
21
+ ```
22
+
23
+ ### 方法 2: NPM 安装
24
+
25
+ ```bash
26
+ openclaw plugins install @openclaw/fontdo-5g-message
27
+ ```
28
+
29
+ ## 配置
30
+
31
+ 在 `~/.openclaw/openclaw.json` 中添加配置:
32
+
33
+ ```json
34
+ {
35
+ "channels": {
36
+ "fontdo-5g-message": {
37
+ "enabled": true,
38
+ "accounts": {
39
+ "default": {
40
+ "host": "5g.fontdo.com",
41
+ "appId": "your-app-id",
42
+ "appKey": "your-app-secret",
43
+ "botName": "OpenClaw Bot",
44
+ "enabled": true
45
+ }
46
+ },
47
+ "dmPolicy": "pairing",
48
+ "allowFrom": []
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### 配置项说明
55
+
56
+ | 字段 | 说明 | 必填 |
57
+ |------|------|------|
58
+ | `host` | IM 服务器地址(不含协议前缀) | ✅ |
59
+ | `appId` | 应用 ID | ✅ |
60
+ | `appKey` | 应用密钥 | ✅ |
61
+ | `botName` | 机器人显示名称 | ❌ |
62
+ | `enabled` | 是否启用该账号 | ❌ (默认 true) |
63
+
64
+ ### 多账号配置
65
+
66
+ ```json
67
+ {
68
+ "channels": {
69
+ "fontdo-5g-message": {
70
+ "defaultAccount": "prod",
71
+ "accounts": {
72
+ "prod": {
73
+ "host": "5g.fontdo.com",
74
+ "appId": "your-app-id",
75
+ "appKey": "your-app-secret",
76
+ "botName": "Production Bot"
77
+ },
78
+ "dev": {
79
+ "host": "test.fontdo.com",
80
+ "appId": "your-app-id",
81
+ "appKey": "your-app-secret",
82
+ "botName": "Dev Bot"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ ```
package/index.ts ADDED
@@ -0,0 +1,872 @@
1
+ /**
2
+ * OpenClaw Channel Plugin for Custom IM Platform
3
+ *
4
+ * This plugin connects OpenClaw to your custom IM platform via WebSocket
5
+ * using JSON-RPC 2.0 protocol.
6
+ */
7
+
8
+ import type { OpenClawPluginApi, ChannelPlugin, RuntimeEnv, ClawdbotConfig, PluginRuntime } from "openclaw/plugin-sdk";
9
+ import {
10
+ emptyPluginConfigSchema,
11
+ buildBaseChannelStatusSummary,
12
+ createDefaultChannelRuntimeState,
13
+ DEFAULT_ACCOUNT_ID,
14
+ createReplyPrefixContext,
15
+ } from "openclaw/plugin-sdk";
16
+ import WebSocket from "ws";
17
+ import crypto from "crypto";
18
+
19
+ // ============================================================================
20
+ // Runtime (set during plugin registration)
21
+ // ============================================================================
22
+
23
+ let pluginRuntime: PluginRuntime | null = null;
24
+
25
+ function getRuntime(): PluginRuntime {
26
+ if (!pluginRuntime) {
27
+ throw new Error("Custom IM plugin runtime not initialized");
28
+ }
29
+ return pluginRuntime;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Global Connection Registry (one WebSocket connection per account)
34
+ // ============================================================================
35
+
36
+ const activeConnections: Map<string, CustomIMClient> = new Map();
37
+
38
+ function registerConnection(accountId: string, client: CustomIMClient): void {
39
+ activeConnections.set(accountId, client);
40
+ console.log(`[CustomIM] Registered connection for account ${accountId}`);
41
+ }
42
+
43
+ function unregisterConnection(accountId: string): void {
44
+ activeConnections.delete(accountId);
45
+ console.log(`[CustomIM] Unregistered connection for account ${accountId}`);
46
+ }
47
+
48
+ function getActiveConnection(accountId: string): CustomIMClient | undefined {
49
+ return activeConnections.get(accountId);
50
+ }
51
+
52
+ // ============================================================================
53
+ // Types
54
+ // ============================================================================
55
+
56
+ interface CustomIMAccount {
57
+ accountId: string;
58
+ host: string;
59
+ appId: string;
60
+ appKey: string;
61
+ botName: string;
62
+ enabled: boolean;
63
+ configured: boolean;
64
+ }
65
+
66
+ interface JsonRpcRequest {
67
+ jsonrpc: "2.0";
68
+ method: string;
69
+ params: Record<string, unknown>;
70
+ id: string;
71
+ }
72
+
73
+ interface JsonRpcResponse {
74
+ jsonrpc: "2.0";
75
+ result?: unknown;
76
+ error?: { code: number; message: string; data?: unknown };
77
+ id: string;
78
+ }
79
+
80
+ interface MessagePayload {
81
+ messageId: string;
82
+ sender: string;
83
+ content: string;
84
+ contentType: "text" | "card" | "file";
85
+ timestamp: number;
86
+ chatId?: string;
87
+ isGroup?: boolean;
88
+ }
89
+
90
+ // ============================================================================
91
+ // Account Resolution
92
+ // ============================================================================
93
+
94
+ function resolveCustomIMAccount(
95
+ cfg: Record<string, unknown>,
96
+ accountId?: string
97
+ ): CustomIMAccount {
98
+ const actualAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
99
+ const channels = cfg.channels as Record<string, Record<string, unknown>> | undefined;
100
+ const customImChannel = channels?.["custom-im"] as Record<string, unknown> | undefined;
101
+ const accounts = customImChannel?.accounts as Record<string, Record<string, unknown>> | undefined;
102
+ const account = accounts?.[actualAccountId] as Record<string, unknown> | undefined;
103
+
104
+ if (!account) {
105
+ return {
106
+ accountId: actualAccountId,
107
+ host: "",
108
+ appId: "",
109
+ appKey: "",
110
+ botName: "OpenClaw Bot",
111
+ enabled: false,
112
+ configured: false,
113
+ };
114
+ }
115
+
116
+ return {
117
+ accountId: actualAccountId,
118
+ host: (account.host as string) ?? "",
119
+ appId: (account.appId as string) ?? "",
120
+ appKey: (account.appKey as string) ?? "",
121
+ botName: (account.botName as string) ?? "OpenClaw Bot",
122
+ enabled: (account.enabled as boolean) ?? true,
123
+ configured: !!(account.host && account.appId && account.appKey),
124
+ };
125
+ }
126
+
127
+ function listCustomIMAccountIds(cfg: Record<string, unknown>): string[] {
128
+ const channels = cfg.channels as Record<string, Record<string, unknown>> | undefined;
129
+ const customImChannel = channels?.["custom-im"] as Record<string, unknown> | undefined;
130
+ const accounts = customImChannel?.accounts as Record<string, Record<string, unknown>> | undefined;
131
+ return accounts ? Object.keys(accounts) : [];
132
+ }
133
+
134
+ // ============================================================================
135
+ // WebSocket Client for Custom IM
136
+ // ============================================================================
137
+
138
+ class CustomIMClient {
139
+ private ws: WebSocket | null = null;
140
+ private account: CustomIMAccount;
141
+ private onMessage: (msg: MessagePayload) => void;
142
+ private onClose: () => void;
143
+ private onHeartbeat?: () => void;
144
+ private logger: Console;
145
+ private heartbeatTimer: NodeJS.Timeout | null = null;
146
+ private readonly heartbeatInterval: number = 30000; // 30 seconds
147
+
148
+ constructor(
149
+ account: CustomIMAccount,
150
+ onMessage: (msg: MessagePayload) => void,
151
+ onClose: () => void,
152
+ logger: Console,
153
+ onHeartbeat?: () => void
154
+ ) {
155
+ this.account = account;
156
+ this.onMessage = onMessage;
157
+ this.onClose = onClose;
158
+ this.onHeartbeat = onHeartbeat;
159
+ this.logger = logger;
160
+ }
161
+
162
+ private generateSignature(timestamp: number): string {
163
+ const data = this.account.appId + this.account.appKey + timestamp;
164
+ return crypto.createHash("sha256").update(data).digest("hex");
165
+ }
166
+
167
+ async connect(): Promise<void> {
168
+ const url = `wss://${this.account.host}/clawgw/ws/v1/chat`;
169
+ const timestamp = Date.now();
170
+ const signature = this.generateSignature(timestamp);
171
+
172
+ this.logger.log(`[CustomIM] Connecting to ${url}...`);
173
+
174
+ return new Promise((resolve, reject) => {
175
+ this.ws = new WebSocket(url, {
176
+ headers: {
177
+ "X-App-Id": this.account.appId,
178
+ "X-Timestamp": String(timestamp),
179
+ "X-Signature": signature,
180
+ },
181
+ });
182
+
183
+ this.ws.on("open", () => {
184
+ this.logger.log(`[CustomIM] Connected to ${url}`);
185
+ this.startHeartbeat();
186
+ resolve();
187
+ });
188
+
189
+ this.ws.on("message", (data: Buffer) => {
190
+ this.logger.log(`[CustomIM] Raw message received: ${data.toString()}`);
191
+ try {
192
+ this.handleMessage(data);
193
+ } catch (error) {
194
+ this.logger.error("[CustomIM] Error handling message:", error);
195
+ }
196
+ });
197
+
198
+ this.ws.on("error", (error) => {
199
+ this.logger.error("[CustomIM] WebSocket error:", error);
200
+ reject(error);
201
+ });
202
+
203
+ this.ws.on("close", (code, reason) => {
204
+ this.logger.log(`[CustomIM] Connection closed (code=${code}, reason=${reason.toString() || 'none'})`);
205
+ this.stopHeartbeat();
206
+ this.onClose();
207
+ });
208
+ });
209
+ }
210
+
211
+ private handleMessage(data: Buffer): void {
212
+ try {
213
+ const msg = JSON.parse(data.toString());
214
+ this.logger.log(`[CustomIM] Parsed message:`, JSON.stringify(msg));
215
+
216
+ // Handle heartbeat request from server - must respond to keep connection alive
217
+ if (msg.method === "heartbeat" && msg.id) {
218
+ this.logger.log(`[CustomIM] Received heartbeat request, sending response`);
219
+ this.sendHeartbeatResponse(msg.id, msg.params?.timestamp);
220
+ return;
221
+ }
222
+
223
+ // Handle direct notification: {"jsonrpc":"2.0","method":"onMessage","params":{...}}
224
+ if (msg.method === "onMessage" && msg.params) {
225
+ const params = msg.params as {
226
+ messageId: string;
227
+ sender: string;
228
+ payload: object;
229
+ msgType?: string;
230
+ timestamp: number;
231
+ chatId?: string;
232
+ isGroup?: boolean;
233
+ };
234
+
235
+ this.logger.log(`[CustomIM] Processing onMessage from ${params.sender}: ${params.payload.content}`);
236
+
237
+ this.onMessage({
238
+ messageId: params.messageId,
239
+ sender: params.sender,
240
+ content: params.payload.content,
241
+ contentType: (params.msgType as "text" | "card" | "file") ?? "text",
242
+ timestamp: params.timestamp,
243
+ chatId: params.chatId,
244
+ isGroup: params.isGroup,
245
+ });
246
+ return;
247
+ }
248
+
249
+ // Handle response with result containing method (old format)
250
+ const response = msg as JsonRpcResponse;
251
+ if (response.error) {
252
+ this.logger.error("[CustomIM] RPC error:", response.error);
253
+ return;
254
+ }
255
+
256
+ if (
257
+ response.result &&
258
+ typeof response.result === "object" &&
259
+ "method" in response.result
260
+ ) {
261
+ const notification = response.result as { method: string; params: Record<string, unknown> };
262
+
263
+ if (notification.method === "onMessage") {
264
+ const params = notification.params as {
265
+ messageId: string;
266
+ sender: string;
267
+ content: string;
268
+ contentType: string;
269
+ timestamp: number;
270
+ chatId?: string;
271
+ isGroup?: boolean;
272
+ };
273
+
274
+ this.onMessage({
275
+ messageId: params.messageId,
276
+ sender: params.sender,
277
+ content: params.content,
278
+ contentType: params.contentType as "text" | "card" | "file",
279
+ timestamp: params.timestamp,
280
+ chatId: params.chatId,
281
+ isGroup: params.isGroup,
282
+ });
283
+ }
284
+ }
285
+ } catch (error) {
286
+ this.logger.error("[CustomIM] Failed to parse message:", error);
287
+ }
288
+ }
289
+
290
+ async sendText(
291
+ receiver: string,
292
+ text: string,
293
+ options?: { replyTo?: string }
294
+ ): Promise<{ messageId: string }> {
295
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
296
+ throw new Error("WebSocket not connected");
297
+ }
298
+
299
+ const id = crypto.randomUUID();
300
+ const request: JsonRpcRequest = {
301
+ jsonrpc: "2.0",
302
+ method: "sendMessage",
303
+ params: {
304
+ msgType: "text",
305
+ receiver,
306
+ payload: {
307
+ content: text, // Changed from 'text' to 'content' to match server API
308
+ },
309
+ replyTo: options?.replyTo,
310
+ },
311
+ id,
312
+ };
313
+
314
+ return new Promise((resolve, reject) => {
315
+ const timeout = setTimeout(() => {
316
+ this.ws?.off("message", handler);
317
+ reject(new Error("Request timeout"));
318
+ }, 30000);
319
+
320
+ const handler = (data: Buffer) => {
321
+ try {
322
+ const response: JsonRpcResponse = JSON.parse(data.toString());
323
+ if (response.id === id) {
324
+ clearTimeout(timeout);
325
+ this.ws?.off("message", handler);
326
+
327
+ if (response.error) {
328
+ reject(new Error(response.error.message));
329
+ } else {
330
+ resolve(response.result as { messageId: string });
331
+ }
332
+ }
333
+ } catch {
334
+ // Ignore
335
+ }
336
+ };
337
+
338
+ this.ws.on("message", handler);
339
+ this.ws.send(JSON.stringify(request));
340
+ });
341
+ }
342
+
343
+ private sendHeartbeatResponse(requestId: string, timestamp?: number): void {
344
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
345
+ this.logger.log(`[CustomIM] Cannot send heartbeat response: WebSocket not connected`);
346
+ return;
347
+ }
348
+
349
+ const response = {
350
+ jsonrpc: "2.0",
351
+ method: "heartbeat",
352
+ result: {
353
+ timestamp: timestamp ?? Date.now(),
354
+ },
355
+ id: requestId,
356
+ };
357
+
358
+ this.ws.send(JSON.stringify(response));
359
+ this.logger.log(`[CustomIM] Heartbeat response sent for request ${requestId}`);
360
+ }
361
+
362
+ private startHeartbeat(): void {
363
+ this.stopHeartbeat();
364
+ this.heartbeatTimer = setInterval(() => {
365
+ this.sendHeartbeat();
366
+ }, this.heartbeatInterval);
367
+ this.logger.log(`[CustomIM] Heartbeat started, interval=${this.heartbeatInterval}ms`);
368
+ }
369
+
370
+ private stopHeartbeat(): void {
371
+ if (this.heartbeatTimer) {
372
+ clearInterval(this.heartbeatTimer);
373
+ this.heartbeatTimer = null;
374
+ this.logger.log(`[CustomIM] Heartbeat stopped`);
375
+ }
376
+ }
377
+
378
+ private sendHeartbeat(): void {
379
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
380
+ this.logger.log(`[CustomIM] Cannot send heartbeat: WebSocket not connected`);
381
+ return;
382
+ }
383
+
384
+ const request = {
385
+ jsonrpc: "2.0",
386
+ method: "heartbeat",
387
+ params: {
388
+ clientTime: Date.now(),
389
+ },
390
+ id: crypto.randomUUID(),
391
+ };
392
+
393
+ this.ws.send(JSON.stringify(request));
394
+ this.logger.log(`[CustomIM] Heartbeat sent`);
395
+
396
+ // Report connection is active to prevent stale-socket detection
397
+ if (this.onHeartbeat) {
398
+ this.onHeartbeat();
399
+ }
400
+ }
401
+
402
+ disconnect(): void {
403
+ this.stopHeartbeat();
404
+ if (this.ws) {
405
+ this.ws.close();
406
+ this.ws = null;
407
+ }
408
+ }
409
+ }
410
+
411
+ // ============================================================================
412
+ // Message Dispatcher (using OpenClaw core API)
413
+ // ============================================================================
414
+
415
+ async function dispatchCustomIMMessage(params: {
416
+ cfg: ClawdbotConfig;
417
+ runtime: RuntimeEnv;
418
+ account: CustomIMAccount;
419
+ msg: MessagePayload;
420
+ client: CustomIMClient;
421
+ }): Promise<void> {
422
+ const { cfg, runtime, account, msg, client } = params;
423
+
424
+ console.log(`[CustomIM] dispatchCustomIMMessage called`);
425
+
426
+ // Get runtime
427
+ let core: PluginRuntime;
428
+ try {
429
+ core = getRuntime();
430
+ console.log(`[CustomIM] getRuntime() returned: ${core ? 'valid' : 'null'}`);
431
+ } catch (error) {
432
+ console.error(`[CustomIM] getRuntime() error: ${error instanceof Error ? error.message : String(error)}`);
433
+ throw error;
434
+ }
435
+
436
+ const isGroup = msg.isGroup ?? false;
437
+ const chatId = msg.chatId ?? msg.sender;
438
+
439
+ // Resolve agent route
440
+ let route;
441
+ try {
442
+ route = core.channel.routing.resolveAgentRoute({
443
+ cfg,
444
+ channel: "custom-im",
445
+ accountId: account.accountId,
446
+ peer: {
447
+ kind: isGroup ? "group" : "direct",
448
+ id: chatId,
449
+ },
450
+ });
451
+ console.log(`[CustomIM] Routing message to session: ${route.sessionKey}`);
452
+ } catch (error) {
453
+ console.error(`[CustomIM] resolveAgentRoute error: ${error instanceof Error ? error.message : String(error)}`);
454
+ throw error;
455
+ }
456
+
457
+ // Build the message context
458
+ const customImFrom = `custom-im:${msg.sender}`;
459
+ const customImTo = isGroup ? `chat:${chatId}` : `user:${msg.sender}`;
460
+
461
+ // Format message envelope
462
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
463
+ const body = core.channel.reply.formatAgentEnvelope({
464
+ channel: "Custom IM",
465
+ from: msg.sender,
466
+ timestamp: new Date(),
467
+ envelope: envelopeOptions,
468
+ body: msg.content,
469
+ });
470
+
471
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
472
+ Body: body,
473
+ BodyForAgent: msg.content,
474
+ RawBody: msg.content,
475
+ CommandBody: msg.content,
476
+ From: customImFrom,
477
+ To: customImTo,
478
+ SessionKey: route.sessionKey,
479
+ AccountId: route.accountId,
480
+ ChatType: isGroup ? "group" : "direct",
481
+ GroupSubject: isGroup ? chatId : undefined,
482
+ SenderName: msg.sender,
483
+ SenderId: msg.sender,
484
+ Provider: "custom-im" as const,
485
+ Surface: "custom-im" as const,
486
+ MessageSid: msg.messageId,
487
+ Timestamp: msg.timestamp,
488
+ WasMentioned: false,
489
+ CommandAuthorized: true,
490
+ OriginatingChannel: "custom-im" as const,
491
+ OriginatingTo: customImTo,
492
+ });
493
+
494
+ console.log(`[CustomIM] ctxPayload created: SessionKey=${ctxPayload.SessionKey}, ChatType=${ctxPayload.ChatType}`);
495
+
496
+ // Create reply dispatcher using OpenClaw's helper
497
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
498
+
499
+ console.log(`[CustomIM] Creating reply dispatcher...`);
500
+ console.log(`[CustomIM] prefixContext.responsePrefix: ${prefixContext.responsePrefix}`);
501
+
502
+ const dispatcherResult = core.channel.reply.createReplyDispatcherWithTyping({
503
+ responsePrefix: prefixContext.responsePrefix,
504
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
505
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
506
+ onReplyStart: () => {
507
+ console.log(`[CustomIM] Starting reply...`);
508
+ },
509
+ deliver: async (payload, info) => {
510
+ console.log(`[CustomIM] deliver called, payload:`, JSON.stringify(payload ?? 'null'));
511
+ const text = payload?.text ?? "";
512
+ console.log(`[CustomIM] deliver text length: ${text.length}`);
513
+ if (!text.trim()) {
514
+ console.log(`[CustomIM] deliver: text is empty, skipping`);
515
+ return;
516
+ }
517
+
518
+ console.log(`[CustomIM] Sending reply to ${msg.sender}: ${text.slice(0, 100)}...`);
519
+
520
+ try {
521
+ const result = await client.sendText(msg.sender, text);
522
+ console.log(`[CustomIM] Reply sent: ${result.messageId}`);
523
+ } catch (error) {
524
+ console.log(`[CustomIM] Failed to send reply: ${error instanceof Error ? error.message : String(error)}`);
525
+ throw error;
526
+ }
527
+ },
528
+ onError: async (error, info) => {
529
+ console.log(`[CustomIM] ${info.kind} reply failed: ${error instanceof Error ? error.message : String(error)}`);
530
+ },
531
+ onIdle: async () => {
532
+ console.log(`[CustomIM] Reply session idle`);
533
+ },
534
+ onCleanup: () => {
535
+ console.log(`[CustomIM] Reply session cleanup`);
536
+ },
537
+ });
538
+
539
+ console.log(`[CustomIM] dispatcherResult keys: ${Object.keys(dispatcherResult).join(', ')}`);
540
+ console.log(`[CustomIM] dispatcher type: ${typeof dispatcherResult.dispatcher}`);
541
+ console.log(`[CustomIM] dispatcher keys: ${dispatcherResult.dispatcher ? Object.keys(dispatcherResult.dispatcher).join(', ') : 'null'}`);
542
+
543
+ const { dispatcher, replyOptions, markDispatchIdle } = dispatcherResult;
544
+
545
+ const finalReplyOptions = {
546
+ ...replyOptions,
547
+ onModelSelected: prefixContext.onModelSelected,
548
+ };
549
+
550
+ console.log(`[CustomIM] Dispatching to agent (session=${route.sessionKey})`);
551
+
552
+ try {
553
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
554
+ ctx: ctxPayload,
555
+ cfg,
556
+ dispatcher,
557
+ replyOptions: finalReplyOptions,
558
+ });
559
+
560
+ console.log(`[CustomIM] Dispatch complete (queuedFinal=${queuedFinal})`);
561
+ console.log(`[CustomIM] counts: ${JSON.stringify(counts)}`);
562
+ console.log(`[CustomIM] dispatcher.getQueuedCounts(): ${JSON.stringify(dispatcher.getQueuedCounts())}`);
563
+
564
+ // Wait for the dispatcher to become idle (agent to finish processing)
565
+ await dispatcher.waitForIdle();
566
+ console.log(`[CustomIM] Dispatcher idle, final counts: ${JSON.stringify(dispatcher.getQueuedCounts())}`);
567
+
568
+ markDispatchIdle();
569
+ } catch (error) {
570
+ const errorMsg = error instanceof Error ? error.message : String(error);
571
+ const errorStack = error instanceof Error ? error.stack : 'N/A';
572
+ console.error(`[CustomIM] Dispatch error: ${errorMsg}`);
573
+ console.error(`[CustomIM] Dispatch stack: ${errorStack}`);
574
+ throw error;
575
+ }
576
+ }
577
+
578
+ // ============================================================================
579
+ // Channel Plugin Definition
580
+ // ============================================================================
581
+
582
+ export const customIMPlugin: ChannelPlugin<CustomIMAccount> = {
583
+ id: "custom-im",
584
+
585
+ meta: {
586
+ id: "custom-im",
587
+ label: "Custom IM",
588
+ selectionLabel: "Custom IM (WebSocket)",
589
+ docsPath: "/channels/custom-im",
590
+ blurb: "Custom IM platform integration via WebSocket JSON-RPC 2.0",
591
+ aliases: ["cim", "custom"],
592
+ },
593
+
594
+ capabilities: {
595
+ chatTypes: ["direct", "group"],
596
+ polls: false,
597
+ threads: false,
598
+ media: false,
599
+ reactions: false,
600
+ edit: false,
601
+ reply: true,
602
+ },
603
+
604
+ reload: { configPrefixes: ["channels.custom-im"] },
605
+
606
+ configSchema: {
607
+ schema: {
608
+ type: "object",
609
+ additionalProperties: false,
610
+ properties: {
611
+ enabled: { type: "boolean" },
612
+ host: { type: "string" },
613
+ appId: { type: "string" },
614
+ appKey: { type: "string" },
615
+ botName: { type: "string" },
616
+ dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
617
+ groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
618
+ autoRestart: { type: "boolean" },
619
+ restartDelayMs: { type: "number" },
620
+ maxRestartDelayMs: { type: "number" },
621
+ maxRestartAttempts: { type: "number" },
622
+ accounts: {
623
+ type: "object",
624
+ additionalProperties: {
625
+ type: "object",
626
+ properties: {
627
+ enabled: { type: "boolean" },
628
+ host: { type: "string" },
629
+ appId: { type: "string" },
630
+ appKey: { type: "string" },
631
+ botName: { type: "string" },
632
+ autoRestart: { type: "boolean" },
633
+ restartDelayMs: { type: "number" },
634
+ maxRestartDelayMs: { type: "number" },
635
+ maxRestartAttempts: { type: "number" },
636
+ },
637
+ },
638
+ },
639
+ },
640
+ },
641
+ },
642
+
643
+ config: {
644
+ listAccountIds: listCustomIMAccountIds,
645
+ resolveAccount: resolveCustomIMAccount,
646
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
647
+ isConfigured: (account) => account.configured,
648
+ describeAccount: (account) => ({
649
+ accountId: account.accountId,
650
+ enabled: account.enabled,
651
+ configured: account.configured,
652
+ host: account.host,
653
+ botName: account.botName,
654
+ }),
655
+ },
656
+
657
+ setup: {
658
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
659
+ applyAccountConfig: ({ cfg, accountId }) => {
660
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
661
+
662
+ if (isDefault) {
663
+ return {
664
+ ...cfg,
665
+ channels: {
666
+ ...cfg.channels,
667
+ "custom-im": {
668
+ ...cfg.channels?.["custom-im"],
669
+ enabled: true,
670
+ },
671
+ },
672
+ };
673
+ }
674
+
675
+ const customImCfg = cfg.channels?.["custom-im"] as Record<string, unknown> | undefined;
676
+ return {
677
+ ...cfg,
678
+ channels: {
679
+ ...cfg.channels,
680
+ "custom-im": {
681
+ ...customImCfg,
682
+ accounts: {
683
+ ...(customImCfg?.accounts as Record<string, unknown>),
684
+ [accountId]: {
685
+ ...(customImCfg?.accounts as Record<string, unknown>)?.[accountId],
686
+ enabled: true,
687
+ },
688
+ },
689
+ },
690
+ },
691
+ };
692
+ },
693
+ },
694
+
695
+ status: {
696
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
697
+ buildChannelSummary: ({ snapshot }) => ({
698
+ ...buildBaseChannelStatusSummary(snapshot),
699
+ }),
700
+ buildAccountSnapshot: ({ account, runtime }) => ({
701
+ accountId: account.accountId,
702
+ enabled: account.enabled,
703
+ configured: account.configured,
704
+ host: account.host,
705
+ botName: account.botName,
706
+ running: runtime?.running ?? false,
707
+ lastStartAt: runtime?.lastStartAt ?? null,
708
+ lastStopAt: runtime?.lastStopAt ?? null,
709
+ lastError: runtime?.lastError ?? null,
710
+ }),
711
+ },
712
+
713
+ gateway: {
714
+ startAccount: async (ctx) => {
715
+ const account = resolveCustomIMAccount(ctx.cfg, ctx.accountId);
716
+
717
+ if (!account.configured) {
718
+ throw new Error(`Custom IM account ${ctx.accountId} not configured`);
719
+ }
720
+
721
+ ctx.log?.info(`starting custom-im[${ctx.accountId}]`);
722
+
723
+ // Set initial status
724
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
725
+
726
+ // Create a promise that will be resolved when we need to stop
727
+ let stopResolver: (() => void) | null = null;
728
+ const stopPromise = new Promise<void>((resolve) => {
729
+ stopResolver = resolve;
730
+ });
731
+
732
+ const client = new CustomIMClient(
733
+ account,
734
+ async (msg) => {
735
+ ctx.log?.info(`[CustomIM] Received message from ${msg.sender}: ${msg.content}`);
736
+
737
+ try {
738
+ await dispatchCustomIMMessage({
739
+ cfg: ctx.cfg,
740
+ runtime: ctx.runtime,
741
+ account,
742
+ msg,
743
+ client,
744
+ });
745
+ ctx.log?.info(`[CustomIM] Message processed successfully`);
746
+ } catch (error) {
747
+ const errorMsg = error instanceof Error ? error.message : String(error);
748
+ const errorStack = error instanceof Error ? error.stack : 'N/A';
749
+ ctx.log?.error(`[CustomIM] Error processing message: ${errorMsg}`);
750
+ ctx.log?.error(`[CustomIM] Error stack: ${errorStack}`);
751
+ }
752
+ },
753
+ () => {
754
+ // Called when WebSocket connection is closed
755
+ ctx.log?.info(`[CustomIM] Connection lost, triggering reconnect...`);
756
+ unregisterConnection(ctx.accountId);
757
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
758
+ // Trigger reconnect by resolving the stop promise
759
+ if (stopResolver) {
760
+ stopResolver();
761
+ }
762
+ },
763
+ console,
764
+ () => {
765
+ // Called on each heartbeat - report connection is active to prevent stale-socket detection
766
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
767
+ }
768
+ );
769
+
770
+ await client.connect();
771
+
772
+ // Register the connection for this account
773
+ registerConnection(ctx.accountId, client);
774
+
775
+ // Update status to connected
776
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
777
+ ctx.log?.info(`[CustomIM] Connection established, channel is now running`);
778
+
779
+ // Wait for either abort signal or connection close
780
+ return new Promise<{ stop: () => void }>((resolve) => {
781
+ const stopHandler = () => {
782
+ ctx.log?.info(`stopping custom-im[${ctx.accountId}]`);
783
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
784
+ unregisterConnection(ctx.accountId);
785
+ client.disconnect();
786
+ resolve({ stop: stopHandler });
787
+ };
788
+
789
+ // Listen for abort signal
790
+ if (ctx.abortSignal) {
791
+ ctx.abortSignal.addEventListener('abort', stopHandler);
792
+ }
793
+
794
+ // Also resolve when connection is closed (stopPromise)
795
+ // This triggers immediate reconnect instead of waiting for OpenClaw's backoff
796
+ stopPromise.then(() => {
797
+ if (ctx.abortSignal) {
798
+ ctx.abortSignal.removeEventListener('abort', stopHandler);
799
+ }
800
+ // Don't resolve immediately - let OpenClaw handle the restart
801
+ // But signal that we want to restart by throwing an error
802
+ resolve({ stop: stopHandler });
803
+ });
804
+ });
805
+ },
806
+ },
807
+
808
+ outbound: {
809
+ deliveryMode: "direct",
810
+
811
+ sendText: async ({ cfg, accountId, peerId, text }) => {
812
+ const account = resolveCustomIMAccount(cfg, accountId);
813
+
814
+ if (!account.configured) {
815
+ throw new Error(`Custom IM account ${accountId} not configured`);
816
+ }
817
+
818
+ const client = getActiveConnection(accountId);
819
+ if (!client) {
820
+ throw new Error(`Custom IM account ${accountId} is not connected`);
821
+ }
822
+
823
+ const result = await client.sendText(peerId, text);
824
+ return { ok: true, messageId: result.messageId };
825
+ },
826
+ },
827
+
828
+ pairing: {
829
+ idLabel: "customImUserId",
830
+ normalizeAllowEntry: (entry) => entry.replace(/^(custom|cim):/i, ""),
831
+ notifyApproval: async ({ cfg, id, accountId }) => {
832
+ const account = resolveCustomIMAccount(cfg, accountId);
833
+
834
+ if (!account.configured) return;
835
+
836
+ const client = getActiveConnection(accountId);
837
+ if (!client) {
838
+ console.error(`[CustomIM] Account ${accountId} is not connected, cannot send approval message`);
839
+ return;
840
+ }
841
+
842
+ try {
843
+ await client.sendText(id, "✅ You have been approved to chat with me!");
844
+ } catch (error) {
845
+ console.error("[CustomIM] Failed to send approval message:", error);
846
+ }
847
+ },
848
+ },
849
+
850
+ security: {
851
+ dmPolicy: "open",
852
+ },
853
+ };
854
+
855
+ // ============================================================================
856
+ // Plugin Registration
857
+ // ============================================================================
858
+
859
+ const plugin = {
860
+ id: "custom-im",
861
+ name: "Custom IM",
862
+ description: "Custom IM platform integration via WebSocket JSON-RPC 2.0",
863
+ configSchema: emptyPluginConfigSchema(),
864
+
865
+ register(api: OpenClawPluginApi) {
866
+ // Store the runtime for use in message dispatching
867
+ pluginRuntime = api.runtime;
868
+ api.registerChannel({ plugin: customIMPlugin });
869
+ },
870
+ };
871
+
872
+ export default plugin;
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "fontdo-5g-message",
3
+ "channels": ["fontdo-5g-message"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "host": {
9
+ "type": "string",
10
+ "title": "服务器地址",
11
+ "description": "Fontdo 5G 消息平台地址(不含协议前缀,如 5g.fontdo.com)"
12
+ },
13
+ "appId": {
14
+ "type": "string",
15
+ "title": "应用 ID",
16
+ "description": "从平台获取的应用 ID"
17
+ },
18
+ "appKey": {
19
+ "type": "string",
20
+ "title": "应用密钥",
21
+ "description": "从平台获取的应用密钥"
22
+ },
23
+ "botName": {
24
+ "type": "string",
25
+ "title": "机器人名称",
26
+ "description": "机器人在对话中的显示名称",
27
+ "default": "Fontdo Bot"
28
+ },
29
+ "enabled": {
30
+ "type": "boolean",
31
+ "title": "启用账号",
32
+ "description": "是否启用该账号",
33
+ "default": true
34
+ }
35
+ }
36
+ }
37
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@fontdo/5g-message",
3
+ "version": "1.0.4",
4
+ "description": "OpenClaw 通道插件,用于连接 Fontdo 5G 消息平台",
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "test": "vitest"
9
+ },
10
+ "dependencies": {
11
+ "ws": "^8.16.0"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "^20.11.0",
15
+ "@types/ws": "^8.5.10",
16
+ "typescript": "^5.3.0",
17
+ "vitest": "^1.2.0"
18
+ },
19
+ "openclaw": {
20
+ "extensions": [
21
+ "./index.ts"
22
+ ],
23
+ "channel": {
24
+ "id": "fontdo-5g-message",
25
+ "label": "Fontdo 5G Message",
26
+ "selectionLabel": "Fontdo 5G Message (WebSocket)",
27
+ "docsPath": "/channels/fontdo-5g-message",
28
+ "docsLabel": "fontdo-5g-message",
29
+ "blurb": "Fontdo 5G 消息平台集成,通过 WebSocket JSON-RPC 2.0 协议",
30
+ "order": 100,
31
+ "aliases": [
32
+ "fontdo",
33
+ "5g",
34
+ "5g-message"
35
+ ]
36
+ }
37
+ }
38
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "outDir": "./dist",
15
+ "rootDir": ".",
16
+ "resolveJsonModule": true
17
+ },
18
+ "include": ["*.ts"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }