@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.
- package/README.md +77 -26
- package/dist/adapters/dingtalk.d.ts +5 -0
- package/dist/adapters/dingtalk.d.ts.map +1 -0
- package/dist/adapters/dingtalk.js +269 -0
- package/dist/adapters/dingtalk.js.map +1 -0
- package/dist/adapters/feishu.d.ts.map +1 -1
- package/dist/adapters/feishu.js +122 -5
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/wecom/adapter.d.ts +3 -1
- package/dist/adapters/wecom/adapter.d.ts.map +1 -1
- package/dist/adapters/wecom/adapter.js +194 -157
- package/dist/adapters/wecom/adapter.js.map +1 -1
- package/dist/adapters/wecom.d.ts +0 -2
- package/dist/adapters/wecom.d.ts.map +1 -1
- package/dist/adapters/wecom.js +0 -1
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/bridge-provider.d.ts +3 -0
- package/dist/bridge-provider.d.ts.map +1 -0
- package/dist/bridge-provider.js +79 -0
- package/dist/bridge-provider.js.map +1 -0
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +316 -8
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +94 -18
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +557 -25
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +93 -22
- package/dist/registry.js.map +1 -1
- package/dist/types.d.ts +38 -12
- package/dist/types.d.ts.map +1 -1
- package/package.json +17 -3
- package/preview.png +0 -0
- package/dist/adapters/wecom/client.d.ts +0 -13
- package/dist/adapters/wecom/client.d.ts.map +0 -1
- package/dist/adapters/wecom/client.js +0 -116
- package/dist/adapters/wecom/client.js.map +0 -1
- package/dist/adapters/wecom/crypto.d.ts +0 -14
- package/dist/adapters/wecom/crypto.d.ts.map +0 -1
- package/dist/adapters/wecom/crypto.js +0 -37
- package/dist/adapters/wecom/crypto.js.map +0 -1
- package/dist/adapters/wecom/errors.d.ts +0 -25
- package/dist/adapters/wecom/errors.d.ts.map +0 -1
- package/dist/adapters/wecom/errors.js +0 -56
- package/dist/adapters/wecom/errors.js.map +0 -1
- package/dist/adapters/wecom/types.d.ts +0 -52
- package/dist/adapters/wecom/types.d.ts.map +0 -1
- package/dist/adapters/wecom/types.js +0 -2
- package/dist/adapters/wecom/types.js.map +0 -1
- package/dist/adapters/wecom/xml.d.ts +0 -4
- package/dist/adapters/wecom/xml.d.ts.map +0 -1
- package/dist/adapters/wecom/xml.js +0 -16
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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:
|
|
104
|
-
?
|
|
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
|
-
:
|
|
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:
|
|
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 "${
|
|
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
|
-
|
|
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 };
|