@amaster.ai/pi-channels 0.1.2-beta.7 → 0.1.2-beta.70
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 +80 -43
- 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 +245 -11
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/webhook.d.ts.map +1 -1
- package/dist/adapters/webhook.js +11 -5
- package/dist/adapters/webhook.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 +385 -27
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +96 -40
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +564 -29
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +15 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +126 -25
- package/dist/registry.js.map +1 -1
- package/dist/types.d.ts +39 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -5
- 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,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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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:
|
|
104
|
-
?
|
|
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
|
-
:
|
|
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:
|
|
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 "${
|
|
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
|
-
|
|
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 };
|