@rowger_go/chatu 0.1.3

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.
Files changed (77) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/publish.yml +55 -0
  3. package/INSTALL.md +285 -0
  4. package/INSTALL.zh.md +285 -0
  5. package/LICENSE +21 -0
  6. package/README.md +293 -0
  7. package/README.zh.md +293 -0
  8. package/dist/index.d.ts +96 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +1381 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/index.test.d.ts +5 -0
  13. package/dist/index.test.d.ts.map +1 -0
  14. package/dist/index.test.js +334 -0
  15. package/dist/index.test.js.map +1 -0
  16. package/dist/sdk/adapters/cache.d.ts +94 -0
  17. package/dist/sdk/adapters/cache.d.ts.map +1 -0
  18. package/dist/sdk/adapters/cache.js +158 -0
  19. package/dist/sdk/adapters/cache.js.map +1 -0
  20. package/dist/sdk/adapters/cache.test.d.ts +14 -0
  21. package/dist/sdk/adapters/cache.test.d.ts.map +1 -0
  22. package/dist/sdk/adapters/cache.test.js +178 -0
  23. package/dist/sdk/adapters/cache.test.js.map +1 -0
  24. package/dist/sdk/adapters/default.d.ts +24 -0
  25. package/dist/sdk/adapters/default.d.ts.map +1 -0
  26. package/dist/sdk/adapters/default.js +151 -0
  27. package/dist/sdk/adapters/default.js.map +1 -0
  28. package/dist/sdk/adapters/webhub.d.ts +336 -0
  29. package/dist/sdk/adapters/webhub.d.ts.map +1 -0
  30. package/dist/sdk/adapters/webhub.js +663 -0
  31. package/dist/sdk/adapters/webhub.js.map +1 -0
  32. package/dist/sdk/adapters/websocket.d.ts +133 -0
  33. package/dist/sdk/adapters/websocket.d.ts.map +1 -0
  34. package/dist/sdk/adapters/websocket.js +314 -0
  35. package/dist/sdk/adapters/websocket.js.map +1 -0
  36. package/dist/sdk/core/channel.d.ts +104 -0
  37. package/dist/sdk/core/channel.d.ts.map +1 -0
  38. package/dist/sdk/core/channel.js +158 -0
  39. package/dist/sdk/core/channel.js.map +1 -0
  40. package/dist/sdk/index.d.ts +27 -0
  41. package/dist/sdk/index.d.ts.map +1 -0
  42. package/dist/sdk/index.js +33 -0
  43. package/dist/sdk/index.js.map +1 -0
  44. package/dist/sdk/types/adapters.d.ts +128 -0
  45. package/dist/sdk/types/adapters.d.ts.map +1 -0
  46. package/dist/sdk/types/adapters.js +10 -0
  47. package/dist/sdk/types/adapters.js.map +1 -0
  48. package/dist/sdk/types/channel.d.ts +270 -0
  49. package/dist/sdk/types/channel.d.ts.map +1 -0
  50. package/dist/sdk/types/channel.js +36 -0
  51. package/dist/sdk/types/channel.js.map +1 -0
  52. package/docs/channel/01-overview.md +117 -0
  53. package/docs/channel/02-configuration.md +138 -0
  54. package/docs/channel/03-capabilities.md +86 -0
  55. package/docs/channel/04-api-reference.md +394 -0
  56. package/docs/channel/05-message-protocol.md +194 -0
  57. package/docs/channel/06-security.md +83 -0
  58. package/docs/channel/README.md +30 -0
  59. package/docs/sdk/README.md +13 -0
  60. package/docs/sdk/v2026.1.29-v2026.2.19.md +630 -0
  61. package/jest.config.js +19 -0
  62. package/openclaw.plugin.json +113 -0
  63. package/package.json +74 -0
  64. package/run-poll.mjs +209 -0
  65. package/scripts/reload-plugin.sh +78 -0
  66. package/src/index.test.ts +432 -0
  67. package/src/index.ts +1638 -0
  68. package/src/sdk/adapters/cache.test.ts +205 -0
  69. package/src/sdk/adapters/cache.ts +193 -0
  70. package/src/sdk/adapters/default.ts +196 -0
  71. package/src/sdk/adapters/webhub.ts +857 -0
  72. package/src/sdk/adapters/websocket.ts +378 -0
  73. package/src/sdk/core/channel.ts +230 -0
  74. package/src/sdk/index.ts +36 -0
  75. package/src/sdk/types/adapters.ts +169 -0
  76. package/src/sdk/types/channel.ts +346 -0
  77. package/tsconfig.json +31 -0
@@ -0,0 +1,857 @@
1
+ /******************************************************************
2
+ * Channel SDK - WebHub Adapter with Performance Degradation
3
+ *
4
+ * 支持从高性能到低性能的优雅退化:
5
+ * 1. WebSocket (最高性能) - 实时双向通信
6
+ * 2. Server-Sent Events (SSE) - 单向推送
7
+ * 3. HTTP Polling (最低性能) - 简单轮询
8
+ *
9
+ * URL 从 config.webhubUrl 配置中获取
10
+ *
11
+ * @see https://github.com/chatu-ai/openclaw-web-hub-channel
12
+ ******************************************************************/
13
+
14
+ import {
15
+ InboundMessage,
16
+ OutboundMessage,
17
+ SendResult,
18
+ ConnectionConfig,
19
+ ConnectionStatus,
20
+ ChannelStats,
21
+ MessageType,
22
+ TargetType,
23
+ Target,
24
+ } from '../types/channel';
25
+ import type {
26
+ ConnectionAdapter,
27
+ MessageCallback,
28
+ StatusCallback,
29
+ AdapterFactory,
30
+ } from '../types/adapters';
31
+ import { v4 as uuidv4 } from 'uuid';
32
+
33
+ /**
34
+ * 将 WebHub API 原始消息 JSON 映射为 Channel SDK InboundMessage 格式。
35
+ *
36
+ * 这是跨层映射的唯一位置 (SC-003):
37
+ * - WebHub sender.name → InboundMessage.sender.displayName
38
+ * - WebHub sender.avatar → InboundMessage.sender.avatarUrl
39
+ * - WebHub authorDisplayName (旧字段兼容) → InboundMessage.sender.displayName
40
+ * - WebHub authorId (旧字段兼容) → InboundMessage.sender.id
41
+ * - WebHub replyTo.id → InboundMessage.replyTo.messageId
42
+ * - WebHub replyTo.quoteText → InboundMessage.replyTo.quotedText
43
+ * - WebHub content (string 或 {text}) → InboundMessage.content.text
44
+ * - WebHub createdAt (string) → InboundMessage.timestamp (ms)
45
+ */
46
+ export function mapRawToInboundMessage(raw: unknown): InboundMessage {
47
+ const msg = raw as Record<string, unknown>;
48
+ const rawSender = (msg.sender && typeof msg.sender === 'object'
49
+ ? msg.sender
50
+ : {}) as Record<string, unknown>;
51
+ const rawContent = msg.content;
52
+ const rawMedia = Array.isArray(msg.media) ? (msg.media as Array<Record<string, unknown>>) : [];
53
+ const rawReplyTo = msg.replyTo && typeof msg.replyTo === 'object'
54
+ ? (msg.replyTo as Record<string, unknown>)
55
+ : undefined;
56
+
57
+ // sender: prefer new-style {id, name, avatar}; fall back to legacy authorId/authorDisplayName
58
+ const senderId: string = String(
59
+ rawSender.id ?? msg.authorId ?? ''
60
+ );
61
+ const senderDisplayName: string = String(
62
+ rawSender.name ?? rawSender.displayName ?? msg.authorDisplayName ?? ''
63
+ );
64
+ const senderAvatarUrl: string | undefined =
65
+ (rawSender.avatar as string | undefined) ??
66
+ (rawSender.avatarUrl as string | undefined) ??
67
+ undefined;
68
+
69
+ // content: new-style string OR legacy {text: ...}
70
+ const contentText: string =
71
+ typeof rawContent === 'string'
72
+ ? rawContent
73
+ : String((rawContent as Record<string, unknown> | undefined)?.text ?? '');
74
+
75
+ // timestamp: ISO string (createdAt) or numeric
76
+ let ts: number;
77
+ if (typeof msg.createdAt === 'string') {
78
+ ts = new Date(msg.createdAt).getTime();
79
+ } else if (typeof msg.timestamp === 'number') {
80
+ ts = msg.timestamp;
81
+ } else {
82
+ ts = Date.now();
83
+ }
84
+
85
+ return {
86
+ id: String(msg.id ?? ''),
87
+ channelId: String(msg.channelId ?? ''),
88
+ sender: {
89
+ id: senderId,
90
+ displayName: senderDisplayName,
91
+ avatarUrl: senderAvatarUrl,
92
+ isBot: senderId === 'webhub',
93
+ },
94
+ target: (msg.target as Target | undefined) ?? { type: TargetType.USER, id: '' },
95
+ content: {
96
+ text: contentText,
97
+ format: (msg.format as 'plain' | 'markdown' | 'html' | undefined) ?? 'plain',
98
+ },
99
+ media: rawMedia.map((m) => ({
100
+ type: (m.type ?? MessageType.FILE) as MessageType,
101
+ url: String(m.url ?? ''),
102
+ mimeType: m.mimeType as string | undefined,
103
+ size: m.size as number | undefined,
104
+ width: m.width as number | undefined,
105
+ height: m.height as number | undefined,
106
+ duration: m.duration as number | undefined,
107
+ thumbnailUrl: m.thumbnailUrl as string | undefined,
108
+ })),
109
+ replyTo: rawReplyTo
110
+ ? {
111
+ messageId: String(rawReplyTo.id ?? ''),
112
+ quotedText: rawReplyTo.quoteText as string | undefined,
113
+ }
114
+ : undefined,
115
+ timestamp: ts,
116
+ metadata: msg.metadata as Record<string, unknown> | undefined,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * 将 Channel SDK Media 对象映射为 WebHub API 可接受的媒体格式。
122
+ *
123
+ * Channel SDK Media 字段名称(thumbnailUrl / width / height)已与 WebHub 对齐;
124
+ * 此函数负责补充 WebHub 要求但 SDK 未提供的 filename 字段(从 url 推断),
125
+ * 是全局唯一的媒体映射位置(SC-003)。
126
+ *
127
+ * @param media - Channel SDK Media 对象
128
+ * @returns WebHub 媒体对象
129
+ */
130
+ export function mapChannelSdkMedia(media: {
131
+ type: MessageType;
132
+ url: string;
133
+ mimeType?: string;
134
+ size?: number;
135
+ width?: number;
136
+ height?: number;
137
+ duration?: number;
138
+ thumbnailUrl?: string;
139
+ }): {
140
+ type: MessageType;
141
+ url: string;
142
+ mimeType?: string;
143
+ size?: number;
144
+ width?: number;
145
+ height?: number;
146
+ duration?: number;
147
+ thumbnailUrl?: string;
148
+ filename?: string;
149
+ } {
150
+ // Infer filename from URL path when not explicitly available
151
+ const filename = media.url
152
+ ? media.url.split('/').pop()?.split('?')[0] || undefined
153
+ : undefined;
154
+ return {
155
+ type: media.type,
156
+ url: media.url,
157
+ mimeType: media.mimeType,
158
+ size: media.size,
159
+ width: media.width,
160
+ height: media.height,
161
+ duration: media.duration,
162
+ thumbnailUrl: media.thumbnailUrl,
163
+ filename,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * 性能模式
169
+ */
170
+ export type PerformanceMode = 'websocket' | 'sse' | 'polling';
171
+
172
+ /**
173
+ * WebHub 适配器配置 (继承 ConnectionConfig)
174
+ */
175
+ export interface WebHubAdapterConfig extends ConnectionConfig {
176
+ /** 首选性能模式 (默认: websocket) */
177
+ preferredMode?: PerformanceMode;
178
+ /** SSE URL 路径 */
179
+ ssePath?: string;
180
+ /** 轮询间隔 (毫秒) */
181
+ pollInterval?: number;
182
+ }
183
+
184
+ /**
185
+ * WebHub 响应格式
186
+ */
187
+ interface WebHubResponse<T> {
188
+ success: boolean;
189
+ data?: T;
190
+ error?: {
191
+ code: string;
192
+ message: string;
193
+ };
194
+ }
195
+
196
+ /**
197
+ * WebHub 适配器
198
+ *
199
+ * 支持三种模式的优雅退化:
200
+ * - WebSocket: 实时双向通信
201
+ * - SSE: 单向推送 + HTTP 发送
202
+ * - Polling: HTTP 轮询
203
+ *
204
+ * URL 从 config.webhubUrl 配置中获取
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * // 配置时设置 webhubUrl
209
+ * const channel = new Channel({
210
+ * config: {
211
+ * channelId: 'wh_ch_xxx',
212
+ * accessToken: 'token_xxx',
213
+ * webhubUrl: 'http://localhost:3000', // 从配置获取
214
+ * }
215
+ * });
216
+ * ```
217
+ */
218
+ export class WebHubAdapter implements ConnectionAdapter {
219
+ /** 配置 [Channel SDK 标准] */
220
+ public config: WebHubAdapterConfig;
221
+
222
+ /** 当前状态 [Channel SDK 标准] */
223
+ private _status: ConnectionStatus = 'disconnected';
224
+
225
+ /** 实际使用的性能模式 */
226
+ private currentMode: PerformanceMode = 'polling';
227
+
228
+ /** WebHub URL (从配置获取) */
229
+ private get webhubUrl(): string {
230
+ return this.config.webhubUrl || 'http://localhost:3000';
231
+ }
232
+
233
+ /** 消息回调 [Channel SDK 标准] */
234
+ private messageCallbacks: Set<MessageCallback> = new Set();
235
+
236
+ /** 状态回调 [Channel SDK 标准] */
237
+ private statusCallbacks: Set<StatusCallback> = new Set();
238
+
239
+ /** 待确认消息 */
240
+ private pendingMessages: Map<string, { timestamp: number }> = new Map();
241
+
242
+ /** 最后心跳时间 */
243
+ private lastHeartbeat: number = 0;
244
+
245
+ /** 心跳定时器 */
246
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
247
+
248
+ /** 轮询定时器 */
249
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
250
+
251
+ /** SSE EventSource */
252
+ private eventSource: EventSource | null = null;
253
+
254
+ /** WebSocket 实例 */
255
+ private ws: WebSocket | null = null;
256
+
257
+ /** 统计 [Channel SDK 标准] */
258
+ private stats: ChannelStats = {
259
+ messagesSent: 0,
260
+ messagesReceived: 0,
261
+ connectedDuration: 0,
262
+ lastActiveAt: Date.now(),
263
+ };
264
+
265
+ /**
266
+ * 创建 WebHub 适配器
267
+ */
268
+ constructor(config: WebHubAdapterConfig) {
269
+ this.config = {
270
+ heartbeatInterval: 30000,
271
+ heartbeatTimeout: 10000,
272
+ maxReconnectAttempts: 3,
273
+ preferredMode: 'websocket',
274
+ ssePath: '/api/channel/events',
275
+ pollInterval: 5000,
276
+ ...config,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * 获取当前状态 [Channel SDK 标准]
282
+ */
283
+ get status(): ConnectionStatus {
284
+ return this._status;
285
+ }
286
+
287
+ /**
288
+ * 获取实际使用的性能模式
289
+ */
290
+ get mode(): PerformanceMode {
291
+ return this.currentMode;
292
+ }
293
+
294
+ /**
295
+ * 连接到 WebHub [Channel SDK 标准]
296
+ */
297
+ async connect(): Promise<void> {
298
+ try {
299
+ // 1. 先注册 (如果还没有 accessToken)
300
+ if (!this.config.accessToken) {
301
+ await this.register();
302
+ }
303
+
304
+ // 2. 连接
305
+ await this.connectToHub();
306
+
307
+ // 3. 尝试高性能连接 (按优先级)
308
+ let connected = false;
309
+
310
+ // 3.1 尝试 WebSocket
311
+ if (this.config.preferredMode === 'websocket') {
312
+ connected = await this.tryWebSocket();
313
+ if (connected) {
314
+ this.currentMode = 'websocket';
315
+ this.notifyStatus('connected');
316
+ return;
317
+ }
318
+ }
319
+
320
+ // 3.2 尝试 SSE
321
+ if (this.config.preferredMode === 'websocket' || this.config.preferredMode === 'sse') {
322
+ connected = await this.trySSE();
323
+ if (connected) {
324
+ this.currentMode = 'sse';
325
+ this.notifyStatus('connected');
326
+ return;
327
+ }
328
+ }
329
+
330
+ // 3.3 降级到 Polling
331
+ this.currentMode = 'polling';
332
+ this.startPolling();
333
+
334
+ // 4. 启动心跳
335
+ this.startHeartbeat();
336
+
337
+ this._status = 'connected';
338
+ this.stats.connectedDuration = Date.now();
339
+ this.notifyStatus('connected');
340
+
341
+ } catch (error) {
342
+ this._status = 'disconnected';
343
+ throw error;
344
+ }
345
+ }
346
+
347
+ /**
348
+ * 获取完整的 WebHub URL
349
+ */
350
+ private getUrl(path: string): string {
351
+ const baseUrl = this.webhubUrl.replace(/\/$/, '');
352
+ const urlPath = path.startsWith('/') ? path : `/${path}`;
353
+ return `${baseUrl}${urlPath}`;
354
+ }
355
+
356
+ /**
357
+ * 尝试 WebSocket 连接
358
+ */
359
+ private async tryWebSocket(): Promise<boolean> {
360
+ const wsUrl = new URL(this.getUrl(this.config.wsUrl || '/ws'));
361
+ wsUrl.searchParams.set('channelId', this.config.channelId);
362
+ wsUrl.searchParams.set('token', this.config.accessToken);
363
+
364
+ return new Promise((resolve) => {
365
+ try {
366
+ this.ws = new WebSocket(wsUrl.toString());
367
+
368
+ const timeout = setTimeout(() => {
369
+ this.cleanupWebSocket();
370
+ resolve(false);
371
+ }, 5000);
372
+
373
+ this.ws.onopen = () => {
374
+ clearTimeout(timeout);
375
+ this.stats.connectedDuration = Date.now();
376
+ this.startHeartbeat();
377
+ resolve(true);
378
+ };
379
+
380
+ this.ws.onmessage = (event) => {
381
+ try {
382
+ const message = JSON.parse(event.data);
383
+ if (message.type === 'message') {
384
+ this.stats.messagesReceived++;
385
+ this.notifyMessage(message.data || message);
386
+ }
387
+ } catch (e) {
388
+ // 忽略解析错误
389
+ }
390
+ };
391
+
392
+ this.ws.onclose = () => {
393
+ clearTimeout(timeout);
394
+ this.cleanupWebSocket();
395
+ resolve(false);
396
+ };
397
+
398
+ this.ws.onerror = () => {
399
+ clearTimeout(timeout);
400
+ this.cleanupWebSocket();
401
+ resolve(false);
402
+ };
403
+
404
+ } catch {
405
+ resolve(false);
406
+ }
407
+ });
408
+ }
409
+
410
+ /**
411
+ * 尝试 SSE 连接
412
+ */
413
+ private async trySSE(): Promise<boolean> {
414
+ const sseUrl = new URL(this.getUrl(this.config.ssePath || '/api/channel/events'));
415
+ sseUrl.searchParams.set('channelId', this.config.channelId);
416
+ sseUrl.searchParams.set('token', this.config.accessToken || this.config.accessToken);
417
+
418
+ return new Promise((resolve) => {
419
+ try {
420
+ this.eventSource = new EventSource(sseUrl.toString());
421
+
422
+ const timeout = setTimeout(() => {
423
+ this.cleanupSSE();
424
+ resolve(false);
425
+ }, 5000);
426
+
427
+ this.eventSource.onopen = () => {
428
+ clearTimeout(timeout);
429
+ this.stats.connectedDuration = Date.now();
430
+ this.startHeartbeat();
431
+ resolve(true);
432
+ };
433
+
434
+ this.eventSource.onmessage = (event) => {
435
+ try {
436
+ const message = JSON.parse(event.data);
437
+ this.stats.messagesReceived++;
438
+ this.notifyMessage(message);
439
+ } catch (e) {
440
+ // 忽略解析错误
441
+ }
442
+ };
443
+
444
+ this.eventSource.onerror = () => {
445
+ clearTimeout(timeout);
446
+ this.cleanupSSE();
447
+ resolve(false);
448
+ };
449
+
450
+ } catch {
451
+ resolve(false);
452
+ }
453
+ });
454
+ }
455
+
456
+ /**
457
+ * 清理 WebSocket
458
+ */
459
+ private cleanupWebSocket(): void {
460
+ if (this.ws) {
461
+ this.ws.close();
462
+ this.ws = null;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * 清理 SSE
468
+ */
469
+ private cleanupSSE(): void {
470
+ if (this.eventSource) {
471
+ this.eventSource.close();
472
+ this.eventSource = null;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * 注册频道
478
+ */
479
+ private async register(): Promise<void> {
480
+ const response = await this.request<{ accessToken: string }>('/api/channel/register', {
481
+ channelId: this.config.channelId,
482
+ secret: this.config.accessToken,
483
+ });
484
+
485
+ if (response.success && response.data?.accessToken) {
486
+ this.config.accessToken = response.data.accessToken;
487
+ } else {
488
+ throw new Error('Failed to register channel');
489
+ }
490
+ }
491
+
492
+ /**
493
+ * 连接到 Hub
494
+ */
495
+ private async connectToHub(): Promise<void> {
496
+ const response = await this.request<{ status: string }>('/api/channel/connect', {
497
+ channelId: this.config.channelId,
498
+ }, true);
499
+
500
+ if (!response.success) {
501
+ throw new Error('Failed to connect to hub');
502
+ }
503
+ }
504
+
505
+ /**
506
+ * 断开连接 [Channel SDK 标准]
507
+ */
508
+ async disconnect(): Promise<void> {
509
+ try {
510
+ this.stopPolling();
511
+ this.stopHeartbeat();
512
+ this.cleanupWebSocket();
513
+ this.cleanupSSE();
514
+
515
+ if (this.config.accessToken) {
516
+ await this.request('/api/channel/disconnect', {
517
+ channelId: this.config.channelId,
518
+ }, true).catch(() => {});
519
+ }
520
+
521
+ } finally {
522
+ this._status = 'disconnected';
523
+ this.notifyStatus('disconnected');
524
+ }
525
+ }
526
+
527
+ /**
528
+ * 发送消息 [Channel SDK 标准]
529
+ */
530
+ async send(message: OutboundMessage): Promise<SendResult> {
531
+ const messageId = message.messageId || uuidv4();
532
+
533
+ try {
534
+ // FR-007: degrade 'channel' target type → 'group' (Channel SDK only supports user/group)
535
+ const targetType = (message.target.type as string) === 'channel'
536
+ ? TargetType.GROUP
537
+ : message.target.type;
538
+
539
+ const response = await this.request<{ messageId: string; deliveredAt: string }>(
540
+ '/api/channel/messages',
541
+ {
542
+ channelId: this.config.channelId,
543
+ messageId,
544
+ target: { type: targetType, id: message.target.id, name: message.target.name },
545
+ content: {
546
+ text: message.content.text,
547
+ type: message.content.format || 'text',
548
+ },
549
+ media: message.media?.map(mapChannelSdkMedia),
550
+ // Map Channel SDK replyTo (string message ID) → WebHub {id} object
551
+ replyTo: message.replyTo ? { id: message.replyTo } : undefined,
552
+ metadata: message.metadata,
553
+ },
554
+ true
555
+ );
556
+
557
+ this.stats.messagesSent++;
558
+ this.stats.lastActiveAt = Date.now();
559
+
560
+ return {
561
+ messageId,
562
+ success: true,
563
+ timestamp: Date.now(),
564
+ };
565
+
566
+ } catch (error) {
567
+ return {
568
+ messageId,
569
+ success: false,
570
+ timestamp: Date.now(),
571
+ error: {
572
+ code: 'SEND_FAILED',
573
+ message: error instanceof Error ? error.message : 'Unknown error',
574
+ },
575
+ };
576
+ }
577
+ }
578
+
579
+ /**
580
+ * US3 Cross-Channel Relay: forward a message from another OpenClaw channel
581
+ * to this ChatU WebHub channel so it appears in the frontend with a special
582
+ * source-channel badge.
583
+ *
584
+ * Calls POST /api/channel/cross-channel-messages authenticated via
585
+ * X-Access-Token (same token used for polling / outbound delivery).
586
+ *
587
+ * @param payload - Cross-channel message payload
588
+ * @returns Resolves with the stored message id, channelId and createdAt from the service
589
+ */
590
+ async postCrossChannelMessage(payload: {
591
+ /** Originating channel id (lowercase, max 64 chars, /^[a-z0-9_-]{1,64}$/) */
592
+ sourceChannel: string;
593
+ /** 'inbound' = AI reply, 'outbound' = user message */
594
+ direction: 'inbound' | 'outbound';
595
+ /** Sender object — id is optional, name is required */
596
+ sender: { id?: string; name: string };
597
+ /** Text content of the message */
598
+ content: string;
599
+ /** Session key from the originating channel */
600
+ sessionKey: string;
601
+ /** Optional extra metadata forwarded as-is */
602
+ metadata?: Record<string, unknown>;
603
+ }): Promise<{ id: string; channelId: string; createdAt: string }> {
604
+ // Note: the cross-channel endpoint returns { id, channelId, createdAt } directly,
605
+ // not wrapped in { success, data }. Cast accordingly.
606
+ const raw = await this.request<unknown>(
607
+ '/api/channel/cross-channel-messages',
608
+ payload as unknown as Record<string, unknown>,
609
+ true,
610
+ ) as unknown as Record<string, unknown>;
611
+
612
+ if (!raw.id) {
613
+ throw new Error(`postCrossChannelMessage failed: ${JSON.stringify(raw)}`);
614
+ }
615
+
616
+ return raw as unknown as { id: string; channelId: string; createdAt: string };
617
+ }
618
+
619
+ /**
620
+ * 订阅消息 [Channel SDK 标准]
621
+ */
622
+ onMessage(callback: MessageCallback): void {
623
+ this.messageCallbacks.add(callback);
624
+ }
625
+
626
+ /**
627
+ * 订阅状态变化 [Channel SDK 标准]
628
+ */
629
+ onStatusChange(callback: StatusCallback): void {
630
+ this.statusCallbacks.add(callback);
631
+ }
632
+
633
+ /**
634
+ * 获取统计 [Channel SDK 标准]
635
+ */
636
+ async getStats(): Promise<ChannelStats> {
637
+ return {
638
+ ...this.stats,
639
+ mode: this.currentMode,
640
+ };
641
+ }
642
+
643
+ /**
644
+ * 发起 HTTP 请求
645
+ */
646
+ private async request<T>(
647
+ path: string,
648
+ body: Record<string, unknown>,
649
+ requireAuth: boolean = false
650
+ ): Promise<WebHubResponse<T>> {
651
+ const headers: Record<string, string> = {
652
+ 'Content-Type': 'application/json',
653
+ };
654
+
655
+ if (requireAuth && this.config.accessToken) {
656
+ headers['X-Access-Token'] = this.config.accessToken;
657
+ }
658
+
659
+ const response = await fetch(this.getUrl(path), {
660
+ method: 'POST',
661
+ headers,
662
+ body: JSON.stringify(body),
663
+ });
664
+
665
+ return response.json();
666
+ }
667
+
668
+ /**
669
+ * 启动轮询接收消息
670
+ */
671
+ private startPolling(): void {
672
+ this.pollTimer = setInterval(async () => {
673
+ try {
674
+ const response = await this.request<InboundMessage[]>('/api/channel/webhook', {
675
+ channelId: this.config.channelId,
676
+ }, true);
677
+
678
+ if (response.success && response.data) {
679
+ const messages = Array.isArray(response.data) ? response.data : [response.data];
680
+ for (const message of messages) {
681
+ this.stats.messagesReceived++;
682
+ this.notifyMessage(message);
683
+ }
684
+ this.stats.lastActiveAt = Date.now();
685
+ }
686
+
687
+ } catch (error) {
688
+ console.error('Polling error:', error);
689
+ }
690
+ }, this.config.pollInterval || 5000);
691
+ }
692
+
693
+ /**
694
+ * 停止轮询
695
+ */
696
+ private stopPolling(): void {
697
+ if (this.pollTimer) {
698
+ clearInterval(this.pollTimer);
699
+ this.pollTimer = null;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * 启动心跳
705
+ */
706
+ private startHeartbeat(): void {
707
+ this.heartbeatTimer = setInterval(() => {
708
+ this.lastHeartbeat = Date.now();
709
+ this.request('/api/channel/heartbeat', {
710
+ channelId: this.config.channelId,
711
+ }, true).catch(() => {});
712
+ }, this.config.heartbeatInterval || 30000);
713
+ }
714
+
715
+ /**
716
+ * 停止心跳
717
+ */
718
+ private stopHeartbeat(): void {
719
+ if (this.heartbeatTimer) {
720
+ clearInterval(this.heartbeatTimer);
721
+ this.heartbeatTimer = null;
722
+ }
723
+ }
724
+
725
+ /**
726
+ * 通知消息
727
+ */
728
+ private notifyMessage(message: InboundMessage): void {
729
+ this.messageCallbacks.forEach((cb) => cb(message));
730
+ }
731
+
732
+ /**
733
+ * 通知状态变化
734
+ */
735
+ private notifyStatus(status: ConnectionStatus, error?: Error): void {
736
+ this.statusCallbacks.forEach((cb) => cb(status, error));
737
+ }
738
+ }
739
+
740
+ /**
741
+ * 性能模式配置
742
+ */
743
+ export interface PerformanceModeConfig {
744
+ /** 首选模式 */
745
+ preferred: PerformanceMode;
746
+ /** WebSocket 配置 */
747
+ websocket?: {
748
+ /** 重试次数 */
749
+ maxRetries?: number;
750
+ /** 重试间隔 (毫秒) */
751
+ retryInterval?: number;
752
+ };
753
+ /** SSE 配置 */
754
+ sse?: {
755
+ /** 重试次数 */
756
+ maxRetries?: number;
757
+ };
758
+ /** Polling 配置 */
759
+ polling?: {
760
+ /** 轮询间隔 (毫秒) */
761
+ interval?: number;
762
+ };
763
+ }
764
+
765
+ /**
766
+ * WebHub 适配器工厂
767
+ */
768
+ export class WebHubAdapterFactory implements AdapterFactory {
769
+ /** 性能模式配置 */
770
+ private modeConfig: PerformanceModeConfig;
771
+
772
+ constructor(modeConfig?: PerformanceModeConfig) {
773
+ this.modeConfig = modeConfig || {
774
+ preferred: 'websocket',
775
+ websocket: { maxRetries: 3, retryInterval: 1000 },
776
+ sse: { maxRetries: 2 },
777
+ polling: { interval: 5000 },
778
+ };
779
+ }
780
+
781
+ /**
782
+ * 创建 WebHub 适配器 (从 config.webhubUrl 获取 URL)
783
+ */
784
+ createConnectionAdapter(config: ConnectionConfig): ConnectionAdapter {
785
+ return new WebHubAdapter(config as WebHubAdapterConfig);
786
+ }
787
+
788
+ /**
789
+ * 创建消息适配器
790
+ */
791
+ createMessageAdapter() {
792
+ return {
793
+ parseInbound: mapRawToInboundMessage,
794
+ formatOutbound: (message: OutboundMessage): unknown => message,
795
+ validate: (message: unknown): boolean => !!message,
796
+ sanitize: (text: string): string => text,
797
+ };
798
+ }
799
+
800
+ /**
801
+ * 创建心跳适配器
802
+ */
803
+ createHeartbeatAdapter() {
804
+ return {
805
+ send: async (): Promise<boolean> => true,
806
+ handleResponse: (): void => {},
807
+ isTimeout: (): boolean => false,
808
+ getNextHeartbeatTime: (): number => Date.now() + 30000,
809
+ };
810
+ }
811
+
812
+ /**
813
+ * 创建认证适配器
814
+ */
815
+ createAuthAdapter() {
816
+ return {
817
+ getAuthHeaders: (): Record<string, string> => ({}),
818
+ validateResponse: (response: unknown): boolean => true,
819
+ refreshToken: async (): Promise<boolean> => true,
820
+ };
821
+ }
822
+
823
+ /**
824
+ * 创建能力适配器
825
+ */
826
+ createCapabilitiesAdapter() {
827
+ return {
828
+ getCapabilities: async () => ({
829
+ messageTypes: [MessageType.TEXT, MessageType.IMAGE, MessageType.VIDEO, MessageType.AUDIO, MessageType.FILE],
830
+ targetTypes: [TargetType.USER],
831
+ richFormats: ['markdown'] as ('markdown' | 'html')[],
832
+ attachments: true,
833
+ reply: true,
834
+ }),
835
+ hasCapability: (): boolean => true,
836
+ };
837
+ }
838
+
839
+ /**
840
+ * 创建日志适配器
841
+ */
842
+ createLoggerAdapter() {
843
+ return {
844
+ debug: (message: string, data?: unknown): void => console.debug(message, data),
845
+ info: (message: string, data?: unknown): void => console.info(message, data),
846
+ warn: (message: string, data?: unknown): void => console.warn(message, data),
847
+ error: (message: string, error?: Error): void => console.error(message, error),
848
+ };
849
+ }
850
+ }
851
+
852
+ /**
853
+ * 创建 WebHub 适配器工厂
854
+ */
855
+ export function createWebHubFactory(modeConfig?: PerformanceModeConfig): WebHubAdapterFactory {
856
+ return new WebHubAdapterFactory(modeConfig);
857
+ }