@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
package/src/index.ts ADDED
@@ -0,0 +1,1638 @@
1
+ /**
2
+ * OpenClaw Chatu Channel Plugin
3
+ *
4
+ * This plugin enables OpenClaw to communicate with Chatu/WebHub services
5
+ * via HTTP polling (inbound) and HTTP POST (outbound).
6
+ *
7
+ * Architecture:
8
+ * User (browser) → WebHub service (POST /api/webhub/channels/:id/messages)
9
+ * Plugin polls → GET /api/channel/messages/pending
10
+ * Plugin dispatches → OpenClaw AI
11
+ * AI responds → plugin outbound.sendText → POST /api/channel/messages
12
+ * WebHub service → WebSocket push → browser
13
+ *
14
+ * @see https://docs.openclaw.ai/channels/chatu
15
+ * @see https://github.com/chatu-ai/openclaw-web-hub-channel
16
+ */
17
+
18
+ import type {
19
+ OpenClawPluginApi,
20
+ ChannelPlugin,
21
+ OpenClawConfig,
22
+ ChannelAccountSnapshot,
23
+ ChannelGatewayContext,
24
+ ChannelLogoutContext,
25
+ ChannelSetupInput,
26
+ ChannelLogSink,
27
+ } from 'openclaw/plugin-sdk';
28
+ import fs from 'fs/promises';
29
+ import path from 'path';
30
+ import os from 'os';
31
+ import pkg from '../package.json';
32
+ import { WebSocketAdapter } from './sdk/adapters/websocket';
33
+ import { MessageCache } from './sdk/adapters/cache';
34
+ import type { InboundMessage } from './sdk/types/channel';
35
+
36
+ /** Resolved per-account configuration for the Chatu channel. */
37
+ export interface ChatuAccount {
38
+ accountId: string;
39
+ apiUrl: string;
40
+ channelId: string;
41
+ secret?: string;
42
+ accessToken?: string;
43
+ timeout: number;
44
+ }
45
+
46
+ /** Custom setup input fields used by the Chatu channel. */
47
+ type ChatuSetupInput = ChannelSetupInput & {
48
+ apiUrl?: string;
49
+ channelId?: string;
50
+ secret?: string;
51
+ };
52
+
53
+ const CHANNEL_ID = 'chatu' as const;
54
+ const POLL_INTERVAL_MS = 2000;
55
+ const MAX_BACKOFF_MS = 30_000;
56
+ const DEFAULT_TIMEOUT_MS = 30000;
57
+ const DEFAULT_CHUNK_LIMIT = 4000;
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // Plugin Entry Point
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+
63
+ export default function (api: OpenClawPluginApi) {
64
+ // ── Config helpers ──────────────────────────────────────────────────────────
65
+
66
+ api.logger.info('[chatu] Initializing channel plugin');
67
+
68
+ // ── T015 Plugin-Channel Realtime: per-account outbound message caches ───────
69
+ /** Stores failed AI replies for retry on reconnect. One per account. */
70
+ const accountCaches = new Map<string, MessageCache>();
71
+
72
+ /**
73
+ * Bridges before_message_write → deliver callback so both relay and direct
74
+ * delivery paths carry the same dedupId (the OpenClaw internal message ID).
75
+ * Key: sessionKey, Value: OpenClaw msg.id
76
+ */
77
+ const pendingRelayIds = new Map<string, string>();
78
+
79
+ function getAccountCache(accountId: string): MessageCache {
80
+ if (!accountCaches.has(accountId)) {
81
+ accountCaches.set(
82
+ accountId,
83
+ new MessageCache({
84
+ logger: api.logger,
85
+ maxCapacity: process.env.CHATU_CACHE_MAX ? parseInt(process.env.CHATU_CACHE_MAX, 10) : 1000,
86
+ filePath: process.env.CHATU_CACHE_FILE
87
+ ? `${process.env.CHATU_CACHE_FILE}.${accountId}.json`
88
+ : undefined,
89
+ }),
90
+ );
91
+ }
92
+ return accountCaches.get(accountId)!;
93
+ }
94
+
95
+
96
+ // ── Config helpers ──────────────────────────────────────────────────────────
97
+
98
+ /** Resolve per-account config, falling back to channel-level then plugin-level. */
99
+ function getAccountConfig(accountId?: string | null) {
100
+ const pluginCfg: Record<string, any> = api.config?.plugins?.entries?.chatu?.config ?? {};
101
+ const channelCfg: Record<string, any> = api.config?.channels?.chatu ?? {};
102
+ const accounts: Record<string, any> = channelCfg.accounts ?? {};
103
+ const acctCfg: Record<string, any> =
104
+ accountId && accounts[accountId] ? accounts[accountId] : {};
105
+
106
+ return {
107
+ apiUrl: acctCfg.apiUrl ?? channelCfg.apiUrl ?? pluginCfg.apiUrl ?? '',
108
+ channelId: acctCfg.channelId ?? channelCfg.channelId ?? pluginCfg.channelId ?? '',
109
+ secret: acctCfg.secret ?? channelCfg.secret ?? pluginCfg.secret ?? '',
110
+ accessToken:acctCfg.accessToken?? channelCfg.accessToken?? pluginCfg.accessToken?? '',
111
+ timeout: acctCfg.timeout ?? channelCfg.timeout ?? pluginCfg.timeout ?? DEFAULT_TIMEOUT_MS,
112
+ };
113
+ }
114
+
115
+ // ── HTTP helpers ─────────────────────────────────────────────────────────────
116
+
117
+ async function timedFetch(
118
+ url: string,
119
+ init: RequestInit,
120
+ timeoutMs: number,
121
+ ): Promise<Response> {
122
+ const ctrl = new AbortController();
123
+ const id = setTimeout(() => ctrl.abort(), timeoutMs);
124
+ try {
125
+ return await fetch(url, { ...init, signal: ctrl.signal });
126
+ } finally {
127
+ clearTimeout(id);
128
+ }
129
+ }
130
+
131
+ // ── Lifecycle: register + connect ─────────────────────────────────────────────
132
+
133
+ /**
134
+ * T023 Plugin-Channel Realtime: If CHATU_KEY and CHATU_URL env vars are set,
135
+ * call POST /api/channel/quick-register to obtain credentials automatically.
136
+ * This runs BEFORE registerAndConnect so WS setup (T012) can use the credentials.
137
+ * Skipped if channelId + accessToken are already configured.
138
+ */
139
+ async function quickRegisterIfNeeded(accountId?: string | null): Promise<void> {
140
+ const key = process.env.CHATU_KEY;
141
+ const apiUrl = process.env.CHATU_URL ?? process.env.CHATU_API_URL;
142
+ if (!key || !apiUrl) return;
143
+
144
+ // If already have credentials, skip
145
+ const cfg = getAccountConfig(accountId);
146
+ if (cfg.channelId && cfg.accessToken) return;
147
+
148
+ try {
149
+ const resp = await timedFetch(
150
+ `${apiUrl}/api/channel/quick-register`,
151
+ {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ key, url: apiUrl }),
155
+ },
156
+ DEFAULT_TIMEOUT_MS,
157
+ );
158
+
159
+ if (resp.ok) {
160
+ const data = await resp.json();
161
+ const channelId: string | undefined = data?.data?.channelId;
162
+ const accessToken: string | undefined = data?.data?.accessToken;
163
+
164
+ if (channelId && accessToken) {
165
+ const base = accountId
166
+ ? `channels.chatu.accounts.${accountId}`
167
+ : 'channels.chatu';
168
+ try {
169
+ await (api as any).config?.set?.(`${base}.channelId`, channelId);
170
+ await (api as any).config?.set?.(`${base}.accessToken`, accessToken);
171
+ await (api as any).config?.set?.(`${base}.apiUrl`, apiUrl);
172
+ } catch (_) { /* config persistence optional */ }
173
+ api.logger.info(
174
+ `[chatu] Quick-registered via CHATU_KEY (channelId=${channelId}, account=${accountId ?? 'default'})`,
175
+ );
176
+ }
177
+ } else {
178
+ api.logger.warn(
179
+ `[chatu] Quick-register returned HTTP ${resp.status} — check CHATU_KEY/CHATU_URL`,
180
+ );
181
+ }
182
+ } catch (err) {
183
+ api.logger.warn(`[chatu] Quick-register failed: ${String(err)}`);
184
+ }
185
+ }
186
+
187
+ async function registerAndConnect(accountId?: string | null): Promise<void> {
188
+ const cfg = getAccountConfig(accountId);
189
+ if (!cfg.apiUrl) return;
190
+
191
+ // Connect directly using accessToken (secret-based registration removed;
192
+ // credentials are obtained via quick-register or manual config).
193
+ const refreshed = getAccountConfig(accountId);
194
+ if (refreshed.accessToken && refreshed.channelId) {
195
+ try {
196
+ const resp = await timedFetch(
197
+ `${refreshed.apiUrl}/api/channel/connect`,
198
+ {
199
+ method: 'POST',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ 'x-access-token': refreshed.accessToken,
203
+ },
204
+ body: JSON.stringify({
205
+ channelId: refreshed.channelId,
206
+ pluginVersion: pkg.version,
207
+ workingDir: os.homedir(),
208
+ }),
209
+ },
210
+ refreshed.timeout,
211
+ );
212
+ if (resp.ok) {
213
+ api.logger.info(`[chatu] Channel connected (channelId=${refreshed.channelId}, v${pkg.version}, workingDir=${os.homedir()})`);
214
+ }
215
+ } catch (err) {
216
+ api.logger.warn(`[chatu] Connect request failed: ${String(err)}`);
217
+ }
218
+ }
219
+ }
220
+
221
+ async function disconnectAccount(accountId?: string | null): Promise<void> {
222
+ const cfg = getAccountConfig(accountId);
223
+ if (!cfg.apiUrl || !cfg.accessToken || !cfg.channelId) return;
224
+ try {
225
+ await timedFetch(
226
+ `${cfg.apiUrl}/api/channel/disconnect`,
227
+ {
228
+ method: 'POST',
229
+ headers: {
230
+ 'Content-Type': 'application/json',
231
+ 'x-access-token': cfg.accessToken,
232
+ },
233
+ body: JSON.stringify({ channelId: cfg.channelId }),
234
+ },
235
+ cfg.timeout,
236
+ );
237
+ } catch (_) { /* best-effort */ }
238
+ }
239
+
240
+ // ── Inbound: deliver AI reply back to service ────────────────────────────────
241
+
242
+ async function deliverOutbound(params: {
243
+ text: string;
244
+ target: string;
245
+ accountId?: string | null;
246
+ replyTo?: string | null;
247
+ mediaUrl?: string;
248
+ mediaType?: string;
249
+ messageType?: string;
250
+ metadata?: Record<string, unknown>;
251
+ raw?: unknown;
252
+ }): Promise<{ ok: boolean; messageId?: string; error?: string }> {
253
+ const cfg = getAccountConfig(params.accountId);
254
+ if (!cfg.apiUrl || !cfg.accessToken) {
255
+ return { ok: false, error: 'Missing apiUrl or accessToken' };
256
+ }
257
+
258
+ const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
259
+ const payload: Record<string, unknown> = {
260
+ messageId,
261
+ target: { type: 'user', id: params.target },
262
+ content: { text: params.text, format: 'plain' },
263
+ timestamp: Date.now(),
264
+ };
265
+ if (params.replyTo) payload.replyTo = { id: params.replyTo };
266
+ if (params.mediaUrl) {
267
+ payload.media = [{ type: params.mediaType ?? 'file', url: params.mediaUrl }];
268
+ }
269
+ if (params.messageType) payload.messageType = params.messageType;
270
+ if (params.metadata) payload.metadata = params.metadata;
271
+ // Phase 11 T049: always stamp role:'ai' so the service can persist the correct author role
272
+ payload.role = 'ai';
273
+ if (params.raw !== undefined) payload.raw = params.raw;
274
+
275
+ try {
276
+ const resp = await timedFetch(
277
+ `${cfg.apiUrl}/api/channel/messages`,
278
+ {
279
+ method: 'POST',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ 'X-Channel-Token': cfg.accessToken,
283
+ 'X-Channel-ID': cfg.channelId,
284
+ },
285
+ body: JSON.stringify(payload),
286
+ },
287
+ cfg.timeout,
288
+ );
289
+ if (!resp.ok) {
290
+ const errorText = await resp.text();
291
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
292
+ }
293
+ const result = await resp.json();
294
+ return { ok: true, messageId: result.messageId ?? messageId };
295
+ } catch (err: any) {
296
+ return { ok: false, error: String(err?.message ?? err) };
297
+ }
298
+ }
299
+
300
+ // ── Streaming relay helpers (T042) ────────────────────────────────────────
301
+
302
+ /**
303
+ * Relay a single streaming chunk to the WebHub API.
304
+ * Called by the outbound.sendStreamChunk handler when OpenClaw AI streams.
305
+ */
306
+ async function deliverStreamChunk(params: {
307
+ messageId: string;
308
+ seq: number;
309
+ delta: string;
310
+ accountId?: string | null;
311
+ }): Promise<{ ok: boolean; error?: string }> {
312
+ const cfg = getAccountConfig(params.accountId);
313
+ if (!cfg.apiUrl || !cfg.accessToken) {
314
+ return { ok: false, error: 'Missing apiUrl or accessToken' };
315
+ }
316
+ try {
317
+ const resp = await timedFetch(
318
+ `${cfg.apiUrl}/api/channel/stream/chunk`,
319
+ {
320
+ method: 'POST',
321
+ headers: {
322
+ 'Content-Type': 'application/json',
323
+ Authorization: `Bearer ${cfg.accessToken}`,
324
+ },
325
+ body: JSON.stringify({ messageId: params.messageId, seq: params.seq, delta: params.delta }),
326
+ },
327
+ cfg.timeout,
328
+ );
329
+ if (!resp.ok) {
330
+ const errorText = await resp.text();
331
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
332
+ }
333
+ return { ok: true };
334
+ } catch (err: any) {
335
+ return { ok: false, error: String(err?.message ?? err) };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Signal streaming completion to the WebHub API.
341
+ * Called by the outbound.sendStreamDone handler when OpenClaw AI finishes.
342
+ */
343
+ async function deliverStreamDone(params: {
344
+ messageId: string;
345
+ totalSeq: number;
346
+ accountId?: string | null;
347
+ }): Promise<{ ok: boolean; error?: string }> {
348
+ const cfg = getAccountConfig(params.accountId);
349
+ if (!cfg.apiUrl || !cfg.accessToken) {
350
+ return { ok: false, error: 'Missing apiUrl or accessToken' };
351
+ }
352
+ try {
353
+ const resp = await timedFetch(
354
+ `${cfg.apiUrl}/api/channel/stream/done`,
355
+ {
356
+ method: 'POST',
357
+ headers: {
358
+ 'Content-Type': 'application/json',
359
+ Authorization: `Bearer ${cfg.accessToken}`,
360
+ },
361
+ body: JSON.stringify({ messageId: params.messageId, totalSeq: params.totalSeq }),
362
+ },
363
+ cfg.timeout,
364
+ );
365
+ if (!resp.ok) {
366
+ const errorText = await resp.text();
367
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
368
+ }
369
+ return { ok: true };
370
+ } catch (err: any) {
371
+ return { ok: false, error: String(err?.message ?? err) };
372
+ }
373
+ }
374
+
375
+ // ── T011 US3: Cross-channel relay helpers ────────────────────────────────────
376
+
377
+ /**
378
+ * Forward a message that arrived on another OpenClaw channel (e.g. TUI,
379
+ * WhatsApp, Telegram) to this ChatU WebHub channel so the conversation
380
+ * appears in the frontend with a cross-channel badge.
381
+ *
382
+ * Call this from any OpenClaw integration point that has access to the
383
+ * per-channel message — for example from an OpenClaw `before_message_write`
384
+ * hook (when it becomes available in the SDK), or from a custom relay script.
385
+ *
386
+ * @param params.sourceChannel Originating channel id (e.g. 'tui', 'whatsapp')
387
+ * @param params.direction 'inbound' (AI reply) or 'outbound' (user message)
388
+ * @param params.senderName Display name of the sender
389
+ * @param params.content Text content of the message
390
+ * @param params.sessionKey Session key in the originating channel
391
+ * @param params.accountId ChatU account id (defaults to 'default')
392
+ */
393
+ async function relayCrossChannelMessage(params: {
394
+ sourceChannel: string;
395
+ direction: 'inbound' | 'outbound';
396
+ sender: { id?: string; name: string };
397
+ content: string;
398
+ sessionKey: string;
399
+ accountId?: string | null;
400
+ dedupId?: string;
401
+ raw?: unknown;
402
+ }): Promise<{ ok: boolean; id?: string; error?: string }> {
403
+ const cfg = getAccountConfig(params.accountId);
404
+ if (!cfg.apiUrl || !cfg.accessToken) {
405
+ return { ok: false, error: 'Missing apiUrl or accessToken for cross-channel relay' };
406
+ }
407
+
408
+ try {
409
+ const resp = await timedFetch(
410
+ `${cfg.apiUrl}/api/channel/cross-channel-messages`,
411
+ {
412
+ method: 'POST',
413
+ headers: {
414
+ 'Content-Type': 'application/json',
415
+ 'X-Access-Token': cfg.accessToken,
416
+ },
417
+ body: JSON.stringify({
418
+ sourceChannel: params.sourceChannel,
419
+ direction: params.direction,
420
+ sender: params.sender,
421
+ content: params.content,
422
+ sessionKey: params.sessionKey,
423
+ ...(params.dedupId ? { dedupId: params.dedupId } : {}),
424
+ ...(params.raw !== undefined ? { raw: params.raw } : {}),
425
+ }),
426
+ },
427
+ cfg.timeout,
428
+ );
429
+
430
+ if (!resp.ok) {
431
+ const errorText = await resp.text();
432
+ api.logger.warn(
433
+ `[chatu] cross-channel relay failed (source=${params.sourceChannel}): HTTP ${resp.status} ${errorText}`,
434
+ );
435
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
436
+ }
437
+
438
+ const result = await resp.json();
439
+ api.logger.info(
440
+ `[chatu] cross_channel_relay_ok (source=${params.sourceChannel}, id=${result.id}, direction=${params.direction})`,
441
+ );
442
+ return { ok: true, id: result.id };
443
+ } catch (err: unknown) {
444
+ const message = err instanceof Error ? err.message : String(err);
445
+ api.logger.error(`[chatu] cross-channel relay error: ${message}`);
446
+ return { ok: false, error: message };
447
+ }
448
+ }
449
+
450
+ // ── Gateway: poll + dispatch inbound user messages ───────────────────────────
451
+
452
+ /**
453
+ * Dispatch a single user message from the web client to the OpenClaw AI
454
+ * pipeline using the PluginRuntime API.
455
+ */
456
+ async function dispatchUserMessage(params: {
457
+ id: string;
458
+ content: string;
459
+ sender: { id?: string; name?: string };
460
+ timestamp?: number;
461
+ accountId: string;
462
+ cfg: any;
463
+ }): Promise<void> {
464
+ const { id, content, sender, timestamp, accountId, cfg } = params;
465
+ const senderId = sender.id ?? 'user';
466
+ const senderName = sender.name;
467
+
468
+ if (!content?.trim()) return;
469
+
470
+ const runtime = api.runtime;
471
+ if (!runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
472
+ api.logger.warn(`[chatu] api.runtime not available; cannot dispatch inbound message (id=${id})`);
473
+ return;
474
+ }
475
+
476
+ const to = `chatu:${senderId}`;
477
+ const fromLabel = senderName ? `${senderName} (${senderId})` : senderId;
478
+
479
+ try {
480
+ const route = runtime.channel.routing.resolveAgentRoute({
481
+ cfg,
482
+ channel: CHANNEL_ID,
483
+ accountId,
484
+ peer: { kind: 'direct' as const, id: senderId },
485
+ });
486
+
487
+ const ctxPayload = runtime.channel.reply.finalizeInboundContext({
488
+ Body: content,
489
+ BodyForAgent: content,
490
+ RawBody: content,
491
+ CommandBody: content,
492
+ From: `chatu:${senderId}`,
493
+ To: to,
494
+ SessionKey: route.sessionKey,
495
+ AccountId: route.accountId,
496
+ ChatType: 'direct',
497
+ ConversationLabel: fromLabel,
498
+ SenderName: senderName ?? senderId,
499
+ SenderId: senderId,
500
+ Provider: CHANNEL_ID,
501
+ Surface: CHANNEL_ID,
502
+ MessageSid: id,
503
+ Timestamp: timestamp ?? Date.now(),
504
+ OriginatingChannel: CHANNEL_ID,
505
+ OriginatingTo: to,
506
+ WasMentioned: true,
507
+ // Authorize slash-commands (messages starting with '/'); regular messages remain unauthorized.
508
+ CommandAuthorized: content.trim().startsWith('/'),
509
+ });
510
+
511
+ api.logger.info(`[chatu] Dispatching user message to AI (id=${id}, sender=${senderId})`);
512
+
513
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
514
+ ctx: ctxPayload,
515
+ cfg,
516
+ dispatcherOptions: {
517
+ deliver: async (payload: any) => {
518
+ const text: string = payload.text ?? '';
519
+ if (!text) return;
520
+ // Retrieve the dedupId stored by before_message_write for this session.
521
+ const dedupId = pendingRelayIds.get(route.sessionKey as string);
522
+ if (dedupId) pendingRelayIds.delete(route.sessionKey as string);
523
+ const result = await deliverOutbound({
524
+ text,
525
+ target: senderId,
526
+ accountId,
527
+ replyTo: payload.replyToId ?? id,
528
+ metadata: dedupId ? { dedupId } : undefined,
529
+ raw: payload,
530
+ });
531
+ if (!result.ok) {
532
+ api.logger.error(`[chatu] Failed to deliver AI reply (target=${senderId}): ${result.error}`);
533
+ // T015 Plugin-Channel Realtime: cache failed delivery for retry on reconnect
534
+ const cfg2 = getAccountConfig(accountId);
535
+ const cache = getAccountCache(accountId);
536
+ const cacheId = result.messageId ?? `retry_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
537
+ cache.enqueue({
538
+ id: cacheId,
539
+ channelId: cfg2.channelId,
540
+ content: { text, target: senderId, replyTo: payload.replyToId ?? id },
541
+ enqueuedAt: Date.now(),
542
+ status: 'pending',
543
+ });
544
+ }
545
+ },
546
+ onError: (err: unknown, info: { kind: string }) => {
547
+ api.logger.error(`[chatu] ${info.kind} reply failed: ${String(err)}`);
548
+ },
549
+ },
550
+ replyOptions: {},
551
+ });
552
+ } catch (err) {
553
+ api.logger.error(`[chatu] Exception dispatching user message (id=${id}): ${String(err)}`);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Acknowledge that a message has been processed by the plugin.
559
+ */
560
+ async function ackMessage(
561
+ apiUrl: string,
562
+ accessToken: string,
563
+ messageId: string,
564
+ timeout: number,
565
+ ): Promise<void> {
566
+ try {
567
+ await timedFetch(
568
+ `${apiUrl}/api/channel/messages/${messageId}/ack`,
569
+ {
570
+ method: 'POST',
571
+ headers: {
572
+ 'Content-Type': 'application/json',
573
+ 'X-Channel-Token': accessToken,
574
+ },
575
+ },
576
+ timeout,
577
+ );
578
+ } catch (_) { /* best-effort */ }
579
+ }
580
+
581
+ // ── T012 display-sender-session: resolveSessionKey helper ──────────────────
582
+
583
+ /**
584
+ * Derive the OpenClaw sessionKey for a senderId using the same routing logic
585
+ * as dispatchUserMessage. This is deterministic and requires no lookup table.
586
+ */
587
+ function resolveSessionKey(senderId: string, accountId: string, cfg: any): string {
588
+ const runtime = api.runtime;
589
+ const route = runtime.channel.routing.resolveAgentRoute({
590
+ cfg,
591
+ channel: CHANNEL_ID,
592
+ accountId,
593
+ peer: { kind: 'direct' as const, id: senderId },
594
+ });
595
+ return route.sessionKey as string;
596
+ }
597
+
598
+ // ── T011 display-sender-session: session command processor ─────────────────
599
+
600
+ /**
601
+ * Fetch and execute pending session commands for this channel.
602
+ * Called at the end of each poll loop iteration.
603
+ * Each command is acked (success or failure) before moving to the next.
604
+ */
605
+ async function processCommands(cfg: Omit<ChatuAccount, 'accountId'>, accountId: string): Promise<void> {
606
+ if (!cfg.accessToken) return;
607
+
608
+ const resp = await timedFetch(
609
+ `${cfg.apiUrl}/api/channel/commands?channelId=${encodeURIComponent(cfg.channelId)}`,
610
+ {
611
+ method: 'GET',
612
+ headers: {
613
+ 'X-Channel-Token': cfg.accessToken,
614
+ 'X-Channel-ID': cfg.channelId,
615
+ },
616
+ },
617
+ cfg.timeout,
618
+ );
619
+
620
+ if (!resp.ok) return;
621
+
622
+ const data = await resp.json();
623
+ const commands: Array<{
624
+ id: string;
625
+ commandType: 'reset' | 'switch';
626
+ senderId: string;
627
+ payload?: { targetSessionKey?: string; reason?: string } | null;
628
+ }> = data?.data?.commands ?? [];
629
+
630
+ for (const cmd of commands) {
631
+ let ackSuccess = false;
632
+ let ackError: string | undefined;
633
+
634
+ try {
635
+ const freshCfg = api.config ?? {};
636
+ const sessionKey = resolveSessionKey(cmd.senderId, accountId, freshCfg);
637
+
638
+ if (cmd.commandType === 'reset') {
639
+ // Resolve the sessions store directory and derive the transcript path
640
+ const storePath = api.runtime.channel.session.resolveStorePath(
641
+ (freshCfg as any)?.session?.store,
642
+ );
643
+ const transcriptPath = path.join(storePath, `${sessionKey}.jsonl`);
644
+ try {
645
+ await fs.unlink(transcriptPath);
646
+ api.logger.info(`[chatu] Session reset: deleted transcript (key=${sessionKey})`);
647
+ } catch (e: any) {
648
+ if (e.code !== 'ENOENT') throw e;
649
+ // ENOENT = already empty/non-existent, treat as success
650
+ }
651
+ ackSuccess = true;
652
+
653
+ } else if (cmd.commandType === 'switch') {
654
+ const targetSessionKey = cmd.payload?.targetSessionKey;
655
+ if (!targetSessionKey) throw new Error('Missing targetSessionKey');
656
+
657
+ const storePath = api.runtime.channel.session.resolveStorePath(
658
+ (freshCfg as any)?.session?.store,
659
+ );
660
+ const currentPath = path.join(storePath, `${sessionKey}.jsonl`);
661
+ const targetPath = path.join(storePath, `${targetSessionKey}.jsonl`);
662
+
663
+ // Restore target session as the current session
664
+ await fs.copyFile(targetPath, currentPath);
665
+ api.logger.info(`[chatu] Session switched to ${targetSessionKey} (sender=${cmd.senderId})`);
666
+ ackSuccess = true;
667
+ }
668
+ } catch (e: unknown) {
669
+ ackError = String(e);
670
+ api.logger.error(`[chatu] Command ${cmd.id} (${cmd.commandType}) failed: ${ackError}`);
671
+ }
672
+
673
+ // Ack regardless of outcome
674
+ try {
675
+ await timedFetch(
676
+ `${cfg.apiUrl}/api/channel/commands/${cmd.id}/ack`,
677
+ {
678
+ method: 'POST',
679
+ headers: {
680
+ 'Content-Type': 'application/json',
681
+ 'X-Channel-Token': cfg.accessToken,
682
+ },
683
+ body: JSON.stringify({
684
+ success: ackSuccess,
685
+ error: ackError,
686
+ channelId: cfg.channelId,
687
+ }),
688
+ },
689
+ cfg.timeout,
690
+ );
691
+ } catch (_) { /* best-effort */ }
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Plugin-Channel Realtime (T012): WebSocket-based gateway loop.
697
+ * Replaces HTTP polling. Connects to /api/channel/ws via WebSocketAdapter
698
+ * and dispatches inbound messages to the OpenClaw AI pipeline.
699
+ * Reconnects automatically with infinite exponential back-off (T009).
700
+ *
701
+ * Runs until `abortSignal` fires.
702
+ */
703
+ async function wsConnectionLoop(ctx: {
704
+ accountId: string;
705
+ abortSignal: AbortSignal;
706
+ setStatus: (s: ChannelAccountSnapshot) => void;
707
+ log?: ChannelLogSink;
708
+ }): Promise<void> {
709
+ const cfg = getAccountConfig(ctx.accountId);
710
+
711
+ if (!cfg.apiUrl) {
712
+ ctx.log?.error?.(`[${ctx.accountId}] chatu: missing apiUrl for WS connection`);
713
+ return;
714
+ }
715
+ if (!cfg.accessToken || !cfg.channelId) {
716
+ ctx.log?.error?.(`[${ctx.accountId}] chatu: missing accessToken/channelId for WS connection`);
717
+ return;
718
+ }
719
+
720
+ // Convert HTTP URL to WebSocket URL scheme
721
+ const wsBase = cfg.apiUrl
722
+ .replace(/^https:\/\//, 'wss://')
723
+ .replace(/^http:\/\//, 'ws://');
724
+
725
+ const adapter = new WebSocketAdapter({
726
+ channelId: cfg.channelId,
727
+ accessToken: cfg.accessToken,
728
+ webhubUrl: `${wsBase}/api/channel/ws`,
729
+ });
730
+
731
+ // Register inbound message handler — dispatches user messages to AI
732
+ adapter.onMessage(async (msg: InboundMessage) => {
733
+ const text = msg.content?.text?.trim() ?? '';
734
+ if (!text) return;
735
+
736
+ const freshCfg = api.config ?? {};
737
+
738
+ // Phase 11 T048 (fixed): role:agent frames come from the human operator via the
739
+ // webhub frontend. api.dispatch() does not exist in the OpenClaw plugin SDK;
740
+ // instead we re-use dispatchUserMessage so the agent message appears in OpenClaw's
741
+ // conversation context (sender = 'webhub-agent'). OpenClaw AI may reply; if it does,
742
+ // the reply is delivered via deliverOutbound → /api/channel/messages → frontend.
743
+ const senderId = (msg as any).role === 'agent'
744
+ ? ((msg as any).sender?.id ?? 'webhub-agent')
745
+ : msg.sender.id;
746
+ const senderName = (msg as any).role === 'agent'
747
+ ? ((msg as any).sender?.displayName ?? 'Agent')
748
+ : msg.sender.displayName;
749
+
750
+ await dispatchUserMessage({
751
+ id: msg.id,
752
+ content: text,
753
+ sender: { id: senderId, name: senderName ?? undefined },
754
+ timestamp: msg.timestamp,
755
+ accountId: ctx.accountId,
756
+ cfg: freshCfg,
757
+ });
758
+ });
759
+
760
+ // Track connection status → surface to OpenClaw gateway
761
+ adapter.onStatusChange((status, err) => {
762
+ if (status === 'connected') {
763
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
764
+ ctx.log?.info?.(`[${ctx.accountId}] chatu: WebSocket connected`);
765
+ } else if (status === 'disconnected') {
766
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
767
+ } else if (status === 'error') {
768
+ ctx.setStatus({
769
+ accountId: ctx.accountId,
770
+ connected: false,
771
+ lastError: err?.message ?? 'WS error',
772
+ });
773
+ }
774
+ });
775
+
776
+ // T015 Plugin-Channel Realtime: flush cached failed deliveries on reconnect
777
+ adapter.onReconnected(async () => {
778
+ const cache = getAccountCache(ctx.accountId);
779
+ if (cache.pendingCount === 0) return;
780
+ api.logger.info(
781
+ `[chatu] Reconnected — flushing ${cache.pendingCount} cached messages (account=${ctx.accountId})`,
782
+ );
783
+ await cache.flush(async (cachedMsg) => {
784
+ const payload = cachedMsg.content as { text: string; target: string; replyTo?: string };
785
+ const result = await deliverOutbound({
786
+ text: payload.text ?? '',
787
+ target: payload.target ?? '',
788
+ accountId: ctx.accountId,
789
+ replyTo: payload.replyTo,
790
+ });
791
+ if (!result.ok) {
792
+ throw new Error(result.error ?? 'Cached delivery failed');
793
+ }
794
+ cache.ack(cachedMsg.id);
795
+ });
796
+ });
797
+
798
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
799
+ ctx.log?.info?.(`[${ctx.accountId}] chatu: starting WebSocket connection to ${wsBase}/api/channel/ws`);
800
+
801
+ // Attempt initial connect (adapter auto-reconnects indefinitely on failure)
802
+ try {
803
+ await adapter.connect();
804
+ } catch (err) {
805
+ api.logger.warn(`[chatu] Initial WS connect failed (account=${ctx.accountId}): ${String(err)}`);
806
+ // Adapter will keep retrying — proceed to wait for abort
807
+ }
808
+
809
+ // Hold until the gateway signals shutdown
810
+ await new Promise<void>((resolve) => {
811
+ if (ctx.abortSignal.aborted) { resolve(); return; }
812
+ ctx.abortSignal.addEventListener('abort', () => resolve(), { once: true });
813
+ });
814
+
815
+ ctx.log?.info?.(`[${ctx.accountId}] chatu: WebSocket connection stopping`);
816
+ await adapter.disconnect();
817
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
818
+ }
819
+
820
+ /**
821
+ * @deprecated Use wsConnectionLoop instead (Plugin-Channel Realtime T012).
822
+ * Long-running poll loop for the gateway.
823
+ * Polls the WebHub service for new user messages and dispatches them to OpenClaw AI.
824
+ * Runs until `abortSignal` fires.
825
+ */
826
+ async function pollLoop(ctx: {
827
+ accountId: string;
828
+ abortSignal: AbortSignal;
829
+ setStatus: (s: ChannelAccountSnapshot) => void;
830
+ log?: ChannelLogSink;
831
+ }): Promise<void> {
832
+ const { accountId, abortSignal } = ctx;
833
+
834
+ // Pre-flight check
835
+ const initCfg = getAccountConfig(accountId);
836
+ if (!initCfg.apiUrl) {
837
+ ctx.log?.error?.(`[${accountId}] chatu: missing apiUrl for polling`);
838
+ return;
839
+ }
840
+
841
+ let lastCursor = '';
842
+ let consecutiveErrors = 0;
843
+ const MAX_ERRORS = 10;
844
+ // Track processed message IDs to handle same-millisecond createdAt duplicates
845
+ const processedIds = new Set<string>();
846
+ const MAX_PROCESSED_IDS = 500;
847
+
848
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
849
+ ctx.log?.info?.(`[${accountId}] chatu: polling started`);
850
+
851
+ while (!abortSignal.aborted) {
852
+ // Exponential back-off: 2s → 4s → 8s → … capped at 30s on consecutive errors
853
+ const backoffMs = Math.min(POLL_INTERVAL_MS * Math.pow(2, consecutiveErrors), MAX_BACKOFF_MS);
854
+ await new Promise<void>((resolve) => {
855
+ const timer = setTimeout(resolve, backoffMs);
856
+ abortSignal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
857
+ });
858
+
859
+ if (abortSignal.aborted) break;
860
+
861
+ try {
862
+ // Re-read config each iteration so a refreshed accessToken is picked up
863
+ const cfg = getAccountConfig(accountId);
864
+ if (!cfg.accessToken) {
865
+ consecutiveErrors++;
866
+ api.logger.warn(`[chatu] No accessToken yet (account=${accountId}), retrying...`);
867
+ continue;
868
+ }
869
+
870
+ const url =
871
+ `${cfg.apiUrl}/api/channel/messages/pending` +
872
+ `?channelId=${encodeURIComponent(cfg.channelId)}` +
873
+ `&after=${encodeURIComponent(lastCursor)}`;
874
+
875
+ const resp = await timedFetch(
876
+ url,
877
+ {
878
+ method: 'GET',
879
+ headers: {
880
+ 'X-Channel-Token': cfg.accessToken,
881
+ 'X-Channel-ID': accountId,
882
+ },
883
+ },
884
+ cfg.timeout,
885
+ );
886
+
887
+ if (!resp.ok) {
888
+ consecutiveErrors++;
889
+ if (consecutiveErrors >= MAX_ERRORS) {
890
+ ctx.setStatus({ accountId: ctx.accountId, connected: false, lastError: `HTTP ${resp.status}` });
891
+ }
892
+ continue;
893
+ }
894
+
895
+ consecutiveErrors = 0;
896
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
897
+
898
+ const data = await resp.json();
899
+ const messages: any[] = data?.data ?? [];
900
+
901
+ for (const msg of messages) {
902
+ // Advance ISO timestamp cursor so next poll fetches only newer messages
903
+ if (msg.createdAt) lastCursor = msg.createdAt as string;
904
+
905
+ // Skip messages already processed in-memory (handles same-ms duplicates)
906
+ if (processedIds.has(msg.id)) continue;
907
+ processedIds.add(msg.id);
908
+ // Bound set growth
909
+ if (processedIds.size > MAX_PROCESSED_IDS) {
910
+ const first = processedIds.values().next().value;
911
+ if (first !== undefined) processedIds.delete(first);
912
+ }
913
+
914
+ // Ack first (idempotency)
915
+ await ackMessage(cfg.apiUrl, cfg.accessToken, msg.id, cfg.timeout);
916
+
917
+ // T099: send typing indicator before dispatching to AI
918
+ const typingChannelId = msg.channelId ?? cfg.channelId;
919
+ if (typingChannelId) {
920
+ timedFetch(
921
+ `${cfg.apiUrl}/api/channel/typing`,
922
+ {
923
+ method: 'POST',
924
+ headers: { 'Content-Type': 'application/json', 'X-Channel-Token': cfg.accessToken },
925
+ body: JSON.stringify({ channelId: typingChannelId }),
926
+ },
927
+ 3000,
928
+ ).catch(() => { /* best-effort */ });
929
+ }
930
+
931
+ const freshCfg = api.config ?? {};
932
+ await dispatchUserMessage({
933
+ id: msg.id,
934
+ content: msg.content ?? msg.text ?? '',
935
+ sender: {
936
+ id: (msg as any).sender?.id ?? 'user',
937
+ name: (msg as any).sender?.name,
938
+ },
939
+ timestamp: msg.createdAt
940
+ ? new Date(msg.createdAt).getTime()
941
+ : Date.now(),
942
+ accountId,
943
+ cfg: freshCfg,
944
+ });
945
+ }
946
+
947
+ // T011 display-sender-session: process pending session commands
948
+ await processCommands(cfg, accountId).catch((e) => {
949
+ api.logger.warn(`[chatu] processCommands error (account=${accountId}): ${String(e)}`);
950
+ });
951
+ } catch (err) {
952
+ consecutiveErrors++;
953
+ api.logger.warn(`[chatu] Poll failed (account=${accountId}, errors=${consecutiveErrors}): ${String(err)}`);
954
+ if (consecutiveErrors >= MAX_ERRORS) {
955
+ ctx.setStatus({ accountId: ctx.accountId, connected: false, lastError: String(err) });
956
+ }
957
+ }
958
+ }
959
+
960
+ ctx.setStatus({ accountId: ctx.accountId, connected: false });
961
+ ctx.log?.info?.(`[${accountId}] chatu: polling stopped`);
962
+ }
963
+
964
+ // ── Channel Plugin Definition ────────────────────────────────────────────────
965
+
966
+ const chatuChannel: ChannelPlugin<ChatuAccount> = {
967
+ id: CHANNEL_ID,
968
+
969
+ // ── Metadata ──────────────────────────────────────────────────────────────
970
+ meta: {
971
+ id: CHANNEL_ID,
972
+ label: 'Chatu',
973
+ selectionLabel: 'Chatu (HTTP/WebSocket)',
974
+ docsPath: '/channels/chatu',
975
+ blurb: 'Connect to any website via HTTP/WebSocket (WebHub service)',
976
+ aliases: ['chatu', 'http-channel', 'webhub'],
977
+ },
978
+
979
+ // ── Capabilities ──────────────────────────────────────────────────────────
980
+ capabilities: {
981
+ chatTypes: ['direct', 'group'] as Array<'direct' | 'group'>,
982
+ reply: true,
983
+ edit: true,
984
+ unsend: true,
985
+ reactions: true,
986
+ polls: false,
987
+ media: true,
988
+ threads: true,
989
+ blockStreaming: false,
990
+ },
991
+
992
+ defaults: { queue: { debounceMs: 0 } },
993
+
994
+ // ── Config Schema (UI hints) ───────────────────────────────────────────────
995
+ configSchema: {
996
+ schema: {
997
+ type: 'object',
998
+ properties: {
999
+ apiUrl: { type: 'string', description: 'WebHub service base URL' },
1000
+ channelId: { type: 'string', description: 'Channel ID from WebHub' },
1001
+ secret: { type: 'string', description: 'Channel secret (wh_secret_...)' },
1002
+ accessToken: { type: 'string', description: 'Access token' },
1003
+ timeout: { type: 'number', description: 'Request timeout in ms' },
1004
+ },
1005
+ },
1006
+ uiHints: {
1007
+ apiUrl: {
1008
+ label: 'API URL',
1009
+ placeholder: 'https://your-webhub-service.example.com',
1010
+ help: 'Base URL of the Chatu WebHub service',
1011
+ },
1012
+ channelId: {
1013
+ label: 'Channel ID',
1014
+ placeholder: 'wh_ch_xxxxxx',
1015
+ help: 'Channel ID from the WebHub service',
1016
+ },
1017
+ secret: {
1018
+ label: 'Channel Secret',
1019
+ sensitive: true,
1020
+ placeholder: 'wh_secret_xxxxxxxxxx',
1021
+ },
1022
+ accessToken: {
1023
+ label: 'Access Token',
1024
+ sensitive: true,
1025
+ placeholder: 'wh_xxxxxxxxxxxxxxxx',
1026
+ advanced: true,
1027
+ },
1028
+ timeout: { label: 'Timeout (ms)', placeholder: '30000', advanced: true },
1029
+ },
1030
+ },
1031
+
1032
+ // ── Setup (CLI) ───────────────────────────────────────────────────────────
1033
+ setup: {
1034
+ applyAccountConfig: ({ cfg, accountId, input }: { cfg: OpenClawConfig; accountId: string; input: ChannelSetupInput }) => {
1035
+ const chatInput = input as ChatuSetupInput;
1036
+ const next = { ...cfg } as Record<string, any>;
1037
+ if (!next['channels']) next['channels'] = {};
1038
+ if (!next['channels'].chatu) next['channels'].chatu = {};
1039
+ if (!next['channels'].chatu.accounts) next['channels'].chatu.accounts = {};
1040
+ if (!next['channels'].chatu.accounts[accountId]) {
1041
+ next['channels'].chatu.accounts[accountId] = {};
1042
+ }
1043
+ const acct = next['channels'].chatu.accounts[accountId];
1044
+ if (chatInput.apiUrl) acct.apiUrl = chatInput.apiUrl;
1045
+ if (chatInput.channelId) acct.channelId = chatInput.channelId;
1046
+ if (chatInput.secret) acct.secret = chatInput.secret;
1047
+ if (input.accessToken) acct.accessToken = input.accessToken;
1048
+ return next as OpenClawConfig;
1049
+ },
1050
+ validateInput: ({ input }: { cfg: OpenClawConfig; accountId: string; input: ChannelSetupInput }): string | null => {
1051
+ const chatInput = input as ChatuSetupInput;
1052
+ if (!chatInput.apiUrl) return 'apiUrl is required';
1053
+ if (!chatInput.channelId) return 'channelId is required';
1054
+ if (!chatInput.secret && !input.accessToken)
1055
+ return 'Either secret or accessToken is required';
1056
+ return null;
1057
+ },
1058
+ },
1059
+
1060
+ // ── Config ────────────────────────────────────────────────────────────────
1061
+ config: {
1062
+ listAccountIds: (cfg: OpenClawConfig): string[] => {
1063
+ const accounts = cfg?.channels?.chatu?.accounts ?? {};
1064
+ const ids = Object.keys(accounts);
1065
+ if (
1066
+ ids.length === 0 &&
1067
+ (cfg?.channels?.chatu?.apiUrl || cfg?.channels?.chatu?.channelId)
1068
+ ) {
1069
+ return ['default'];
1070
+ }
1071
+ return ids;
1072
+ },
1073
+
1074
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ChatuAccount => {
1075
+ const accounts = cfg?.channels?.chatu?.accounts ?? {};
1076
+ const channelCfg = cfg?.channels?.chatu ?? {};
1077
+ const id = accountId ?? 'default';
1078
+ const acct = accounts[id] ?? {};
1079
+ return {
1080
+ accountId: id,
1081
+ apiUrl: acct.apiUrl ?? channelCfg.apiUrl ?? '',
1082
+ channelId: acct.channelId ?? channelCfg.channelId ?? '',
1083
+ secret: acct.secret ?? channelCfg.secret,
1084
+ accessToken: acct.accessToken ?? channelCfg.accessToken,
1085
+ timeout: acct.timeout ?? channelCfg.timeout ?? DEFAULT_TIMEOUT_MS,
1086
+ };
1087
+ },
1088
+
1089
+ isConfigured: (account: ChatuAccount, _cfg: OpenClawConfig): boolean =>
1090
+ Boolean(
1091
+ account?.apiUrl &&
1092
+ account?.channelId &&
1093
+ (account?.accessToken || account?.secret),
1094
+ ),
1095
+
1096
+ unconfiguredReason: (account: ChatuAccount, _cfg: OpenClawConfig): string => {
1097
+ if (!account?.apiUrl) return 'apiUrl not configured';
1098
+ if (!account?.channelId) return 'channelId not configured';
1099
+ if (!account?.accessToken && !account?.secret)
1100
+ return 'accessToken or secret not configured';
1101
+ return 'Not configured';
1102
+ },
1103
+
1104
+ isEnabled: (account: ChatuAccount, cfg: OpenClawConfig): boolean => {
1105
+ if (cfg?.channels?.chatu?.enabled === false) return false;
1106
+ return Boolean(account?.apiUrl);
1107
+ },
1108
+
1109
+ disabledReason: (_account: ChatuAccount, cfg: OpenClawConfig): string => {
1110
+ if (cfg?.channels?.chatu?.enabled === false) return 'Channel disabled in config';
1111
+ return 'Not enabled';
1112
+ },
1113
+
1114
+ describeAccount: (account: ChatuAccount, _cfg: OpenClawConfig): ChannelAccountSnapshot => ({
1115
+ accountId: account.accountId,
1116
+ name: `Chatu (${account.channelId || account.accountId || 'unknown'})`,
1117
+ connected: Boolean(account.accessToken),
1118
+ baseUrl: account.apiUrl || undefined,
1119
+ }),
1120
+ },
1121
+
1122
+ // ── Pairing ───────────────────────────────────────────────────────────────
1123
+ pairing: {
1124
+ idLabel: 'Channel ID',
1125
+ normalizeAllowEntry: (entry: string) => entry.trim().toLowerCase(),
1126
+ },
1127
+
1128
+ // ── Security ──────────────────────────────────────────────────────────────
1129
+ security: {
1130
+ resolveDmPolicy: () => null, // WebHub controls access
1131
+ },
1132
+
1133
+ // ── Groups ────────────────────────────────────────────────────────────────
1134
+ groups: {
1135
+ resolveRequireMention: () => false,
1136
+ },
1137
+
1138
+ // ── Streaming ─────────────────────────────────────────────────────────────
1139
+ streaming: {
1140
+ blockStreamingCoalesceDefaults: { minChars: 40, idleMs: 300 },
1141
+ },
1142
+
1143
+ // ── Threading ─────────────────────────────────────────────────────────────
1144
+ threading: {
1145
+ resolveReplyToMode: () => 'first' as const,
1146
+ allowExplicitReplyTagsWhenOff: true,
1147
+ },
1148
+
1149
+ // ── Messaging ─────────────────────────────────────────────────────────────
1150
+ messaging: {
1151
+ normalizeTarget: (raw: string) =>
1152
+ raw?.trim().replace(/^chatu:/i, '').toLowerCase() || undefined,
1153
+ targetResolver: {
1154
+ looksLikeId: (raw: string) => Boolean(raw?.trim()),
1155
+ hint: 'User ID or channel ID from the WebHub service',
1156
+ },
1157
+ },
1158
+
1159
+ // ── Status ────────────────────────────────────────────────────────────────
1160
+ status: {
1161
+ probeAccount: async ({ account, timeoutMs }: { account: ChatuAccount; timeoutMs: number; cfg: OpenClawConfig }) => {
1162
+ const cfg = getAccountConfig(account?.accountId);
1163
+ if (!cfg.apiUrl || !cfg.accessToken) {
1164
+ return { ok: false, error: 'Not configured' };
1165
+ }
1166
+ try {
1167
+ const resp = await timedFetch(
1168
+ `${cfg.apiUrl}/api/channel/status`,
1169
+ {
1170
+ headers: {
1171
+ 'x-access-token': cfg.accessToken,
1172
+ 'X-Channel-ID': account?.accountId ?? cfg.channelId,
1173
+ },
1174
+ },
1175
+ Math.min(timeoutMs, 5000),
1176
+ );
1177
+ if (resp.ok) {
1178
+ const data = await resp.json();
1179
+ return { ok: true, status: data?.data?.status ?? 'unknown' };
1180
+ }
1181
+ return { ok: false, error: `HTTP ${resp.status}` };
1182
+ } catch (err: any) {
1183
+ return { ok: false, error: String(err?.message ?? err) };
1184
+ }
1185
+ },
1186
+
1187
+ buildAccountSnapshot: ({ account, probe }: { account: ChatuAccount; cfg: OpenClawConfig; probe?: unknown }): ChannelAccountSnapshot => {
1188
+ const p = probe as { ok?: boolean; error?: string; status?: string } | undefined;
1189
+ return {
1190
+ accountId: account.accountId,
1191
+ connected: p?.ok === true,
1192
+ lastError: p?.ok ? null : (p?.error ?? null),
1193
+ baseUrl: account.apiUrl || undefined,
1194
+ name: `Chatu (${account.channelId || account.accountId})`,
1195
+ };
1196
+ },
1197
+ },
1198
+
1199
+ // ── Heartbeat ─────────────────────────────────────────────────────────────
1200
+ heartbeat: {
1201
+ checkReady: async ({ accountId }: { cfg: OpenClawConfig; accountId?: string | null; deps?: unknown }) => {
1202
+ const aid = accountId ?? 'default';
1203
+ const cfg = getAccountConfig(aid);
1204
+ if (!cfg.apiUrl) return { ok: false, reason: 'apiUrl not configured' };
1205
+ if (!cfg.accessToken) return { ok: false, reason: 'accessToken not configured' };
1206
+ try {
1207
+ const resp = await timedFetch(`${cfg.apiUrl}/health`, {}, 5000);
1208
+ if (resp.ok) return { ok: true, reason: 'Service reachable' };
1209
+ return { ok: false, reason: `Service returned HTTP ${resp.status}` };
1210
+ } catch (err: any) {
1211
+ return {
1212
+ ok: false,
1213
+ reason: `Cannot reach service: ${String(err?.message ?? err)}`,
1214
+ };
1215
+ }
1216
+ },
1217
+ },
1218
+
1219
+ // ── Gateway (long-running per-account connection) ─────────────────────────
1220
+ gateway: {
1221
+ startAccount: async (ctx: ChannelGatewayContext<ChatuAccount>): Promise<void> => {
1222
+ api.logger.info(`[chatu] WebHub channel plugin v${pkg.version} starting`);
1223
+ // T023: quick-register via env vars if no credentials configured
1224
+ await quickRegisterIfNeeded(ctx.accountId);
1225
+ await registerAndConnect(ctx.accountId);
1226
+ // Plugin-Channel Realtime (T012): use WebSocket instead of HTTP polling
1227
+ await wsConnectionLoop({
1228
+ accountId: ctx.accountId,
1229
+ abortSignal: ctx.abortSignal,
1230
+ setStatus: ctx.setStatus,
1231
+ log: ctx.log,
1232
+ });
1233
+ },
1234
+
1235
+ stopAccount: async (ctx: ChannelGatewayContext<ChatuAccount>): Promise<void> => {
1236
+ await disconnectAccount(ctx.accountId);
1237
+ },
1238
+
1239
+ logoutAccount: async (ctx: ChannelLogoutContext<ChatuAccount>) => {
1240
+ const { accountId } = ctx;
1241
+ const cfgKey =
1242
+ accountId === 'default'
1243
+ ? 'channels.chatu.accessToken'
1244
+ : `channels.chatu.accounts.${accountId}.accessToken`;
1245
+ try { await (api as any).config?.set?.(cfgKey, ''); } catch (_) { /* ok */ }
1246
+ await disconnectAccount(accountId);
1247
+ return { cleared: true, loggedOut: true };
1248
+ },
1249
+ },
1250
+
1251
+ // ── Outbound ──────────────────────────────────────────────────────────────
1252
+ outbound: {
1253
+ deliveryMode: 'direct' as const,
1254
+ textChunkLimit: DEFAULT_CHUNK_LIMIT,
1255
+
1256
+ resolveTarget: (params) => {
1257
+ const raw = params?.to ?? params?.accountId ?? 'default';
1258
+ const normalized = String(raw).trim().replace(/^chatu:/i, '');
1259
+ if (!normalized) {
1260
+ return { ok: false as const, error: new Error('Empty target') };
1261
+ }
1262
+ return { ok: true as const, to: normalized };
1263
+ },
1264
+
1265
+ sendText: async (ctx) => {
1266
+ const { to, text, accountId, replyToId, silent } = ctx;
1267
+ if (silent) return { channel: CHANNEL_ID, messageId: 'silent' };
1268
+
1269
+ const result = await deliverOutbound({ text, target: to, accountId, replyTo: replyToId, raw: ctx });
1270
+
1271
+ if (!result.ok) {
1272
+ api.logger.error(`[chatu] Failed to send text (to=${to}): ${result.error}`);
1273
+ throw new Error(result.error ?? 'sendText failed');
1274
+ }
1275
+ api.logger.info(`[chatu] Text sent (to=${to}, messageId=${result.messageId})`);
1276
+ return { channel: CHANNEL_ID, messageId: result.messageId ?? '' };
1277
+ },
1278
+
1279
+ sendMedia: async (ctx) => {
1280
+ const { to, mediaUrl, text, accountId, replyToId } = ctx;
1281
+ // Infer mediaType from URL extension since ChannelOutboundContext has no mediaType field
1282
+ const inferMediaType = (url?: string): string => {
1283
+ if (!url) return 'file';
1284
+ const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? '';
1285
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image';
1286
+ if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
1287
+ if (['mp3', 'wav', 'aac', 'flac', 'm4a'].includes(ext)) return 'audio';
1288
+ return 'file';
1289
+ };
1290
+ const result = await deliverOutbound({
1291
+ text: text ?? '',
1292
+ target: to,
1293
+ accountId,
1294
+ replyTo: replyToId,
1295
+ mediaUrl,
1296
+ mediaType: inferMediaType(mediaUrl),
1297
+ raw: ctx,
1298
+ });
1299
+ if (!result.ok) {
1300
+ api.logger.error(`[chatu] Failed to send media (to=${to}): ${result.error}`);
1301
+ throw new Error(result.error ?? 'sendMedia failed');
1302
+ }
1303
+ return { channel: CHANNEL_ID, messageId: result.messageId ?? '' };
1304
+ },
1305
+
1306
+ // T098: send a rich payload (richCard, structured content)
1307
+ sendPayload: async (ctx: any) => {
1308
+ const { to, accountId, replyToId, messageType, metadata, text } = ctx;
1309
+ const result = await deliverOutbound({
1310
+ text: text ?? '',
1311
+ target: to,
1312
+ accountId,
1313
+ replyTo: replyToId,
1314
+ messageType,
1315
+ metadata,
1316
+ raw: ctx,
1317
+ });
1318
+ if (!result.ok) {
1319
+ api.logger.error(`[chatu] Failed to send payload (to=${to}): ${result.error}`);
1320
+ throw new Error(result.error ?? 'sendPayload failed');
1321
+ }
1322
+ return { channel: CHANNEL_ID, messageId: result.messageId ?? '' };
1323
+ },
1324
+
1325
+ // T098: send a poll message
1326
+ sendPoll: async (ctx: any) => {
1327
+ const { to, accountId, replyToId, question, options, multiple } = ctx;
1328
+ const result = await deliverOutbound({
1329
+ text: question ?? 'Poll',
1330
+ target: to,
1331
+ accountId,
1332
+ replyTo: replyToId,
1333
+ messageType: 'poll',
1334
+ metadata: { poll: { question, options, multiple: multiple ?? false } },
1335
+ raw: ctx,
1336
+ });
1337
+ if (!result.ok) {
1338
+ api.logger.error(`[chatu] Failed to send poll (to=${to}): ${result.error}`);
1339
+ throw new Error(result.error ?? 'sendPoll failed');
1340
+ }
1341
+ return { channel: CHANNEL_ID, messageId: result.messageId ?? '' };
1342
+ },
1343
+
1344
+ // T042: streaming relay — forward AI stream chunks/done to WebHub API
1345
+ // Cast to any: sendStreamChunk/sendStreamDone are chatu-specific extensions
1346
+ // not yet in the openclaw plugin-sdk ChannelOutboundAdapter type.
1347
+ ...(({
1348
+ sendStreamChunk: async (ctx: any) => {
1349
+ const { messageId, seq, delta, accountId } = ctx;
1350
+ const result = await deliverStreamChunk({ messageId, seq, delta, accountId });
1351
+ if (!result.ok) {
1352
+ api.logger.warn(`[chatu] stream chunk relay failed (messageId=${messageId}): ${result.error}`);
1353
+ }
1354
+ return result;
1355
+ },
1356
+
1357
+ sendStreamDone: async (ctx: any) => {
1358
+ const { messageId, totalSeq, accountId } = ctx;
1359
+ const result = await deliverStreamDone({ messageId, totalSeq, accountId });
1360
+ if (!result.ok) {
1361
+ api.logger.warn(`[chatu] stream done relay failed (messageId=${messageId}): ${result.error}`);
1362
+ }
1363
+ return result;
1364
+ },
1365
+ }) as any),
1366
+ },
1367
+ };
1368
+
1369
+ // ── Register channel with OpenClaw ─────────────────────────────────────────
1370
+ api.registerChannel({ plugin: chatuChannel });
1371
+
1372
+ // ── T011 US3: Cross-channel relay via before_message_write hook ─────────────
1373
+ //
1374
+ // Every time OpenClaw writes a message to any session transcript, this hook
1375
+ // fires synchronously. We relay messages from channels OTHER than ChatU so
1376
+ // they show up in the ChatU frontend with a cross-channel badge.
1377
+ //
1378
+ // The hook MUST be synchronous. Async relay is fired-and-forgotten (.catch).
1379
+ api.on('before_message_write', (event, ctx) => {
1380
+ const sessionKey = ctx.sessionKey ?? '';
1381
+
1382
+ // Skip ChatU's own channel sessions to prevent relay loops.
1383
+ // ChatU session keys always contain the CHANNEL_ID token 'chatu'.
1384
+ if (!sessionKey || sessionKey.includes('chatu')) return;
1385
+
1386
+ const msg = event.message as any;
1387
+ const role: string = msg?.role ?? '';
1388
+
1389
+ // Only relay user (outbound) and assistant (inbound) messages; skip tool/system.
1390
+ if (role !== 'user' && role !== 'assistant') return;
1391
+
1392
+ const direction: 'inbound' | 'outbound' = role === 'assistant' ? 'inbound' : 'outbound';
1393
+
1394
+ // Extract plain-text content from the AgentMessage (string or content-block array).
1395
+ let content = '';
1396
+ if (typeof msg.content === 'string') {
1397
+ content = msg.content;
1398
+ } else if (Array.isArray(msg.content)) {
1399
+ content = (msg.content as any[])
1400
+ .filter((b) => b?.type === 'text')
1401
+ .map((b) => b.text ?? '')
1402
+ .join('\n');
1403
+ }
1404
+
1405
+ // Strip OpenClaw system metadata prefix and extract embedded metadata.
1406
+ // Pattern: "Conversation info (untrusted metadata): ```json\n{...}\n``` [date] actual_message"
1407
+ const metaPrefixMatch = content.match(
1408
+ /^Conversation info \(untrusted metadata\):\s*```(?:json)?\s*([\s\S]*?)```\s*(?:\[[^\]]*\])?\s*/,
1409
+ );
1410
+ if (metaPrefixMatch) {
1411
+ // Parse the embedded metadata to detect the sender channel.
1412
+ try {
1413
+ const embeddedMeta = JSON.parse(metaPrefixMatch[1].trim());
1414
+ // If the message originated from our own webhub frontend, skip relay to avoid duplicates.
1415
+ // OpenClaw injects sender_id="webhub" for messages forwarded from the chatu channel plugin.
1416
+ const embeddedSender: string = embeddedMeta?.sender_id ?? embeddedMeta?.sender ?? '';
1417
+ if (embeddedSender === 'webhub' || embeddedSender.startsWith('chatu')) return;
1418
+ } catch { /* ignore parse errors */ }
1419
+ // Strip the whole prefix regardless of parse success.
1420
+ content = content.replace(
1421
+ /^Conversation info \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*(?:\[[^\]]*\])?\s*/,
1422
+ '',
1423
+ ).trim();
1424
+ }
1425
+
1426
+ if (!content.trim()) return; // skip empty or tool-only messages
1427
+
1428
+ // Derive source channel from session key.
1429
+ // Session key format: "{agentId}:{channel}:{peerId}" (approx.)
1430
+ // 'main' channel = TUI / CLI direct mode → label as 'tui'.
1431
+ const parts = sessionKey.split(':');
1432
+ const channelPart = parts[1] || parts[0] || 'tui';
1433
+ const rawSource = channelPart === 'main' ? 'tui' : channelPart;
1434
+ // Sanitize to match backend /^[a-z0-9_-]{1,64}$/ validation.
1435
+ const sourceChannel =
1436
+ rawSource
1437
+ .replace(/[^a-z0-9_-]/g, '-')
1438
+ .replace(/^-+|-+$/g, '')
1439
+ .slice(0, 64) || 'tui';
1440
+
1441
+ const senderName = direction === 'inbound' ? 'OpenClaw' : sourceChannel;
1442
+
1443
+ // Store the OpenClaw message ID so the deliver callback (deliverOutbound path)
1444
+ // can retrieve and attach it as dedupId, making both write paths carry the
1445
+ // same identifier for reliable ID-based dedup on the backend.
1446
+ const ocMsgId: string = (msg as any).id ?? '';
1447
+ if (ocMsgId) pendingRelayIds.set(sessionKey, ocMsgId);
1448
+
1449
+ // Fire-and-forget with a short delay so that the direct deliverOutbound path
1450
+ // (which calls POST /api/channel/messages) has time to complete first.
1451
+ const RELAY_DEDUP_DELAY_MS = 500;
1452
+ setTimeout(() => {
1453
+ relayCrossChannelMessage({
1454
+ sourceChannel,
1455
+ direction,
1456
+ sender: { name: senderName },
1457
+ content: content.trim(),
1458
+ sessionKey,
1459
+ accountId: null,
1460
+ dedupId: ocMsgId || undefined,
1461
+ raw: msg,
1462
+ }).catch((err: unknown) => {
1463
+ api.logger.warn(
1464
+ `[chatu] before_message_write relay failed (source=${sourceChannel}, dir=${direction}): ${String(err)}`,
1465
+ );
1466
+ });
1467
+ }, RELAY_DEDUP_DELAY_MS);
1468
+
1469
+ // Return undefined → don't block the message write.
1470
+ });
1471
+
1472
+ api.logger.info('[chatu] Channel plugin loaded');
1473
+
1474
+ // Return plugin lifecycle
1475
+ return {
1476
+ name: 'chatu-channel',
1477
+ async dispose() {
1478
+ api.logger.info('[chatu] Disposing channel plugin');
1479
+ await disconnectAccount();
1480
+ },
1481
+ };
1482
+ }
1483
+
1484
+ // ── Testable utility exports ────────────────────────────────────────────────
1485
+
1486
+ /**
1487
+ * Computes the exponential back-off wait time in milliseconds.
1488
+ * On each consecutive error the wait doubles starting from baseMs, capped at maxMs.
1489
+ *
1490
+ * consecutiveErrors=0 → baseMs (normal interval, no back-off)
1491
+ * consecutiveErrors=1 → baseMs * 2
1492
+ * consecutiveErrors=2 → baseMs * 4
1493
+ * ...
1494
+ *
1495
+ * @param consecutiveErrors - Number of consecutive failures so far
1496
+ * @param baseMs - Base interval in milliseconds (default 2000)
1497
+ * @param maxMs - Maximum allowed wait in milliseconds (default 30000)
1498
+ */
1499
+ export function computeBackoffMs(
1500
+ consecutiveErrors: number,
1501
+ baseMs: number = POLL_INTERVAL_MS,
1502
+ maxMs: number = MAX_BACKOFF_MS,
1503
+ ): number {
1504
+ return Math.min(baseMs * Math.pow(2, consecutiveErrors), maxMs);
1505
+ }
1506
+
1507
+ /**
1508
+ * T011 US3 testable export: forward a cross-channel message to the ChatU WebHub
1509
+ * service so it appears in the frontend with a source-channel badge.
1510
+ *
1511
+ * Can be called from OpenClaw pipeline hooks (e.g. `before_message_write`) or
1512
+ * from standalone relay scripts that have access to the channel credentials.
1513
+ *
1514
+ * @param apiUrl - WebHub service base URL
1515
+ * @param accessToken - Channel access token (`X-Access-Token`)
1516
+ * @param sourceChannel - Originating channel id (e.g. 'tui', 'whatsapp')
1517
+ * @param direction - 'inbound' (AI reply) or 'outbound' (user message)
1518
+ * @param sender - Sender object: name required, id optional (cross-channel may lack user ID)
1519
+ * @param content - Text content of the message
1520
+ * @param sessionKey - Session key in the originating channel
1521
+ * @param timeoutMs - Fetch timeout in milliseconds (default 30 s)
1522
+ */
1523
+ export async function relayCrossChannelMessage(
1524
+ apiUrl: string,
1525
+ accessToken: string,
1526
+ sourceChannel: string,
1527
+ direction: 'inbound' | 'outbound',
1528
+ sender: { id?: string; name: string },
1529
+ content: string,
1530
+ sessionKey: string,
1531
+ timeoutMs: number = 30_000,
1532
+ ): Promise<{ ok: boolean; id?: string; error?: string }> {
1533
+ const ctrl = new AbortController();
1534
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1535
+ try {
1536
+ const resp = await fetch(`${apiUrl}/api/channel/cross-channel-messages`, {
1537
+ method: 'POST',
1538
+ headers: {
1539
+ 'Content-Type': 'application/json',
1540
+ 'X-Access-Token': accessToken,
1541
+ },
1542
+ body: JSON.stringify({ sourceChannel, direction, sender, content, sessionKey }),
1543
+ signal: ctrl.signal,
1544
+ });
1545
+ clearTimeout(timer);
1546
+ if (!resp.ok) {
1547
+ const errorText = await resp.text();
1548
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
1549
+ }
1550
+ const result = await resp.json();
1551
+ return { ok: true, id: result.id };
1552
+ } catch (err: unknown) {
1553
+ clearTimeout(timer);
1554
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * T042 testable export: relay a streaming AI chunk to the WebHub API.
1560
+ *
1561
+ * @param apiUrl - WebHub service base URL
1562
+ * @param accessToken - Channel access token (Bearer)
1563
+ * @param messageId - Unique ID for the streaming message
1564
+ * @param seq - 0-based sequential chunk index
1565
+ * @param delta - Text delta for this chunk
1566
+ * @param timeoutMs - Fetch timeout in milliseconds
1567
+ */
1568
+ export async function relayStreamChunk(
1569
+ apiUrl: string,
1570
+ accessToken: string,
1571
+ messageId: string,
1572
+ seq: number,
1573
+ delta: string,
1574
+ timeoutMs: number = 30_000,
1575
+ ): Promise<{ ok: boolean; error?: string }> {
1576
+ const ctrl = new AbortController();
1577
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1578
+ try {
1579
+ const resp = await fetch(`${apiUrl}/api/channel/stream/chunk`, {
1580
+ method: 'POST',
1581
+ headers: {
1582
+ 'Content-Type': 'application/json',
1583
+ Authorization: `Bearer ${accessToken}`,
1584
+ },
1585
+ body: JSON.stringify({ messageId, seq, delta }),
1586
+ signal: ctrl.signal,
1587
+ });
1588
+ clearTimeout(timer);
1589
+ if (!resp.ok) {
1590
+ const errorText = await resp.text();
1591
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
1592
+ }
1593
+ return { ok: true };
1594
+ } catch (err: any) {
1595
+ clearTimeout(timer);
1596
+ return { ok: false, error: String(err?.message ?? err) };
1597
+ }
1598
+ }
1599
+
1600
+ /**
1601
+ * T042 testable export: signal streaming completion to the WebHub API.
1602
+ *
1603
+ * @param apiUrl - WebHub service base URL
1604
+ * @param accessToken - Channel access token (Bearer)
1605
+ * @param messageId - Unique ID for the streaming message
1606
+ * @param totalSeq - Total number of chunks sent
1607
+ * @param timeoutMs - Fetch timeout in milliseconds
1608
+ */
1609
+ export async function relayStreamDone(
1610
+ apiUrl: string,
1611
+ accessToken: string,
1612
+ messageId: string,
1613
+ totalSeq: number,
1614
+ timeoutMs: number = 30_000,
1615
+ ): Promise<{ ok: boolean; error?: string }> {
1616
+ const ctrl = new AbortController();
1617
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
1618
+ try {
1619
+ const resp = await fetch(`${apiUrl}/api/channel/stream/done`, {
1620
+ method: 'POST',
1621
+ headers: {
1622
+ 'Content-Type': 'application/json',
1623
+ Authorization: `Bearer ${accessToken}`,
1624
+ },
1625
+ body: JSON.stringify({ messageId, totalSeq }),
1626
+ signal: ctrl.signal,
1627
+ });
1628
+ clearTimeout(timer);
1629
+ if (!resp.ok) {
1630
+ const errorText = await resp.text();
1631
+ return { ok: false, error: `HTTP ${resp.status}: ${errorText}` };
1632
+ }
1633
+ return { ok: true };
1634
+ } catch (err: any) {
1635
+ clearTimeout(timer);
1636
+ return { ok: false, error: String(err?.message ?? err) };
1637
+ }
1638
+ }