@cecwxf/wtt 0.1.0

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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/bin/openclaw-wtt-bootstrap.mjs +181 -0
  4. package/dist/channel.d.ts +275 -0
  5. package/dist/channel.d.ts.map +1 -0
  6. package/dist/channel.js +2088 -0
  7. package/dist/channel.js.map +1 -0
  8. package/dist/commands/account.d.ts +16 -0
  9. package/dist/commands/account.d.ts.map +1 -0
  10. package/dist/commands/account.js +37 -0
  11. package/dist/commands/account.js.map +1 -0
  12. package/dist/commands/bind.d.ts +3 -0
  13. package/dist/commands/bind.d.ts.map +1 -0
  14. package/dist/commands/bind.js +102 -0
  15. package/dist/commands/bind.js.map +1 -0
  16. package/dist/commands/config.d.ts +3 -0
  17. package/dist/commands/config.d.ts.map +1 -0
  18. package/dist/commands/config.js +38 -0
  19. package/dist/commands/config.js.map +1 -0
  20. package/dist/commands/delegate.d.ts +7 -0
  21. package/dist/commands/delegate.d.ts.map +1 -0
  22. package/dist/commands/delegate.js +99 -0
  23. package/dist/commands/delegate.js.map +1 -0
  24. package/dist/commands/formatter.d.ts +8 -0
  25. package/dist/commands/formatter.d.ts.map +1 -0
  26. package/dist/commands/formatter.js +198 -0
  27. package/dist/commands/formatter.js.map +1 -0
  28. package/dist/commands/handlers.d.ts +3 -0
  29. package/dist/commands/handlers.d.ts.map +1 -0
  30. package/dist/commands/handlers.js +79 -0
  31. package/dist/commands/handlers.js.map +1 -0
  32. package/dist/commands/http.d.ts +26 -0
  33. package/dist/commands/http.d.ts.map +1 -0
  34. package/dist/commands/http.js +190 -0
  35. package/dist/commands/http.js.map +1 -0
  36. package/dist/commands/index.d.ts +3 -0
  37. package/dist/commands/index.d.ts.map +1 -0
  38. package/dist/commands/index.js +2 -0
  39. package/dist/commands/index.js.map +1 -0
  40. package/dist/commands/parser.d.ts +5 -0
  41. package/dist/commands/parser.d.ts.map +1 -0
  42. package/dist/commands/parser.js +325 -0
  43. package/dist/commands/parser.js.map +1 -0
  44. package/dist/commands/pipeline.d.ts +7 -0
  45. package/dist/commands/pipeline.d.ts.map +1 -0
  46. package/dist/commands/pipeline.js +99 -0
  47. package/dist/commands/pipeline.js.map +1 -0
  48. package/dist/commands/router.d.ts +18 -0
  49. package/dist/commands/router.d.ts.map +1 -0
  50. package/dist/commands/router.js +74 -0
  51. package/dist/commands/router.js.map +1 -0
  52. package/dist/commands/setup.d.ts +7 -0
  53. package/dist/commands/setup.d.ts.map +1 -0
  54. package/dist/commands/setup.js +89 -0
  55. package/dist/commands/setup.js.map +1 -0
  56. package/dist/commands/task.d.ts +26 -0
  57. package/dist/commands/task.d.ts.map +1 -0
  58. package/dist/commands/task.js +438 -0
  59. package/dist/commands/task.js.map +1 -0
  60. package/dist/commands/types.d.ts +173 -0
  61. package/dist/commands/types.d.ts.map +1 -0
  62. package/dist/commands/types.js +2 -0
  63. package/dist/commands/types.js.map +1 -0
  64. package/dist/e2e-crypto.d.ts +36 -0
  65. package/dist/e2e-crypto.d.ts.map +1 -0
  66. package/dist/e2e-crypto.js +166 -0
  67. package/dist/e2e-crypto.js.map +1 -0
  68. package/dist/index.d.ts +20 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +21 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/plugin-config.d.ts +32 -0
  73. package/dist/plugin-config.d.ts.map +1 -0
  74. package/dist/plugin-config.js +268 -0
  75. package/dist/plugin-config.js.map +1 -0
  76. package/dist/runtime/index.d.ts +13 -0
  77. package/dist/runtime/index.d.ts.map +1 -0
  78. package/dist/runtime/index.js +7 -0
  79. package/dist/runtime/index.js.map +1 -0
  80. package/dist/runtime/progress-ticker.d.ts +65 -0
  81. package/dist/runtime/progress-ticker.d.ts.map +1 -0
  82. package/dist/runtime/progress-ticker.js +116 -0
  83. package/dist/runtime/progress-ticker.js.map +1 -0
  84. package/dist/runtime/session-binding.d.ts +16 -0
  85. package/dist/runtime/session-binding.d.ts.map +1 -0
  86. package/dist/runtime/session-binding.js +20 -0
  87. package/dist/runtime/session-binding.js.map +1 -0
  88. package/dist/runtime/status-transition.d.ts +19 -0
  89. package/dist/runtime/status-transition.d.ts.map +1 -0
  90. package/dist/runtime/status-transition.js +95 -0
  91. package/dist/runtime/status-transition.js.map +1 -0
  92. package/dist/runtime/task-executor-persistence.d.ts +63 -0
  93. package/dist/runtime/task-executor-persistence.d.ts.map +1 -0
  94. package/dist/runtime/task-executor-persistence.js +201 -0
  95. package/dist/runtime/task-executor-persistence.js.map +1 -0
  96. package/dist/runtime/task-executor.d.ts +169 -0
  97. package/dist/runtime/task-executor.d.ts.map +1 -0
  98. package/dist/runtime/task-executor.js +1230 -0
  99. package/dist/runtime/task-executor.js.map +1 -0
  100. package/dist/runtime/task-status-handler.d.ts +28 -0
  101. package/dist/runtime/task-status-handler.d.ts.map +1 -0
  102. package/dist/runtime/task-status-handler.js +102 -0
  103. package/dist/runtime/task-status-handler.js.map +1 -0
  104. package/dist/types.d.ts +159 -0
  105. package/dist/types.d.ts.map +1 -0
  106. package/dist/types.js +6 -0
  107. package/dist/types.js.map +1 -0
  108. package/dist/ws-client.d.ts +90 -0
  109. package/dist/ws-client.d.ts.map +1 -0
  110. package/dist/ws-client.js +385 -0
  111. package/dist/ws-client.js.map +1 -0
  112. package/index.ts +19 -0
  113. package/openclaw.plugin.json +49 -0
  114. package/package.json +62 -0
  115. package/scripts/install-bootstrap-cli.sh +54 -0
@@ -0,0 +1,2088 @@
1
+ /**
2
+ * WTT Channel Plugin for OpenClaw.
3
+ *
4
+ * Scope of this module (P0/P1 foundation):
5
+ * - Register WTT as a first-class channel plugin
6
+ * - Manage per-account WS clients
7
+ * - Provide outbound text/media delivery through WTT publish/p2p
8
+ *
9
+ * Command parity status:
10
+ * - Core @wtt topic/message commands routed in src/commands/* (P2 first batch)
11
+ * - task/pipeline/delegate command scaffolding available via HTTP API in src/commands/*
12
+ */
13
+ import { createWTTCommandRouter } from "./commands/router.js";
14
+ import { normalizeAccountContext } from "./commands/account.js";
15
+ import { executeTaskRunById } from "./commands/task.js";
16
+ import { createTaskStatusEventHandler } from "./runtime/task-status-handler.js";
17
+ import { WTTCloudClient } from "./ws-client.js";
18
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
19
+ import { createRequire } from "node:module";
20
+ import os from "node:os";
21
+ import { dirname, join as joinPath } from "node:path";
22
+ import { randomBytes } from "node:crypto";
23
+ import { pathToFileURL } from "node:url";
24
+ const DEFAULT_ACCOUNT_ID = "default";
25
+ const DEFAULT_CLOUD_URL = "https://www.waxbyte.com";
26
+ const CHANNEL_ID = "wtt";
27
+ const DEFAULT_INBOUND_POLL_INTERVAL_MS = 0;
28
+ const DEFAULT_INBOUND_POLL_LIMIT = 20;
29
+ const DEFAULT_INBOUND_DEDUP_WINDOW_MS = 5 * 60_000;
30
+ const DEFAULT_INBOUND_DEDUP_MAX_ENTRIES = 2000;
31
+ const DEFAULT_TASK_RECOVERY_INTERVAL_MS = 60_000;
32
+ const DEFAULT_TASK_RECOVERY_LOOKBACK_MS = 6 * 60 * 60_000;
33
+ const DEFAULT_SLASH_COMPAT_ENABLED = false;
34
+ const DEFAULT_SLASH_COMPAT_WTT_PREFIX_ONLY = true;
35
+ const DEFAULT_SLASH_BYPASS_MENTION_GATE = false;
36
+ const DEFAULT_NATURAL_BRIDGE_MIN_DOING_MS = 2500;
37
+ const hooks = { before: [], after: [] };
38
+ const clients = new Map();
39
+ const topicTypeCache = new Map();
40
+ const DEFAULT_P2P_E2E_ENABLED = true;
41
+ function registerHook(phase, fn) {
42
+ if (phase === "before_tool_call")
43
+ hooks.before.push(fn);
44
+ else
45
+ hooks.after.push(fn);
46
+ }
47
+ async function runHooks(phase, ctx) {
48
+ for (const fn of hooks[phase])
49
+ await fn(ctx);
50
+ }
51
+ function listAccountIds(cfg) {
52
+ const section = cfg.channels?.wtt;
53
+ if (!section)
54
+ return [];
55
+ const ids = Object.keys(section.accounts ?? {});
56
+ if (ids.length > 0)
57
+ return ids;
58
+ // Backward compatibility with flat single-account config under channels.wtt
59
+ if (section.agentId || section.token || section.cloudUrl || section.name)
60
+ return [DEFAULT_ACCOUNT_ID];
61
+ return [];
62
+ }
63
+ function resolveRawAccountConfig(cfg, accountId) {
64
+ const section = cfg.channels?.wtt ?? {};
65
+ const id = accountId ?? DEFAULT_ACCOUNT_ID;
66
+ if (section.accounts?.[id])
67
+ return section.accounts[id];
68
+ if (id === DEFAULT_ACCOUNT_ID) {
69
+ const { accounts: _accounts, ...flat } = section;
70
+ return flat;
71
+ }
72
+ return {};
73
+ }
74
+ function resolveAccount(cfg, accountId) {
75
+ const id = accountId ?? DEFAULT_ACCOUNT_ID;
76
+ const raw = resolveRawAccountConfig(cfg, id);
77
+ const enabled = raw.enabled !== false;
78
+ const configured = Boolean(raw.agentId?.trim() && raw.token?.trim());
79
+ return {
80
+ accountId: id,
81
+ name: raw.name ?? id,
82
+ enabled,
83
+ configured,
84
+ cloudUrl: raw.cloudUrl ?? DEFAULT_CLOUD_URL,
85
+ agentId: raw.agentId ?? "",
86
+ token: raw.token ?? "",
87
+ config: raw,
88
+ };
89
+ }
90
+ function openclawConfigPath() {
91
+ const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim();
92
+ if (fromEnv)
93
+ return fromEnv;
94
+ return joinPath(os.homedir(), ".openclaw", "openclaw.json");
95
+ }
96
+ function generateE2EPassword() {
97
+ return randomBytes(24).toString("base64url");
98
+ }
99
+ async function ensureAccountE2EPassword(accountId, account, log) {
100
+ if (account.config.e2ePassword?.trim())
101
+ return;
102
+ const password = generateE2EPassword();
103
+ const configPath = openclawConfigPath();
104
+ try {
105
+ let cfgRaw = {};
106
+ try {
107
+ const text = await readFile(configPath, "utf8");
108
+ cfgRaw = JSON.parse(text);
109
+ }
110
+ catch (err) {
111
+ const code = err?.code;
112
+ if (code !== "ENOENT")
113
+ throw err;
114
+ }
115
+ const channels = (cfgRaw.channels ?? {});
116
+ const wtt = (channels.wtt ?? {});
117
+ const accounts = (wtt.accounts ?? {});
118
+ const accountKey = accountId || DEFAULT_ACCOUNT_ID;
119
+ const currentAccount = (accounts[accountKey] ?? {});
120
+ const existing = typeof currentAccount.e2ePassword === "string" ? currentAccount.e2ePassword.trim() : "";
121
+ if (existing) {
122
+ account.config.e2ePassword = existing;
123
+ return;
124
+ }
125
+ const mergedAccount = {
126
+ ...currentAccount,
127
+ e2ePassword: password,
128
+ };
129
+ const next = {
130
+ ...cfgRaw,
131
+ channels: {
132
+ ...channels,
133
+ wtt: {
134
+ ...wtt,
135
+ accounts: {
136
+ ...accounts,
137
+ [accountKey]: mergedAccount,
138
+ },
139
+ },
140
+ },
141
+ };
142
+ await mkdir(dirname(configPath), { recursive: true });
143
+ const tempPath = `${configPath}.tmp-${Date.now()}`;
144
+ await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
145
+ await rename(tempPath, configPath);
146
+ account.config.e2ePassword = password;
147
+ log("info", `[${accountId}] auto-generated e2ePassword and persisted to openclaw.json`);
148
+ }
149
+ catch (err) {
150
+ log("warn", `[${accountId}] failed to auto-generate e2ePassword`, err);
151
+ }
152
+ }
153
+ function rememberTopicType(topicId, topicType) {
154
+ const id = (topicId ?? "").trim();
155
+ if (!id)
156
+ return;
157
+ const type = (topicType ?? "").trim().toLowerCase();
158
+ if (!type)
159
+ return;
160
+ topicTypeCache.set(id, type);
161
+ while (topicTypeCache.size > 5000) {
162
+ const oldest = topicTypeCache.keys().next().value;
163
+ if (!oldest)
164
+ break;
165
+ topicTypeCache.delete(oldest);
166
+ }
167
+ }
168
+ function isP2PTopicId(topicId) {
169
+ const type = topicTypeCache.get(topicId.trim());
170
+ return type === "p2p";
171
+ }
172
+ function isP2PE2EEnabled(account) {
173
+ const raw = account.config.p2pE2EEnabled;
174
+ return raw === undefined ? DEFAULT_P2P_E2E_ENABLED : raw !== false;
175
+ }
176
+ function getClient(accountId) {
177
+ return clients.get(accountId);
178
+ }
179
+ function hasMeaningfulAccountConfig(raw) {
180
+ return Boolean(raw.agentId || raw.token || raw.cloudUrl || raw.name || raw.enabled !== undefined);
181
+ }
182
+ function detectAccountSource(cfg, accountId) {
183
+ const section = cfg?.channels?.wtt;
184
+ if (!section)
185
+ return "runtime";
186
+ if (section.accounts?.[accountId])
187
+ return `channels.wtt.accounts.${accountId}`;
188
+ if (accountId === DEFAULT_ACCOUNT_ID) {
189
+ const { accounts: _accounts, ...flat } = section;
190
+ if (hasMeaningfulAccountConfig(flat))
191
+ return "channels.wtt";
192
+ }
193
+ return "runtime";
194
+ }
195
+ function resolveCommandAccountContext(accountId, cfg) {
196
+ if (cfg?.channels?.wtt) {
197
+ const account = resolveAccount(cfg, accountId);
198
+ return {
199
+ accountId: account.accountId,
200
+ name: account.name,
201
+ source: detectAccountSource(cfg, accountId),
202
+ cloudUrl: account.cloudUrl,
203
+ agentId: account.agentId,
204
+ token: account.token,
205
+ enabled: account.enabled,
206
+ configured: account.configured,
207
+ };
208
+ }
209
+ const client = getClient(accountId);
210
+ if (!client)
211
+ return undefined;
212
+ const runtime = client.getAccount();
213
+ return {
214
+ accountId: runtime.accountId,
215
+ name: runtime.name,
216
+ source: "runtime.client",
217
+ cloudUrl: runtime.cloudUrl,
218
+ agentId: runtime.agentId,
219
+ token: runtime.token,
220
+ enabled: runtime.enabled,
221
+ configured: runtime.configured,
222
+ };
223
+ }
224
+ const commandRouter = createWTTCommandRouter({
225
+ getClient,
226
+ getAccount: (accountId) => resolveCommandAccountContext(accountId),
227
+ defaultAccountId: DEFAULT_ACCOUNT_ID,
228
+ });
229
+ function extractDispatchText(payload) {
230
+ if (!payload || typeof payload !== "object")
231
+ return "";
232
+ const data = payload;
233
+ if (typeof data.text === "string" && data.text.trim()) {
234
+ return data.text.trim();
235
+ }
236
+ const lines = [];
237
+ const blocks = Array.isArray(data.blocks) ? data.blocks : [];
238
+ for (const block of blocks) {
239
+ if (!block || typeof block !== "object")
240
+ continue;
241
+ const piece = block.text;
242
+ if (typeof piece === "string" && piece.trim())
243
+ lines.push(piece.trim());
244
+ }
245
+ if (lines.length > 0)
246
+ return lines.join("\n\n");
247
+ return "";
248
+ }
249
+ function toNumberOrZero(raw) {
250
+ const n = Number(raw);
251
+ if (!Number.isFinite(n) || n < 0)
252
+ return 0;
253
+ return Math.floor(n);
254
+ }
255
+ function toTimestampMs(raw) {
256
+ if (typeof raw === "number" && Number.isFinite(raw)) {
257
+ if (raw > 1_000_000_000_000)
258
+ return Math.floor(raw);
259
+ if (raw > 1_000_000_000)
260
+ return Math.floor(raw * 1000);
261
+ return undefined;
262
+ }
263
+ if (typeof raw === "string" && raw.trim()) {
264
+ const parsed = Date.parse(raw);
265
+ if (Number.isFinite(parsed))
266
+ return parsed;
267
+ }
268
+ return undefined;
269
+ }
270
+ async function collectSessionUsageDelta(params) {
271
+ const filePath = joinPath(params.storePath, `${params.sessionKey}.jsonl`);
272
+ let content;
273
+ try {
274
+ content = await readFile(filePath, "utf8");
275
+ }
276
+ catch {
277
+ return undefined;
278
+ }
279
+ const lines = content.split("\n");
280
+ if (lines.length === 0)
281
+ return undefined;
282
+ const untilMs = Number.isFinite(params.untilMs) ? params.untilMs : Date.now();
283
+ let promptTokens = 0;
284
+ let completionTokens = 0;
285
+ let cacheReadTokens = 0;
286
+ let cacheWriteTokens = 0;
287
+ let totalTokens = 0;
288
+ let matched = 0;
289
+ let provider;
290
+ let model;
291
+ for (const line of lines) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed)
294
+ continue;
295
+ let entry;
296
+ try {
297
+ const parsed = JSON.parse(trimmed);
298
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
299
+ continue;
300
+ entry = parsed;
301
+ }
302
+ catch {
303
+ continue;
304
+ }
305
+ if (entry.type !== "message")
306
+ continue;
307
+ const message = asRecord(entry.message);
308
+ if (!message || message.role !== "assistant")
309
+ continue;
310
+ const ts = toTimestampMs(entry.timestamp ?? message.timestamp);
311
+ if (typeof ts !== "number")
312
+ continue;
313
+ if (ts < params.sinceMs || ts > untilMs + 2_000)
314
+ continue;
315
+ const usage = asRecord(message.usage);
316
+ if (!usage)
317
+ continue;
318
+ matched += 1;
319
+ const input = toNumberOrZero(usage.input);
320
+ const output = toNumberOrZero(usage.output);
321
+ const cacheRead = toNumberOrZero(usage.cacheRead);
322
+ const cacheWrite = toNumberOrZero(usage.cacheWrite);
323
+ const total = toNumberOrZero(usage.totalTokens);
324
+ promptTokens += input;
325
+ completionTokens += output;
326
+ cacheReadTokens += cacheRead;
327
+ cacheWriteTokens += cacheWrite;
328
+ totalTokens += total > 0 ? total : (input + output + cacheRead + cacheWrite);
329
+ const msgProvider = typeof message.provider === "string" ? message.provider.trim() : "";
330
+ const msgModel = typeof message.model === "string" ? message.model.trim() : "";
331
+ if (msgProvider)
332
+ provider = msgProvider;
333
+ if (msgModel)
334
+ model = msgModel;
335
+ }
336
+ if (matched <= 0)
337
+ return undefined;
338
+ return {
339
+ promptTokens,
340
+ completionTokens,
341
+ cacheReadTokens,
342
+ cacheWriteTokens,
343
+ totalTokens,
344
+ source: "openclaw_session_usage_delta",
345
+ provider,
346
+ model,
347
+ };
348
+ }
349
+ let openClawQueueDepthReaderPromise = null;
350
+ async function loadOpenClawQueueDepthReader() {
351
+ if (openClawQueueDepthReaderPromise)
352
+ return openClawQueueDepthReaderPromise;
353
+ openClawQueueDepthReaderPromise = (async () => {
354
+ try {
355
+ const require = createRequire(import.meta.url);
356
+ const sdkEntry = require.resolve("openclaw/plugin-sdk");
357
+ const enqueuePath = joinPath(dirname(sdkEntry), "auto-reply/reply/queue/enqueue.js");
358
+ const mod = await import(pathToFileURL(enqueuePath).href);
359
+ if (typeof mod.getFollowupQueueDepth === "function") {
360
+ return mod.getFollowupQueueDepth;
361
+ }
362
+ }
363
+ catch {
364
+ // best-effort only
365
+ }
366
+ return null;
367
+ })();
368
+ return openClawQueueDepthReaderPromise;
369
+ }
370
+ async function resolveOpenClawQueueDepth(sessionKey) {
371
+ if (!sessionKey)
372
+ return undefined;
373
+ try {
374
+ const reader = await loadOpenClawQueueDepthReader();
375
+ if (!reader)
376
+ return undefined;
377
+ const raw = reader(sessionKey);
378
+ if (!Number.isFinite(raw))
379
+ return undefined;
380
+ return Math.max(0, Math.floor(raw));
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ }
386
+ async function resolveOpenClawQueueModeFromStore(storePath, sessionKey) {
387
+ if (!storePath || !sessionKey)
388
+ return undefined;
389
+ try {
390
+ const raw = await readFile(storePath, "utf8");
391
+ const parsed = JSON.parse(raw);
392
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
393
+ return undefined;
394
+ const entry = parsed[sessionKey];
395
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
396
+ return undefined;
397
+ const mode = entry.queueMode;
398
+ return typeof mode === "string" && mode.trim() ? mode.trim() : undefined;
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ }
404
+ function createTaskInferenceRuntimeHooks(params) {
405
+ const runtime = params.channelRuntime;
406
+ if (!runtime)
407
+ return undefined;
408
+ return {
409
+ getSessionRuntimeMetrics: async (request) => {
410
+ const topicId = request.topicId?.trim();
411
+ if (!topicId || topicId === "-")
412
+ return undefined;
413
+ const route = runtime.routing.resolveAgentRoute({
414
+ cfg: params.cfg,
415
+ channel: CHANNEL_ID,
416
+ accountId: params.accountId,
417
+ peer: { kind: "group", id: topicId },
418
+ });
419
+ const storePath = runtime.session.resolveStorePath(params.cfg.session?.store, {
420
+ agentId: route.agentId,
421
+ });
422
+ const [queueDepth, queueMode] = await Promise.all([
423
+ resolveOpenClawQueueDepth(route.sessionKey),
424
+ resolveOpenClawQueueModeFromStore(storePath, route.sessionKey),
425
+ ]);
426
+ return {
427
+ source: (typeof queueDepth === "number" || Boolean(queueMode)) ? "openclaw" : "fallback",
428
+ queueDepth,
429
+ queueMode,
430
+ sessionKey: route.sessionKey,
431
+ inflight: true,
432
+ };
433
+ },
434
+ dispatchTaskInference: async (request) => {
435
+ let topicId = request.task.topicId?.trim();
436
+ if (!topicId || topicId === "-") {
437
+ const token = params.account?.token?.trim();
438
+ const cloudUrl = params.account?.cloudUrl?.trim();
439
+ if (token && cloudUrl) {
440
+ try {
441
+ const resp = await fetch(`${cloudUrl.replace(/\/$/, "")}/tasks/${encodeURIComponent(request.taskId)}`, {
442
+ method: "GET",
443
+ headers: {
444
+ Accept: "application/json",
445
+ Authorization: `Bearer ${token}`,
446
+ "X-Agent-Token": token,
447
+ },
448
+ });
449
+ if (resp.ok) {
450
+ const detail = await resp.json();
451
+ const fromDetail = toOptionalString(detail.topic_id) ?? toOptionalString(detail.topicId);
452
+ if (fromDetail && fromDetail !== "-") {
453
+ topicId = fromDetail;
454
+ }
455
+ }
456
+ }
457
+ catch {
458
+ // keep fallback below
459
+ }
460
+ }
461
+ }
462
+ if (!topicId || topicId === "-") {
463
+ throw new Error(`task_topic_unresolved:${request.taskId}`);
464
+ }
465
+ const emitTypingSignal = async (state) => {
466
+ if (!params.typingSignal)
467
+ return;
468
+ try {
469
+ await params.typingSignal({ topicId, state, ttlMs: 6000 });
470
+ }
471
+ catch {
472
+ // best-effort only
473
+ }
474
+ };
475
+ const route = runtime.routing.resolveAgentRoute({
476
+ cfg: params.cfg,
477
+ channel: CHANNEL_ID,
478
+ accountId: params.accountId,
479
+ peer: { kind: "group", id: topicId },
480
+ });
481
+ const storePath = runtime.session.resolveStorePath(params.cfg.session?.store, {
482
+ agentId: route.agentId,
483
+ });
484
+ const previousTimestamp = runtime.session.readSessionUpdatedAt({
485
+ storePath,
486
+ sessionKey: route.sessionKey,
487
+ });
488
+ const runStartedMs = Date.now();
489
+ const timestamp = new Date().toISOString();
490
+ const from = `wtt:topic:${topicId}`;
491
+ const to = `topic:${topicId}`;
492
+ const chatType = "group";
493
+ const envelopeOptions = runtime.reply.resolveEnvelopeFormatOptions(params.cfg);
494
+ const body = runtime.reply.formatAgentEnvelope({
495
+ channel: "WTT",
496
+ from: "WTT Task Executor",
497
+ timestamp,
498
+ previousTimestamp,
499
+ envelope: envelopeOptions,
500
+ body: request.prompt,
501
+ });
502
+ const ctxPayload = runtime.reply.finalizeInboundContext({
503
+ Body: body,
504
+ BodyForAgent: request.prompt,
505
+ RawBody: request.prompt,
506
+ CommandBody: request.prompt,
507
+ From: from,
508
+ To: to,
509
+ SessionKey: route.sessionKey,
510
+ AccountId: route.accountId,
511
+ ChatType: chatType,
512
+ ConversationLabel: `topic:${topicId}`,
513
+ SenderName: "WTT Task Executor",
514
+ SenderId: params.account?.agentId || "wtt-task-executor",
515
+ GroupSubject: topicId,
516
+ Provider: CHANNEL_ID,
517
+ Surface: CHANNEL_ID,
518
+ MessageSid: `task-run:${request.taskId}:${Date.now()}`,
519
+ Timestamp: timestamp,
520
+ OriginatingChannel: CHANNEL_ID,
521
+ OriginatingTo: to,
522
+ });
523
+ await runtime.session.recordInboundSession({
524
+ storePath,
525
+ sessionKey: route.sessionKey,
526
+ ctx: ctxPayload,
527
+ updateLastRoute: {
528
+ sessionKey: route.sessionKey,
529
+ channel: CHANNEL_ID,
530
+ to,
531
+ accountId: route.accountId,
532
+ },
533
+ onRecordError: () => {
534
+ // keep inference path running even if session recording fails
535
+ },
536
+ });
537
+ const chunks = [];
538
+ let dispatchResult;
539
+ await emitTypingSignal("start");
540
+ try {
541
+ dispatchResult = await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
542
+ ctx: ctxPayload,
543
+ cfg: params.cfg,
544
+ dispatcherOptions: {
545
+ deliver: async (payload) => {
546
+ if (payload.isReasoning)
547
+ return;
548
+ const text = extractDispatchText(payload);
549
+ if (text)
550
+ chunks.push(text);
551
+ },
552
+ },
553
+ });
554
+ }
555
+ finally {
556
+ await emitTypingSignal("stop");
557
+ }
558
+ const fallback = extractDispatchText(dispatchResult);
559
+ const outputText = chunks.join("\n\n").trim() || fallback;
560
+ const usage = await collectSessionUsageDelta({
561
+ storePath,
562
+ sessionKey: route.sessionKey,
563
+ sinceMs: runStartedMs,
564
+ untilMs: Date.now(),
565
+ });
566
+ return {
567
+ outputText,
568
+ provider: "channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher",
569
+ usage,
570
+ raw: dispatchResult,
571
+ };
572
+ },
573
+ };
574
+ }
575
+ export async function processWTTCommandText(params) {
576
+ const accountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
577
+ const account = resolveCommandAccountContext(accountId, params.cfg);
578
+ return commandRouter.processText(params.text, {
579
+ accountId,
580
+ account,
581
+ runtimeHooks: createTaskInferenceRuntimeHooks({
582
+ cfg: params.cfg ?? {},
583
+ accountId,
584
+ account,
585
+ channelRuntime: params.channelRuntime,
586
+ }),
587
+ });
588
+ }
589
+ async function resolveAgentDisplayName(account) {
590
+ const token = account.token?.trim();
591
+ const agentId = account.agentId?.trim();
592
+ if (!token || !agentId)
593
+ return undefined;
594
+ const authHeaders = {
595
+ Accept: "application/json",
596
+ Authorization: `Bearer ${token}`,
597
+ "X-Agent-Token": token,
598
+ };
599
+ // Preferred endpoint: lists current user's bound agents including display_name.
600
+ try {
601
+ const myResp = await fetch(`${account.cloudUrl.replace(/\/$/, "")}/agents/my`, {
602
+ method: "GET",
603
+ headers: authHeaders,
604
+ });
605
+ if (myResp.ok) {
606
+ const payload = await myResp.json();
607
+ const rows = Array.isArray(payload)
608
+ ? payload
609
+ : (payload && typeof payload === "object" && Array.isArray(payload.agents))
610
+ ? payload.agents
611
+ : [];
612
+ for (const rowRaw of rows) {
613
+ if (!rowRaw || typeof rowRaw !== "object")
614
+ continue;
615
+ const row = rowRaw;
616
+ const rowId = typeof row.agent_id === "string" ? row.agent_id.trim() : "";
617
+ if (rowId !== agentId)
618
+ continue;
619
+ const display = typeof row.display_name === "string" ? row.display_name.trim() : "";
620
+ if (display)
621
+ return display;
622
+ }
623
+ }
624
+ }
625
+ catch {
626
+ // ignore and try fallback endpoint
627
+ }
628
+ // Fallback endpoint.
629
+ try {
630
+ const profileResp = await fetch(`${account.cloudUrl.replace(/\/$/, "")}/agents/${encodeURIComponent(agentId)}/profile`, {
631
+ method: "GET",
632
+ headers: authHeaders,
633
+ });
634
+ if (profileResp.ok) {
635
+ const profile = await profileResp.json();
636
+ const display = profile.display_name?.trim();
637
+ if (display)
638
+ return display;
639
+ }
640
+ }
641
+ catch {
642
+ // ignore
643
+ }
644
+ return undefined;
645
+ }
646
+ async function startWsAccount(accountId, account, api) {
647
+ const existing = clients.get(accountId);
648
+ if (existing) {
649
+ existing.disconnect();
650
+ clients.delete(accountId);
651
+ }
652
+ if (!account.enabled || !account.configured) {
653
+ api.log("info", `WTT account ${accountId} skipped (${!account.enabled ? "disabled" : "not configured"})`);
654
+ return undefined;
655
+ }
656
+ await ensureAccountE2EPassword(accountId, account, api.log);
657
+ const client = new WTTCloudClient({
658
+ account,
659
+ onMessage: (msg) => api.onMessage?.(accountId, msg),
660
+ onTaskStatus: (msg) => api.onTaskStatus?.(accountId, msg),
661
+ onConnect: () => api.log("info", `[${accountId}] connected`),
662
+ onDisconnect: () => api.log("info", `[${accountId}] disconnected`),
663
+ onError: (err) => api.log("error", `[${accountId}] ${err.message}`),
664
+ log: (level, msg, data) => api.log(level, `[${accountId}] ${msg}`, data),
665
+ });
666
+ clients.set(accountId, client);
667
+ await client.connect();
668
+ // Resolve display name from server (supports claimed+renamed agent names).
669
+ try {
670
+ const displayName = await resolveAgentDisplayName(account);
671
+ if (displayName && displayName !== account.agentId) {
672
+ account.name = displayName;
673
+ api.log("info", `[${accountId}] display name resolved: ${displayName}`);
674
+ }
675
+ }
676
+ catch {
677
+ // Non-critical — inference gating will still match agentId.
678
+ }
679
+ return client;
680
+ }
681
+ async function stopAccount(accountId) {
682
+ const client = clients.get(accountId);
683
+ if (!client)
684
+ return;
685
+ client.disconnect();
686
+ clients.delete(accountId);
687
+ }
688
+ function parseWttTarget(rawTarget) {
689
+ const target = (rawTarget || "").trim();
690
+ if (target.startsWith("topic:"))
691
+ return { mode: "topic", value: target.slice("topic:".length).trim() };
692
+ if (target.startsWith("p2p:"))
693
+ return { mode: "p2p", value: target.slice("p2p:".length).trim() };
694
+ if (target.startsWith("agent:"))
695
+ return { mode: "p2p", value: target.slice("agent:".length).trim() };
696
+ // default behavior: treat as topic id for compatibility with existing topic-based routing
697
+ return { mode: "topic", value: target };
698
+ }
699
+ async function sendText(params) {
700
+ const accountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
701
+ const client = getClient(accountId);
702
+ if (!client?.connected)
703
+ throw new Error(`WTT account ${accountId} is not connected`);
704
+ const target = parseWttTarget(params.to);
705
+ await runHooks("before", { tool: "sendText", args: { ...params, target } });
706
+ let response;
707
+ const account = client.getAccount();
708
+ const p2pEncryptEnabled = isP2PE2EEnabled(account) && client.hasE2EKey();
709
+ if (target.mode === "p2p") {
710
+ response = await client.p2p(target.value, params.text, p2pEncryptEnabled);
711
+ }
712
+ else {
713
+ const shouldEncryptTopic = p2pEncryptEnabled && isP2PTopicId(target.value);
714
+ response = await client.publish(target.value, params.text, { encrypt: shouldEncryptTopic });
715
+ }
716
+ await runHooks("after", { tool: "sendText", args: { ...params, target }, result: response });
717
+ return {
718
+ channel: "wtt",
719
+ messageId: String(response?.id ?? response?.message_id ?? response?.request_id ?? Date.now()),
720
+ conversationId: String(response?.topic_id ?? target.value),
721
+ meta: {
722
+ mode: target.mode,
723
+ raw: response ?? null,
724
+ },
725
+ };
726
+ }
727
+ async function sendMedia(params) {
728
+ const payload = JSON.stringify({
729
+ type: "media",
730
+ mediaUrl: params.mediaUrl ?? "",
731
+ caption: params.text ?? "",
732
+ });
733
+ return sendText({
734
+ to: params.to,
735
+ text: payload,
736
+ accountId: params.accountId,
737
+ cfg: params.cfg,
738
+ });
739
+ }
740
+ function waitForAbort(signal) {
741
+ if (signal.aborted)
742
+ return Promise.resolve();
743
+ return new Promise((resolve) => {
744
+ signal.addEventListener("abort", () => {
745
+ resolve();
746
+ }, { once: true });
747
+ });
748
+ }
749
+ function toIsoTimestamp(raw) {
750
+ if (typeof raw === "string" && raw.trim()) {
751
+ const d = new Date(raw);
752
+ if (!Number.isNaN(d.getTime()))
753
+ return d.toISOString();
754
+ return raw;
755
+ }
756
+ if (typeof raw === "number" && Number.isFinite(raw)) {
757
+ const d = new Date(raw);
758
+ if (!Number.isNaN(d.getTime()))
759
+ return d.toISOString();
760
+ }
761
+ return new Date().toISOString();
762
+ }
763
+ function toOptionalString(value) {
764
+ if (typeof value !== "string")
765
+ return undefined;
766
+ const trimmed = value.trim();
767
+ return trimmed ? trimmed : undefined;
768
+ }
769
+ function hasMeaningfulPipelineId(value) {
770
+ const normalized = String(value ?? "").trim().toLowerCase();
771
+ if (!normalized)
772
+ return false;
773
+ if (["default", "none", "null", "undefined", "n/a", "na", "-", "0"].includes(normalized)) {
774
+ return false;
775
+ }
776
+ return true;
777
+ }
778
+ function sanitizeInboundText(raw) {
779
+ let text = raw || "";
780
+ // Strip WTT source marker banner block if present.
781
+ text = text.replace(/┌─ 来源标识[\s\S]*?└[^\n]*\n?/g, "").trim();
782
+ return text;
783
+ }
784
+ function isMeaningfulUserText(text) {
785
+ const t = (text || "").trim();
786
+ if (!t)
787
+ return false;
788
+ // Drop visual-only box characters/separators.
789
+ const visualOnly = t.replace(/[\s│┌┐└┘─\-_=]+/g, "").trim();
790
+ if (!visualOnly)
791
+ return false;
792
+ // Drop status heartbeat style lines from execution stream.
793
+ if (/^\[\d{4}-\d{2}-\d{2}T[^\]]+\]\s*状态=/.test(t))
794
+ return false;
795
+ if (/^状态=.+\|\s*动作=.+\|\s*心跳=\d+s$/i.test(t))
796
+ return false;
797
+ return true;
798
+ }
799
+ function isSystemLikeInbound(raw, text) {
800
+ const semanticType = String(raw.semantic_type ?? "").toLowerCase();
801
+ const senderType = String(raw.sender_type ?? "").toLowerCase();
802
+ const contentType = String(raw.content_type ?? "").toLowerCase();
803
+ if (senderType === "system")
804
+ return true;
805
+ if (semanticType.startsWith("system") || semanticType === "task_progress")
806
+ return true;
807
+ if (["task_request", "task_run", "task_status", "task_summary", "task_blocked", "task_review", "notification"].includes(semanticType)) {
808
+ return true;
809
+ }
810
+ if (contentType === "system")
811
+ return true;
812
+ if (text.startsWith("[system:"))
813
+ return true;
814
+ return false;
815
+ }
816
+ function isTaskBootstrapPlaceholderText(text) {
817
+ const t = (text || "").trim().toLowerCase();
818
+ if (!t)
819
+ return true;
820
+ return t === "new task" || t === "新任务" || t === "(无描述)" || t === "无描述";
821
+ }
822
+ function isBlockedDiscussModelCommand(raw, text) {
823
+ const topicType = String(raw.topic_type ?? "").toLowerCase();
824
+ if (topicType !== "discussion")
825
+ return false;
826
+ // Task-linked discussion topics must keep model selection available.
827
+ const taskId = toOptionalString(raw.task_id);
828
+ const topicName = String(raw.topic_name ?? "");
829
+ const isTaskLinked = Boolean(taskId || topicName.startsWith("TASK-"));
830
+ if (isTaskLinked)
831
+ return false;
832
+ const normalized = (text || "").trim().toLowerCase();
833
+ return /^\/models?(\s|$)/.test(normalized);
834
+ }
835
+ function normalizeMentionToken(raw) {
836
+ const lowered = (raw || "")
837
+ .normalize("NFKC")
838
+ .trim()
839
+ .replace(/^@+/, "")
840
+ .toLowerCase();
841
+ return lowered.replace(/[^\p{L}\p{N}]+/gu, "");
842
+ }
843
+ function extractMentionHandles(content) {
844
+ const handles = new Set();
845
+ // Parse @mentions as agent-like handles using ASCII-safe token rules.
846
+ // Why:
847
+ // - Discuss topics often contain CJK text immediately after mention, e.g. "@lyz_agent看看"
848
+ // - Unicode-wide token regex may swallow trailing CJK and produce "lyzagent看看"
849
+ // - We need strict/precise handle extraction for routing gate.
850
+ const regex = /(^|[^A-Za-z0-9_])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})/g;
851
+ let match;
852
+ while ((match = regex.exec(content)) !== null) {
853
+ const token = normalizeMentionToken(match[2] || "");
854
+ if (token)
855
+ handles.add(token);
856
+ }
857
+ return Array.from(handles);
858
+ }
859
+ function buildAgentMentionAliases(agentId, agentName) {
860
+ const aliases = new Set();
861
+ const addAlias = (candidate) => {
862
+ if (!candidate)
863
+ return;
864
+ const normalized = normalizeMentionToken(candidate);
865
+ if (normalized)
866
+ aliases.add(normalized);
867
+ };
868
+ addAlias(agentId);
869
+ if (agentName) {
870
+ addAlias(agentName);
871
+ addAlias(agentName.replace(/\s+/g, "_"));
872
+ addAlias(agentName.replace(/\s+/g, "-"));
873
+ }
874
+ return aliases;
875
+ }
876
+ function resolveMentionMatch(content, agentId, agentName) {
877
+ const mentions = extractMentionHandles(content || "");
878
+ if (mentions.length === 0) {
879
+ return { hasMentions: false, matchesAgent: false };
880
+ }
881
+ const aliases = buildAgentMentionAliases(agentId, agentName);
882
+ const matchesAgent = mentions.some((mention) => aliases.has(mention));
883
+ return {
884
+ hasMentions: true,
885
+ matchesAgent,
886
+ };
887
+ }
888
+ function matchesAgentIdentity(candidate, agentId, agentName) {
889
+ const normalized = normalizeMentionToken(candidate ?? "");
890
+ if (!normalized)
891
+ return false;
892
+ const aliases = buildAgentMentionAliases(agentId, agentName);
893
+ return aliases.has(normalized);
894
+ }
895
+ function isStandaloneSlashCommandText(text) {
896
+ const trimmed = text.trim();
897
+ if (!trimmed.startsWith("/"))
898
+ return false;
899
+ if (trimmed.includes("\n"))
900
+ return false;
901
+ return /^\/[a-z][a-z0-9_:-]*(?:\s+[\s\S]*)?$/i.test(trimmed);
902
+ }
903
+ function normalizeSlashForWttCommandRouter(text, account) {
904
+ const trimmed = text.trim();
905
+ if (!trimmed.startsWith("/"))
906
+ return text;
907
+ const prefixOnly = account.config.slashCompatWttPrefixOnly ?? DEFAULT_SLASH_COMPAT_WTT_PREFIX_ONLY;
908
+ if (prefixOnly)
909
+ return text;
910
+ const aliasMap = {
911
+ task: "task",
912
+ pipeline: "pipeline",
913
+ pipe: "pipeline",
914
+ delegate: "delegate",
915
+ list: "list",
916
+ find: "find",
917
+ join: "join",
918
+ leave: "leave",
919
+ publish: "publish",
920
+ poll: "poll",
921
+ history: "history",
922
+ p2p: "p2p",
923
+ detail: "detail",
924
+ subscribed: "subscribed",
925
+ config: "config",
926
+ cfg: "config",
927
+ bind: "bind",
928
+ whoami: "whoami",
929
+ help: "help",
930
+ };
931
+ const match = trimmed.match(/^\/([a-z][a-z0-9_:-]*)(?:\s+([\s\S]*))?$/i);
932
+ if (!match)
933
+ return text;
934
+ const verb = match[1].toLowerCase();
935
+ if (verb === "wtt")
936
+ return text;
937
+ const mapped = aliasMap[verb];
938
+ if (!mapped)
939
+ return text;
940
+ const args = (match[2] ?? "").trim();
941
+ return args ? `/wtt ${mapped} ${args}` : `/wtt ${mapped}`;
942
+ }
943
+ /**
944
+ * Inference gating: decide if this inbound message should trigger LLM reasoning.
945
+ *
946
+ * Rules:
947
+ * - Broadcast topics never trigger.
948
+ * - Task-linked topics do not require @mention; runner_agent_id/name is used when available.
949
+ * - Discussion topics require explicit @mention for this agent.
950
+ * - P2P topics never require @mention.
951
+ */
952
+ function shouldTriggerInference(raw, agentId, agentName) {
953
+ const topicType = String(raw.topic_type ?? "").toLowerCase();
954
+ // Broadcast topics — never auto-infer.
955
+ if (topicType === "broadcast") {
956
+ return { trigger: false, reason: "broadcast_no_infer" };
957
+ }
958
+ const taskId = toOptionalString(raw.task_id);
959
+ const topicName = String(raw.topic_name ?? "");
960
+ const isTaskLinked = Boolean(taskId || topicName.startsWith("TASK-"));
961
+ if (isTaskLinked) {
962
+ const senderType = String(raw.sender_type ?? "").toLowerCase();
963
+ if (senderType && senderType !== "human" && senderType !== "user") {
964
+ return { trigger: false, reason: "task_non_human_sender" };
965
+ }
966
+ const runnerAgentId = toOptionalString(raw.runner_agent_id) ?? toOptionalString(raw.runnerAgentId);
967
+ const runnerAgentName = toOptionalString(raw.runner_agent_name) ?? toOptionalString(raw.runnerAgentName);
968
+ if (runnerAgentId || runnerAgentName) {
969
+ if (matchesAgentIdentity(runnerAgentId, agentId, agentName)
970
+ || matchesAgentIdentity(runnerAgentName, agentId, agentName)) {
971
+ return { trigger: true, reason: "task_linked_runner_match" };
972
+ }
973
+ return { trigger: false, reason: "task_runner_mismatch" };
974
+ }
975
+ return { trigger: true, reason: "task_linked" };
976
+ }
977
+ // Only discuss topics require explicit mention.
978
+ // Fallback: accept backend-resolved runner targeting for renamed agent names.
979
+ if (topicType === "discussion") {
980
+ const content = String(raw.content ?? "");
981
+ const mentionMatch = resolveMentionMatch(content, agentId, agentName);
982
+ if (mentionMatch.matchesAgent) {
983
+ return { trigger: true };
984
+ }
985
+ const runnerAgentId = toOptionalString(raw.runner_agent_id) ?? toOptionalString(raw.runnerAgentId);
986
+ const runnerAgentName = toOptionalString(raw.runner_agent_name) ?? toOptionalString(raw.runnerAgentName);
987
+ if (matchesAgentIdentity(runnerAgentId, agentId, agentName)
988
+ || matchesAgentIdentity(runnerAgentName, agentId, agentName)) {
989
+ return { trigger: true, reason: "discussion_runner_match" };
990
+ }
991
+ return { trigger: false, reason: "discussion_no_mention" };
992
+ }
993
+ // p2p / collaborative / unknown — infer by default.
994
+ return { trigger: true };
995
+ }
996
+ function toPositiveInt(raw, fallback) {
997
+ const n = Number(raw);
998
+ if (!Number.isFinite(n) || n <= 0)
999
+ return fallback;
1000
+ return Math.floor(n);
1001
+ }
1002
+ function asRecord(value) {
1003
+ if (!value || typeof value !== "object")
1004
+ return undefined;
1005
+ return value;
1006
+ }
1007
+ function parseInboundMetadata(raw) {
1008
+ const direct = raw.metadata;
1009
+ if (direct && typeof direct === "object")
1010
+ return direct;
1011
+ if (typeof direct === "string") {
1012
+ try {
1013
+ const parsed = JSON.parse(direct);
1014
+ return parsed && typeof parsed === "object" ? parsed : undefined;
1015
+ }
1016
+ catch {
1017
+ return undefined;
1018
+ }
1019
+ }
1020
+ return undefined;
1021
+ }
1022
+ function coerceWsNewMessage(raw) {
1023
+ const record = asRecord(raw);
1024
+ if (!record)
1025
+ return undefined;
1026
+ const wrapped = asRecord(record.message);
1027
+ if (record.type === "new_message" && wrapped) {
1028
+ return {
1029
+ type: "new_message",
1030
+ message: wrapped,
1031
+ };
1032
+ }
1033
+ if (wrapped && toOptionalString(wrapped.id)) {
1034
+ return {
1035
+ type: "new_message",
1036
+ message: wrapped,
1037
+ };
1038
+ }
1039
+ if (toOptionalString(record.id) && resolveInboundTopicId(record) && toOptionalString(record.sender_id)) {
1040
+ return {
1041
+ type: "new_message",
1042
+ message: record,
1043
+ };
1044
+ }
1045
+ return undefined;
1046
+ }
1047
+ export function extractPolledInboundMessages(raw) {
1048
+ let candidates = [];
1049
+ if (Array.isArray(raw)) {
1050
+ candidates = raw;
1051
+ }
1052
+ else {
1053
+ const record = asRecord(raw);
1054
+ const nested = asRecord(record?.data);
1055
+ if (Array.isArray(record?.messages))
1056
+ candidates = record.messages;
1057
+ else if (Array.isArray(record?.data))
1058
+ candidates = record.data;
1059
+ else if (Array.isArray(nested?.messages))
1060
+ candidates = nested.messages;
1061
+ else if (Array.isArray(nested?.items))
1062
+ candidates = nested.items;
1063
+ }
1064
+ const parsed = [];
1065
+ for (const item of candidates) {
1066
+ const msg = coerceWsNewMessage(item);
1067
+ if (msg)
1068
+ parsed.push(msg);
1069
+ }
1070
+ return parsed;
1071
+ }
1072
+ function resolveInboundTopicId(msg) {
1073
+ const direct = toOptionalString(msg.topic_id) ?? toOptionalString(msg.topicId) ?? toOptionalString(msg.p2p_topic_id);
1074
+ if (direct)
1075
+ return direct;
1076
+ const nestedTopic = asRecord(msg.topic);
1077
+ const nestedId = nestedTopic ? toOptionalString(nestedTopic.id) : undefined;
1078
+ if (nestedId)
1079
+ return nestedId;
1080
+ return "";
1081
+ }
1082
+ function resolveInboundDedupKey(msg) {
1083
+ const payload = msg.message;
1084
+ const directId = toOptionalString(payload.id);
1085
+ if (directId)
1086
+ return directId;
1087
+ const topicId = resolveInboundTopicId(payload) || "no-topic";
1088
+ const senderId = toOptionalString(payload.sender_id) ?? "unknown";
1089
+ const createdAt = toOptionalString(payload.created_at) ?? "";
1090
+ const content = String(payload.content ?? "").slice(0, 96);
1091
+ return `${topicId}:${senderId}:${createdAt}:${content}`;
1092
+ }
1093
+ function isLikelyP2PMessage(msg) {
1094
+ const topicType = String(msg.topic_type ?? "").toLowerCase();
1095
+ if (topicType === "p2p")
1096
+ return true;
1097
+ const topicId = resolveInboundTopicId(msg);
1098
+ // Legacy p2p push payloads only contain id/topic_id/sender_id/content/created_at/encrypted.
1099
+ // If type hints are absent and we still have a topic+sender, treat as p2p.
1100
+ if (!topicType && topicId && msg.sender_id && !msg.content_type && !msg.semantic_type && !msg.sender_type) {
1101
+ return true;
1102
+ }
1103
+ // Current deployment only enables encryption on P2P; if we observe encrypted payload
1104
+ // without explicit topic_type (e.g. poll fallback), treat as P2P for routing/encryption cache.
1105
+ if (!topicType && topicId && Boolean(msg.encrypted)) {
1106
+ return true;
1107
+ }
1108
+ return false;
1109
+ }
1110
+ export function normalizeInboundWsMessage(params) {
1111
+ const raw = params.msg.message;
1112
+ const senderId = String(raw.sender_id || "unknown");
1113
+ const topicId = resolveInboundTopicId(raw);
1114
+ const senderName = toOptionalString(raw.sender_display_name);
1115
+ const topicName = toOptionalString(raw.topic_name);
1116
+ const content = sanitizeInboundText(params.decryptedContent ?? String(raw.content ?? ""));
1117
+ const messageId = toOptionalString(raw.id) ?? `${topicId || "no-topic"}:${senderId}:${Date.now()}`;
1118
+ const timestamp = toIsoTimestamp(raw.created_at);
1119
+ const isP2P = isLikelyP2PMessage(raw);
1120
+ const hasTopicId = Boolean(topicId);
1121
+ const chatType = isP2P ? "direct" : "group";
1122
+ const routePeerId = isP2P
1123
+ ? (hasTopicId ? topicId : senderId)
1124
+ : topicId || senderId;
1125
+ // Always publish to the specific topic_id so responses land in the correct
1126
+ // P2P topic (e.g. worker topics vs default P2P between the same two parties).
1127
+ const to = hasTopicId ? `topic:${topicId}` : `p2p:${senderId}`;
1128
+ const from = `wtt:${senderId}`;
1129
+ const conversationLabel = isP2P
1130
+ ? `p2p:${topicName ?? senderName ?? (hasTopicId ? topicId : senderId)}`
1131
+ : `topic:${topicName ?? (topicId || "unknown")}`;
1132
+ return {
1133
+ text: content,
1134
+ senderId,
1135
+ senderName,
1136
+ topicId,
1137
+ topicName,
1138
+ messageId,
1139
+ timestamp,
1140
+ chatType,
1141
+ routePeerId,
1142
+ to,
1143
+ from,
1144
+ conversationLabel,
1145
+ };
1146
+ }
1147
+ export function createInboundMessageRelay(params) {
1148
+ const dedupWindowMs = toPositiveInt(params.dedupWindowMs, DEFAULT_INBOUND_DEDUP_WINDOW_MS);
1149
+ const dedupMaxEntries = toPositiveInt(params.dedupMaxEntries, DEFAULT_INBOUND_DEDUP_MAX_ENTRIES);
1150
+ const dedupSeenAt = new Map();
1151
+ const stats = {
1152
+ pushReceivedCount: 0,
1153
+ pollFetchedCount: 0,
1154
+ routedCount: 0,
1155
+ dedupDroppedCount: 0,
1156
+ };
1157
+ const pruneDedup = (nowMs) => {
1158
+ const ttlCutoff = nowMs - dedupWindowMs;
1159
+ for (const [id, seenAt] of dedupSeenAt) {
1160
+ if (seenAt >= ttlCutoff)
1161
+ break;
1162
+ dedupSeenAt.delete(id);
1163
+ }
1164
+ while (dedupSeenAt.size > dedupMaxEntries) {
1165
+ const oldest = dedupSeenAt.keys().next().value;
1166
+ if (!oldest)
1167
+ break;
1168
+ dedupSeenAt.delete(oldest);
1169
+ }
1170
+ };
1171
+ const isDuplicate = (msg) => {
1172
+ const key = resolveInboundDedupKey(msg);
1173
+ const nowMs = Date.now();
1174
+ pruneDedup(nowMs);
1175
+ const seenAt = dedupSeenAt.get(key);
1176
+ if (typeof seenAt === "number" && nowMs - seenAt <= dedupWindowMs) {
1177
+ return true;
1178
+ }
1179
+ dedupSeenAt.set(key, nowMs);
1180
+ return false;
1181
+ };
1182
+ const routeOne = async (msg, source) => {
1183
+ if (isDuplicate(msg)) {
1184
+ stats.dedupDroppedCount += 1;
1185
+ params.log?.("debug", `[${params.accountId}] inbound dedup dropped source=${source} dedup_dropped=${stats.dedupDroppedCount}`);
1186
+ return { routed: false };
1187
+ }
1188
+ const result = await routeInboundWsMessage({
1189
+ cfg: params.cfg,
1190
+ accountId: params.accountId,
1191
+ account: params.getLatestAccount?.() ?? params.account,
1192
+ msg,
1193
+ channelRuntime: params.channelRuntime,
1194
+ decryptContent: params.decryptContent,
1195
+ deliver: params.deliver,
1196
+ typingSignal: params.typingSignal,
1197
+ log: params.log,
1198
+ });
1199
+ if (result.routed)
1200
+ stats.routedCount += 1;
1201
+ params.log?.("debug", `[${params.accountId}] inbound counters push_received=${stats.pushReceivedCount} poll_fetched=${stats.pollFetchedCount} routed=${stats.routedCount} dedup_dropped=${stats.dedupDroppedCount}`);
1202
+ return result;
1203
+ };
1204
+ return {
1205
+ stats,
1206
+ async handlePush(msg) {
1207
+ stats.pushReceivedCount += 1;
1208
+ return routeOne(msg, "push");
1209
+ },
1210
+ async handlePollResult(rawPollResult) {
1211
+ const messages = extractPolledInboundMessages(rawPollResult);
1212
+ stats.pollFetchedCount += messages.length;
1213
+ const beforeRouted = stats.routedCount;
1214
+ const beforeDedup = stats.dedupDroppedCount;
1215
+ for (const msg of messages) {
1216
+ await routeOne(msg, "poll");
1217
+ }
1218
+ const routed = stats.routedCount - beforeRouted;
1219
+ const dedupDropped = stats.dedupDroppedCount - beforeDedup;
1220
+ params.log?.("info", `[${params.accountId}] inbound poll fetched=${messages.length} routed=${routed} dedup_dropped=${dedupDropped} totals(push=${stats.pushReceivedCount},poll=${stats.pollFetchedCount},routed=${stats.routedCount},dedup=${stats.dedupDroppedCount})`);
1221
+ return {
1222
+ fetched: messages.length,
1223
+ routed,
1224
+ dedupDropped,
1225
+ };
1226
+ },
1227
+ };
1228
+ }
1229
+ async function deliverReplyPayload(params) {
1230
+ const text = typeof params.payload.text === "string" ? params.payload.text : "";
1231
+ const isReasoning = Boolean(params.payload.isReasoning);
1232
+ if (isReasoning)
1233
+ return;
1234
+ const mediaUrls = Array.isArray(params.payload.mediaUrls)
1235
+ ? params.payload.mediaUrls.filter((item) => typeof item === "string" && item.trim().length > 0)
1236
+ : [];
1237
+ const mediaUrl = typeof params.payload.mediaUrl === "string" ? params.payload.mediaUrl.trim() : "";
1238
+ if (mediaUrl)
1239
+ mediaUrls.push(mediaUrl);
1240
+ if (mediaUrls.length > 0) {
1241
+ let first = true;
1242
+ for (const media of mediaUrls) {
1243
+ await sendMedia({
1244
+ to: params.to,
1245
+ text: first ? text : "",
1246
+ mediaUrl: media,
1247
+ accountId: params.accountId,
1248
+ cfg: params.cfg,
1249
+ });
1250
+ first = false;
1251
+ }
1252
+ return;
1253
+ }
1254
+ if (!text)
1255
+ return;
1256
+ await sendText({
1257
+ to: params.to,
1258
+ text,
1259
+ accountId: params.accountId,
1260
+ cfg: params.cfg,
1261
+ });
1262
+ }
1263
+ export async function routeInboundWsMessage(params) {
1264
+ const runtime = params.channelRuntime;
1265
+ if (!runtime) {
1266
+ params.log?.("warn", `[${params.accountId}] channelRuntime unavailable; inbound message skipped`);
1267
+ return { routed: false, reason: "runtime_unavailable" };
1268
+ }
1269
+ const rawContent = String(params.msg.message.content ?? "");
1270
+ let decryptedContent = rawContent;
1271
+ if (params.msg.message.encrypted && params.decryptContent) {
1272
+ try {
1273
+ decryptedContent = await params.decryptContent(rawContent);
1274
+ }
1275
+ catch (err) {
1276
+ params.log?.("warn", `[${params.accountId}] Failed to decrypt inbound content, using raw`, err);
1277
+ decryptedContent = rawContent;
1278
+ }
1279
+ }
1280
+ const normalized = normalizeInboundWsMessage({
1281
+ msg: params.msg,
1282
+ decryptedContent,
1283
+ });
1284
+ const rawMsg = params.msg.message;
1285
+ const inboundTaskId = toOptionalString(rawMsg.task_id);
1286
+ const inferredTopicType = String(rawMsg.topic_type ?? "").toLowerCase();
1287
+ if (normalized.topicId) {
1288
+ if (inferredTopicType) {
1289
+ rememberTopicType(normalized.topicId, inferredTopicType);
1290
+ }
1291
+ else if (isLikelyP2PMessage(rawMsg)) {
1292
+ rememberTopicType(normalized.topicId, "p2p");
1293
+ }
1294
+ }
1295
+ const typingTopicId = normalized.topicId?.trim() || "";
1296
+ const emitTypingSignal = async (state) => {
1297
+ if (!typingTopicId || !params.typingSignal)
1298
+ return;
1299
+ try {
1300
+ await params.typingSignal({ topicId: typingTopicId, state, ttlMs: 6000 });
1301
+ }
1302
+ catch (err) {
1303
+ params.log?.("debug", `[${params.accountId}] typing signal ${state} failed topic=${typingTopicId}`, err);
1304
+ }
1305
+ };
1306
+ const patchTaskStatus = async (taskId, status, notes) => {
1307
+ if (!params.account.token)
1308
+ return false;
1309
+ try {
1310
+ const body = {
1311
+ status,
1312
+ runner_agent_id: params.account.agentId || undefined,
1313
+ };
1314
+ if (notes && notes.trim()) {
1315
+ body.notes = notes.trim();
1316
+ }
1317
+ const resp = await fetch(`${params.account.cloudUrl.replace(/\/$/, "")}/tasks/${encodeURIComponent(taskId)}`, {
1318
+ method: "PATCH",
1319
+ headers: {
1320
+ Accept: "application/json",
1321
+ "Content-Type": "application/json",
1322
+ Authorization: `Bearer ${params.account.token}`,
1323
+ "X-Agent-Token": params.account.token,
1324
+ },
1325
+ body: JSON.stringify(body),
1326
+ });
1327
+ return resp.ok;
1328
+ }
1329
+ catch {
1330
+ return false;
1331
+ }
1332
+ };
1333
+ if (!isMeaningfulUserText(normalized.text)) {
1334
+ // Ignore empty/visual-only/status payloads to avoid creating empty task requests.
1335
+ return { routed: false, reason: "empty_message" };
1336
+ }
1337
+ if (isSystemLikeInbound(rawMsg, normalized.text)) {
1338
+ // Ignore transport/system chatter in user-facing conversation flow.
1339
+ return { routed: false, reason: "system_message" };
1340
+ }
1341
+ if (normalized.senderId === params.account.agentId) {
1342
+ // Ignore own echo to avoid self-reply loops.
1343
+ return { routed: false, reason: "self_echo" };
1344
+ }
1345
+ if (inboundTaskId && isTaskBootstrapPlaceholderText(normalized.text)) {
1346
+ // Ignore placeholder bootstrap text in task topics (e.g. default "New Task").
1347
+ return { routed: false, reason: "empty_message" };
1348
+ }
1349
+ if (isBlockedDiscussModelCommand(rawMsg, normalized.text)) {
1350
+ // In discussion topics, block model switching commands to avoid all agents
1351
+ // responding to the same slash command.
1352
+ return { routed: false, reason: "system_message" };
1353
+ }
1354
+ const slashCompatEnabled = params.account.config.slashCompat ?? DEFAULT_SLASH_COMPAT_ENABLED;
1355
+ const slashBypassMentionGate = params.account.config.slashBypassMentionGate ?? DEFAULT_SLASH_BYPASS_MENTION_GATE;
1356
+ const topicType = String(rawMsg.topic_type ?? "").toLowerCase();
1357
+ const topicName = String(rawMsg.topic_name ?? "");
1358
+ const isTaskLinkedTopic = Boolean(inboundTaskId || topicName.startsWith("TASK-"));
1359
+ const isSlashLike = /^\/\S+/.test((normalized.text || "").trim());
1360
+ const inboundMetadata = parseInboundMetadata(rawMsg);
1361
+ const mentionTargetedDiscussion = topicType === "discussion"
1362
+ && resolveMentionMatch(String(rawMsg.content ?? ""), params.account.agentId, params.account.name).matchesAgent;
1363
+ if (slashCompatEnabled) {
1364
+ if (topicType === "discussion" && !isTaskLinkedTopic && isSlashLike) {
1365
+ const targetAgentId = toOptionalString(inboundMetadata?.command_target_agent_id)
1366
+ ?? toOptionalString(inboundMetadata?.commandTargetAgentId);
1367
+ // For non-task discuss slash commands: execute only on explicitly targeted agent.
1368
+ if (!targetAgentId || !matchesAgentIdentity(targetAgentId, params.account.agentId, params.account.name)) {
1369
+ return { routed: false, reason: "agent_no_mention" };
1370
+ }
1371
+ }
1372
+ const wttCommandText = normalizeSlashForWttCommandRouter(normalized.text, params.account);
1373
+ const commandResult = await processWTTCommandText({
1374
+ text: wttCommandText,
1375
+ accountId: params.accountId,
1376
+ cfg: params.cfg,
1377
+ channelRuntime: runtime,
1378
+ });
1379
+ if (commandResult.handled) {
1380
+ const responseText = (commandResult.response ?? "").trim();
1381
+ if (responseText) {
1382
+ const payload = { text: responseText };
1383
+ if (params.deliver) {
1384
+ await params.deliver({
1385
+ to: normalized.to,
1386
+ payload,
1387
+ });
1388
+ }
1389
+ else {
1390
+ await deliverReplyPayload({
1391
+ to: normalized.to,
1392
+ payload,
1393
+ accountId: params.accountId,
1394
+ cfg: params.cfg,
1395
+ });
1396
+ }
1397
+ }
1398
+ params.log?.("info", `[${params.accountId}] command_router handled command=${commandResult.command ?? "unknown"}`);
1399
+ return { routed: true };
1400
+ }
1401
+ }
1402
+ const standaloneSlash = isStandaloneSlashCommandText(normalized.text);
1403
+ const bypassInferenceGate = slashCompatEnabled && slashBypassMentionGate && standaloneSlash;
1404
+ let inferDecision;
1405
+ // Inference gating: skip messages that shouldn't trigger reasoning
1406
+ if (!bypassInferenceGate) {
1407
+ inferDecision = shouldTriggerInference(rawMsg, params.account.agentId, params.account.name);
1408
+ params.log?.("info", `[${params.accountId}] inference_gate topic_type=${String(rawMsg.topic_type)} task_id=${String(rawMsg.task_id ?? "")} trigger=${inferDecision.trigger} reason=${inferDecision.reason ?? "ok"} content_preview=${String(rawMsg.content ?? "").slice(0, 60)} agentName=${params.account.name}`);
1409
+ if (!inferDecision.trigger) {
1410
+ return { routed: false, reason: inferDecision.reason };
1411
+ }
1412
+ }
1413
+ else {
1414
+ params.log?.("info", `[${params.accountId}] inference_gate bypassed reason=slash_command topic_type=${String(rawMsg.topic_type)}`);
1415
+ }
1416
+ const route = runtime.routing.resolveAgentRoute({
1417
+ cfg: params.cfg,
1418
+ channel: CHANNEL_ID,
1419
+ accountId: params.accountId,
1420
+ peer: {
1421
+ kind: normalized.chatType,
1422
+ id: normalized.routePeerId,
1423
+ },
1424
+ });
1425
+ const storePath = runtime.session.resolveStorePath(params.cfg.session?.store, {
1426
+ agentId: route.agentId,
1427
+ });
1428
+ const previousTimestamp = runtime.session.readSessionUpdatedAt({
1429
+ storePath,
1430
+ sessionKey: route.sessionKey,
1431
+ });
1432
+ const envelopeOptions = runtime.reply.resolveEnvelopeFormatOptions(params.cfg);
1433
+ const fromLabel = normalized.chatType === "direct"
1434
+ ? normalized.senderName ?? normalized.senderId
1435
+ : `${normalized.senderName ?? normalized.senderId} @ ${normalized.topicName ?? (normalized.topicId || "topic")}`;
1436
+ const body = runtime.reply.formatAgentEnvelope({
1437
+ channel: "WTT",
1438
+ from: fromLabel,
1439
+ timestamp: normalized.timestamp,
1440
+ previousTimestamp,
1441
+ envelope: envelopeOptions,
1442
+ body: normalized.text,
1443
+ });
1444
+ const taskTitleCandidate = toOptionalString(rawMsg.task_title)
1445
+ ?? toOptionalString(rawMsg.taskTitle)
1446
+ ?? toOptionalString(rawMsg.title)
1447
+ ?? (normalized.topicName && !normalized.topicName.startsWith("TASK-") ? normalized.topicName : undefined);
1448
+ const mentionDirective = (topicType === "discussion" && mentionTargetedDiscussion)
1449
+ ? "注意:这是讨论话题中明确 @ 你的消息,必须直接回应用户问题,禁止输出 NO_REPLY。\n\n"
1450
+ : "";
1451
+ const bodyForAgent = inboundTaskId && taskTitleCandidate
1452
+ ? `${mentionDirective}任务标题: ${taskTitleCandidate}\n\n用户消息: ${normalized.text}`
1453
+ : `${mentionDirective}${normalized.text}`;
1454
+ const ctxPayload = runtime.reply.finalizeInboundContext({
1455
+ // Keep Body plain so downstream task/title extraction uses user text directly.
1456
+ Body: normalized.text,
1457
+ BodyForAgent: bodyForAgent,
1458
+ RawBody: normalized.text,
1459
+ CommandBody: normalized.text,
1460
+ EnvelopeBody: body,
1461
+ From: normalized.from,
1462
+ To: normalized.to,
1463
+ SessionKey: route.sessionKey,
1464
+ AccountId: route.accountId,
1465
+ ChatType: normalized.chatType,
1466
+ ConversationLabel: normalized.conversationLabel,
1467
+ SenderName: normalized.senderName,
1468
+ SenderId: normalized.senderId,
1469
+ GroupSubject: normalized.chatType === "group" ? normalized.topicName ?? normalized.topicId : undefined,
1470
+ Provider: CHANNEL_ID,
1471
+ Surface: CHANNEL_ID,
1472
+ MessageSid: normalized.messageId,
1473
+ Timestamp: normalized.timestamp,
1474
+ OriginatingChannel: CHANNEL_ID,
1475
+ OriginatingTo: normalized.to,
1476
+ });
1477
+ const taskExecutorScope = (params.account.config.taskExecutorScope ?? "all").toLowerCase();
1478
+ let naturalBridgeTaskStatus = "";
1479
+ let naturalBridgeEnabled = false;
1480
+ let naturalBridgeDoingAtMs = null;
1481
+ if (inboundTaskId && taskExecutorScope === "pipeline_only" && params.account.token) {
1482
+ try {
1483
+ const detailResp = await fetch(`${params.account.cloudUrl.replace(/\/$/, "")}/tasks/${encodeURIComponent(inboundTaskId)}`, {
1484
+ method: "GET",
1485
+ headers: {
1486
+ Accept: "application/json",
1487
+ Authorization: `Bearer ${params.account.token}`,
1488
+ "X-Agent-Token": params.account.token,
1489
+ },
1490
+ });
1491
+ if (detailResp.ok) {
1492
+ const detailPayload = await detailResp.json();
1493
+ const liveTaskType = String(detailPayload.task_type ?? detailPayload.taskType ?? "").trim().toLowerCase();
1494
+ const liveStatus = String(detailPayload.status ?? "").trim().toLowerCase();
1495
+ if (liveTaskType && liveTaskType !== "pipeline") {
1496
+ naturalBridgeEnabled = true;
1497
+ naturalBridgeTaskStatus = ["todo", "doing", "review", "done", "blocked"].includes(liveStatus)
1498
+ ? liveStatus
1499
+ : "";
1500
+ // Delay todo->doing transition until we confirm meaningful output was delivered.
1501
+ }
1502
+ }
1503
+ }
1504
+ catch (err) {
1505
+ params.log?.("warn", `[${params.accountId}] natural task status bridge pre-dispatch failed task=${inboundTaskId}`, err);
1506
+ }
1507
+ }
1508
+ if (naturalBridgeEnabled && inboundTaskId && naturalBridgeTaskStatus === "todo" && isMeaningfulUserText(normalized.text)) {
1509
+ const movedDoing = await patchTaskStatus(inboundTaskId, "doing");
1510
+ if (movedDoing) {
1511
+ naturalBridgeTaskStatus = "doing";
1512
+ naturalBridgeDoingAtMs = Date.now();
1513
+ }
1514
+ }
1515
+ await runtime.session.recordInboundSession({
1516
+ storePath,
1517
+ sessionKey: route.sessionKey,
1518
+ ctx: ctxPayload,
1519
+ updateLastRoute: {
1520
+ sessionKey: route.sessionKey,
1521
+ channel: CHANNEL_ID,
1522
+ to: normalized.to,
1523
+ accountId: route.accountId,
1524
+ },
1525
+ onRecordError: (err) => {
1526
+ params.log?.("warn", `[${params.accountId}] Failed to record inbound session`, err);
1527
+ },
1528
+ });
1529
+ let dispatchProducedOutput = false;
1530
+ await emitTypingSignal("start");
1531
+ try {
1532
+ await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
1533
+ ctx: ctxPayload,
1534
+ cfg: params.cfg,
1535
+ dispatcherOptions: {
1536
+ deliver: async (payload) => {
1537
+ const isReasoning = Boolean(payload?.isReasoning);
1538
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
1539
+ const mediaUrl = typeof payload?.mediaUrl === "string" ? payload.mediaUrl.trim() : "";
1540
+ const mediaUrls = Array.isArray(payload?.mediaUrls)
1541
+ ? payload.mediaUrls.filter((item) => typeof item === "string" && item.trim().length > 0)
1542
+ : [];
1543
+ if (!isReasoning && (text.length > 0 || mediaUrl.length > 0 || mediaUrls.length > 0)) {
1544
+ dispatchProducedOutput = true;
1545
+ }
1546
+ if (params.deliver) {
1547
+ await params.deliver({
1548
+ to: normalized.to,
1549
+ payload,
1550
+ });
1551
+ return;
1552
+ }
1553
+ await deliverReplyPayload({
1554
+ to: normalized.to,
1555
+ payload,
1556
+ accountId: params.accountId,
1557
+ cfg: params.cfg,
1558
+ });
1559
+ },
1560
+ onError: (err, info) => {
1561
+ params.log?.("error", `[${params.accountId}] WTT inbound dispatch failed (${String(info?.kind ?? "unknown")})`, err);
1562
+ },
1563
+ },
1564
+ });
1565
+ }
1566
+ finally {
1567
+ await emitTypingSignal("stop");
1568
+ }
1569
+ const shouldForceMentionAck = topicType === "discussion"
1570
+ && Boolean(inferDecision?.trigger)
1571
+ && (mentionTargetedDiscussion || inferDecision?.reason === "discussion_runner_match");
1572
+ if (!dispatchProducedOutput && shouldForceMentionAck) {
1573
+ params.log?.("warn", `[${params.accountId}] discussion mention produced no visible output (model empty/NO_REPLY)`);
1574
+ }
1575
+ if (naturalBridgeEnabled && inboundTaskId) {
1576
+ const terminal = new Set(["review", "done", "cancelled", "blocked"]);
1577
+ if (!terminal.has(naturalBridgeTaskStatus)) {
1578
+ if (!dispatchProducedOutput) {
1579
+ params.log?.("info", `[${params.accountId}] natural task status bridge skipped (no output) task=${inboundTaskId}`);
1580
+ }
1581
+ else {
1582
+ if (naturalBridgeTaskStatus === "doing") {
1583
+ if (typeof naturalBridgeDoingAtMs === "number") {
1584
+ const elapsedMs = Date.now() - naturalBridgeDoingAtMs;
1585
+ const waitMs = Math.max(0, DEFAULT_NATURAL_BRIDGE_MIN_DOING_MS - elapsedMs);
1586
+ if (waitMs > 0) {
1587
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1588
+ }
1589
+ }
1590
+ const movedReview = await patchTaskStatus(inboundTaskId, "review");
1591
+ if (!movedReview) {
1592
+ params.log?.("warn", `[${params.accountId}] natural task status bridge post-dispatch failed task=${inboundTaskId}`);
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ }
1598
+ return { routed: true };
1599
+ }
1600
+ function parseRecoveryTaskCandidates(raw) {
1601
+ const payload = asRecord(raw);
1602
+ const dataCandidate = asRecord(payload?.data);
1603
+ const source = Array.isArray(raw)
1604
+ ? raw
1605
+ : Array.isArray(payload?.tasks)
1606
+ ? payload.tasks
1607
+ : Array.isArray(payload?.items)
1608
+ ? payload.items
1609
+ : Array.isArray(dataCandidate?.tasks)
1610
+ ? dataCandidate.tasks
1611
+ : Array.isArray(dataCandidate?.items)
1612
+ ? dataCandidate.items
1613
+ : [];
1614
+ const parsed = [];
1615
+ for (const item of source) {
1616
+ const record = asRecord(item);
1617
+ if (!record)
1618
+ continue;
1619
+ const id = toOptionalString(record.id);
1620
+ const status = toOptionalString(record.status);
1621
+ if (!id || !status)
1622
+ continue;
1623
+ parsed.push({
1624
+ id,
1625
+ status: status.toLowerCase(),
1626
+ runnerAgentId: toOptionalString(record.runner_agent_id),
1627
+ ownerAgentId: toOptionalString(record.owner_agent_id),
1628
+ updatedAt: toOptionalString(record.updated_at) ?? toOptionalString(record.created_at),
1629
+ });
1630
+ }
1631
+ return parsed;
1632
+ }
1633
+ function parseIsoMs(input) {
1634
+ if (!input)
1635
+ return undefined;
1636
+ const t = Date.parse(input);
1637
+ return Number.isFinite(t) ? t : undefined;
1638
+ }
1639
+ async function startGatewayAccount(ctx) {
1640
+ const log = (level, msg, data) => {
1641
+ if (level === "debug")
1642
+ ctx.log?.debug?.(msg);
1643
+ else if (level === "info")
1644
+ ctx.log?.info?.(msg);
1645
+ else if (level === "warn")
1646
+ ctx.log?.warn?.(msg);
1647
+ else
1648
+ ctx.log?.error?.(data ? `${msg} ${String(data)}` : msg);
1649
+ };
1650
+ let activeClient;
1651
+ let pollTimer;
1652
+ let recoveryTimer;
1653
+ let pollInFlight = false;
1654
+ let recoveryInFlight = false;
1655
+ const pollIntervalMs = toPositiveInt(ctx.account.config.inboundPollIntervalMs, DEFAULT_INBOUND_POLL_INTERVAL_MS);
1656
+ const pollLimit = toPositiveInt(ctx.account.config.inboundPollLimit, DEFAULT_INBOUND_POLL_LIMIT);
1657
+ const recoveryIntervalMs = DEFAULT_TASK_RECOVERY_INTERVAL_MS;
1658
+ const recoveryLookbackMs = DEFAULT_TASK_RECOVERY_LOOKBACK_MS;
1659
+ const recoverySeenAt = new Map();
1660
+ const todoRecheckTimers = new Map();
1661
+ const inboundRelay = createInboundMessageRelay({
1662
+ cfg: ctx.cfg,
1663
+ accountId: ctx.accountId,
1664
+ account: ctx.account,
1665
+ getLatestAccount: () => {
1666
+ const client = getClient(ctx.accountId);
1667
+ return client ? client.getAccount() : ctx.account;
1668
+ },
1669
+ channelRuntime: ctx.channelRuntime,
1670
+ decryptContent: async (content) => {
1671
+ const runtimeClient = getClient(ctx.accountId);
1672
+ if (!runtimeClient)
1673
+ return content;
1674
+ return runtimeClient.decryptMessage(content);
1675
+ },
1676
+ typingSignal: async ({ topicId, state, ttlMs }) => {
1677
+ const runtimeClient = getClient(ctx.accountId);
1678
+ if (!runtimeClient || !runtimeClient.connected)
1679
+ return;
1680
+ await runtimeClient.typing(topicId, state, ttlMs ?? 6000);
1681
+ },
1682
+ log,
1683
+ dedupWindowMs: ctx.account.config.inboundDedupWindowMs,
1684
+ dedupMaxEntries: ctx.account.config.inboundDedupMaxEntries,
1685
+ });
1686
+ const scheduleTodoRecheck = (taskId, delayMs = 5000) => {
1687
+ if (todoRecheckTimers.has(taskId))
1688
+ return;
1689
+ const timer = setTimeout(async () => {
1690
+ todoRecheckTimers.delete(taskId);
1691
+ try {
1692
+ if (ctx.abortSignal.aborted || !ctx.channelRuntime)
1693
+ return;
1694
+ const accountContext = resolveCommandAccountContext(ctx.accountId, ctx.cfg);
1695
+ const account = normalizeAccountContext(ctx.accountId, accountContext);
1696
+ if (!account.hasToken || !account.agentId)
1697
+ return;
1698
+ const detailResp = await fetch(`${account.cloudUrl.replace(/\/$/, "")}/tasks/${encodeURIComponent(taskId)}`, {
1699
+ method: "GET",
1700
+ headers: {
1701
+ Accept: "application/json",
1702
+ Authorization: `Bearer ${account.token}`,
1703
+ "X-Agent-Token": account.token,
1704
+ },
1705
+ });
1706
+ if (!detailResp.ok)
1707
+ return;
1708
+ const detailPayload = await detailResp.json();
1709
+ const liveStatus = String(detailPayload.status ?? "").trim().toLowerCase();
1710
+ const TERMINAL_STATUSES = new Set(["review", "done", "cancelled", "blocked"]);
1711
+ if (TERMINAL_STATUSES.has(liveStatus))
1712
+ return;
1713
+ const taskExecutorScope = (ctx.account.config.taskExecutorScope ?? "all").toLowerCase();
1714
+ if (taskExecutorScope === "pipeline_only") {
1715
+ const liveTaskType = String(detailPayload.task_type ?? detailPayload.taskType ?? "").trim().toLowerCase();
1716
+ const liveExecMode = String(detailPayload.exec_mode ?? detailPayload.execMode ?? "").trim().toLowerCase();
1717
+ const liveTaskMode = String(detailPayload.task_mode ?? detailPayload.taskMode ?? "").trim().toLowerCase();
1718
+ const livePipelineId = detailPayload.pipeline_id ?? detailPayload.pipelineId ?? "";
1719
+ const isPipelineTask = [liveTaskType, liveExecMode, liveTaskMode].includes("pipeline") || hasMeaningfulPipelineId(livePipelineId);
1720
+ if (!isPipelineTask) {
1721
+ log("info", `[${ctx.accountId}] task_status todo recheck skipped non-pipeline task=${taskId}`);
1722
+ return;
1723
+ }
1724
+ }
1725
+ const runtimeHooks = createTaskInferenceRuntimeHooks({
1726
+ cfg: ctx.cfg,
1727
+ accountId: ctx.accountId,
1728
+ account: accountContext,
1729
+ channelRuntime: ctx.channelRuntime,
1730
+ });
1731
+ const runResult = await executeTaskRunById({
1732
+ taskId,
1733
+ ctx: {
1734
+ accountId: ctx.accountId,
1735
+ account,
1736
+ client: activeClient,
1737
+ clientConnected: Boolean(activeClient?.connected),
1738
+ fetchImpl: fetch,
1739
+ runtimeHooks,
1740
+ },
1741
+ account,
1742
+ note: "triggered by task_status todo recheck",
1743
+ heartbeatSeconds: 60,
1744
+ publishHeartbeatToStream: true,
1745
+ });
1746
+ const enqueue = runResult.enqueueResult;
1747
+ const detail = enqueue.deduplicated
1748
+ ? `idempotency=${enqueue.idempotency.decision} duplicate_state=${enqueue.idempotency.duplicateState ?? "-"}`
1749
+ : `idempotency=${enqueue.idempotency.decision} final_status=${enqueue.finalStatus} transition=${enqueue.transitionApplied}`;
1750
+ log("info", `[${ctx.accountId}] task_status todo recheck dispatched task=${taskId} ${detail}`);
1751
+ }
1752
+ catch (err) {
1753
+ log("warn", `[${ctx.accountId}] task_status todo recheck failed task=${taskId}`, err);
1754
+ }
1755
+ }, delayMs);
1756
+ todoRecheckTimers.set(taskId, timer);
1757
+ };
1758
+ const taskStatusHandler = createTaskStatusEventHandler({
1759
+ runTask: async ({ taskId, status, event }) => {
1760
+ if (!ctx.channelRuntime) {
1761
+ throw new Error("channel_runtime_unavailable");
1762
+ }
1763
+ const accountContext = resolveCommandAccountContext(ctx.accountId, ctx.cfg);
1764
+ const account = normalizeAccountContext(ctx.accountId, accountContext);
1765
+ if (!account.hasToken) {
1766
+ throw new Error("missing_account_token");
1767
+ }
1768
+ const taskExecutorScope = (ctx.account.config.taskExecutorScope ?? "all").toLowerCase();
1769
+ let liveTaskType = "";
1770
+ let liveExecMode = "";
1771
+ let liveTaskMode = "";
1772
+ let livePipelineId = "";
1773
+ // Guard: verify live task status before dispatching run from WS task_status events.
1774
+ // - todo: actionable if still todo/doing; terminal statuses are skipped.
1775
+ // - doing: only actionable when live status is still doing (avoid stale doing replay after review/done).
1776
+ if (status === "todo" || status === "doing") {
1777
+ const TERMINAL_STATUSES = new Set(["review", "done", "cancelled", "blocked"]);
1778
+ try {
1779
+ const detailResp = await fetch(`${account.cloudUrl.replace(/\/$/, "")}/tasks/${encodeURIComponent(taskId)}`, {
1780
+ method: "GET",
1781
+ headers: {
1782
+ Accept: "application/json",
1783
+ Authorization: `Bearer ${account.token}`,
1784
+ "X-Agent-Token": account.token,
1785
+ },
1786
+ });
1787
+ if (!detailResp.ok) {
1788
+ if (status === "todo") {
1789
+ scheduleTodoRecheck(taskId);
1790
+ return {
1791
+ deduplicated: true,
1792
+ detail: `skip_todo_detail_fetch_failed:http_${detailResp.status}`,
1793
+ };
1794
+ }
1795
+ return {
1796
+ deduplicated: true,
1797
+ detail: `skip_doing_detail_fetch_failed:http_${detailResp.status}`,
1798
+ };
1799
+ }
1800
+ const detailPayload = await detailResp.json();
1801
+ liveTaskType = String(detailPayload.task_type ?? detailPayload.taskType ?? "").trim().toLowerCase();
1802
+ liveExecMode = String(detailPayload.exec_mode ?? detailPayload.execMode ?? "").trim().toLowerCase();
1803
+ liveTaskMode = String(detailPayload.task_mode ?? detailPayload.taskMode ?? "").trim().toLowerCase();
1804
+ livePipelineId = String(detailPayload.pipeline_id ?? detailPayload.pipelineId ?? "").trim();
1805
+ const liveStatus = String(detailPayload.status ?? "").trim().toLowerCase();
1806
+ if (TERMINAL_STATUSES.has(liveStatus)) {
1807
+ // Pipeline task events can briefly replay stale blocked before converging to todo/doing.
1808
+ // For TODO events, schedule a delayed recheck to avoid one-shot deadlocks.
1809
+ if (status === "todo") {
1810
+ scheduleTodoRecheck(taskId, 6000);
1811
+ return {
1812
+ deduplicated: true,
1813
+ detail: `skip_${status}_live_status_terminal:${liveStatus}_recheck_scheduled`,
1814
+ };
1815
+ }
1816
+ return {
1817
+ deduplicated: true,
1818
+ detail: `skip_${status}_live_status_terminal:${liveStatus}`,
1819
+ };
1820
+ }
1821
+ if (status === "doing" && liveStatus !== "doing") {
1822
+ return {
1823
+ deduplicated: true,
1824
+ detail: `skip_doing_live_status_mismatch:${liveStatus || "unknown"}`,
1825
+ };
1826
+ }
1827
+ }
1828
+ catch (err) {
1829
+ // On fetch error, still proceed — executor has its own idempotency guards.
1830
+ const msg = err instanceof Error ? err.message : String(err);
1831
+ log("warn", `[${ctx.accountId}] task_status ${status} detail fetch error task=${taskId}: ${msg}`);
1832
+ }
1833
+ }
1834
+ if (taskExecutorScope === "pipeline_only") {
1835
+ const eventTaskType = String(event.task_type ?? "").trim().toLowerCase();
1836
+ const eventExecMode = String(event.exec_mode ?? "").trim().toLowerCase();
1837
+ const pipelineHints = [liveTaskType, liveExecMode, liveTaskMode, eventTaskType, eventExecMode];
1838
+ const isPipelineTask = pipelineHints.includes("pipeline") || hasMeaningfulPipelineId(livePipelineId);
1839
+ if (!isPipelineTask) {
1840
+ const hint = (pipelineHints.find(Boolean) || "unknown");
1841
+ return {
1842
+ deduplicated: true,
1843
+ detail: `skip_non_pipeline_task_type:${hint}`,
1844
+ };
1845
+ }
1846
+ }
1847
+ const runtimeHooks = createTaskInferenceRuntimeHooks({
1848
+ cfg: ctx.cfg,
1849
+ accountId: ctx.accountId,
1850
+ account: accountContext,
1851
+ channelRuntime: ctx.channelRuntime,
1852
+ typingSignal: async ({ topicId, state, ttlMs }) => {
1853
+ if (!activeClient?.connected)
1854
+ return;
1855
+ await activeClient.typing(topicId, state, ttlMs ?? 6000);
1856
+ },
1857
+ });
1858
+ if (!runtimeHooks?.dispatchTaskInference) {
1859
+ throw new Error("runtime_hook_unavailable");
1860
+ }
1861
+ if (!activeClient?.connected) {
1862
+ throw new Error("wtt_client_disconnected");
1863
+ }
1864
+ const runResult = await executeTaskRunById({
1865
+ taskId,
1866
+ ctx: {
1867
+ accountId: ctx.accountId,
1868
+ account: accountContext,
1869
+ clientConnected: activeClient.connected,
1870
+ client: activeClient,
1871
+ runtimeHooks,
1872
+ },
1873
+ account,
1874
+ note: `triggered by task_status (${status})`,
1875
+ heartbeatSeconds: 60,
1876
+ publishHeartbeatToStream: true,
1877
+ });
1878
+ const enqueue = runResult.enqueueResult;
1879
+ const detail = enqueue.deduplicated
1880
+ ? `idempotency=${enqueue.idempotency.decision} duplicate_state=${enqueue.idempotency.duplicateState ?? "-"}`
1881
+ : `idempotency=${enqueue.idempotency.decision} final_status=${enqueue.finalStatus} transition=${enqueue.transitionApplied}`;
1882
+ return {
1883
+ deduplicated: enqueue.deduplicated,
1884
+ detail,
1885
+ };
1886
+ },
1887
+ });
1888
+ const runPollCatchup = async () => {
1889
+ if (ctx.abortSignal.aborted || pollInFlight)
1890
+ return;
1891
+ if (!activeClient?.connected)
1892
+ return;
1893
+ pollInFlight = true;
1894
+ try {
1895
+ const raw = await activeClient.poll(pollLimit);
1896
+ await inboundRelay.handlePollResult(raw);
1897
+ }
1898
+ catch (err) {
1899
+ log("warn", `[${ctx.accountId}] inbound poll catch-up failed`, err);
1900
+ }
1901
+ finally {
1902
+ pollInFlight = false;
1903
+ }
1904
+ };
1905
+ const runTaskRecoverySweep = async () => {
1906
+ if (ctx.abortSignal.aborted || recoveryInFlight)
1907
+ return;
1908
+ if (!activeClient?.connected)
1909
+ return;
1910
+ if (!ctx.channelRuntime)
1911
+ return;
1912
+ const accountContext = resolveCommandAccountContext(ctx.accountId, ctx.cfg);
1913
+ const account = normalizeAccountContext(ctx.accountId, accountContext);
1914
+ if (!account.hasToken || !account.agentId)
1915
+ return;
1916
+ recoveryInFlight = true;
1917
+ try {
1918
+ const endpoint = `${account.cloudUrl.replace(/\/$/, "")}/tasks`;
1919
+ const response = await fetch(endpoint, {
1920
+ method: "GET",
1921
+ headers: {
1922
+ Accept: "application/json",
1923
+ Authorization: `Bearer ${account.token}`,
1924
+ "X-Agent-Token": account.token,
1925
+ },
1926
+ });
1927
+ if (!response.ok) {
1928
+ throw new Error(`tasks list failed: HTTP ${response.status}`);
1929
+ }
1930
+ const payload = await response.json();
1931
+ const tasks = parseRecoveryTaskCandidates(payload);
1932
+ const nowMs = Date.now();
1933
+ const cutoffMs = nowMs - recoveryLookbackMs;
1934
+ for (const [key, seenAt] of recoverySeenAt) {
1935
+ if (seenAt < cutoffMs)
1936
+ recoverySeenAt.delete(key);
1937
+ }
1938
+ let scanned = 0;
1939
+ let triggered = 0;
1940
+ for (const task of tasks) {
1941
+ if (task.status !== "doing")
1942
+ continue;
1943
+ if (task.runnerAgentId !== account.agentId)
1944
+ continue;
1945
+ const updatedAtMs = parseIsoMs(task.updatedAt);
1946
+ if (typeof updatedAtMs === "number" && updatedAtMs < cutoffMs)
1947
+ continue;
1948
+ scanned += 1;
1949
+ const dedupKey = `${task.id}:${task.status}:${task.updatedAt ?? ""}`;
1950
+ if (recoverySeenAt.has(dedupKey))
1951
+ continue;
1952
+ recoverySeenAt.set(dedupKey, nowMs);
1953
+ triggered += 1;
1954
+ const consume = await taskStatusHandler.handle({
1955
+ type: "task_status",
1956
+ task_id: task.id,
1957
+ status: "doing",
1958
+ });
1959
+ const dedupSource = consume.dedupSource ? ` dedup_source=${consume.dedupSource}` : "";
1960
+ const dispatchDetail = consume.dispatch?.detail ? ` detail=${consume.dispatch.detail}` : "";
1961
+ log("info", `[${ctx.accountId}] recovery task_status consume decision=${consume.decision} task=${consume.taskId || task.id} status=doing reason=${consume.reason}${dedupSource}${dispatchDetail}`);
1962
+ }
1963
+ if (scanned > 0 || triggered > 0) {
1964
+ log("info", `[${ctx.accountId}] recovery sweep scanned=${scanned} triggered=${triggered}`);
1965
+ }
1966
+ }
1967
+ catch (err) {
1968
+ log("warn", `[${ctx.accountId}] task recovery sweep failed`, err);
1969
+ }
1970
+ finally {
1971
+ recoveryInFlight = false;
1972
+ }
1973
+ };
1974
+ try {
1975
+ activeClient = await startWsAccount(ctx.accountId, ctx.account, {
1976
+ log,
1977
+ onMessage: (_accountId, msg) => {
1978
+ void inboundRelay.handlePush(msg).catch((err) => {
1979
+ log("error", `[${ctx.accountId}] inbound routing error`, err);
1980
+ });
1981
+ },
1982
+ onTaskStatus: (_accountId, status) => {
1983
+ void taskStatusHandler.handle(status)
1984
+ .then((consume) => {
1985
+ const dedupSource = consume.dedupSource ? ` dedup_source=${consume.dedupSource}` : "";
1986
+ const dispatchDetail = consume.dispatch?.detail ? ` detail=${consume.dispatch.detail}` : "";
1987
+ log("info", `[${ctx.accountId}] task_status consume decision=${consume.decision} task=${consume.taskId || "-"} status=${consume.status} reason=${consume.reason}${dedupSource}${dispatchDetail}`);
1988
+ })
1989
+ .catch((err) => {
1990
+ log("error", `[${ctx.accountId}] task_status handler failed`, err);
1991
+ });
1992
+ },
1993
+ });
1994
+ if (pollIntervalMs > 0) {
1995
+ log("info", `[${ctx.accountId}] inbound poll catch-up enabled interval=${pollIntervalMs}ms limit=${pollLimit}`);
1996
+ void runPollCatchup();
1997
+ pollTimer = setInterval(() => {
1998
+ void runPollCatchup();
1999
+ }, pollIntervalMs);
2000
+ }
2001
+ if (recoveryIntervalMs > 0) {
2002
+ log("info", `[${ctx.accountId}] task recovery sweep enabled interval=${recoveryIntervalMs}ms lookback_ms=${recoveryLookbackMs}`);
2003
+ void runTaskRecoverySweep();
2004
+ recoveryTimer = setInterval(() => {
2005
+ void runTaskRecoverySweep();
2006
+ }, recoveryIntervalMs);
2007
+ }
2008
+ await waitForAbort(ctx.abortSignal);
2009
+ }
2010
+ finally {
2011
+ if (pollTimer) {
2012
+ clearInterval(pollTimer);
2013
+ pollTimer = undefined;
2014
+ }
2015
+ if (recoveryTimer) {
2016
+ clearInterval(recoveryTimer);
2017
+ recoveryTimer = undefined;
2018
+ }
2019
+ for (const timer of todoRecheckTimers.values()) {
2020
+ clearTimeout(timer);
2021
+ }
2022
+ todoRecheckTimers.clear();
2023
+ log("info", `[${ctx.accountId}] inbound summary push_received=${inboundRelay.stats.pushReceivedCount} poll_fetched=${inboundRelay.stats.pollFetchedCount} routed=${inboundRelay.stats.routedCount} dedup_dropped=${inboundRelay.stats.dedupDroppedCount}`);
2024
+ await stopAccount(ctx.accountId);
2025
+ }
2026
+ }
2027
+ const A2UI_MESSAGE_TOOL_HINTS = [
2028
+ "WTT supports action fenced code blocks for interactive UI.",
2029
+ "Use ```action JSON blocks with kinds: buttons/confirm/select/input.",
2030
+ "Prefer compact button choices over long numbered lists.",
2031
+ ].join("\n");
2032
+ export const wttPlugin = {
2033
+ id: "wtt",
2034
+ meta: {
2035
+ id: "wtt",
2036
+ label: "WTT",
2037
+ selectionLabel: "WTT (WebSocket)",
2038
+ docsPath: "/channels/wtt",
2039
+ docsLabel: "wtt",
2040
+ blurb: "WTT real-time topic + p2p channel.",
2041
+ aliases: ["want-to-talk"],
2042
+ order: 95,
2043
+ },
2044
+ capabilities: {
2045
+ chatTypes: ["direct", "group", "thread"],
2046
+ threads: true,
2047
+ media: true,
2048
+ },
2049
+ reload: {
2050
+ configPrefixes: ["channels.wtt"],
2051
+ },
2052
+ config: {
2053
+ listAccountIds,
2054
+ resolveAccount,
2055
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
2056
+ isConfigured: (account) => account.configured,
2057
+ describeAccount: (account) => ({
2058
+ accountId: account.accountId,
2059
+ name: account.name,
2060
+ enabled: account.enabled,
2061
+ configured: account.configured,
2062
+ cloudUrl: account.cloudUrl,
2063
+ }),
2064
+ },
2065
+ gateway: {
2066
+ startAccount: startGatewayAccount,
2067
+ stopAccount: async (ctx) => {
2068
+ await stopAccount(ctx.accountId);
2069
+ },
2070
+ },
2071
+ outbound: {
2072
+ deliveryMode: "direct",
2073
+ textChunkLimit: 4000,
2074
+ resolveTarget: ({ to }) => to,
2075
+ sendText,
2076
+ sendMedia,
2077
+ },
2078
+ agentPrompt: {
2079
+ messageToolHints: () => [A2UI_MESSAGE_TOOL_HINTS],
2080
+ },
2081
+ hooks: {
2082
+ register: registerHook,
2083
+ runBefore: (ctx) => runHooks("before", ctx),
2084
+ runAfter: (ctx) => runHooks("after", ctx),
2085
+ },
2086
+ getClient,
2087
+ };
2088
+ //# sourceMappingURL=channel.js.map