lark-remote 0.4.0 → 0.4.1
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 +18 -0
- package/dist/bridge/inbound-media.d.ts +13 -20
- package/dist/bridge/inbound-media.js +107 -93
- package/dist/bridge/index.d.ts +6 -6
- package/dist/bridge/index.js +6 -12
- package/dist/cli.js +5 -1
- package/dist/config/dir.d.ts +9 -0
- package/dist/config/dir.js +69 -0
- package/dist/config/index.d.ts +22 -3
- package/dist/config/index.js +25 -3
- package/dist/connector/index.d.ts +22 -1
- package/dist/connector/index.js +79 -14
- package/dist/inbound/event.d.ts +41 -0
- package/dist/inbound/event.js +11 -0
- package/dist/inbound/placeholder.d.ts +26 -0
- package/dist/inbound/placeholder.js +149 -0
- package/dist/inbound/prompt.d.ts +20 -0
- package/dist/inbound/prompt.js +47 -0
- package/dist/inbound/receipt.d.ts +28 -0
- package/dist/inbound/receipt.js +62 -0
- package/dist/inbound/turn-assembler.d.ts +63 -0
- package/dist/inbound/turn-assembler.js +233 -0
- package/dist/inbound/turn.d.ts +48 -0
- package/dist/inbound/turn.js +10 -0
- package/dist/index.js +96 -71
- package/dist/router/config/opencode.d.ts +1 -1
- package/dist/router/config/opencode.js +11 -0
- package/dist/router/index.d.ts +17 -0
- package/dist/router/index.js +37 -9
- package/dist/runner/common/connection-based-runner.js +6 -2
- package/dist/runner/kimi/acp/runner.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.js +1 -1
- package/dist/runner/opencode/acp/runner.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.1] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
### 新增
|
|
11
|
+
|
|
12
|
+
- **入站消息统一处理(图 + 文合并为一个回合)**:图片、文件、视频、语音、表情、富文本内嵌图一律自动保存到当前工作目录(`.lark-remote-temp/<时间目录>/`),附件绝对路径**自动写进 agent 的提示词**,不再需要你把"文件已保存"提示卡整段转述给 agent。同一静默期窗口(700 毫秒)内到达的文字与附件合并成一个回合:先发图再发文字、先发文字再发图,agent 拿到的内容完全相同
|
|
13
|
+
- **附件按种类命名**:视频/语音/表情保留原文件名;无原名时按 MIME 类型或文件头自动补扩展名(`.mp4` / `.opus` / `.gif` 等)
|
|
14
|
+
- **不支持消息类型明确回执**:卡片、位置、投票、日程、合并转发等拿不到内容的消息,回一条"暂不支持"提示,不再把占位符静默塞给 agent;纯附件无文字时只回执"已保存 N 个文件" + 引导语,不自动起回合(避免误耗 token)
|
|
15
|
+
- **`--advance-help` 高级配置参考**:命令行可输出高级配置项说明
|
|
16
|
+
- **OpenCode 配置卡支持回合空闲超时**:`turnIdleTimeoutMinutes`(默认 30 分钟,0 关闭),回合长时间无输出自动结束
|
|
17
|
+
|
|
18
|
+
### 修复
|
|
19
|
+
|
|
20
|
+
- **P0:以图片占位符开头的消息被误当 shell 命令执行**:飞书会把"图片 + 文字"合成富文本,图片被渲染成 `` 后首字符恰好是 `!`,命中 `!` 命令判定,整段用户消息被送进 shell 执行。现在命令识别有前置条件:仅当消息是纯文本、剥离结构占位符后仍以 `/` 或 `!` 开头时才按命令处理,其余一律按文本转发
|
|
21
|
+
|
|
22
|
+
### 变更
|
|
23
|
+
|
|
24
|
+
- `inboundMedia.maxFileSizeMb` 默认值 50 → **100 MB**(更大的飞书文件需要分片下载,SDK 不支持,超限提示里已写明)
|
|
25
|
+
|
|
8
26
|
## [0.4.0] - 2026-09-15
|
|
9
27
|
|
|
10
28
|
### 新增
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { type AppConfig } from '../config/index.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { MediaOutcome } from '../inbound/turn.js';
|
|
3
|
+
import type { InboundResourceKind, InboundMediaItem, InboundMediaPayload } from '../connector/index.js';
|
|
3
4
|
export interface InboundMediaDeps {
|
|
4
5
|
/** 解析用户 cwd(bridge.resolveCwd 注入,统一三份复制点)。 */
|
|
5
6
|
resolveCwd: (userId: string) => string | undefined;
|
|
6
7
|
/** 读取当前配置(活引用,随 /config 保存更新,避免启动快照过期)。 */
|
|
7
8
|
getConfig: () => AppConfig;
|
|
8
|
-
/** 发送提示消息(bridge.sendResult 包装)。 */
|
|
9
|
-
send(ctx: {
|
|
10
|
-
userId: string;
|
|
11
|
-
chatId: string;
|
|
12
|
-
messageId: string;
|
|
13
|
-
}, text: string): Promise<boolean>;
|
|
14
9
|
}
|
|
15
10
|
/**
|
|
16
11
|
* 文件名 sanitize:
|
|
@@ -19,10 +14,10 @@ export interface InboundMediaDeps {
|
|
|
19
14
|
*/
|
|
20
15
|
export declare function sanitizeFileName(name: string): string;
|
|
21
16
|
/**
|
|
22
|
-
*
|
|
17
|
+
* 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
|
|
23
18
|
* 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
|
|
24
19
|
*/
|
|
25
|
-
export declare function
|
|
20
|
+
export declare function extensionFor(_kind: InboundResourceKind, mimeType: string | undefined, head: Buffer): string | undefined;
|
|
26
21
|
/**
|
|
27
22
|
* 文件名长度限制(字节级,保留扩展名),避免 ENAMETOOLONG 整文件失败。
|
|
28
23
|
* 极罕见情况(扩展名本身超限)直接截断全名。
|
|
@@ -30,23 +25,22 @@ export declare function imageExtension(mimeType: string | undefined, head: Buffe
|
|
|
30
25
|
export declare function limitFileNameLength(name: string, maxBytes?: number): string;
|
|
31
26
|
/**
|
|
32
27
|
* 生成落盘文件名:
|
|
33
|
-
* -
|
|
34
|
-
* -
|
|
28
|
+
* - image:`image_<HHmmss>_<n>.<ext>`(ext 按 MIME/魔数);
|
|
29
|
+
* - 其它有原名的资源(file/video/audio/sticker):保留原名(已 sanitize);
|
|
30
|
+
* - 无原名的资源:`<kind>_<HHmmss>_<n>.<ext>`,ext 按 MIME/魔数
|
|
31
|
+
* (旧实现落成无扩展名的 `file_HHmmss_n`,mp4/opus/gif 都不可识别)。
|
|
35
32
|
*/
|
|
36
33
|
export declare function buildFileName(item: InboundMediaItem, index: number, receivedAt: Date): string;
|
|
37
34
|
/** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
|
|
38
35
|
export declare function uniqueTargetPath(dir: string, fileName: string): string;
|
|
39
36
|
export declare class InboundMediaHandler {
|
|
40
37
|
private readonly deps;
|
|
41
|
-
private readonly pending;
|
|
42
|
-
private readonly batchWindowMs;
|
|
43
38
|
constructor(deps: InboundMediaDeps);
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
flushAllPending(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* 落盘并返回结果(不改时间语义、不发消息)。
|
|
41
|
+
* 失败/超限/无 cwd 一律进 `rejected`,由装配器决定回执文案。
|
|
42
|
+
*/
|
|
43
|
+
save(payload: InboundMediaPayload): Promise<MediaOutcome>;
|
|
50
44
|
/**
|
|
51
45
|
* dirName 配置校验:只允许单层目录名(不允许 `/`、`\`、`..`、`.`、控制字符),
|
|
52
46
|
* 非法值回退默认 `.lark-remote-temp`,避免配置意外把文件写到 cwd 之外。
|
|
@@ -55,5 +49,4 @@ export declare class InboundMediaHandler {
|
|
|
55
49
|
/** 清理未被移动的临时文件(无 cwd / 目录创建失败等提前返回路径)。 */
|
|
56
50
|
private cleanupTemps;
|
|
57
51
|
private resolveCwd;
|
|
58
|
-
private flush;
|
|
59
52
|
}
|
|
@@ -8,26 +8,37 @@ import { silentlyUnlink } from '../common/fs.js';
|
|
|
8
8
|
* 入站媒体落盘。
|
|
9
9
|
*
|
|
10
10
|
* 核心设计:
|
|
11
|
-
* -
|
|
11
|
+
* - 存储「到达即存」:每个 media 立即原子写入
|
|
12
12
|
* `<cwd>/.lark-remote-temp/<YYYYMMDDHHmm>/`,不等待合批窗口;
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* - 只负责「落盘 + 报告结果」:把落盘路径与失败原因交回装配器
|
|
14
|
+
* (`InboundTurnAssembler`),由装配器决定注入 prompt 还是发回执。
|
|
15
|
+
* 时间语义统一由装配器的静默期窗口负责(2026-09-15 起取代旧的
|
|
16
|
+
* 500ms 合批提示窗口 ——「现有合批窗口由 700ms 装配窗口取代」)。
|
|
17
17
|
*/
|
|
18
|
-
/** 合批窗口(毫秒)。同一窗口内到达的保存提示合并为一条。 */
|
|
19
|
-
const DEFAULT_BATCH_WINDOW_MS = 500;
|
|
20
|
-
/** 单条提示最多列出的文件数;超出折叠为 "… 等 N 个文件"。 */
|
|
21
|
-
const MAX_PATHS_IN_NOTIFICATION = 10;
|
|
22
18
|
/** 文件名最长字节数(macOS/APFS 单组件上限 255 字节,留余量用 240)。 */
|
|
23
19
|
const MAX_FILE_NAME_BYTES = 240;
|
|
24
|
-
/** 魔数检测读取的头部字节数(png/jpg/gif/webp 签名都在前 12 字节内)。 */
|
|
20
|
+
/** 魔数检测读取的头部字节数(png/jpg/gif/webp/mp4/ogg 签名都在前 12 字节内)。 */
|
|
25
21
|
const MAGIC_HEAD_BYTES = 12;
|
|
26
22
|
const MIME_TO_EXT = {
|
|
27
23
|
'image/png': 'png',
|
|
28
24
|
'image/jpeg': 'jpg',
|
|
25
|
+
'image/jpg': 'jpg',
|
|
29
26
|
'image/gif': 'gif',
|
|
30
27
|
'image/webp': 'webp',
|
|
28
|
+
'image/bmp': 'bmp',
|
|
29
|
+
'video/mp4': 'mp4',
|
|
30
|
+
'video/quicktime': 'mov',
|
|
31
|
+
'video/webm': 'webm',
|
|
32
|
+
'video/x-matroska': 'mkv',
|
|
33
|
+
'audio/opus': 'opus',
|
|
34
|
+
'audio/ogg': 'ogg',
|
|
35
|
+
'audio/mpeg': 'mp3',
|
|
36
|
+
'audio/mp3': 'mp3',
|
|
37
|
+
'audio/wav': 'wav',
|
|
38
|
+
'audio/x-wav': 'wav',
|
|
39
|
+
'audio/aac': 'aac',
|
|
40
|
+
'audio/amr': 'amr',
|
|
41
|
+
'audio/mp4': 'm4a',
|
|
31
42
|
};
|
|
32
43
|
function pad2(n) {
|
|
33
44
|
return String(n).padStart(2, '0');
|
|
@@ -74,17 +85,23 @@ export function sanitizeFileName(name) {
|
|
|
74
85
|
return sanitized;
|
|
75
86
|
}
|
|
76
87
|
/**
|
|
77
|
-
*
|
|
88
|
+
* 资源扩展名(图片/视频/语音):优先 MIME 映射,未知时按魔数兜底;
|
|
78
89
|
* 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
|
|
79
90
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
export function extensionFor(_kind, mimeType, head) {
|
|
92
|
+
const normalized = mimeType?.split(';')[0]?.trim().toLowerCase();
|
|
93
|
+
if (normalized && MIME_TO_EXT[normalized])
|
|
94
|
+
return MIME_TO_EXT[normalized];
|
|
83
95
|
if (head.length >= 12 &&
|
|
84
96
|
head.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
85
97
|
head.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
86
98
|
return 'webp';
|
|
87
99
|
}
|
|
100
|
+
if (head.length >= 12 &&
|
|
101
|
+
head.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
102
|
+
head.subarray(8, 12).toString('latin1') === 'WAVE') {
|
|
103
|
+
return 'wav';
|
|
104
|
+
}
|
|
88
105
|
if (head.length >= 8 &&
|
|
89
106
|
head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
90
107
|
return 'png';
|
|
@@ -95,6 +112,29 @@ export function imageExtension(mimeType, head) {
|
|
|
95
112
|
if (head.length >= 4 && head.subarray(0, 4).toString('latin1') === 'GIF8') {
|
|
96
113
|
return 'gif';
|
|
97
114
|
}
|
|
115
|
+
if (head.length >= 4 && head.subarray(0, 4).toString('latin1') === 'OggS') {
|
|
116
|
+
return 'opus';
|
|
117
|
+
}
|
|
118
|
+
// MP4/MOV 家族:ISO BMFF 在偏移 4 处写 'ftyp'。
|
|
119
|
+
if (head.length >= 8 && head.subarray(4, 8).toString('latin1') === 'ftyp') {
|
|
120
|
+
return 'mp4';
|
|
121
|
+
}
|
|
122
|
+
if (head.length >= 2 && head[0] === 0x42 && head[1] === 0x4d) {
|
|
123
|
+
return 'bmp';
|
|
124
|
+
}
|
|
125
|
+
if (head.length >= 3 && head.subarray(0, 3).toString('latin1') === 'ID3') {
|
|
126
|
+
return 'mp3';
|
|
127
|
+
}
|
|
128
|
+
// MPEG audio frame sync(0xFFE0 掩码)+ 常见 layer/bitrate 组合。
|
|
129
|
+
if (head.length >= 2 && head[0] === 0xff && (head[1] & 0xfe) === 0xfa) {
|
|
130
|
+
return 'mp3';
|
|
131
|
+
}
|
|
132
|
+
if (head.length >= 5 && head.subarray(0, 5).toString('latin1') === '#!AMR') {
|
|
133
|
+
return 'amr';
|
|
134
|
+
}
|
|
135
|
+
if (head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3) {
|
|
136
|
+
return 'mkv';
|
|
137
|
+
}
|
|
98
138
|
return undefined;
|
|
99
139
|
}
|
|
100
140
|
/** 按 UTF-8 字节数截断字符串(避免截在多字节字符中间)。 */
|
|
@@ -127,17 +167,17 @@ function readFileHead(filePath, maxBytes = MAGIC_HEAD_BYTES) {
|
|
|
127
167
|
}
|
|
128
168
|
/**
|
|
129
169
|
* 生成落盘文件名:
|
|
130
|
-
* -
|
|
131
|
-
* -
|
|
170
|
+
* - image:`image_<HHmmss>_<n>.<ext>`(ext 按 MIME/魔数);
|
|
171
|
+
* - 其它有原名的资源(file/video/audio/sticker):保留原名(已 sanitize);
|
|
172
|
+
* - 无原名的资源:`<kind>_<HHmmss>_<n>.<ext>`,ext 按 MIME/魔数
|
|
173
|
+
* (旧实现落成无扩展名的 `file_HHmmss_n`,mp4/opus/gif 都不可识别)。
|
|
132
174
|
*/
|
|
133
175
|
export function buildFileName(item, index, receivedAt) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const ext = imageExtension(item.mimeType, readFileHead(item.tempPath));
|
|
140
|
-
return `image_${timeStampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
|
|
176
|
+
const original = item.fileName ? limitFileNameLength(sanitizeFileName(item.fileName)) : '';
|
|
177
|
+
if (item.kind !== 'image' && original)
|
|
178
|
+
return original;
|
|
179
|
+
const ext = extensionFor(item.kind, item.mimeType, readFileHead(item.tempPath));
|
|
180
|
+
return `${item.kind}_${timeStampHms(receivedAt)}_${index}${ext ? `.${ext}` : ''}`;
|
|
141
181
|
}
|
|
142
182
|
/** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
|
|
143
183
|
export function uniqueTargetPath(dir, fileName) {
|
|
@@ -151,27 +191,29 @@ export function uniqueTargetPath(dir, fileName) {
|
|
|
151
191
|
}
|
|
152
192
|
return candidate;
|
|
153
193
|
}
|
|
154
|
-
function formatFailure(f) {
|
|
155
|
-
return f.fileName ? `${f.fileName}: ${f.reason}` : f.reason;
|
|
156
|
-
}
|
|
157
|
-
function batchKey(payload) {
|
|
158
|
-
return `${payload.userId}:${payload.chatId}:${payload.replyToMessageId ?? ''}`;
|
|
159
|
-
}
|
|
160
194
|
export class InboundMediaHandler {
|
|
161
195
|
deps;
|
|
162
|
-
pending = new Map();
|
|
163
|
-
batchWindowMs;
|
|
164
196
|
constructor(deps) {
|
|
165
197
|
this.deps = deps;
|
|
166
|
-
this.batchWindowMs = DEFAULT_BATCH_WINDOW_MS;
|
|
167
198
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
199
|
+
/**
|
|
200
|
+
* 落盘并返回结果(不改时间语义、不发消息)。
|
|
201
|
+
* 失败/超限/无 cwd 一律进 `rejected`,由装配器决定回执文案。
|
|
202
|
+
*/
|
|
203
|
+
async save(payload) {
|
|
170
204
|
const cwd = this.resolveCwd(payload.userId);
|
|
171
205
|
if (!cwd) {
|
|
172
206
|
this.cleanupTemps(payload);
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
return {
|
|
208
|
+
attachments: [],
|
|
209
|
+
rejected: [
|
|
210
|
+
{
|
|
211
|
+
kind: 'file',
|
|
212
|
+
reason: '未设置工作目录,无法保存文件。请先使用 /cd <path> 或 /ws use 设置',
|
|
213
|
+
sourceMsgId: payload.messageId,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
175
217
|
}
|
|
176
218
|
const receivedAt = new Date();
|
|
177
219
|
const dir = path.join(cwd, this.safeDirName(), timeStampDir(receivedAt));
|
|
@@ -181,55 +223,49 @@ export class InboundMediaHandler {
|
|
|
181
223
|
catch (err) {
|
|
182
224
|
// 写盘失败必须明确提示;临时文件一并清理避免泄漏。
|
|
183
225
|
this.cleanupTemps(payload);
|
|
184
|
-
|
|
185
|
-
|
|
226
|
+
return {
|
|
227
|
+
attachments: [],
|
|
228
|
+
rejected: [
|
|
229
|
+
{
|
|
230
|
+
kind: 'file',
|
|
231
|
+
reason: `保存失败:无法创建目录 ${dir}:${err.message}`,
|
|
232
|
+
sourceMsgId: payload.messageId,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
186
236
|
}
|
|
187
|
-
const
|
|
188
|
-
|
|
237
|
+
const rejected = payload.failures.map((f) => ({
|
|
238
|
+
kind: f.kind ?? 'file',
|
|
239
|
+
reason: f.fileName ? `${f.fileName}: ${f.reason}` : f.reason,
|
|
240
|
+
sourceMsgId: payload.messageId,
|
|
241
|
+
}));
|
|
242
|
+
const attachments = [];
|
|
189
243
|
for (let i = 0; i < payload.media.length; i += 1) {
|
|
190
244
|
const item = payload.media[i];
|
|
191
245
|
try {
|
|
192
246
|
const target = uniqueTargetPath(dir, buildFileName(item, i + 1, receivedAt));
|
|
193
247
|
atomicMoveFile(item.tempPath, target);
|
|
194
|
-
|
|
248
|
+
attachments.push({
|
|
249
|
+
path: target,
|
|
250
|
+
kind: item.kind,
|
|
251
|
+
sourceMsgId: payload.messageId,
|
|
252
|
+
originalName: item.fileName,
|
|
253
|
+
durationMs: item.durationMs,
|
|
254
|
+
});
|
|
195
255
|
}
|
|
196
256
|
catch (err) {
|
|
197
257
|
silentlyUnlink(item.tempPath);
|
|
198
258
|
const label = item.fileName
|
|
199
259
|
? sanitizeFileName(item.fileName) || item.fileName
|
|
200
260
|
: `第 ${i + 1} 个`;
|
|
201
|
-
|
|
261
|
+
rejected.push({
|
|
262
|
+
kind: item.kind,
|
|
263
|
+
reason: `${label}: ${err.message}`,
|
|
264
|
+
sourceMsgId: payload.messageId,
|
|
265
|
+
});
|
|
202
266
|
}
|
|
203
267
|
}
|
|
204
|
-
|
|
205
|
-
await this.deps.send({ userId: payload.userId, chatId: payload.chatId, messageId: payload.messageId }, `⚠️ 保存失败:${errors.join(';') || '未知错误'}`);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const key = batchKey(payload);
|
|
209
|
-
const existing = this.pending.get(key);
|
|
210
|
-
if (existing) {
|
|
211
|
-
existing.saved.push(...saved);
|
|
212
|
-
existing.errors.push(...errors);
|
|
213
|
-
clearTimeout(existing.timer);
|
|
214
|
-
existing.timer = setTimeout(() => void this.flush(key), this.batchWindowMs);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const timer = setTimeout(() => void this.flush(key), this.batchWindowMs);
|
|
218
|
-
this.pending.set(key, { payload, saved, errors, timer });
|
|
219
|
-
}
|
|
220
|
-
/** 立即冲刷某用户的全部待合批提示(文本到达时调用)。 */
|
|
221
|
-
flushAll(userId, chatId) {
|
|
222
|
-
for (const key of [...this.pending.keys()]) {
|
|
223
|
-
const batch = this.pending.get(key);
|
|
224
|
-
if (batch && batch.payload.userId === userId && batch.payload.chatId === chatId) {
|
|
225
|
-
void this.flush(key);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
/** 冲刷全部待合批提示(/exit、/restart 干净退出前调用,避免丢最后一条提示)。 */
|
|
230
|
-
async flushAllPending() {
|
|
231
|
-
const keys = [...this.pending.keys()];
|
|
232
|
-
await Promise.all(keys.map((key) => this.flush(key)));
|
|
268
|
+
return { attachments, rejected };
|
|
233
269
|
}
|
|
234
270
|
/**
|
|
235
271
|
* dirName 配置校验:只允许单层目录名(不允许 `/`、`\`、`..`、`.`、控制字符),
|
|
@@ -257,27 +293,5 @@ export class InboundMediaHandler {
|
|
|
257
293
|
resolveCwd(userId) {
|
|
258
294
|
return this.deps.resolveCwd(userId);
|
|
259
295
|
}
|
|
260
|
-
async flush(key) {
|
|
261
|
-
const batch = this.pending.get(key);
|
|
262
|
-
if (!batch)
|
|
263
|
-
return;
|
|
264
|
-
this.pending.delete(key);
|
|
265
|
-
clearTimeout(batch.timer);
|
|
266
|
-
const lines = batch.saved.slice(0, MAX_PATHS_IN_NOTIFICATION).map((p) => `- ${p}`);
|
|
267
|
-
const overflow = batch.saved.length > MAX_PATHS_IN_NOTIFICATION ? `\n… 等 ${batch.saved.length} 个文件` : '';
|
|
268
|
-
const failures = batch.errors.length > 0 ? `\n⚠️ ${batch.errors.join(';')}` : '';
|
|
269
|
-
// 提示带上保存目录:用户把整句转给 agent 即可定位,无需靠目录约定反查。
|
|
270
|
-
// 合批窗口跨分钟时可能落在两个目录,此时全部列出。
|
|
271
|
-
const dirs = [...new Set(batch.saved.map((p) => path.dirname(p)))];
|
|
272
|
-
const hint = dirs.length === 1
|
|
273
|
-
? `\n💡 你可以直接说:请处理 ${dirs[0]} 下的文件`
|
|
274
|
-
: `\n💡 你可以直接说:请处理以下目录下的文件:${dirs.join('、')}`;
|
|
275
|
-
const text = `📎 已保存 ${batch.saved.length} 个文件:\n` + lines.join('\n') + overflow + failures + hint;
|
|
276
|
-
await this.deps.send({
|
|
277
|
-
userId: batch.payload.userId,
|
|
278
|
-
chatId: batch.payload.chatId,
|
|
279
|
-
messageId: batch.payload.messageId,
|
|
280
|
-
}, text);
|
|
281
|
-
}
|
|
282
296
|
}
|
|
283
297
|
//# sourceMappingURL=inbound-media.js.map
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { RunTerminal } from '../card/run-state.js';
|
|
|
7
7
|
import type { AgentKind } from '../runner/types.js';
|
|
8
8
|
import { QueueManager, type QueuedTask, type EnqueueOptions, type AgentBinding } from './queue-manager.js';
|
|
9
9
|
import type { InboundMediaPayload } from '../connector/index.js';
|
|
10
|
+
import type { MediaOutcome } from '../inbound/turn.js';
|
|
10
11
|
/**
|
|
11
12
|
* Seam (§7): the channel capabilities the bridge needs. `sendWithRetry` is
|
|
12
13
|
* the minimum a Feishu channel must offer for the bridge to push messages
|
|
@@ -318,12 +319,11 @@ export declare class Bridge {
|
|
|
318
319
|
updateCardInPlace(card: object, ctx: BridgeContext): Promise<boolean>;
|
|
319
320
|
/** Send a file to the user via Feishu. */
|
|
320
321
|
sendFile(filePath: string, ctx: BridgeContext): Promise<void>;
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
flushAllMediaNotifications(): Promise<void>;
|
|
322
|
+
/**
|
|
323
|
+
* 入站媒体落盘(connector 下载完成后调用):只落盘并回报结果,
|
|
324
|
+
* 时间语义与回执由 `InboundTurnAssembler` 统一负责。
|
|
325
|
+
*/
|
|
326
|
+
saveInboundMedia(payload: InboundMediaPayload): Promise<MediaOutcome>;
|
|
327
327
|
/**
|
|
328
328
|
* Resolve the working directory for a user.
|
|
329
329
|
* First checks sessionStore (set by /cd), then falls back to first workspace.
|
package/dist/bridge/index.js
CHANGED
|
@@ -135,7 +135,6 @@ export class Bridge {
|
|
|
135
135
|
this.mediaHandler = new InboundMediaHandler({
|
|
136
136
|
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
137
137
|
getConfig: () => this.config,
|
|
138
|
-
send: async (ctx, text) => this.sendResult({ text }, ctx),
|
|
139
138
|
});
|
|
140
139
|
}
|
|
141
140
|
/**
|
|
@@ -671,17 +670,12 @@ export class Bridge {
|
|
|
671
670
|
await this.sendResult({ text: `发送文件失败: ${err.message}` }, ctx);
|
|
672
671
|
}
|
|
673
672
|
}
|
|
674
|
-
/**
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
this.mediaHandler.flushAll(userId, chatId);
|
|
681
|
-
}
|
|
682
|
-
/** 冲刷全部待合批提示(/exit、/restart 干净退出前调用)。 */
|
|
683
|
-
async flushAllMediaNotifications() {
|
|
684
|
-
await this.mediaHandler.flushAllPending();
|
|
673
|
+
/**
|
|
674
|
+
* 入站媒体落盘(connector 下载完成后调用):只落盘并回报结果,
|
|
675
|
+
* 时间语义与回执由 `InboundTurnAssembler` 统一负责。
|
|
676
|
+
*/
|
|
677
|
+
async saveInboundMedia(payload) {
|
|
678
|
+
return this.mediaHandler.save(payload);
|
|
685
679
|
}
|
|
686
680
|
/**
|
|
687
681
|
* Resolve the working directory for a user.
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
import { printHelp, printVersion } from './config/dir.js';
|
|
6
|
+
import { printAdvanceHelp, printHelp, printVersion } from './config/dir.js';
|
|
7
7
|
const entryUrl = new URL('./index.js', import.meta.url);
|
|
8
8
|
const entry = fileURLToPath(entryUrl);
|
|
9
9
|
/**
|
|
@@ -32,6 +32,10 @@ export function handlePreflight(args, nodeVersion = process.versions.node) {
|
|
|
32
32
|
printHelp();
|
|
33
33
|
return 0;
|
|
34
34
|
}
|
|
35
|
+
if (arg === '--advance-help') {
|
|
36
|
+
printAdvanceHelp();
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
35
39
|
}
|
|
36
40
|
const major = parseInt(String(nodeVersion).split('.')[0], 10);
|
|
37
41
|
if (!Number.isNaN(major) && major > 0 && major < MIN_NODE_MAJOR) {
|
package/dist/config/dir.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ interface CliArgs {
|
|
|
5
5
|
help?: boolean;
|
|
6
6
|
version?: boolean;
|
|
7
7
|
update?: boolean;
|
|
8
|
+
advanceHelp?: boolean;
|
|
8
9
|
}
|
|
9
10
|
export declare function resolveConfigDir(configDirArg: string | undefined): string;
|
|
10
11
|
export declare function getConfigDir(): string;
|
|
@@ -16,4 +17,12 @@ export declare function getVersion(): string;
|
|
|
16
17
|
export declare function printVersion(): void;
|
|
17
18
|
/** Print CLI help to stdout. */
|
|
18
19
|
export declare function printHelp(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Print advanced-config reference to stdout (CLI `--advance-help`).
|
|
22
|
+
*
|
|
23
|
+
* 目标读者是 coding agent(用户让 agent 代改 config.yaml):披露所有
|
|
24
|
+
* /config 卡片未暴露的 YAML 字段——名称、默认值、语义、改法。飞书侧
|
|
25
|
+
* 等价命令为 /advancehelp(内容同源,卡片渲染)。
|
|
26
|
+
*/
|
|
27
|
+
export declare function printAdvanceHelp(): void;
|
|
19
28
|
export {};
|
package/dist/config/dir.js
CHANGED
|
@@ -52,6 +52,9 @@ export function parseCliArgs(args = process.argv.slice(2)) {
|
|
|
52
52
|
else if (arg === '--dev') {
|
|
53
53
|
result.dev = true;
|
|
54
54
|
}
|
|
55
|
+
else if (arg === '--advance-help') {
|
|
56
|
+
result.advanceHelp = true;
|
|
57
|
+
}
|
|
55
58
|
else if (arg === '--update' || arg === 'update') {
|
|
56
59
|
// 裸子命令等价形式:`lark-remote update` 与 `--update` 同义。
|
|
57
60
|
// 不识别时它会落到守护进程路径去抢单例锁,被正在运行的实例挡下
|
|
@@ -91,9 +94,75 @@ export function printHelp() {
|
|
|
91
94
|
' --settings <path> 指定 Claude 配置文件路径',
|
|
92
95
|
' --dev 开发模式:标记从源码 bun src/index.ts 启动(看门狗据此选择拉起方式)',
|
|
93
96
|
' --update 升级到最新版本后退出(用于 cron/脚本自动化升级)',
|
|
97
|
+
' --advance-help 高级配置参考:config.yaml 全部高级字段的语义与改法(面向 coding agent)',
|
|
94
98
|
' -h, --help 显示本帮助信息',
|
|
95
99
|
' -v, --version 显示版本号',
|
|
96
100
|
];
|
|
97
101
|
process.stdout.write(lines.join('\n') + '\n');
|
|
98
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Print advanced-config reference to stdout (CLI `--advance-help`).
|
|
105
|
+
*
|
|
106
|
+
* 目标读者是 coding agent(用户让 agent 代改 config.yaml):披露所有
|
|
107
|
+
* /config 卡片未暴露的 YAML 字段——名称、默认值、语义、改法。飞书侧
|
|
108
|
+
* 等价命令为 /advancehelp(内容同源,卡片渲染)。
|
|
109
|
+
*/
|
|
110
|
+
export function printAdvanceHelp() {
|
|
111
|
+
const configDir = resolveConfigDir(parseCliArgs().configDir);
|
|
112
|
+
const configPath = configDir + '/config.yaml';
|
|
113
|
+
const lines = [
|
|
114
|
+
'lark-remote 高级配置参考(--advance-help)',
|
|
115
|
+
'',
|
|
116
|
+
'目标读者:coding agent。用户把本输出交给 agent,agent 据此代改配置。',
|
|
117
|
+
'',
|
|
118
|
+
'配置文件:' + configPath,
|
|
119
|
+
'',
|
|
120
|
+
'修改方式(三选一):',
|
|
121
|
+
' 1. 直接编辑上面的 config.yaml,改完由用户发送 /restart 生效',
|
|
122
|
+
' 2. 用户发送 /config <key> <value> 文本直写(立盘即生效),',
|
|
123
|
+
' 如 /config claude.approvalTimeoutMs 300000',
|
|
124
|
+
' 3. 常用项走 /config 卡片交互(模型/审批档位/超时等已暴露,不在此列)',
|
|
125
|
+
'',
|
|
126
|
+
'Key 映射规则:pi./codex./opencode./kimi./dsh. 开头自动映射到 agents.<agent>.<字段>;',
|
|
127
|
+
'claude. 开头在顶层;其余原样。',
|
|
128
|
+
'',
|
|
129
|
+
'Claude(顶层 claude.*):',
|
|
130
|
+
' approvalTimeoutMs (默认 300000) 审批请求超时(ms),超时自动 cancel;0=立即过期;勿随意改短',
|
|
131
|
+
' idleTtlMinutes (默认 30) 会话空闲回收(分钟),turn 间无新消息超窗回收长驻进程;0=不回收',
|
|
132
|
+
' stopGraceMs (默认 5000) SIGTERM→SIGKILL 宽限(ms),内部实现细节,勿改',
|
|
133
|
+
'',
|
|
134
|
+
'Codex(agents.codex.appServer.*):',
|
|
135
|
+
' binary (默认 codex) codex 二进制路径',
|
|
136
|
+
' requestTimeoutMs (默认 60000) 单次 RPC 请求超时(ms)',
|
|
137
|
+
' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
|
|
138
|
+
' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
|
|
139
|
+
'',
|
|
140
|
+
'Kimi(agents.kimi.acp.*):',
|
|
141
|
+
' binary (默认 kimi) kimi 二进制路径',
|
|
142
|
+
' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
|
|
143
|
+
' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
|
|
144
|
+
' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
|
|
145
|
+
'',
|
|
146
|
+
'OpenCode(agents.opencode.acp.*):',
|
|
147
|
+
' binary (默认 opencode) opencode 二进制路径',
|
|
148
|
+
' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
|
|
149
|
+
' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
|
|
150
|
+
' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
|
|
151
|
+
'',
|
|
152
|
+
'Pi:',
|
|
153
|
+
' agents.pi.tools (默认 read,bash,edit,write,grep,find,ls) 逗号分隔工具白名单',
|
|
154
|
+
'',
|
|
155
|
+
'入站媒体(inboundMedia.*):',
|
|
156
|
+
' enabled (默认 true) 图片/文件落盘开关',
|
|
157
|
+
' dirName (默认 .lark-remote-temp) 落盘目录名(位于当前 cwd 下)',
|
|
158
|
+
' maxFileSizeMb (默认 100) 单文件上限(MiB);飞书 >100MB 需分片,实际不可用更大值',
|
|
159
|
+
'',
|
|
160
|
+
'其他:',
|
|
161
|
+
' checkUpdateOnStartup (默认 false) 启动时检查更新',
|
|
162
|
+
'',
|
|
163
|
+
'字段全集与校验规则见 Zod schema(src/config/index.ts,代码内唯一事实源)。',
|
|
164
|
+
'直接改 YAML 后必须 /restart;/config 文本直写与卡片保存即时生效。',
|
|
165
|
+
];
|
|
166
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
167
|
+
}
|
|
99
168
|
//# sourceMappingURL=dir.js.map
|
package/dist/config/index.d.ts
CHANGED
|
@@ -20,9 +20,16 @@ export declare const PI_THINKING_LEVELS: readonly ["off", "minimal", "low", "med
|
|
|
20
20
|
export declare const DEFAULT_STOP_GRACE_MS: 5000;
|
|
21
21
|
/** Codex app-server turn idle timeout default (minutes). */
|
|
22
22
|
export declare const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES: 30;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* 入站媒体默认配置(schema 默认与 connector 兜底共用,避免双源漂移)。
|
|
25
|
+
*
|
|
26
|
+
* 单文件上限默认 100MB:50MB 对视频偏小(2026-09-15 事故就是 mp4 被丢弃)。
|
|
27
|
+
* 注意飞书侧 `type=file` 超过 100MB 必须用 Range 分片下载,而 SDK 的
|
|
28
|
+
* `downloadResourceToFile` 不分片(必报 234037),所以 100 是实际可用上限
|
|
29
|
+
* (inbound-message-matrix.md §4.4)。
|
|
30
|
+
*/
|
|
24
31
|
export declare const DEFAULT_INBOUND_MEDIA_DIR_NAME = ".lark-remote-temp";
|
|
25
|
-
export declare const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB =
|
|
32
|
+
export declare const DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB = 100;
|
|
26
33
|
/** 模型 ID → alias 映射。 */
|
|
27
34
|
export declare const MODEL_ID_TO_ALIAS: Record<string, string>;
|
|
28
35
|
/** alias → model ID 反向映射。 */
|
|
@@ -71,7 +78,7 @@ export declare const CodexConfigSchema: z.ZodObject<{
|
|
|
71
78
|
turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
|
|
72
79
|
}, z.core.$strip>>;
|
|
73
80
|
}, z.core.$strip>;
|
|
74
|
-
/** OpenCode-specific configuration (
|
|
81
|
+
/** OpenCode-specific configuration (pure ACP mode: `opencode acp` over stdio). */
|
|
75
82
|
declare const OpencodeConfigSchema: z.ZodObject<{
|
|
76
83
|
providerID: z.ZodDefault<z.ZodString>;
|
|
77
84
|
modelID: z.ZodDefault<z.ZodString>;
|
|
@@ -79,6 +86,12 @@ declare const OpencodeConfigSchema: z.ZodObject<{
|
|
|
79
86
|
plan: "plan";
|
|
80
87
|
build: "build";
|
|
81
88
|
}>>;
|
|
89
|
+
acp: z.ZodOptional<z.ZodObject<{
|
|
90
|
+
binary: z.ZodDefault<z.ZodString>;
|
|
91
|
+
requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
92
|
+
idleTtlMs: z.ZodDefault<z.ZodNumber>;
|
|
93
|
+
turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
|
|
94
|
+
}, z.core.$strip>>;
|
|
82
95
|
}, z.core.$strip>;
|
|
83
96
|
/** Pi-specific configuration. pi is a spawn-per-message CLI like Claude. */
|
|
84
97
|
declare const PiConfigSchema: z.ZodObject<{
|
|
@@ -179,6 +192,12 @@ export declare const AppConfigSchema: z.ZodObject<{
|
|
|
179
192
|
plan: "plan";
|
|
180
193
|
build: "build";
|
|
181
194
|
}>>;
|
|
195
|
+
acp: z.ZodOptional<z.ZodObject<{
|
|
196
|
+
binary: z.ZodDefault<z.ZodString>;
|
|
197
|
+
requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
198
|
+
idleTtlMs: z.ZodDefault<z.ZodNumber>;
|
|
199
|
+
turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
|
|
200
|
+
}, z.core.$strip>>;
|
|
182
201
|
}, z.core.$strip>>;
|
|
183
202
|
pi: z.ZodOptional<z.ZodObject<{
|
|
184
203
|
provider: z.ZodDefault<z.ZodString>;
|