@fontdo/5g-message 1.0.8 → 1.0.9

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/index.ts DELETED
@@ -1,872 +0,0 @@
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?.["5g-message"] 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?.["5g-message"] 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: "5g-message",
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 = `5g-message:${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: "5g-message" as const,
485
- Surface: "5g-message" as const,
486
- MessageSid: msg.messageId,
487
- Timestamp: msg.timestamp,
488
- WasMentioned: false,
489
- CommandAuthorized: true,
490
- OriginatingChannel: "5g-message" 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 fontdo5GMessagePlugin: ChannelPlugin<CustomIMAccount> = {
583
- id: "5g-message",
584
-
585
- meta: {
586
- id: "5g-message",
587
- label: "Fontdo 5G Message",
588
- selectionLabel: "Fontdo 5G Message (WebSocket)",
589
- docsPath: "/channels/5g-message",
590
- blurb: "Fontdo 5G 消息平台集成,通过 WebSocket JSON-RPC 2.0 协议",
591
- aliases: ["fontdo", "5g", "5g-message"],
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.5g-message"] },
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
- "5g-message": {
668
- ...cfg.channels?.["5g-message"],
669
- enabled: true,
670
- },
671
- },
672
- };
673
- }
674
-
675
- const customImCfg = cfg.channels?.["5g-message"] as Record<string, unknown> | undefined;
676
- return {
677
- ...cfg,
678
- channels: {
679
- ...cfg.channels,
680
- "5g-message": {
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 5g-message[${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 5g-message[${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: "5g-message",
861
- name: "Fontdo 5G Message",
862
- description: "Fontdo 5G 消息平台集成,通过 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: fontdo5GMessagePlugin });
869
- },
870
- };
871
-
872
- export default plugin;