lark-remote 0.2.2 → 0.4.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/CHANGELOG.md +33 -0
- package/README.en.md +8 -5
- package/README.md +10 -6
- package/dist/binder.d.ts +9 -16
- package/dist/binder.js +14 -40
- package/dist/bridge/final-usage.js +2 -2
- package/dist/bridge/inbound-media.js +7 -5
- package/dist/bridge/index.js +75 -75
- package/dist/clone.d.ts +139 -0
- package/dist/clone.js +415 -0
- package/dist/common/fs.js +1 -1
- package/dist/config/codex-config.js +79 -24
- package/dist/config/index.js +1 -1
- package/dist/config/kimi-config.js +16 -5
- package/dist/connector/index.d.ts +25 -0
- package/dist/connector/index.js +133 -50
- package/dist/error-classification.d.ts +7 -0
- package/dist/error-classification.js +37 -1
- package/dist/index.js +48 -15
- package/dist/platform/command.js +1 -1
- package/dist/platform/sleep-blocker.d.ts +17 -0
- package/dist/platform/sleep-blocker.js +73 -0
- package/dist/platform/terminator-win32.js +1 -1
- package/dist/restart.d.ts +20 -3
- package/dist/restart.js +42 -23
- package/dist/router/config/kimi.js +4 -3
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.js +44 -6
- package/dist/runner/common/acp/base-acp-runner.js +7 -2
- package/dist/runner/common/acp/base-translator.d.ts +19 -0
- package/dist/runner/common/acp/base-translator.js +82 -0
- package/dist/runner/common/acp/protocol-types.d.ts +16 -2
- package/dist/runner/common/acp/protocol-types.js +3 -0
- package/dist/runner/kimi/acp/translator.js +60 -4
- package/dist/session/session-store.d.ts +1 -1
- package/dist/session/session-store.js +1 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.js +1 -1
- package/package.json +2 -2
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* - catalog 模式(config.toml 声明 model_catalog_json):模型列表/档位来自活动目录
|
|
9
9
|
* - 非 catalog 模式:openai 用 bundled 全量;anthropic 仅在用户显式配置时存在
|
|
10
10
|
*/
|
|
11
|
-
import { execFileSync, execFile } from 'node:child_process';
|
|
12
11
|
import fs from 'node:fs';
|
|
13
12
|
import os from 'node:os';
|
|
14
13
|
import path from 'node:path';
|
|
15
14
|
import TOML from '@iarna/toml';
|
|
16
15
|
import { getLogger } from '../logger/index.js';
|
|
16
|
+
import { spawnProcess, spawnProcessSync } from '../platform/spawn.js';
|
|
17
|
+
/** codex CLI 名(cross-spawn 负责 win32 .cmd 垫片解析)。 */
|
|
18
|
+
const CODEX_BINARY = 'codex';
|
|
19
|
+
/** codex CLI 调用超时 / 输出上限(同步与异步路径一致)。 */
|
|
20
|
+
const CODEX_TIMEOUT_MS = 8_000;
|
|
21
|
+
const CODEX_MAX_BUFFER = 4 * 1024 * 1024;
|
|
17
22
|
/**
|
|
18
23
|
* Resolve codex home directory.
|
|
19
24
|
* Priority: explicit codexHome param > $CODEX_HOME env > ~/.codex
|
|
@@ -170,14 +175,7 @@ export function getCodexCatalogModels(codexHome) {
|
|
|
170
175
|
}
|
|
171
176
|
const now = Date.now();
|
|
172
177
|
try {
|
|
173
|
-
const stdout =
|
|
174
|
-
encoding: 'utf-8',
|
|
175
|
-
timeout: 8_000,
|
|
176
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
177
|
-
env: process.env,
|
|
178
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
179
|
-
windowsHide: true, // codex 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
|
|
180
|
-
});
|
|
178
|
+
const stdout = runCodexCommandSync(catalogArgs(bundled));
|
|
181
179
|
const models = parseCodexModelsOutput(stdout);
|
|
182
180
|
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
183
181
|
return models;
|
|
@@ -208,7 +206,7 @@ export async function loadCodexCatalogModelsAsync(codexHome) {
|
|
|
208
206
|
const p = (async () => {
|
|
209
207
|
const now = Date.now();
|
|
210
208
|
try {
|
|
211
|
-
const stdout = await
|
|
209
|
+
const stdout = await runCodexCommandAsync(catalogArgs(bundled));
|
|
212
210
|
const models = parseCodexModelsOutput(stdout);
|
|
213
211
|
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
214
212
|
return models;
|
|
@@ -236,7 +234,7 @@ export function warmCodexCatalogCache(codexHome) {
|
|
|
236
234
|
}
|
|
237
235
|
/** 计算目录缓存键(binary/home/mode + 指纹),同步与异步路径共享。 */
|
|
238
236
|
function computeCodexCatalogKey(codexHome) {
|
|
239
|
-
const binary =
|
|
237
|
+
const binary = CODEX_BINARY;
|
|
240
238
|
const home = resolveCodexHome(codexHome);
|
|
241
239
|
const catalogPath = readModelCatalogJsonPath(home);
|
|
242
240
|
const bundled = catalogPath === undefined || catalogPath === TOML_PARSE_FAILED_SENTINEL;
|
|
@@ -277,20 +275,77 @@ function readCachedCatalog(key) {
|
|
|
277
275
|
function catalogArgs(bundled) {
|
|
278
276
|
return bundled ? ['debug', 'models', '--bundled'] : ['debug', 'models'];
|
|
279
277
|
}
|
|
280
|
-
/**
|
|
281
|
-
|
|
278
|
+
/**
|
|
279
|
+
* 同步运行 codex CLI(`debug models ...`),成功返回 stdout。
|
|
280
|
+
*
|
|
281
|
+
* 必须经 platform/spawn(cross-spawn)而非裸 execFileSync:codex 是 npm 全局
|
|
282
|
+
* 安装的 .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析 —— 裸名 'codex'
|
|
283
|
+
* 会被解析到无扩展名的 sh 脚本(给 Git Bash/WSL 用的那份),Windows 原生
|
|
284
|
+
* 执行直接失败(bun 下 exit 1「系统找不到指定的路径」,node 下 ENOENT);
|
|
285
|
+
* 而 execFileSync('codex.cmd') 又因 execFile 不经 shell 直接 EINVAL。
|
|
286
|
+
* cross-spawn 与 runner spawn 走同一垫片执行路径(posix 直通)。
|
|
287
|
+
* stderr 参与 pipe:失败信息带上 stderr 片段,避免只留一句无诊断价值的
|
|
288
|
+
* "Command failed"(历史教训:真因只存在于被丢弃的 stderr 里)。
|
|
289
|
+
*/
|
|
290
|
+
function runCodexCommandSync(args) {
|
|
291
|
+
const res = spawnProcessSync(CODEX_BINARY, args, {
|
|
292
|
+
encoding: 'utf-8',
|
|
293
|
+
timeout: CODEX_TIMEOUT_MS,
|
|
294
|
+
maxBuffer: CODEX_MAX_BUFFER,
|
|
295
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
296
|
+
});
|
|
297
|
+
if (res.error)
|
|
298
|
+
throw res.error;
|
|
299
|
+
if (res.status !== 0) {
|
|
300
|
+
const stderr = String(res.stderr ?? '').trim();
|
|
301
|
+
throw new Error(`codex exited ${res.status}${stderr ? `: ${stderr.slice(0, 200)}` : ''}`);
|
|
302
|
+
}
|
|
303
|
+
return String(res.stdout ?? '');
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 异步运行 codex CLI(spawnProcess + 流式收集),成功返回完整 stdout。
|
|
307
|
+
* 与同步路径同超时 / 输出上限;stdout 超 4MB 主动 kill(对齐 execFile maxBuffer 语义)。
|
|
308
|
+
*/
|
|
309
|
+
function runCodexCommandAsync(args) {
|
|
282
310
|
return new Promise((resolve, reject) => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
311
|
+
const child = spawnProcess(CODEX_BINARY, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
312
|
+
let stdout = '';
|
|
313
|
+
let stderr = '';
|
|
314
|
+
let settled = false;
|
|
315
|
+
const settle = (fn) => {
|
|
316
|
+
if (settled)
|
|
317
|
+
return;
|
|
318
|
+
settled = true;
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
fn();
|
|
321
|
+
};
|
|
322
|
+
const timer = setTimeout(() => {
|
|
323
|
+
settle(() => {
|
|
324
|
+
child.kill();
|
|
325
|
+
reject(new Error(`codex timed out after ${CODEX_TIMEOUT_MS}ms`));
|
|
326
|
+
});
|
|
327
|
+
}, CODEX_TIMEOUT_MS);
|
|
328
|
+
child.stdout?.on('data', (chunk) => {
|
|
329
|
+
stdout += String(chunk);
|
|
330
|
+
if (stdout.length > CODEX_MAX_BUFFER) {
|
|
331
|
+
settle(() => reject(new Error('codex stdout exceeded maxBuffer (4MB)')));
|
|
332
|
+
child.kill();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
child.stderr?.on('data', (chunk) => {
|
|
336
|
+
stderr += String(chunk);
|
|
337
|
+
});
|
|
338
|
+
child.on('error', (err) => settle(() => reject(err)));
|
|
339
|
+
child.on('close', (code) => {
|
|
340
|
+
settle(() => {
|
|
341
|
+
if (code === 0) {
|
|
342
|
+
resolve(stdout);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const tail = stderr.trim().slice(0, 200);
|
|
346
|
+
reject(new Error(`codex exited ${code}${tail ? `: ${tail}` : ''}`));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
294
349
|
});
|
|
295
350
|
});
|
|
296
351
|
}
|
package/dist/config/index.js
CHANGED
|
@@ -256,7 +256,7 @@ export const AppConfigSchema = z.object({
|
|
|
256
256
|
inboundMedia: InboundMediaConfigSchema.default(InboundMediaConfigSchema.parse({})),
|
|
257
257
|
/** Default agent for new runs. */
|
|
258
258
|
defaultAgent: z.enum(['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh']).default('claude'),
|
|
259
|
-
/** Check for updates on
|
|
259
|
+
/** Check for updates on lark-remote startup (default: false). */
|
|
260
260
|
checkUpdateOnStartup: z.boolean().default(false),
|
|
261
261
|
});
|
|
262
262
|
const TEMPLATE = `feishu:
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Dynamically reads available models from `kimi provider list --json`,
|
|
5
5
|
* provides dynamic dropdown options for /config card.
|
|
6
6
|
*/
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
7
|
import { getLogger } from '../logger/index.js';
|
|
8
|
+
import { spawnProcessSync } from '../platform/spawn.js';
|
|
9
9
|
/** Valid thinking effort values for kimi (Layer 1: Schema enum source) */
|
|
10
10
|
export const KIMI_THINKING_EFFORTS = ['low', 'high', 'max'];
|
|
11
11
|
/** Fallback efforts when all values are filtered out */
|
|
@@ -30,13 +30,24 @@ export function loadKimiConfig() {
|
|
|
30
30
|
}
|
|
31
31
|
const logger = getLogger();
|
|
32
32
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// spawnProcessSync(cross-spawn)而非 execFileSync:kimi 是 npm 全局安装的
|
|
34
|
+
// .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析(裸名解析到无扩展名的
|
|
35
|
+
// sh 脚本直接执行失败,.cmd 又 EINVAL);cross-spawn 与 runner spawn 走
|
|
36
|
+
// 同一垫片执行路径(posix 直通)。stderr 参与 pipe 以便失败信息可诊断。
|
|
37
|
+
const res = spawnProcessSync('kimi', ['provider', 'list', '--json'], {
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
39
|
timeout: 10000,
|
|
36
40
|
maxBuffer: 2 * 1024 * 1024,
|
|
37
|
-
|
|
41
|
+
encoding: 'utf-8',
|
|
38
42
|
});
|
|
39
|
-
|
|
43
|
+
if (res.error)
|
|
44
|
+
throw res.error;
|
|
45
|
+
if (res.status !== 0) {
|
|
46
|
+
const stderr = String(res.stderr ?? '').trim();
|
|
47
|
+
throw new Error(`kimi exited ${res.status}${stderr ? `: ${stderr.slice(0, 200)}` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
const output = String(res.stdout ?? '');
|
|
50
|
+
const data = JSON.parse(output);
|
|
40
51
|
if (!data.models || Object.keys(data.models).length === 0) {
|
|
41
52
|
logger.warn('[kimi-config] no models found in provider list, using defaults');
|
|
42
53
|
return buildDefaultResult();
|
|
@@ -82,6 +82,12 @@ type InboundMediaDetectedHandler = (msg: InboundMediaMessage) => void | Promise<
|
|
|
82
82
|
* means "no immediate response" (original behavior).
|
|
83
83
|
*/
|
|
84
84
|
type CardActionHandler = (action: CardActionEvent) => void | CardActionResponse | Promise<void | CardActionResponse>;
|
|
85
|
+
/**
|
|
86
|
+
* 卡片 patch 的重试上限。含义:1 次原始尝试 + 最多 PATCH_MAX_RETRIES 次重试。
|
|
87
|
+
*/
|
|
88
|
+
export declare const PATCH_MAX_RETRIES = 3;
|
|
89
|
+
/** 重试退避基数(ms);第 n 次重试前等待 n × 基数,给对端恢复的时间。 */
|
|
90
|
+
export declare const PATCH_RETRY_BASE_DELAY_MS = 150;
|
|
85
91
|
export declare class FeishuConnector {
|
|
86
92
|
/** 飞书 channel 实例(public:测试直接访问/注入 mock,替代 as unknown as)。 */
|
|
87
93
|
channel: LarkChannel;
|
|
@@ -140,11 +146,30 @@ export declare class FeishuConnector {
|
|
|
140
146
|
* The SDK resolves the reaction id internally; missing reaction is a no-op.
|
|
141
147
|
*/
|
|
142
148
|
removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
|
|
151
|
+
* im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
|
|
152
|
+
*/
|
|
153
|
+
private checkUploadable;
|
|
154
|
+
/**
|
|
155
|
+
* sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
|
|
156
|
+
* → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
|
|
157
|
+
* 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
|
|
158
|
+
* (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
|
|
159
|
+
* 标签('sendFile' | 'sendImage')。
|
|
160
|
+
*/
|
|
161
|
+
private uploadAndSendMedia;
|
|
143
162
|
/**
|
|
144
163
|
* Upload a file to Feishu and send it to the specified chat.
|
|
145
164
|
* Uses the im/v1/files upload API.
|
|
146
165
|
*/
|
|
147
166
|
sendFile(chatId: string, filePath: string): Promise<string>;
|
|
167
|
+
/**
|
|
168
|
+
* Upload a local image file to Feishu and send it as an image message
|
|
169
|
+
* (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
|
|
170
|
+
* scan-to-create QR code inline instead of as a downloadable file.
|
|
171
|
+
*/
|
|
172
|
+
sendImage(chatId: string, filePath: string): Promise<string>;
|
|
148
173
|
/**
|
|
149
174
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
150
175
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|
package/dist/connector/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getLogger } from '../logger/index.js';
|
|
|
3
3
|
import { MAX_FILE_UPLOAD_SIZE } from './file-limits.js';
|
|
4
4
|
import { DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB } from '../config/index.js';
|
|
5
5
|
import { sleep } from '../common/sleep.js';
|
|
6
|
+
import { isTransientTransportError } from '../error-classification.js';
|
|
6
7
|
import axios from 'axios';
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import { silentlyUnlink } from '../common/fs.js';
|
|
@@ -88,6 +89,72 @@ function shouldRetrySendError(err) {
|
|
|
88
89
|
return true;
|
|
89
90
|
return false;
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* 卡片 patch 的重试上限。含义:1 次原始尝试 + 最多 PATCH_MAX_RETRIES 次重试。
|
|
94
|
+
*/
|
|
95
|
+
export const PATCH_MAX_RETRIES = 3;
|
|
96
|
+
/** 重试退避基数(ms);第 n 次重试前等待 n × 基数,给对端恢复的时间。 */
|
|
97
|
+
export const PATCH_RETRY_BASE_DELAY_MS = 150;
|
|
98
|
+
/** 记日志用的错误摘要(不打印 axios 巨型循环对象)。 */
|
|
99
|
+
function formatErrorForLog(err) {
|
|
100
|
+
if (err instanceof Error) {
|
|
101
|
+
// For axios errors, extract useful info without circular refs
|
|
102
|
+
const axiosErr = err;
|
|
103
|
+
if (axiosErr.response?.status) {
|
|
104
|
+
return `${err.message} (HTTP ${axiosErr.response.status})`;
|
|
105
|
+
}
|
|
106
|
+
if (axiosErr.code) {
|
|
107
|
+
return `${err.message} (code: ${axiosErr.code})`;
|
|
108
|
+
}
|
|
109
|
+
return err.message;
|
|
110
|
+
}
|
|
111
|
+
return String(err);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 卡片 patch 是否值得重试:传输层瞬态失败 + 5xx。
|
|
115
|
+
* 4xx(业务拒绝/请求构造错误)不重试 —— 重试只会得到同样的结果。
|
|
116
|
+
*/
|
|
117
|
+
function isRetryablePatchError(err) {
|
|
118
|
+
if (isTransientTransportError(err))
|
|
119
|
+
return true;
|
|
120
|
+
if (err == null || typeof err !== 'object')
|
|
121
|
+
return false;
|
|
122
|
+
const status = err.response?.status;
|
|
123
|
+
return typeof status === 'number' && status >= 500 && status < 600;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 带重试的 `im.v1.message.patch`。
|
|
127
|
+
*
|
|
128
|
+
* 为什么必须在这一层重试:流式卡片的 patch 由 @larksuite/channel 的
|
|
129
|
+
* CardStreamController 经 throttle 延迟触发 —— `controller.update()` 调完
|
|
130
|
+
* `throttle.note()` 就 resolve,不等真正的 patch。失败时是一条脱离 await 链的
|
|
131
|
+
* detached rejection,只能冒泡到 `process.unhandledRejection`,调用方的
|
|
132
|
+
* try/catch 根本挡不住。2026-09-15 00:09 的事故正是如此:卡片 patch 时
|
|
133
|
+
* socket 被关(AxiosError `ERR_SOCKET_CLOSED`),整进程退出,正在跑的 run 陪葬。
|
|
134
|
+
*
|
|
135
|
+
* 这里在唯一的底层 patch 出口上兜住:瞬态错误重试最多 PATCH_MAX_RETRIES 次,
|
|
136
|
+
* 全部失败才把最后一个错误抛出去(此时上层 classifyRejection 也会判为
|
|
137
|
+
* recoverable,不会击穿进程)。patch 是整卡替换,重试天然幂等。
|
|
138
|
+
*/
|
|
139
|
+
async function patchWithRetry(patchFn, request, options) {
|
|
140
|
+
let lastError;
|
|
141
|
+
for (let attempt = 0; attempt <= PATCH_MAX_RETRIES; attempt += 1) {
|
|
142
|
+
try {
|
|
143
|
+
return await patchFn(request, options);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
lastError = err;
|
|
147
|
+
if (attempt === PATCH_MAX_RETRIES || !isRetryablePatchError(err))
|
|
148
|
+
throw err;
|
|
149
|
+
const delayMs = PATCH_RETRY_BASE_DELAY_MS * (attempt + 1);
|
|
150
|
+
getLogger().warn(`[feishu] message.patch transient failure ` +
|
|
151
|
+
`(attempt ${attempt + 1}/${PATCH_MAX_RETRIES + 1}), retrying in ${delayMs}ms: ` +
|
|
152
|
+
`${formatErrorForLog(err)}`);
|
|
153
|
+
await sleep(delayMs);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw lastError;
|
|
157
|
+
}
|
|
91
158
|
/**
|
|
92
159
|
* Safely extract the im.v1.message.patch service from a LarkChannel.
|
|
93
160
|
* Returns undefined when the channel mock omits rawClient (unit tests).
|
|
@@ -126,16 +193,19 @@ export class FeishuConnector {
|
|
|
126
193
|
},
|
|
127
194
|
},
|
|
128
195
|
});
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
196
|
+
// 卡片 patch 的统一出口(观测 + 重试):
|
|
197
|
+
// 1) 观测(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
|
|
198
|
+
// 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
|
|
199
|
+
// 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里把业务码记 warn,
|
|
200
|
+
// 返回值原样透传。
|
|
201
|
+
// 2) 重试(2026-09-15 socket-close 事故):瞬态传输失败就地重试,最多
|
|
202
|
+
// PATCH_MAX_RETRIES 次,不把失败直接落到 unhandledRejection 打死进程。
|
|
133
203
|
// Guard: unit-test mocks may omit rawClient; skip probe installation in that case.
|
|
134
204
|
const messageService = tryGetPatchService(this.channel);
|
|
135
205
|
if (messageService?.patch) {
|
|
136
206
|
const origPatch = messageService.patch.bind(messageService);
|
|
137
207
|
messageService.patch = (async (request, options) => {
|
|
138
|
-
const res = await origPatch
|
|
208
|
+
const res = await patchWithRetry(origPatch, request, options);
|
|
139
209
|
if (typeof res?.code === 'number' && res.code !== 0) {
|
|
140
210
|
const content = request?.data?.content;
|
|
141
211
|
const bytes = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : undefined;
|
|
@@ -344,18 +414,7 @@ export class FeishuConnector {
|
|
|
344
414
|
}
|
|
345
415
|
/** Format error for logging without causing circular serialization */
|
|
346
416
|
formatError(err) {
|
|
347
|
-
|
|
348
|
-
// For axios errors, extract useful info without circular refs
|
|
349
|
-
const axiosErr = err;
|
|
350
|
-
if (axiosErr.response?.status) {
|
|
351
|
-
return `${err.message} (HTTP ${axiosErr.response.status})`;
|
|
352
|
-
}
|
|
353
|
-
if (axiosErr.code) {
|
|
354
|
-
return `${err.message} (code: ${axiosErr.code})`;
|
|
355
|
-
}
|
|
356
|
-
return err.message;
|
|
357
|
-
}
|
|
358
|
-
return String(err);
|
|
417
|
+
return formatErrorForLog(err);
|
|
359
418
|
}
|
|
360
419
|
async addReaction(messageId, emoji) {
|
|
361
420
|
try {
|
|
@@ -378,11 +437,10 @@ export class FeishuConnector {
|
|
|
378
437
|
}
|
|
379
438
|
}
|
|
380
439
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
440
|
+
* sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
|
|
441
|
+
* im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
|
|
383
442
|
*/
|
|
384
|
-
|
|
385
|
-
// Check file existence first with proper error handling
|
|
443
|
+
checkUploadable(filePath, noun) {
|
|
386
444
|
let stat;
|
|
387
445
|
try {
|
|
388
446
|
stat = fs.statSync(filePath);
|
|
@@ -390,33 +448,32 @@ export class FeishuConnector {
|
|
|
390
448
|
catch (err) {
|
|
391
449
|
const code = err.code;
|
|
392
450
|
if (code === 'ENOENT') {
|
|
393
|
-
throw new Error(`Cannot access
|
|
451
|
+
throw new Error(`Cannot access ${noun}: file not found (${filePath})`, { cause: err });
|
|
394
452
|
}
|
|
395
|
-
throw new Error(`Cannot access
|
|
453
|
+
throw new Error(`Cannot access ${noun}: ${err.message}`, { cause: err });
|
|
396
454
|
}
|
|
397
|
-
// Check file size limit (30MB, aligned with Feishu im/v1/files API)
|
|
398
455
|
if (stat.size > MAX_FILE_UPLOAD_SIZE) {
|
|
399
|
-
|
|
456
|
+
const nounCap = noun.charAt(0).toUpperCase() + noun.slice(1);
|
|
457
|
+
throw new Error(`${nounCap} too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
|
|
400
458
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
|
|
462
|
+
* → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
|
|
463
|
+
* 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
|
|
464
|
+
* (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
|
|
465
|
+
* 标签('sendFile' | 'sendImage')。
|
|
466
|
+
*/
|
|
467
|
+
async uploadAndSendMedia(chatId, filePath, label, uploadUrl, buildForm, keyField, msgType) {
|
|
405
468
|
let fileStream = null;
|
|
406
469
|
try {
|
|
407
|
-
// P2-18: tenant_access_token cache
|
|
408
|
-
// (token is valid ~2h); validate data.code before trusting the token.
|
|
470
|
+
// P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
|
|
409
471
|
const accessToken = await this.getTenantAccessToken();
|
|
410
|
-
// Upload file
|
|
411
|
-
const form = new FormData();
|
|
412
472
|
fileStream = fs.createReadStream(filePath);
|
|
413
|
-
form
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
|
|
418
|
-
// keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
|
|
419
|
-
const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
|
|
473
|
+
const form = buildForm(fileStream);
|
|
474
|
+
// P2-18: timeout 120s(大文件慢链路);httpsAgent: noKeepAlive 防止
|
|
475
|
+
// Bun 复用已被服务端 RST 的 keep-alive socket(ECONNRESET after ~30s)。
|
|
476
|
+
const uploadResp = await axios.post(uploadUrl, form, {
|
|
420
477
|
headers: {
|
|
421
478
|
...form.getHeaders(),
|
|
422
479
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -425,15 +482,14 @@ export class FeishuConnector {
|
|
|
425
482
|
httpsAgent: noKeepAliveAgent,
|
|
426
483
|
});
|
|
427
484
|
if (uploadResp.data.code !== 0) {
|
|
428
|
-
throw new Error(`
|
|
485
|
+
throw new Error(`Upload failed: ${uploadResp.data.msg}`);
|
|
429
486
|
}
|
|
430
|
-
const
|
|
431
|
-
//
|
|
432
|
-
// httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
|
|
487
|
+
const mediaKey = uploadResp.data.data[keyField];
|
|
488
|
+
// P2-18: timeout 30s;noKeepAlive 同上。
|
|
433
489
|
const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
|
|
434
490
|
receive_id: chatId,
|
|
435
|
-
msg_type:
|
|
436
|
-
content: JSON.stringify({
|
|
491
|
+
msg_type: msgType,
|
|
492
|
+
content: JSON.stringify({ [keyField]: mediaKey }),
|
|
437
493
|
}, {
|
|
438
494
|
headers: {
|
|
439
495
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -444,7 +500,7 @@ export class FeishuConnector {
|
|
|
444
500
|
httpsAgent: noKeepAliveAgent,
|
|
445
501
|
});
|
|
446
502
|
if (sendResp.data.code !== 0) {
|
|
447
|
-
throw new Error(`
|
|
503
|
+
throw new Error(`Media message failed: ${sendResp.data.msg}`);
|
|
448
504
|
}
|
|
449
505
|
return sendResp.data.data.message_id;
|
|
450
506
|
}
|
|
@@ -458,12 +514,39 @@ export class FeishuConnector {
|
|
|
458
514
|
/* already destroyed */
|
|
459
515
|
}
|
|
460
516
|
}
|
|
461
|
-
// Log the error but throw a formatted error to prevent unhandled rejection
|
|
462
517
|
const errorInfo = this.formatError(err);
|
|
463
|
-
getLogger().error(
|
|
464
|
-
throw new Error(
|
|
518
|
+
getLogger().error(`[feishu] ${label} failed:`, errorInfo);
|
|
519
|
+
throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
|
|
465
520
|
}
|
|
466
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Upload a file to Feishu and send it to the specified chat.
|
|
524
|
+
* Uses the im/v1/files upload API.
|
|
525
|
+
*/
|
|
526
|
+
async sendFile(chatId, filePath) {
|
|
527
|
+
this.checkUploadable(filePath, 'file');
|
|
528
|
+
return this.uploadAndSendMedia(chatId, filePath, 'sendFile', 'https://open.feishu.cn/open-apis/im/v1/files', (stream) => {
|
|
529
|
+
const form = new FormData();
|
|
530
|
+
form.append('file', stream);
|
|
531
|
+
form.append('file_name', displayName(filePath));
|
|
532
|
+
form.append('file_type', 'stream');
|
|
533
|
+
return form;
|
|
534
|
+
}, 'file_key', 'file');
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Upload a local image file to Feishu and send it as an image message
|
|
538
|
+
* (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
|
|
539
|
+
* scan-to-create QR code inline instead of as a downloadable file.
|
|
540
|
+
*/
|
|
541
|
+
async sendImage(chatId, filePath) {
|
|
542
|
+
this.checkUploadable(filePath, 'image');
|
|
543
|
+
return this.uploadAndSendMedia(chatId, filePath, 'sendImage', 'https://open.feishu.cn/open-apis/im/v1/images', (stream) => {
|
|
544
|
+
const form = new FormData();
|
|
545
|
+
form.append('image_type', 'message');
|
|
546
|
+
form.append('image', stream);
|
|
547
|
+
return form;
|
|
548
|
+
}, 'image_key', 'image');
|
|
549
|
+
}
|
|
467
550
|
/**
|
|
468
551
|
* Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
|
|
469
552
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|
|
@@ -11,5 +11,12 @@
|
|
|
11
11
|
* (recoverable,记日志不 exit)还是「进程状态损坏」(fatal,exit)。
|
|
12
12
|
*/
|
|
13
13
|
type RejectionVerdict = 'recoverable' | 'fatal';
|
|
14
|
+
/**
|
|
15
|
+
* Whether `reason` is a transient transport failure (connection/socket level).
|
|
16
|
+
*
|
|
17
|
+
* Exported because the card-patch retry path needs the same judgement:
|
|
18
|
+
* one definition, so "retry" and "don't kill the process" never drift apart.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isTransientTransportError(reason: unknown): boolean;
|
|
14
21
|
export declare function classifyRejection(reason: unknown): RejectionVerdict;
|
|
15
22
|
export {};
|
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 传输层瞬态错误码(Node errno + axios 包装码)。
|
|
3
|
+
*
|
|
4
|
+
* 这些错误代表"这次请求没送出去/连接断了",重试或忽略都是安全的;
|
|
5
|
+
* 它们不携带任何"程序状态损坏"的信息。典型场景:本地 mitmdump 代理重启
|
|
6
|
+
* (ECONNREFUSED)、Wi-Fi 抖动(ENOTFOUND/EAI_AGAIN)、对端关连接
|
|
7
|
+
* (ECONNRESET/EPIPE/ERR_SOCKET_CLOSED)。
|
|
8
|
+
*/
|
|
9
|
+
const TRANSIENT_TRANSPORT_CODES = new Set([
|
|
10
|
+
'ETIMEDOUT',
|
|
11
|
+
'ECONNRESET',
|
|
12
|
+
'ECONNREFUSED',
|
|
13
|
+
'ECONNABORTED',
|
|
14
|
+
'EPIPE',
|
|
15
|
+
'EHOSTUNREACH',
|
|
16
|
+
'ENETUNREACH',
|
|
17
|
+
'ENOTFOUND',
|
|
18
|
+
'EAI_AGAIN',
|
|
19
|
+
'ERR_SOCKET_CLOSED',
|
|
20
|
+
'ERR_NETWORK',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Whether `reason` is a transient transport failure (connection/socket level).
|
|
24
|
+
*
|
|
25
|
+
* Exported because the card-patch retry path needs the same judgement:
|
|
26
|
+
* one definition, so "retry" and "don't kill the process" never drift apart.
|
|
27
|
+
*/
|
|
28
|
+
export function isTransientTransportError(reason) {
|
|
29
|
+
if (reason == null || typeof reason !== 'object')
|
|
30
|
+
return false;
|
|
31
|
+
const code = reason.code;
|
|
32
|
+
return typeof code === 'string' && TRANSIENT_TRANSPORT_CODES.has(code);
|
|
33
|
+
}
|
|
1
34
|
export function classifyRejection(reason) {
|
|
2
35
|
// Guard against null/undefined/primitive reasons: the `as` cast below is
|
|
3
36
|
// TS-only (erased at runtime), so `null.response` would throw a TypeError
|
|
@@ -10,7 +43,10 @@ export function classifyRejection(reason) {
|
|
|
10
43
|
const status = err.response?.status;
|
|
11
44
|
if (status !== undefined && status >= 500 && status < 600)
|
|
12
45
|
return 'recoverable';
|
|
13
|
-
|
|
46
|
+
// 传输层瞬态失败:连接/socket 级别,与进程状态无关。
|
|
47
|
+
// 2026-09-15 00:09 的崩溃就是这条漏了 —— AxiosError `ERR_SOCKET_CLOSED`
|
|
48
|
+
// (卡片 patch 时 socket 被关)落到 fatal → process.exit(1),正在跑的 agent run 陪葬。
|
|
49
|
+
if (isTransientTransportError(reason))
|
|
14
50
|
return 'recoverable';
|
|
15
51
|
// 飞书 API 业务错误(如 230027 外部会话无权限)—— 经 SDK throttle detach 路径逃逸的
|
|
16
52
|
// patch rejection。流式卡片是尽力而为的展示层,飞书侧业务拒绝不应击穿进程。
|