@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.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/publish.yml +55 -0
- package/INSTALL.md +285 -0
- package/INSTALL.zh.md +285 -0
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/README.zh.md +293 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1381 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +5 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +334 -0
- package/dist/index.test.js.map +1 -0
- package/dist/sdk/adapters/cache.d.ts +94 -0
- package/dist/sdk/adapters/cache.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.js +158 -0
- package/dist/sdk/adapters/cache.js.map +1 -0
- package/dist/sdk/adapters/cache.test.d.ts +14 -0
- package/dist/sdk/adapters/cache.test.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.test.js +178 -0
- package/dist/sdk/adapters/cache.test.js.map +1 -0
- package/dist/sdk/adapters/default.d.ts +24 -0
- package/dist/sdk/adapters/default.d.ts.map +1 -0
- package/dist/sdk/adapters/default.js +151 -0
- package/dist/sdk/adapters/default.js.map +1 -0
- package/dist/sdk/adapters/webhub.d.ts +336 -0
- package/dist/sdk/adapters/webhub.d.ts.map +1 -0
- package/dist/sdk/adapters/webhub.js +663 -0
- package/dist/sdk/adapters/webhub.js.map +1 -0
- package/dist/sdk/adapters/websocket.d.ts +133 -0
- package/dist/sdk/adapters/websocket.d.ts.map +1 -0
- package/dist/sdk/adapters/websocket.js +314 -0
- package/dist/sdk/adapters/websocket.js.map +1 -0
- package/dist/sdk/core/channel.d.ts +104 -0
- package/dist/sdk/core/channel.d.ts.map +1 -0
- package/dist/sdk/core/channel.js +158 -0
- package/dist/sdk/core/channel.js.map +1 -0
- package/dist/sdk/index.d.ts +27 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +33 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/types/adapters.d.ts +128 -0
- package/dist/sdk/types/adapters.d.ts.map +1 -0
- package/dist/sdk/types/adapters.js +10 -0
- package/dist/sdk/types/adapters.js.map +1 -0
- package/dist/sdk/types/channel.d.ts +270 -0
- package/dist/sdk/types/channel.d.ts.map +1 -0
- package/dist/sdk/types/channel.js +36 -0
- package/dist/sdk/types/channel.js.map +1 -0
- package/docs/channel/01-overview.md +117 -0
- package/docs/channel/02-configuration.md +138 -0
- package/docs/channel/03-capabilities.md +86 -0
- package/docs/channel/04-api-reference.md +394 -0
- package/docs/channel/05-message-protocol.md +194 -0
- package/docs/channel/06-security.md +83 -0
- package/docs/channel/README.md +30 -0
- package/docs/sdk/README.md +13 -0
- package/docs/sdk/v2026.1.29-v2026.2.19.md +630 -0
- package/jest.config.js +19 -0
- package/openclaw.plugin.json +113 -0
- package/package.json +74 -0
- package/run-poll.mjs +209 -0
- package/scripts/reload-plugin.sh +78 -0
- package/src/index.test.ts +432 -0
- package/src/index.ts +1638 -0
- package/src/sdk/adapters/cache.test.ts +205 -0
- package/src/sdk/adapters/cache.ts +193 -0
- package/src/sdk/adapters/default.ts +196 -0
- package/src/sdk/adapters/webhub.ts +857 -0
- package/src/sdk/adapters/websocket.ts +378 -0
- package/src/sdk/core/channel.ts +230 -0
- package/src/sdk/index.ts +36 -0
- package/src/sdk/types/adapters.ts +169 -0
- package/src/sdk/types/channel.ts +346 -0
- 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
|
+
}
|