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