@amaster.ai/pi-channels 0.1.2-beta.7 → 0.1.2-beta.71

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