@xmanrui/dsh-im 4.22.0 → 4.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/client.js +2505 -1152
- package/lib/index.js +290 -299
- package/package.json +8 -1
- package/plugin-src/client/channel-card-meta.js +2 -0
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/api.js +5 -1
- package/plugin-src/client/channels/dingtalk/index.js +15 -13
- package/plugin-src/client/channels/email/api.js +52 -0
- package/plugin-src/client/channels/email/index.js +728 -0
- package/plugin-src/client/channels/email/styles.js +51 -0
- package/plugin-src/client/channels/feishu/api.js +4 -2
- package/plugin-src/client/channels/feishu/index.js +16 -3
- package/plugin-src/client/channels/imessage/index.js +2 -1
- package/plugin-src/client/channels/office/api.js +2 -0
- package/plugin-src/client/channels/office/index.js +6 -5
- package/plugin-src/client/channels/qq/api.js +7 -0
- package/plugin-src/client/channels/qq/index.js +16 -3
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +28 -3
- package/plugin-src/client/channels/slack/index.js +2 -1
- package/plugin-src/client/channels/wecom/api.js +7 -1
- package/plugin-src/client/channels/wecom/index.js +16 -3
- package/plugin-src/client/channels/wecom-app/api.js +6 -1
- package/plugin-src/client/channels/wecom-app/index.js +16 -3
- package/plugin-src/client/channels/weixin/api.js +2 -1
- package/plugin-src/client/channels/weixin/connection-error.js +1 -71
- package/plugin-src/client/channels/whatsapp/api.js +5 -0
- package/plugin-src/client/channels/whatsapp/index.js +16 -3
- package/plugin-src/client/connection-error.js +87 -0
- package/plugin-src/client/global-settings.js +81 -1
- package/plugin-src/client/i18n.js +61 -0
- package/plugin-src/client/index.js +30 -2
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/styles.js +6 -0
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
- package/plugin-src/host/channels/email/availability.mjs +60 -0
- package/plugin-src/host/channels/email/index.mjs +36 -0
- package/plugin-src/host/channels/email/production.mjs +58 -0
- package/plugin-src/host/channels/email/rpc.mjs +163 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
- package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
- package/plugin-src/host/channels/office/rpc.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -0
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +52 -5
- package/plugin-src/host/channels/shared/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
- package/plugin-src/host/channels/shared/startup.mjs +11 -6
- package/plugin-src/host/channels/slack/production.mjs +2 -0
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +2 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
- package/plugin-src/host/delivery-adapter.mjs +11 -0
- package/plugin-src/host/image-input-rpc.mjs +24 -0
- package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/connection-error.mjs +5 -12
- package/src/channels/dingtalk/device-auth.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
- package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
- package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
- package/src/channels/discord/discord-api.mjs +3 -3
- package/src/channels/discord/discord-runtime.mjs +18 -12
- package/src/channels/email/config-store.mjs +194 -0
- package/src/channels/email/email-api.mjs +20 -0
- package/src/channels/email/email-bridge.mjs +17 -0
- package/src/channels/email/email-controller.mjs +824 -0
- package/src/channels/email/email-runtime.mjs +692 -0
- package/src/channels/email/harness-client.mjs +7 -0
- package/src/channels/email/mail-format.mjs +91 -0
- package/src/channels/email/state-store.mjs +176 -0
- package/src/channels/email/transport.mjs +64 -0
- package/src/channels/email/transports/agent-mail.mjs +593 -0
- package/src/channels/email/transports/agently-cli.mjs +288 -0
- package/src/channels/email/transports/imap-smtp.mjs +165 -0
- package/src/channels/feishu/bridge.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/message-utils.mjs +3 -2
- package/src/channels/feishu/multi-bot-controller.mjs +56 -47
- package/src/channels/feishu/registration-manager.mjs +8 -4
- package/src/channels/feishu/repair-manager.mjs +2 -1
- package/src/channels/imessage/runtime.mjs +9 -5
- package/src/channels/office/office-controller.mjs +23 -12
- package/src/channels/office/office-runtime.mjs +6 -3
- package/src/channels/office/office-transport.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +7 -5
- package/src/channels/qq/qq-controller.mjs +56 -42
- package/src/channels/qq/qq-runtime.mjs +16 -11
- package/src/channels/shared/bot-workspace-store.mjs +29 -10
- package/src/channels/shared/connection-error.mjs +191 -0
- package/src/channels/shared/connection-test.mjs +3 -1
- package/src/channels/shared/conversation-state-store.mjs +46 -1
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +63 -18
- package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
- package/src/channels/shared/i18n-en/email.mjs +25 -0
- package/src/channels/shared/i18n-en/image-input.mjs +17 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
- package/src/channels/shared/i18n-en.mjs +6 -0
- package/src/channels/shared/image-input-policy.mjs +35 -0
- package/src/channels/shared/image-input-settings-store.mjs +60 -0
- package/src/channels/shared/image-input.mjs +124 -0
- package/src/channels/shared/image-prompt.mjs +15 -7
- package/src/channels/shared/message-failure.mjs +12 -0
- package/src/channels/shared/semantic/reply-reference.mjs +3 -3
- package/src/channels/shared/text-harness-bridge.mjs +90 -11
- package/src/channels/shared/token-bot-controller.mjs +52 -47
- package/src/channels/slack/slack-api.mjs +3 -3
- package/src/channels/slack/slack-controller.mjs +59 -51
- package/src/channels/slack/slack-runtime.mjs +18 -12
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-runtime.mjs +17 -13
- package/src/channels/wecom/wecom-bridge.mjs +15 -6
- package/src/channels/wecom/wecom-controller.mjs +51 -37
- package/src/channels/wecom/wecom-runtime.mjs +10 -6
- package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
- package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
- package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
- package/src/channels/weixin/connection-error.mjs +26 -92
- package/src/channels/weixin/diagnostic-details.mjs +1 -63
- package/src/channels/weixin/weixin-bridge.mjs +7 -5
- package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
- package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the official `agently-cli` and returns its JSON.
|
|
3
|
+
*
|
|
4
|
+
* The CLI owns the Agent mailbox protocol, including OAuth and the token
|
|
5
|
+
* refresh. A hand-written client could not refresh: the server issued an
|
|
6
|
+
* access token that worked while the matching refresh token was rejected with
|
|
7
|
+
* `invalid_grant` the moment it was used, so the mailbox died an hour after
|
|
8
|
+
* every authorization. The CLI keeps credentials in the system keychain, tracks
|
|
9
|
+
* `expires_at`, refreshes ahead of expiry, and re-authorizes a watcher that has
|
|
10
|
+
* gone stale — none of which is reproducible from outside.
|
|
11
|
+
*
|
|
12
|
+
* Contract notes learned from the binary and its own schemas:
|
|
13
|
+
* - stdout carries the JSON document; the `tip:` lines go to stderr.
|
|
14
|
+
* - The exit code is 0 even for an error document, so `ok` is the only
|
|
15
|
+
* reliable success signal.
|
|
16
|
+
* - A `--print-output-schema` flag documents every command's fields.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
/** Platform-specific binaries, mirroring the CLI's own packaging. */
|
|
26
|
+
const PLATFORM_PACKAGES = {
|
|
27
|
+
'darwin-arm64': '@tencent-qqmail/agently-cli-darwin-arm64',
|
|
28
|
+
'darwin-x64': '@tencent-qqmail/agently-cli-darwin-x64',
|
|
29
|
+
'linux-arm64': '@tencent-qqmail/agently-cli-linux-arm64',
|
|
30
|
+
'linux-x64': '@tencent-qqmail/agently-cli-linux-x64',
|
|
31
|
+
'win32-arm64': '@tencent-qqmail/agently-cli-win32-arm64',
|
|
32
|
+
'win32-x64': '@tencent-qqmail/agently-cli-win32-x64',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** How long a single CLI invocation may run before it is abandoned. */
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
export class AgentMailCliError extends Error {
|
|
39
|
+
constructor(message, { code = 'cli-failed', status = null, detail = null, cause } = {}) {
|
|
40
|
+
super(message, { cause });
|
|
41
|
+
this.name = 'AgentMailCliError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
if (status !== null) this.status = status;
|
|
44
|
+
if (detail !== null) this.detail = detail;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the CLI binary.
|
|
50
|
+
*
|
|
51
|
+
* The bundled platform package is preferred so a working install never depends
|
|
52
|
+
* on a global one; `agently-cli` on the PATH is the fallback for a user who
|
|
53
|
+
* installed it themselves.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveCliBinary() {
|
|
56
|
+
const platformPackage = PLATFORM_PACKAGES[`${process.platform}-${process.arch}`];
|
|
57
|
+
if (platformPackage) {
|
|
58
|
+
try {
|
|
59
|
+
const resolved = require.resolve(`${platformPackage}/package.json`);
|
|
60
|
+
const name = process.platform === 'win32' ? 'agently-cli.exe' : 'agently-cli';
|
|
61
|
+
return { command: join(dirname(resolved), 'bin', name), source: 'bundled' };
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to the wrapper, then PATH.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const wrapper = require.resolve('@tencent-qqmail/agently-cli/package.json');
|
|
68
|
+
return { command: join(dirname(wrapper), 'scripts', 'run.js'), source: 'wrapper' };
|
|
69
|
+
} catch {
|
|
70
|
+
return { command: 'agently-cli', source: 'path' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the CLI can be launched at all. */
|
|
75
|
+
export function isCliAvailable() {
|
|
76
|
+
const { command, source } = resolveCliBinary();
|
|
77
|
+
if (source === 'path') return false;
|
|
78
|
+
return Boolean(command);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Start `auth login` and resolve as soon as it prints the authorization URL.
|
|
83
|
+
*
|
|
84
|
+
* The command then blocks until the user completes the scan, so it is left
|
|
85
|
+
* running rather than waited on; the caller polls `auth status` to observe the
|
|
86
|
+
* outcome, and the process exits on its own once the scan lands or it expires.
|
|
87
|
+
*/
|
|
88
|
+
export function startCliLogin({ signal, timeoutMs = 30_000, env = {}, workspace } = {}) {
|
|
89
|
+
const { command } = resolveCliBinary();
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = spawn(command, ['auth', 'login'], {
|
|
94
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
95
|
+
env: { ...process.env, ...cliEnv(workspace), ...env },
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
reject(new AgentMailCliError(`unable to launch agently-cli: ${error.message}`, {
|
|
99
|
+
code: 'cli-unavailable', cause: error,
|
|
100
|
+
}));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let settled = false;
|
|
105
|
+
let buffered = '';
|
|
106
|
+
const stop = () => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
111
|
+
};
|
|
112
|
+
const timer = timeoutMs > 0
|
|
113
|
+
? setTimeout(() => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
stop();
|
|
116
|
+
reject(new AgentMailCliError('agently-cli printed no authorization URL', {
|
|
117
|
+
code: 'authorization-url-missing', detail: buffered.slice(0, 400),
|
|
118
|
+
}));
|
|
119
|
+
}, timeoutMs)
|
|
120
|
+
: null;
|
|
121
|
+
timer?.unref?.();
|
|
122
|
+
|
|
123
|
+
const onAbort = () => {
|
|
124
|
+
child.kill('SIGTERM');
|
|
125
|
+
stop();
|
|
126
|
+
reject(new AgentMailCliError('authorization aborted', { code: 'cli-aborted' }));
|
|
127
|
+
};
|
|
128
|
+
if (signal) {
|
|
129
|
+
if (signal.aborted) { onAbort(); return; }
|
|
130
|
+
signal.addEventListener?.('abort', onAbort, { once: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const scan = (chunk) => {
|
|
134
|
+
buffered += chunk;
|
|
135
|
+
const url = /https:\/\/agent\.qq\.com\/page\/oauth\S*/.exec(buffered)?.[0];
|
|
136
|
+
if (!url) return;
|
|
137
|
+
stop();
|
|
138
|
+
// The process keeps running so the scan can complete; it is detached from
|
|
139
|
+
// this promise on purpose.
|
|
140
|
+
resolve({ browserUrl: url, inputCode: /user_code=([^&\s]+)/.exec(url)?.[1] ?? '', child });
|
|
141
|
+
};
|
|
142
|
+
child.stdout.on('data', (chunk) => scan(String(chunk)));
|
|
143
|
+
child.stderr.on('data', (chunk) => scan(String(chunk)));
|
|
144
|
+
child.on('error', (error) => {
|
|
145
|
+
stop();
|
|
146
|
+
reject(new AgentMailCliError(`agently-cli failed to start: ${error.message}`, {
|
|
147
|
+
code: 'cli-unavailable', cause: error,
|
|
148
|
+
}));
|
|
149
|
+
});
|
|
150
|
+
child.on('close', () => {
|
|
151
|
+
if (settled) return;
|
|
152
|
+
stop();
|
|
153
|
+
reject(new AgentMailCliError('agently-cli exited before printing an authorization URL', {
|
|
154
|
+
code: 'authorization-url-missing', detail: buffered.slice(0, 400),
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run the CLI and parse its JSON document.
|
|
162
|
+
*
|
|
163
|
+
* `input` is written to stdin, which is how a body avoids both the argument
|
|
164
|
+
* list and the shell.
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* The environment a CLI call runs with.
|
|
168
|
+
*
|
|
169
|
+
* The CLI isolates accounts by workspace, so a mailbox is addressed by its own
|
|
170
|
+
* workspace name. Without this every Agent mailbox shared one login and the
|
|
171
|
+
* second one always read the first one's account.
|
|
172
|
+
*/
|
|
173
|
+
export function cliEnv(workspace) {
|
|
174
|
+
return workspace ? { AGENTLY_WORKSPACE: String(workspace) } : {};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function runCli(args, {
|
|
178
|
+
input = null, timeoutMs = DEFAULT_TIMEOUT_MS, signal, env = {},
|
|
179
|
+
} = {}) {
|
|
180
|
+
const { command, source } = resolveCliBinary();
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
let child;
|
|
183
|
+
try {
|
|
184
|
+
child = spawn(command, args, {
|
|
185
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
186
|
+
env: { ...process.env, ...env },
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
reject(new AgentMailCliError(`unable to launch agently-cli (${source}): ${error.message}`, {
|
|
190
|
+
code: 'cli-unavailable', cause: error,
|
|
191
|
+
}));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let stdout = '';
|
|
196
|
+
let stderr = '';
|
|
197
|
+
let settled = false;
|
|
198
|
+
const finish = (fn, value) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
203
|
+
fn(value);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const timer = timeoutMs > 0
|
|
207
|
+
? setTimeout(() => {
|
|
208
|
+
child.kill('SIGTERM');
|
|
209
|
+
finish(reject, new AgentMailCliError(`agently-cli timed out after ${timeoutMs}ms`, {
|
|
210
|
+
code: 'cli-timeout',
|
|
211
|
+
}));
|
|
212
|
+
}, timeoutMs)
|
|
213
|
+
: null;
|
|
214
|
+
timer?.unref?.();
|
|
215
|
+
|
|
216
|
+
const onAbort = () => {
|
|
217
|
+
child.kill('SIGTERM');
|
|
218
|
+
finish(reject, new AgentMailCliError('agently-cli aborted', { code: 'cli-aborted' }));
|
|
219
|
+
};
|
|
220
|
+
if (signal) {
|
|
221
|
+
if (signal.aborted) { onAbort(); return; }
|
|
222
|
+
signal.addEventListener?.('abort', onAbort, { once: true });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
226
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
227
|
+
child.on('error', (error) => {
|
|
228
|
+
finish(reject, new AgentMailCliError(`agently-cli failed to start: ${error.message}`, {
|
|
229
|
+
code: 'cli-unavailable', cause: error,
|
|
230
|
+
}));
|
|
231
|
+
});
|
|
232
|
+
child.on('close', (exitCode) => {
|
|
233
|
+
// The exit code is 0 even for an error document, so `ok` decides.
|
|
234
|
+
let document = null;
|
|
235
|
+
const text = stdout.trim();
|
|
236
|
+
if (text) {
|
|
237
|
+
try {
|
|
238
|
+
document = JSON.parse(text);
|
|
239
|
+
} catch {
|
|
240
|
+
finish(reject, new AgentMailCliError('agently-cli returned unparseable output', {
|
|
241
|
+
code: 'cli-invalid-output', detail: text.slice(0, 400),
|
|
242
|
+
}));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (document && document.ok === false) {
|
|
247
|
+
const failure = document.error ?? {};
|
|
248
|
+
finish(reject, new AgentMailCliError(
|
|
249
|
+
String(failure.message ?? 'agently-cli reported a failure'),
|
|
250
|
+
{
|
|
251
|
+
code: String(failure.type ?? 'cli-failed'),
|
|
252
|
+
status: typeof failure.status === 'number' ? failure.status : null,
|
|
253
|
+
detail: failure,
|
|
254
|
+
},
|
|
255
|
+
));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!document) {
|
|
259
|
+
finish(reject, new AgentMailCliError(
|
|
260
|
+
`agently-cli produced no output (exit ${exitCode})`,
|
|
261
|
+
{ code: 'cli-empty-output', detail: stderr.trim().slice(0, 400) },
|
|
262
|
+
));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
finish(resolve, { document, stdout, stderr, exitCode });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (input !== null) child.stdin.end(input);
|
|
269
|
+
else child.stdin.end();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Run the CLI through an injected implementation.
|
|
275
|
+
*
|
|
276
|
+
* Exists so tests can drive the failure and confirmation paths without a real
|
|
277
|
+
* binary; production always goes through `runCli`.
|
|
278
|
+
*/
|
|
279
|
+
export async function runCliDocumentForTests(args, { runCliImpl, input = null } = {}) {
|
|
280
|
+
const result = await runCliImpl(args, { input });
|
|
281
|
+
if (result.document && result.document.ok === false) {
|
|
282
|
+
const failure = result.document.error ?? {};
|
|
283
|
+
throw new AgentMailCliError(String(failure.message ?? 'agently-cli reported a failure'), {
|
|
284
|
+
code: String(failure.type ?? 'cli-failed'), detail: failure,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard mailbox transport: IMAP receive + SMTP send.
|
|
3
|
+
*
|
|
4
|
+
* One dedicated mailbox (the bot identity). Inbound mail is polled over IMAP;
|
|
5
|
+
* replies go back over SMTP inside the same mail thread. Everything above the
|
|
6
|
+
* transport — thread mapping, allowlist, quote stripping, loop prevention —
|
|
7
|
+
* is shared with every other transport.
|
|
8
|
+
*/
|
|
9
|
+
import { ImapFlow } from 'imapflow';
|
|
10
|
+
import { simpleParser } from 'mailparser';
|
|
11
|
+
import nodemailer from 'nodemailer';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
MAX_REPLY_CHARS, imapSecurity, normalizeAddress, smtpSecurity,
|
|
15
|
+
} from '../mail-format.mjs';
|
|
16
|
+
|
|
17
|
+
export class ImapSmtpTransport {
|
|
18
|
+
#config;
|
|
19
|
+
#signal;
|
|
20
|
+
#imap = null;
|
|
21
|
+
#transport = null;
|
|
22
|
+
|
|
23
|
+
constructor({ config, signal } = {}) {
|
|
24
|
+
if (!config?.address || !config?.password) {
|
|
25
|
+
throw new TypeError('ImapSmtpTransport requires an address and password');
|
|
26
|
+
}
|
|
27
|
+
this.#config = config;
|
|
28
|
+
this.#signal = signal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get address() {
|
|
32
|
+
return this.#config.address;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Open the IMAP connection and select the monitored mailbox. */
|
|
36
|
+
async connect() {
|
|
37
|
+
if (this.#imap) return;
|
|
38
|
+
const client = new ImapFlow({
|
|
39
|
+
host: this.#config.imapHost,
|
|
40
|
+
port: this.#config.imapPort,
|
|
41
|
+
...imapSecurity(this.#config.imapPort, this.#config.imapSecure),
|
|
42
|
+
auth: { user: this.#config.address, pass: this.#config.password },
|
|
43
|
+
logger: false,
|
|
44
|
+
...(this.#config.rejectUnauthorized === false ? { tls: { rejectUnauthorized: false } } : {}),
|
|
45
|
+
});
|
|
46
|
+
client.on('error', () => { /* surfaced by the caller's poll/connection state */ });
|
|
47
|
+
await client.connect();
|
|
48
|
+
await client.mailboxOpen(this.#config.mailbox ?? 'INBOX');
|
|
49
|
+
this.#imap = client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async disconnect() {
|
|
53
|
+
const client = this.#imap;
|
|
54
|
+
this.#imap = null;
|
|
55
|
+
if (client) await client.logout().catch(() => client.close?.());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Highest UID currently in the mailbox, used to skip pre-existing mail. */
|
|
59
|
+
async latestUid() {
|
|
60
|
+
await this.connect();
|
|
61
|
+
const status = await this.#imap.status(this.#config.mailbox ?? 'INBOX', { uidNext: true, messages: true });
|
|
62
|
+
const next = Number(status?.uidNext);
|
|
63
|
+
return Number.isFinite(next) && next > 1 ? next - 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch messages with a UID greater than `afterUid`.
|
|
68
|
+
*
|
|
69
|
+
* The sender is read from the lightweight envelope first and checked against
|
|
70
|
+
* `allowSenders` before the body is requested. A monitored mailbox also
|
|
71
|
+
* receives ordinary personal mail, and that content must not be downloaded
|
|
72
|
+
* or parsed at all — not merely filtered after the fact.
|
|
73
|
+
*/
|
|
74
|
+
async listMessages({ afterUid = 0, limit = 25, allowSenders = null } = {}) {
|
|
75
|
+
await this.connect();
|
|
76
|
+
const mailbox = this.#config.mailbox ?? 'INBOX';
|
|
77
|
+
// A Set — even an empty one — means the caller supplied a policy: an
|
|
78
|
+
// empty allowlist admits nobody, so no body is fetched at all. Treating
|
|
79
|
+
// it as "no filter" downloaded mail the policy had already refused.
|
|
80
|
+
const allowed = allowSenders instanceof Set ? allowSenders : null;
|
|
81
|
+
|
|
82
|
+
// Step 1: read only the lightweight envelopes. ImapFlow cannot run a second
|
|
83
|
+
// fetch while one is being iterated, so the accepted UIDs are collected
|
|
84
|
+
// first and their bodies pulled afterwards.
|
|
85
|
+
const accepted = [];
|
|
86
|
+
for await (const message of this.#imap.fetch(
|
|
87
|
+
{ uid: `${afterUid + 1}:*` },
|
|
88
|
+
{ uid: true, envelope: true },
|
|
89
|
+
{ uid: true },
|
|
90
|
+
)) {
|
|
91
|
+
// A range fetch that matches nothing still yields the last message, so
|
|
92
|
+
// the UID bound is re-checked here.
|
|
93
|
+
if (!Number.isFinite(message.uid) || message.uid <= afterUid) continue;
|
|
94
|
+
if (allowed) {
|
|
95
|
+
const from = normalizeAddress(message.envelope?.from?.[0]?.address);
|
|
96
|
+
if (!from || !allowed.has(from)) continue;
|
|
97
|
+
}
|
|
98
|
+
accepted.push(message.uid);
|
|
99
|
+
if (accepted.length >= limit) break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 2: fetch and parse the bodies of accepted senders only. Mail from
|
|
103
|
+
// anyone else is never downloaded, so its content is not read at all.
|
|
104
|
+
const found = [];
|
|
105
|
+
for (const uid of accepted) {
|
|
106
|
+
const source = await this.#fetchSource(uid);
|
|
107
|
+
if (!source) continue;
|
|
108
|
+
const parsed = await simpleParser(source);
|
|
109
|
+
// Carry the UID alongside the parsed mail: it is the polling cursor and
|
|
110
|
+
// is not part of the RFC822 source.
|
|
111
|
+
parsed.uid = uid;
|
|
112
|
+
found.push(parsed);
|
|
113
|
+
}
|
|
114
|
+
await this.#imap.mailboxOpen(mailbox, { readOnly: false }).catch(() => {});
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read one message's RFC822 source by UID. */
|
|
119
|
+
async #fetchSource(uid) {
|
|
120
|
+
try {
|
|
121
|
+
const message = await this.#imap.fetchOne(String(uid), { source: true }, { uid: true });
|
|
122
|
+
return message?.source ?? null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Send a reply inside the originating thread. */
|
|
129
|
+
async sendReply({ to, subject, text, inReplyTo, references, attachments = [] } = {}) {
|
|
130
|
+
const body = String(text ?? '').slice(0, MAX_REPLY_CHARS);
|
|
131
|
+
const transport = await this.#transportFor();
|
|
132
|
+
const info = await transport.sendMail({
|
|
133
|
+
from: this.#config.from ?? this.#config.address,
|
|
134
|
+
to,
|
|
135
|
+
subject: subject || '(no subject)',
|
|
136
|
+
text: body,
|
|
137
|
+
// RFC 3834: mark this as an automatic reply so any bot on the other side
|
|
138
|
+
// (including this mailbox replying to itself) can refuse to auto-answer
|
|
139
|
+
// it. That is the standard loop break, and it lets a mailbox accept its
|
|
140
|
+
// own address as a sender without risking an endless exchange.
|
|
141
|
+
headers: { 'Auto-Submitted': 'auto-replied' },
|
|
142
|
+
...(inReplyTo ? { inReplyTo } : {}),
|
|
143
|
+
...(references?.length ? { references: references.join(' ') } : {}),
|
|
144
|
+
...(attachments.length ? { attachments } : {}),
|
|
145
|
+
});
|
|
146
|
+
return { sent: true, messageId: info?.messageId ?? null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Send a standalone message (proactive delivery, no thread). */
|
|
150
|
+
async sendText({ to, subject, text, attachments = [] } = {}) {
|
|
151
|
+
return this.sendReply({ to, subject, text, attachments });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async #transportFor() {
|
|
155
|
+
if (this.#transport) return this.#transport;
|
|
156
|
+
this.#transport = nodemailer.createTransport({
|
|
157
|
+
host: this.#config.smtpHost,
|
|
158
|
+
port: this.#config.smtpPort,
|
|
159
|
+
...smtpSecurity(this.#config.smtpPort, this.#config.smtpSecure),
|
|
160
|
+
auth: { user: this.#config.address, pass: this.#config.password },
|
|
161
|
+
...(this.#config.rejectUnauthorized === false ? { tls: { rejectUnauthorized: false } } : {}),
|
|
162
|
+
});
|
|
163
|
+
return this.#transport;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -4062,7 +4062,7 @@ export class FeishuHarnessBridge {
|
|
|
4062
4062
|
await Promise.allSettled(sessionIds.map((sessionId) => this.#scheduleCompensation(sessionId)));
|
|
4063
4063
|
}
|
|
4064
4064
|
|
|
4065
|
-
#interactionAskOptions(event, key, files) {
|
|
4065
|
+
#interactionAskOptions(event, key, files, images) {
|
|
4066
4066
|
return {
|
|
4067
4067
|
timeoutMs: this.#replyTimeoutMs,
|
|
4068
4068
|
signal: this.#signal,
|
|
@@ -4076,6 +4076,7 @@ export class FeishuHarnessBridge {
|
|
|
4076
4076
|
}),
|
|
4077
4077
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
4078
4078
|
files,
|
|
4079
|
+
images,
|
|
4079
4080
|
};
|
|
4080
4081
|
}
|
|
4081
4082
|
|
|
@@ -4894,7 +4895,7 @@ export class FeishuHarnessBridge {
|
|
|
4894
4895
|
// 的上下文增强按原样重放。直推分流发生在 `#answerWithStream` 构造之前,
|
|
4895
4896
|
// 这里就是本回合唯一一次构造(无重复的 prompt 往返)。
|
|
4896
4897
|
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
4897
|
-
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
4898
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
4898
4899
|
: undefined;
|
|
4899
4900
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
4900
4901
|
let contextEnhanced = false;
|
|
@@ -4940,7 +4941,7 @@ export class FeishuHarnessBridge {
|
|
|
4940
4941
|
// /stop 打标:回合进行中收到停止请求时,封存为「已停止」而非「已完成」。
|
|
4941
4942
|
const stepStopFlag = streamingCard ? { requested: false } : null;
|
|
4942
4943
|
if (streamingCard) this.#stepStopFlags.set(key, stepStopFlag);
|
|
4943
|
-
const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
|
|
4944
|
+
const baseAskOptions = this.#interactionAskOptions(event, key, message.files, message.images);
|
|
4944
4945
|
|
|
4945
4946
|
// 上下文注入动作在详细级别下也体现为一条步骤(注入细节可忽略)。
|
|
4946
4947
|
// 不计入工具/助手消息的熔断计数(规格口径:熔断只管「工具 + 助手」)。
|
|
@@ -5214,7 +5215,7 @@ export class FeishuHarnessBridge {
|
|
|
5214
5215
|
return this.#answerWithStepPush(event, key, message, { onAskComplete });
|
|
5215
5216
|
}
|
|
5216
5217
|
let content = hasInboundImages(message) || hasReplyReference(message)
|
|
5217
|
-
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
5218
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
5218
5219
|
: undefined;
|
|
5219
5220
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
5220
5221
|
let contextEnhanced = false;
|
|
@@ -5241,7 +5242,7 @@ export class FeishuHarnessBridge {
|
|
|
5241
5242
|
contextEnhanced,
|
|
5242
5243
|
createOptions: { signal: this.#signal },
|
|
5243
5244
|
existsOptions: { signal: this.#signal },
|
|
5244
|
-
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
5245
|
+
askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
|
|
5245
5246
|
});
|
|
5246
5247
|
markAskComplete();
|
|
5247
5248
|
let textReceipt;
|
|
@@ -5285,7 +5286,7 @@ export class FeishuHarnessBridge {
|
|
|
5285
5286
|
stream = await this.#channel.stream(chatId, {
|
|
5286
5287
|
markdown: async (controller) => {
|
|
5287
5288
|
promptStarted = true;
|
|
5288
|
-
const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
|
|
5289
|
+
const baseAskOptions = this.#interactionAskOptions(event, key, message.files, message.images);
|
|
5289
5290
|
const askOptions = {
|
|
5290
5291
|
...baseAskOptions,
|
|
5291
5292
|
// issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前
|
|
@@ -5394,7 +5395,7 @@ export class FeishuHarnessBridge {
|
|
|
5394
5395
|
contextEnhanced,
|
|
5395
5396
|
createOptions: { signal: this.#signal },
|
|
5396
5397
|
existsOptions: { signal: this.#signal },
|
|
5397
|
-
askOptions: this.#interactionAskOptions(event, key, message.files),
|
|
5398
|
+
askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
|
|
5398
5399
|
});
|
|
5399
5400
|
markAskComplete();
|
|
5400
5401
|
let textReceipt;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
3
4
|
import { cardActionProbeCard } from './feishu-cards.mjs';
|
|
@@ -125,6 +126,7 @@ export class FeishuRuntime {
|
|
|
125
126
|
#requestTimeoutMs;
|
|
126
127
|
#wsAgent;
|
|
127
128
|
#logger;
|
|
129
|
+
#diagnostics;
|
|
128
130
|
#repair;
|
|
129
131
|
#client = null;
|
|
130
132
|
#bridge = null;
|
|
@@ -200,7 +202,7 @@ export class FeishuRuntime {
|
|
|
200
202
|
this.#requestTimeoutMs = requestTimeoutMs;
|
|
201
203
|
this.#slashCommands = Boolean(slashCommands);
|
|
202
204
|
this.#wsAgent = wsAgent;
|
|
203
|
-
this.#logger = logger;
|
|
205
|
+
this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'feishu', logger });
|
|
204
206
|
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
|
205
207
|
}
|
|
206
208
|
|
|
@@ -277,10 +279,10 @@ export class FeishuRuntime {
|
|
|
277
279
|
};
|
|
278
280
|
this.#status.startedAt = new Date().toISOString();
|
|
279
281
|
this.#status.feishuLongConnectionState = 'connecting';
|
|
280
|
-
this.#status.lastError = null;
|
|
282
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
281
283
|
|
|
282
284
|
try {
|
|
283
|
-
await this.#harness.ensureRunning({ signal });
|
|
285
|
+
await atConnectionStage('harness.check', () => this.#harness.ensureRunning({ signal }));
|
|
284
286
|
assertCurrentStart();
|
|
285
287
|
this.#status.harnessReachable = true;
|
|
286
288
|
|
|
@@ -391,15 +393,16 @@ export class FeishuRuntime {
|
|
|
391
393
|
if (!isCurrentStart()) return;
|
|
392
394
|
this.#status.feishuLongConnectionState = 'connected';
|
|
393
395
|
this.#status.ready = true;
|
|
394
|
-
this.#status.lastError = null;
|
|
396
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
395
397
|
settleReady();
|
|
396
398
|
},
|
|
397
399
|
onError: (error) => {
|
|
398
400
|
if (!isCurrentStart()) return;
|
|
399
401
|
this.#status.feishuLongConnectionState = 'failed';
|
|
400
402
|
this.#status.ready = false;
|
|
401
|
-
this.#status.
|
|
402
|
-
this.#
|
|
403
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.monitor', botId: this.#botId, automatic: true }).publicError;
|
|
404
|
+
this.#status.lastError = this.#status.error.message;
|
|
405
|
+
|
|
403
406
|
settleError(error);
|
|
404
407
|
},
|
|
405
408
|
onReconnecting: () => {
|
|
@@ -411,7 +414,7 @@ export class FeishuRuntime {
|
|
|
411
414
|
if (!isCurrentStart()) return;
|
|
412
415
|
this.#status.feishuLongConnectionState = 'connected';
|
|
413
416
|
this.#status.ready = true;
|
|
414
|
-
this.#status.lastError = null;
|
|
417
|
+
this.#status.lastError = null; this.#status.error = null; this.#diagnostics.clear();
|
|
415
418
|
},
|
|
416
419
|
});
|
|
417
420
|
this.#wsClient = wsClient;
|
|
@@ -436,7 +439,8 @@ export class FeishuRuntime {
|
|
|
436
439
|
if (signal.aborted) throw error;
|
|
437
440
|
this.#status.ready = false;
|
|
438
441
|
this.#status.feishuLongConnectionState = 'failed';
|
|
439
|
-
this.#status.
|
|
442
|
+
this.#status.error = this.#diagnostics.report(error, { operation: 'connection.restore', reuse: true, botId: this.#botId, automatic: true }).publicError;
|
|
443
|
+
this.#status.lastError = this.#status.error.message;
|
|
440
444
|
await this.#cleanup({ preserveError: true, abortController });
|
|
441
445
|
throw error;
|
|
442
446
|
}
|
|
@@ -720,6 +724,7 @@ export class FeishuRuntime {
|
|
|
720
724
|
|
|
721
725
|
async #cleanup({ preserveError = false, abortController } = {}) {
|
|
722
726
|
const error = preserveError ? this.#status.lastError : null;
|
|
727
|
+
const diagnostic = preserveError ? this.#status.error : null;
|
|
723
728
|
if (this.#abortController === abortController) this.#abortController = null;
|
|
724
729
|
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
|
725
730
|
for (const probe of this.#pendingCardActionProbes.values()) {
|
|
@@ -742,7 +747,9 @@ export class FeishuRuntime {
|
|
|
742
747
|
this.#client = null;
|
|
743
748
|
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
|
744
749
|
this.#status.slashCommandRegistration = 'idle';
|
|
750
|
+
this.#status.error = diagnostic;
|
|
745
751
|
this.#status.lastError = error;
|
|
752
|
+
if (!preserveError) this.#diagnostics.clear();
|
|
746
753
|
return this.status;
|
|
747
754
|
}
|
|
748
755
|
}
|
|
@@ -27,7 +27,7 @@ export class GroupMessagePermissionManager {
|
|
|
27
27
|
#appId;
|
|
28
28
|
#domain;
|
|
29
29
|
|
|
30
|
-
constructor({ registerApp, onCredentials, appId, domain = 'feishu' } = {}) {
|
|
30
|
+
constructor({ registerApp, onCredentials, appId, domain = 'feishu', diagnostics } = {}) {
|
|
31
31
|
if (typeof registerApp !== 'function') throw new TypeError('registerApp is required');
|
|
32
32
|
if (typeof onCredentials !== 'function') throw new TypeError('onCredentials is required');
|
|
33
33
|
if (typeof appId !== 'string' || !appId.trim()) throw new TypeError('appId is required');
|
|
@@ -36,6 +36,7 @@ export class GroupMessagePermissionManager {
|
|
|
36
36
|
this.#appId = appId.trim();
|
|
37
37
|
this.#domain = domain;
|
|
38
38
|
this.#manager = new RegistrationManager({
|
|
39
|
+
diagnostics,
|
|
39
40
|
registerApp: (options) => registerApp({
|
|
40
41
|
...options,
|
|
41
42
|
onQRCodeReady: (info) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
|
|
1
2
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
2
3
|
import { t } from '../shared/i18n.mjs';
|
|
3
4
|
|
|
@@ -256,7 +257,7 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
|
256
257
|
throw new ImagePromptError(
|
|
257
258
|
'image-too-large',
|
|
258
259
|
`Feishu image exceeds ${maxBytes} bytes`,
|
|
259
|
-
|
|
260
|
+
imageDownloadLimitMessage(maxBytes),
|
|
260
261
|
);
|
|
261
262
|
}
|
|
262
263
|
chunks.push(data);
|
|
@@ -392,7 +393,7 @@ function feishuImageSource(event, client, key) {
|
|
|
392
393
|
throw new ImagePromptError(
|
|
393
394
|
'image-too-large',
|
|
394
395
|
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
395
|
-
|
|
396
|
+
imageDownloadLimitMessage(maxBytes),
|
|
396
397
|
);
|
|
397
398
|
}
|
|
398
399
|
return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
|