@amaster.ai/pi-channels 0.1.2-beta.2 → 0.1.2-beta.21

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 (59) hide show
  1. package/README.md +77 -26
  2. package/dist/adapters/dingtalk.d.ts +5 -0
  3. package/dist/adapters/dingtalk.d.ts.map +1 -0
  4. package/dist/adapters/dingtalk.js +269 -0
  5. package/dist/adapters/dingtalk.js.map +1 -0
  6. package/dist/adapters/feishu.d.ts.map +1 -1
  7. package/dist/adapters/feishu.js +122 -5
  8. package/dist/adapters/feishu.js.map +1 -1
  9. package/dist/adapters/wecom/adapter.d.ts +3 -1
  10. package/dist/adapters/wecom/adapter.d.ts.map +1 -1
  11. package/dist/adapters/wecom/adapter.js +194 -157
  12. package/dist/adapters/wecom/adapter.js.map +1 -1
  13. package/dist/adapters/wecom.d.ts +0 -2
  14. package/dist/adapters/wecom.d.ts.map +1 -1
  15. package/dist/adapters/wecom.js +0 -1
  16. package/dist/adapters/wecom.js.map +1 -1
  17. package/dist/bridge-provider.d.ts +3 -0
  18. package/dist/bridge-provider.d.ts.map +1 -0
  19. package/dist/bridge-provider.js +79 -0
  20. package/dist/bridge-provider.js.map +1 -0
  21. package/dist/bridge.d.ts +1 -0
  22. package/dist/bridge.d.ts.map +1 -1
  23. package/dist/bridge.js +316 -8
  24. package/dist/bridge.js.map +1 -1
  25. package/dist/config.d.ts +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +94 -18
  28. package/dist/config.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +557 -25
  31. package/dist/index.js.map +1 -1
  32. package/dist/registry.d.ts +12 -2
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +93 -22
  35. package/dist/registry.js.map +1 -1
  36. package/dist/types.d.ts +38 -12
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +17 -3
  39. package/preview.png +0 -0
  40. package/dist/adapters/wecom/client.d.ts +0 -13
  41. package/dist/adapters/wecom/client.d.ts.map +0 -1
  42. package/dist/adapters/wecom/client.js +0 -116
  43. package/dist/adapters/wecom/client.js.map +0 -1
  44. package/dist/adapters/wecom/crypto.d.ts +0 -14
  45. package/dist/adapters/wecom/crypto.d.ts.map +0 -1
  46. package/dist/adapters/wecom/crypto.js +0 -37
  47. package/dist/adapters/wecom/crypto.js.map +0 -1
  48. package/dist/adapters/wecom/errors.d.ts +0 -25
  49. package/dist/adapters/wecom/errors.d.ts.map +0 -1
  50. package/dist/adapters/wecom/errors.js +0 -56
  51. package/dist/adapters/wecom/errors.js.map +0 -1
  52. package/dist/adapters/wecom/types.d.ts +0 -52
  53. package/dist/adapters/wecom/types.d.ts.map +0 -1
  54. package/dist/adapters/wecom/types.js +0 -2
  55. package/dist/adapters/wecom/types.js.map +0 -1
  56. package/dist/adapters/wecom/xml.d.ts +0 -4
  57. package/dist/adapters/wecom/xml.d.ts.map +0 -1
  58. package/dist/adapters/wecom/xml.js +0 -16
  59. package/dist/adapters/wecom/xml.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,14 +1,44 @@
1
1
  import { Type } from 'typebox';
2
2
  import { ChatBridge } from './bridge.js';
3
- import { loadChannelConfig } from './config.js';
3
+ import { loadChannelConfig, updateLocalChannelConfig } from './config.js';
4
4
  import { ChannelRegistry } from './registry.js';
5
+ const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
6
+ const RECONNECT_STABLE_RESET_MS = 60_000;
5
7
  function enumSchema(values, description) {
6
8
  return Type.Union(values.map((value) => Type.Literal(value)), { description });
7
9
  }
8
10
  export default function piChannelsExtension(pi) {
9
11
  const registry = new ChannelRegistry();
10
12
  let bridge = null;
13
+ let sessionCwd = process.cwd();
14
+ let currentCtx = null;
15
+ let configFingerprint = '';
16
+ let configReloading = false;
17
+ let connectedAt;
18
+ let lastError;
19
+ let adapterStates = {};
20
+ let reconnectAttempts = 0;
21
+ let reconnectTimer;
22
+ let reconnectResetTimer;
23
+ const captureWaiters = new Set();
11
24
  const log = (event, data, level = 'INFO') => {
25
+ if (event === 'wecom-server-disconnected' || event === 'wecom-client-error') {
26
+ const error = typeof data?.error === 'string' ? data.error : event;
27
+ const adapter = typeof data?.adapter === 'string' ? data.adapter : undefined;
28
+ lastError = error;
29
+ connectedAt = undefined;
30
+ if (adapter) {
31
+ adapterStates = {
32
+ ...adapterStates,
33
+ [adapter]: {
34
+ state: 'error',
35
+ updatedAt: new Date().toISOString(),
36
+ error,
37
+ },
38
+ };
39
+ }
40
+ scheduleReconnect(event, error);
41
+ }
12
42
  if (level === 'ERROR')
13
43
  console.error('[pi-channels]', event, data ?? {});
14
44
  else
@@ -17,32 +47,110 @@ export default function piChannelsExtension(pi) {
17
47
  registry.setLogger(log);
18
48
  registry.setOnIncoming(async (message) => {
19
49
  pi.events.emit('channel:receive', message);
50
+ const captured = notifyCaptureWaiters(message);
51
+ autoFillEmptyRouteRecipient(sessionCwd, message, log);
52
+ if (captured)
53
+ return;
54
+ const turn = bridge?.isActive() ? channelIncomingTurn(message) : undefined;
55
+ if (turn)
56
+ pi.events.emit('channel:turn', turn);
20
57
  if (bridge?.isActive())
21
58
  await bridge.handleMessage(message);
22
59
  });
60
+ async function applyChannelConfig(ctx, reason, force = false) {
61
+ if (configReloading)
62
+ return registry.getErrors();
63
+ configReloading = true;
64
+ try {
65
+ sessionCwd = ctx.cwd;
66
+ const config = loadChannelConfig(ctx.cwd);
67
+ const nextFingerprint = JSON.stringify(config);
68
+ if (!force && nextFingerprint === configFingerprint && !lastError)
69
+ return registry.getErrors();
70
+ configFingerprint = nextFingerprint;
71
+ adapterStates = Object.fromEntries(Object.keys(config.adapters ?? {}).map((adapter) => [
72
+ adapter,
73
+ {
74
+ state: 'connecting',
75
+ updatedAt: new Date().toISOString(),
76
+ },
77
+ ]));
78
+ bridge?.stop();
79
+ bridge = null;
80
+ await registry.loadConfig(config, ctx.cwd);
81
+ log(reason === 'session_start' ? 'session_start' : 'config_reload', {
82
+ reason,
83
+ cwd: ctx.cwd,
84
+ adapters: Object.keys(config.adapters ?? {}),
85
+ routes: Object.keys(config.routes ?? {}),
86
+ registry: registry.list(),
87
+ });
88
+ bridge = new ChatBridge(config.bridge, ctx.cwd, registry);
89
+ if (config.bridge?.enabled) {
90
+ await registry.startListening();
91
+ bridge.start();
92
+ }
93
+ const errors = registry.getErrors();
94
+ lastError = errors.map((item) => `${item.adapter}: ${item.error}`).join('; ') || undefined;
95
+ connectedAt = errors.length === 0 ? new Date().toISOString() : undefined;
96
+ adapterStates = adapterStatesForRegistry(config, errors, connectedAt);
97
+ if (errors.length === 0)
98
+ scheduleReconnectAttemptReset();
99
+ for (const error of errors) {
100
+ ctx.ui.notify(`pi-channels: ${error.adapter}: ${error.error}`, 'warning');
101
+ }
102
+ ctx.ui.setStatus?.('pi-channels', `channels: ${registry.list().filter((item) => item.type === 'adapter').length}`);
103
+ return errors;
104
+ }
105
+ catch (error) {
106
+ lastError = error instanceof Error ? error.message : String(error);
107
+ connectedAt = undefined;
108
+ throw error;
109
+ }
110
+ finally {
111
+ configReloading = false;
112
+ }
113
+ }
23
114
  pi.on('session_start', async (_event, ctx) => {
24
- const config = loadChannelConfig(ctx.cwd);
25
- await registry.loadConfig(config, ctx.cwd);
26
- await registry.startListening();
27
- bridge = new ChatBridge(config.bridge, ctx.cwd, registry);
28
- if (config.bridge?.enabled)
29
- bridge.start();
30
- const errors = registry.getErrors();
31
- for (const error of errors) {
32
- ctx.ui.notify(`pi-channels: ${error.adapter}: ${error.error}`, 'warning');
115
+ if (sessionAutostartDisabled()) {
116
+ sessionCwd = ctx.cwd;
117
+ log('session_start_skipped', {
118
+ reason: 'PI_CHANNELS_DISABLE_SESSION_AUTOSTART',
119
+ cwd: ctx.cwd,
120
+ });
121
+ return;
33
122
  }
34
- ctx.ui.setStatus?.('pi-channels', `channels: ${registry.list().filter((item) => item.type === 'adapter').length}`);
123
+ currentCtx = ctx;
124
+ sessionCwd = ctx.cwd;
125
+ await applyChannelConfig(ctx, 'session_start', false);
35
126
  });
36
127
  pi.on('session_shutdown', async (_event, ctx) => {
128
+ currentCtx = null;
129
+ connectedAt = undefined;
130
+ lastError = undefined;
131
+ clearReconnectTimers();
132
+ adapterStates = Object.fromEntries(Object.keys(adapterStates).map((adapter) => [
133
+ adapter,
134
+ {
135
+ state: 'disconnected',
136
+ updatedAt: new Date().toISOString(),
137
+ },
138
+ ]));
139
+ rejectCaptureWaiters('pi-channels session is shutting down.');
37
140
  bridge?.stop();
38
141
  bridge = null;
39
142
  await registry.stopAll();
40
143
  ctx.ui.setStatus?.('pi-channels', undefined);
41
144
  });
42
145
  pi.registerCommand('channel', {
43
- description: 'Manage pi channels: /channel [list|bridge on|bridge off|bridge status]',
146
+ description: 'Manage pi channels: /channel [list|reload|bridge on|bridge off|bridge status]',
44
147
  handler: async (args, ctx) => {
45
148
  const tokens = (args ?? '').trim().split(/\s+/).filter(Boolean);
149
+ if (tokens[0] === 'reload') {
150
+ await applyChannelConfig(ctx, 'command', true);
151
+ ctx.ui.notify('pi-channels config reloaded.', 'info');
152
+ return;
153
+ }
46
154
  if (tokens[0] === 'bridge') {
47
155
  if (!bridge) {
48
156
  ctx.ui.notify('pi-channels bridge is not initialized.', 'warning');
@@ -71,7 +179,7 @@ export default function piChannelsExtension(pi) {
71
179
  ctx.ui.notify(items.length
72
180
  ? items
73
181
  .map((item) => item.type === 'route'
74
- ? `${item.name} route -> ${item.target}`
182
+ ? `${item.name}${item.label ? ` (${item.label})` : ''} route -> ${item.target}`
75
183
  : `${item.name} adapter (${item.direction})`)
76
184
  .join('\n')
77
185
  : 'No channels configured.', 'info');
@@ -80,9 +188,9 @@ export default function piChannelsExtension(pi) {
80
188
  pi.registerTool({
81
189
  name: 'notify',
82
190
  label: 'Channel',
83
- description: 'Send messages through configured pi channels. Supports native Feishu, WeCom, and webhooks.',
191
+ description: 'Send messages through configured pi channels. Supports native Feishu, DingTalk, WeCom, and webhooks. Use configured adapter names or route aliases. A chat mention such as @local:channels_dingtalk:ops means route alias "ops" on the DingTalk adapter; @local:channels alone selects the plugin, not a send target.',
84
192
  parameters: Type.Object({
85
- action: enumSchema(['send', 'list', 'test'], 'Action to perform'),
193
+ action: enumSchema(['send', 'list', 'list-adapters', 'list-routes', 'test'], 'Action to perform'),
86
194
  adapter: Type.Optional(Type.String({ description: 'Adapter name or route alias.' })),
87
195
  recipient: Type.Optional(Type.String({ description: 'Recipient id. Optional for routes.' })),
88
196
  text: Type.Optional(Type.String({ description: 'Text to send.' })),
@@ -94,19 +202,28 @@ export default function piChannelsExtension(pi) {
94
202
  }),
95
203
  async execute(_toolCallId, rawParams) {
96
204
  const params = rawParams;
97
- if (params.action === 'list') {
205
+ if (params.action === 'list' ||
206
+ params.action === 'list-adapters' ||
207
+ params.action === 'list-routes') {
98
208
  const items = registry.list();
209
+ const filteredItems = items.filter((item) => {
210
+ if (params.action === 'list-adapters')
211
+ return item.type === 'adapter';
212
+ if (params.action === 'list-routes')
213
+ return item.type === 'route';
214
+ return true;
215
+ });
99
216
  return {
100
217
  content: [
101
218
  {
102
219
  type: 'text',
103
- text: items.length
104
- ? items
220
+ text: filteredItems.length
221
+ ? filteredItems
105
222
  .map((item) => item.type === 'route'
106
- ? `- ${item.name} route -> ${item.target}`
223
+ ? `- ${item.name}${item.label ? ` (${item.label})` : ''} route -> ${item.target}`
107
224
  : `- ${item.name} adapter (${item.direction})`)
108
225
  .join('\n')
109
- : 'No channels configured.',
226
+ : emptyListMessage(params.action),
110
227
  },
111
228
  ],
112
229
  details: undefined,
@@ -114,6 +231,9 @@ export default function piChannelsExtension(pi) {
114
231
  }
115
232
  if (!params.adapter)
116
233
  return textToolResult('Missing required field: adapter.');
234
+ const adapterName = normalizeAdapterName(params.adapter, params.recipient, registry.list());
235
+ if (!adapterName.ok)
236
+ return textToolResult(adapterName.error);
117
237
  let rawBody;
118
238
  if (params.json) {
119
239
  try {
@@ -130,8 +250,8 @@ export default function piChannelsExtension(pi) {
130
250
  webhook.contentType = params.contentType;
131
251
  const text = params.action === 'test' ? `pi-channels test: ${new Date().toISOString()}` : params.text;
132
252
  const message = {
133
- adapter: params.adapter,
134
- recipient: params.recipient ?? '',
253
+ adapter: adapterName.value,
254
+ recipient: adapterName.recipient ?? params.recipient ?? '',
135
255
  payloadMode: params.payloadMode ?? (params.json ? 'raw' : 'envelope'),
136
256
  ...(Object.keys(webhook).length > 0 ? { webhook } : {}),
137
257
  };
@@ -145,14 +265,31 @@ export default function piChannelsExtension(pi) {
145
265
  message.rawBody = rawBody;
146
266
  const result = await registry.send(message);
147
267
  return textToolResult(result.ok
148
- ? `Sent via "${params.adapter}"${params.recipient ? ` to ${params.recipient}` : ''}.`
268
+ ? `Sent via "${adapterName.value}"${params.recipient ? ` to ${params.recipient}` : ''}.`
149
269
  : `Failed: ${result.error}`);
150
270
  },
151
271
  });
152
272
  pi.events.on('channel:send', (raw) => {
153
273
  const data = raw;
154
- const { callback, ...message } = data;
155
- registry.send(message).then((result) => callback?.(result));
274
+ const { callback, config, cwd, ...message } = data;
275
+ void ensureSendAdapter(message, config, cwd)
276
+ .then((ensureResult) => (ensureResult.ok ? registry.send(message) : ensureResult))
277
+ .then((result) => callback?.(result))
278
+ .catch((error) => callback?.({ ok: false, error: error instanceof Error ? error.message : String(error) }));
279
+ });
280
+ pi.events.on('channel:ensure-listening', (raw) => {
281
+ const data = raw;
282
+ const adapter = typeof data.adapter === 'string' ? data.adapter.trim() : '';
283
+ if (!adapter) {
284
+ data.callback?.({ ok: false, error: 'adapter is required' });
285
+ return;
286
+ }
287
+ void ensureListeningAdapter(adapter, data.config, typeof data.cwd === 'string' ? data.cwd : undefined)
288
+ .then((result) => data.callback?.(result))
289
+ .catch((error) => data.callback?.({
290
+ ok: false,
291
+ error: error instanceof Error ? error.message : String(error),
292
+ }));
156
293
  });
157
294
  pi.events.on('channel:register', (raw) => {
158
295
  const data = raw;
@@ -171,6 +308,401 @@ export default function piChannelsExtension(pi) {
171
308
  const data = raw;
172
309
  data.callback?.(registry.list());
173
310
  });
311
+ pi.events.on('channel:status', (raw) => {
312
+ const data = raw;
313
+ const errors = registry.getErrors();
314
+ const error = errors.map((item) => `${item.adapter}: ${item.error}`).join('; ') || lastError;
315
+ data.callback?.({
316
+ ok: Boolean(currentCtx) && errors.length === 0 && !lastError,
317
+ active: Boolean(currentCtx),
318
+ ...(currentCtx ? { cwd: sessionCwd } : {}),
319
+ ...(connectedAt ? { connectedAt } : {}),
320
+ ...(error ? { error } : {}),
321
+ errors,
322
+ items: registry.list(),
323
+ bridgeActive: Boolean(bridge?.isActive()),
324
+ adapterStates,
325
+ });
326
+ });
327
+ pi.events.on('channel:capture', (raw) => {
328
+ const data = raw;
329
+ if (!currentCtx) {
330
+ data.callback?.({ ok: false, error: 'pi-channels session is not active.' });
331
+ return;
332
+ }
333
+ const adapter = typeof data.adapter === 'string' ? data.adapter.trim() : '';
334
+ if (!adapter) {
335
+ data.callback?.({ ok: false, error: 'adapter is required' });
336
+ return;
337
+ }
338
+ const timeoutMs = typeof data.timeoutMs === 'number' && Number.isFinite(data.timeoutMs)
339
+ ? Math.max(5_000, Math.min(120_000, Math.floor(data.timeoutMs)))
340
+ : 60_000;
341
+ const captureToken = typeof data.captureToken === 'string' && data.captureToken.trim()
342
+ ? data.captureToken.trim()
343
+ : undefined;
344
+ let waiter;
345
+ const cleanup = () => {
346
+ clearTimeout(waiter.timer);
347
+ captureWaiters.delete(waiter);
348
+ };
349
+ waiter = {
350
+ adapter,
351
+ ...(captureToken ? { captureToken } : {}),
352
+ callback: (result) => {
353
+ cleanup();
354
+ data.callback?.(result);
355
+ },
356
+ timer: setTimeout(() => {
357
+ cleanup();
358
+ data.callback?.({
359
+ ok: false,
360
+ error: `等待群消息超时,请在 ${Math.round(timeoutMs / 1000)} 秒内给机器人发送消息`,
361
+ });
362
+ }, timeoutMs),
363
+ };
364
+ captureWaiters.add(waiter);
365
+ });
366
+ pi.events.on('channel:reload', (raw) => {
367
+ const data = raw;
368
+ const ctx = currentCtx ?? channelContextFromReload(data);
369
+ if (!ctx) {
370
+ data.callback?.({ ok: false, error: 'pi-channels session is not active.' });
371
+ return;
372
+ }
373
+ currentCtx = ctx;
374
+ sessionCwd = ctx.cwd;
375
+ void applyChannelConfig(ctx, data.reason ?? 'event', data.force !== false)
376
+ .then((errors) => data.callback?.(errors.length > 0
377
+ ? {
378
+ ok: false,
379
+ error: errors.map((item) => `${item.adapter}: ${item.error}`).join('; '),
380
+ }
381
+ : { ok: true }))
382
+ .catch((error) => {
383
+ data.callback?.({
384
+ ok: false,
385
+ error: error instanceof Error ? error.message : String(error),
386
+ });
387
+ });
388
+ });
389
+ function notifyCaptureWaiters(message) {
390
+ let captured = false;
391
+ for (const waiter of [...captureWaiters]) {
392
+ if (waiter.adapter !== message.adapter)
393
+ continue;
394
+ if (waiter.captureToken && !message.text.includes(waiter.captureToken))
395
+ continue;
396
+ captured = true;
397
+ waiter.callback({ ok: true, message });
398
+ }
399
+ return captured;
400
+ }
401
+ async function ensureSendAdapter(message, config, cwd) {
402
+ if (!config)
403
+ return { ok: true };
404
+ sessionCwd = cwd?.trim() || sessionCwd;
405
+ registry.loadRoutes(config);
406
+ const resolved = registry.resolveTarget(message.adapter, message.recipient);
407
+ await registry.ensureAdapter(resolved.adapter, config, sessionCwd);
408
+ const error = registry.getErrors().find((item) => item.adapter === resolved.adapter);
409
+ if (error)
410
+ return { ok: false, error: error.error };
411
+ adapterStates = {
412
+ ...adapterStates,
413
+ [resolved.adapter]: {
414
+ state: 'connected',
415
+ updatedAt: new Date().toISOString(),
416
+ connectedAt: new Date().toISOString(),
417
+ },
418
+ };
419
+ return { ok: true };
420
+ }
421
+ async function ensureListeningAdapter(adapter, config, cwd) {
422
+ if (!config) {
423
+ const existing = registry.getAdapter(adapter);
424
+ if (!existing)
425
+ return { ok: false, error: `No adapter "${adapter}"` };
426
+ await registry.startListening(adapter);
427
+ return { ok: true };
428
+ }
429
+ sessionCwd = cwd?.trim() || sessionCwd;
430
+ registry.loadRoutes(config);
431
+ await registry.ensureAdapter(adapter, config, sessionCwd);
432
+ await registry.startListening(adapter);
433
+ const error = registry.getErrors().find((item) => item.adapter === adapter);
434
+ adapterStates = {
435
+ ...adapterStates,
436
+ [adapter]: error
437
+ ? {
438
+ state: 'error',
439
+ updatedAt: new Date().toISOString(),
440
+ error: error.error,
441
+ }
442
+ : {
443
+ state: 'connected',
444
+ updatedAt: new Date().toISOString(),
445
+ connectedAt: new Date().toISOString(),
446
+ },
447
+ };
448
+ return error ? { ok: false, error: error.error } : { ok: true };
449
+ }
450
+ function rejectCaptureWaiters(error) {
451
+ for (const waiter of [...captureWaiters]) {
452
+ waiter.callback({ ok: false, error });
453
+ }
454
+ }
455
+ function scheduleReconnect(event, error) {
456
+ const ctx = currentCtx;
457
+ if (!ctx || reconnectTimer)
458
+ return;
459
+ clearReconnectResetTimer();
460
+ const nextAttempt = reconnectAttempts + 1;
461
+ if (nextAttempt > RECONNECT_DELAYS_MS.length) {
462
+ log('channel_reconnect_exhausted', { event, error, attempts: reconnectAttempts }, 'WARN');
463
+ return;
464
+ }
465
+ reconnectAttempts = nextAttempt;
466
+ const delayMs = RECONNECT_DELAYS_MS[nextAttempt - 1] ?? RECONNECT_DELAYS_MS.at(-1);
467
+ log('channel_reconnect_scheduled', { event, error, attempt: nextAttempt, delayMs }, 'WARN');
468
+ reconnectTimer = setTimeout(() => {
469
+ reconnectTimer = undefined;
470
+ const activeCtx = currentCtx;
471
+ if (!activeCtx)
472
+ return;
473
+ void applyChannelConfig(activeCtx, 'reconnect', true).catch((reconnectError) => {
474
+ log('channel_reconnect_failed', {
475
+ attempt: nextAttempt,
476
+ error: reconnectError instanceof Error ? reconnectError.message : String(reconnectError),
477
+ }, 'ERROR');
478
+ });
479
+ }, delayMs);
480
+ }
481
+ function scheduleReconnectAttemptReset() {
482
+ clearReconnectResetTimer();
483
+ reconnectResetTimer = setTimeout(() => {
484
+ reconnectAttempts = 0;
485
+ reconnectResetTimer = undefined;
486
+ }, RECONNECT_STABLE_RESET_MS);
487
+ }
488
+ function clearReconnectTimers() {
489
+ if (reconnectTimer)
490
+ clearTimeout(reconnectTimer);
491
+ reconnectTimer = undefined;
492
+ clearReconnectResetTimer();
493
+ }
494
+ function clearReconnectResetTimer() {
495
+ if (reconnectResetTimer)
496
+ clearTimeout(reconnectResetTimer);
497
+ reconnectResetTimer = undefined;
498
+ }
499
+ }
500
+ function channelContextFromReload(data) {
501
+ const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd.trim() : '';
502
+ if (!cwd)
503
+ return null;
504
+ return {
505
+ cwd,
506
+ ui: {
507
+ notify: () => undefined,
508
+ setStatus: () => undefined,
509
+ },
510
+ };
511
+ }
512
+ function sessionAutostartDisabled() {
513
+ return /^(1|true|yes)$/i.test(process.env.PI_CHANNELS_DISABLE_SESSION_AUTOSTART ?? '');
514
+ }
515
+ function adapterStatesForRegistry(config, errors, connectedAt) {
516
+ const updatedAt = new Date().toISOString();
517
+ const errorsByAdapter = new Map(errors.map((error) => [error.adapter, error.error]));
518
+ return Object.fromEntries(Object.keys(config.adapters ?? {}).map((adapter) => {
519
+ const error = errorsByAdapter.get(adapter);
520
+ return [
521
+ adapter,
522
+ error
523
+ ? { state: 'error', updatedAt, error }
524
+ : {
525
+ state: 'connected',
526
+ updatedAt,
527
+ ...(connectedAt ? { connectedAt } : {}),
528
+ },
529
+ ];
530
+ }));
531
+ }
532
+ function channelIncomingTurn(message) {
533
+ const text = message.text.trim();
534
+ if (!text)
535
+ return undefined;
536
+ const sessionId = recipientFromIncoming(message);
537
+ if (!sessionId)
538
+ return undefined;
539
+ return {
540
+ sessionId,
541
+ adapter: message.adapter,
542
+ recipient: sessionId,
543
+ userMessage: text,
544
+ title: displayNameFromIncoming(message) ?? sessionId,
545
+ createdAt: new Date().toISOString(),
546
+ };
547
+ }
548
+ function autoFillEmptyRouteRecipient(cwd, message, log) {
549
+ const recipient = recipientFromIncoming(message);
550
+ if (!recipient)
551
+ return;
552
+ const displayName = displayNameFromIncoming(message);
553
+ try {
554
+ let filledRoute;
555
+ const updated = updateLocalChannelConfig(cwd, (config) => {
556
+ const routes = config.routes ?? {};
557
+ const fillableRoutes = Object.entries(routes).filter(([, route]) => route.adapter === message.adapter && !trimToUndefined(route.recipient));
558
+ const captureRoutes = fillableRoutes.filter(([, route]) => route.capture === true);
559
+ const routesToFill = captureRoutes.length > 0 ? captureRoutes : fillableRoutes;
560
+ if (routesToFill.length !== 1)
561
+ return config;
562
+ const [routeName, route] = routesToFill[0];
563
+ filledRoute = routeName;
564
+ return {
565
+ ...config,
566
+ routes: {
567
+ ...routes,
568
+ [routeName]: {
569
+ ...route,
570
+ recipient,
571
+ capture: false,
572
+ ...(displayName && !trimToUndefined(route.name) ? { name: displayName } : {}),
573
+ },
574
+ },
575
+ };
576
+ });
577
+ if (updated && filledRoute) {
578
+ log('route_recipient_auto_filled', {
579
+ route: filledRoute,
580
+ adapter: message.adapter,
581
+ recipient,
582
+ });
583
+ }
584
+ }
585
+ catch (error) {
586
+ log('route_recipient_auto_fill_failed', {
587
+ adapter: message.adapter,
588
+ error: error instanceof Error ? error.message : String(error),
589
+ }, 'ERROR');
590
+ }
591
+ }
592
+ function displayNameFromIncoming(message) {
593
+ const metadata = message.metadata ?? {};
594
+ return trimToUndefined(typeof metadata.chatName === 'string'
595
+ ? metadata.chatName
596
+ : typeof metadata.groupName === 'string'
597
+ ? metadata.groupName
598
+ : undefined);
599
+ }
600
+ function recipientFromIncoming(message) {
601
+ const metadata = message.metadata ?? {};
602
+ if (message.adapter === 'feishu') {
603
+ return trimToUndefined(typeof metadata.chatId === 'string' ? metadata.chatId : undefined);
604
+ }
605
+ if (message.adapter === 'wecom') {
606
+ return (firstStringMetadata(metadata, ['chatId', 'groupId', 'conversationId', 'roomId']) ??
607
+ trimToUndefined(message.sender.split(':')[0]));
608
+ }
609
+ return trimToUndefined(message.sender);
610
+ }
611
+ function trimToUndefined(value) {
612
+ const trimmed = value?.trim();
613
+ return trimmed ? trimmed : undefined;
614
+ }
615
+ function firstStringMetadata(metadata, keys) {
616
+ for (const key of keys) {
617
+ const value = metadata[key];
618
+ const trimmed = trimToUndefined(typeof value === 'string' ? value : undefined);
619
+ if (trimmed)
620
+ return trimmed;
621
+ }
622
+ return undefined;
623
+ }
624
+ function emptyListMessage(action) {
625
+ if (action === 'list-adapters')
626
+ return 'No channel adapters configured.';
627
+ if (action === 'list-routes')
628
+ return 'No channel routes configured.';
629
+ return 'No channels configured.';
630
+ }
631
+ function normalizeAdapterName(value, recipient, items) {
632
+ const trimmed = value.trim();
633
+ const trimmedRecipient = recipient?.trim();
634
+ const routeSelector = /^@?local:channels_([\w.-]+):([\w.-]+)$/.exec(trimmed);
635
+ if (routeSelector) {
636
+ const adapterName = routeSelector[1];
637
+ const routeName = routeSelector[2];
638
+ return normalizeProviderRoute(adapterName, routeName, items);
639
+ }
640
+ const splitRouteSelector = /^@?local:channels_([\w.-]+)$/.exec(trimmed);
641
+ if (splitRouteSelector) {
642
+ const adapterName = splitRouteSelector[1];
643
+ if (!trimmedRecipient) {
644
+ return {
645
+ ok: false,
646
+ error: `Channel route mentions must include a route, for example @local:channels_${adapterName}:ops.`,
647
+ };
648
+ }
649
+ return normalizeProviderRoute(adapterName, trimmedRecipient, items, true);
650
+ }
651
+ if (/^@?local:channels[:/][\w.-]+$/.test(trimmed)) {
652
+ return {
653
+ ok: false,
654
+ error: 'Channel route mentions must include the provider, for example @local:channels_wecom:ops.',
655
+ };
656
+ }
657
+ if (trimmed !== '@local:channels' && trimmed !== 'local:channels') {
658
+ if (trimmedRecipient) {
659
+ const route = items.find((item) => item.type === 'route' && item.name === trimmedRecipient && item.adapter === trimmed);
660
+ if (route)
661
+ return { ok: true, value: route.name, recipient: '' };
662
+ }
663
+ return { ok: true, value: trimmed };
664
+ }
665
+ const routes = items.filter((item) => item.type === 'route');
666
+ if (routes.length > 0) {
667
+ return {
668
+ ok: false,
669
+ error: `@local:channels selects the plugin, not a route. Use one of these channel route mentions: ${routes
670
+ .map((item) => `@local:channels_${item.adapter ?? 'adapter'}:${item.name}`)
671
+ .join(', ')}.`,
672
+ };
673
+ }
674
+ const adapters = items.filter((item) => item.type === 'adapter');
675
+ if (adapters.length === 1) {
676
+ return {
677
+ ok: false,
678
+ error: `@local:channels selects the plugin, not a recipient. Use adapter "${adapters[0].name}" with a recipient.`,
679
+ };
680
+ }
681
+ return {
682
+ ok: false,
683
+ error: '@local:channels selects the plugin, not a send target. Run notify with action "list" and use an adapter name or route alias.',
684
+ };
685
+ }
686
+ function normalizeProviderRoute(adapterName, routeName, items, clearRecipient = false) {
687
+ const route = items.find((item) => item.type === 'route' && item.name === routeName);
688
+ if (route) {
689
+ if (route.adapter && route.adapter !== adapterName) {
690
+ return {
691
+ ok: false,
692
+ error: `Channel route "${routeName}" uses adapter "${route.adapter}", not "${adapterName}".`,
693
+ };
694
+ }
695
+ return { ok: true, value: route.name, ...(clearRecipient ? { recipient: '' } : {}) };
696
+ }
697
+ const routes = items.filter((item) => item.type === 'route');
698
+ return {
699
+ ok: false,
700
+ error: routes.length
701
+ ? `Unknown channel route "${routeName}". Use one of: ${routes
702
+ .map((item) => `local:channels_${item.adapter ?? 'adapter'}:${item.name}`)
703
+ .join(', ')}.`
704
+ : `Unknown channel route "${routeName}". No routes are configured.`,
705
+ };
174
706
  }
175
707
  function textToolResult(text) {
176
708
  return { content: [{ type: 'text', text }], details: undefined };