@sidleo3/dsh-chat-feishu 0.0.4

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.
@@ -0,0 +1,543 @@
1
+ /**
2
+ * lark-cli 的**唯一调用入口**:把"只能用自己的授权"变成结构,而不是纪律。
3
+ *
4
+ * 背景:lark-cli 允许在同一台机器上登录**多个应用**(`~/.lark-cli/config.json` 的 `apps[]`),
5
+ * 并且有一个**全局可变的"生效 profile"**(`profile use` 会改它)。谁不显式指定 profile,
6
+ * 谁就是在用来路不明的那一份授权——对机器人来说这是致命的:它可能以**另一个机器人**、
7
+ * 甚至**另一个人的用户身份**说话。
8
+ *
9
+ * 所以这里定死四条:
10
+ * ① **只有本模块可以拉起 `lark-cli`**(`scripts/verify-package.mjs` 的守门会强制);
11
+ * ② 每次调用都必然带 `--profile <本应用在 lark-cli 里的那份 profile>`——找不到就失败,
12
+ * **绝不回退**到当前生效 profile(lark-cli 自己也会以退出码 3 + `error.field === '--profile'` 拒绝)。
13
+ * **profile 是按 appId 唯一的**(真机实测:`profile add` 同 appId 会被 lark-cli 拒——
14
+ * `each profile must have a unique app-id`),所以身份策略**不能**靠改它的
15
+ * `strict-mode` / `default-as` 来实现(那份 profile 用户自己也在用)。
16
+ * 身份由这里逐次核对 + 调用方显式声明,模型自己跑的那条路由 `lark-guard.mjs` 兜底;
17
+ * ③ 身份(`--as`)由 intent 显式声明,**绝不省略**(省略时 lark-cli 会自己"看着办",不可控);
18
+ * ④ 用 `--as user` 需要该机器人在设置页显式开启,且**钉住用户**:`whoami` 报回来的
19
+ * `appId` / `onBehalfOf.openId` 与预期不符就失败,**目标命令一次都不执行**。
20
+ *
21
+ * 另外:子进程环境里会剔除 `LARK_CHANNEL` / `OPENCLAW_HOME` / `HERMES_HOME`——它们会让
22
+ * lark-cli 把配置切到别的"workspace"(实测 `OPENCLAW_HOME=/tmp/x lark-cli config show`
23
+ * 直接报 `openclaw context detected but lark-cli is not bound to it`),与这里的 pin 互相打架。
24
+ *
25
+ * @module dsh-chat-feishu/lark-cli
26
+ */
27
+
28
+ import { spawn } from 'node:child_process';
29
+
30
+ /** 身份策略取值:只用应用身份,或允许该应用在 lark-cli 里登录的那个用户。 */
31
+ export const LARK_IDENTITY_MODES = Object.freeze(['bot-only', 'user-allowed']);
32
+
33
+ /** App ID 的合法形状(与控制器同一份判据)。 */
34
+ const APP_ID_PATTERN = /^cli_[A-Za-z0-9_-]{4,64}$/;
35
+
36
+ /**
37
+ * 会改写 lark-cli "workspace" 的环境变量:决不让它们影响我们的子进程。
38
+ * 只在这一个子进程里剔除,不碰宿主环境。
39
+ */
40
+ const WORKSPACE_ENV_KEYS = Object.freeze(['LARK_CHANNEL', 'OPENCLAW_HOME', 'HERMES_HOME']);
41
+
42
+ function cleanString(value) {
43
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
44
+ }
45
+
46
+ /**
47
+ * 归一化身份策略:**保守方向**——认不出来一律 `bot-only`。
48
+ *
49
+ * @param value - 任意历史值。
50
+ * @returns 'bot-only' | 'user-allowed'。
51
+ */
52
+ export function normalizeLarkUserIdentity(value) {
53
+ return value === 'user-allowed' ? 'user-allowed' : 'bot-only';
54
+ }
55
+
56
+ /**
57
+ * 本机器人在 lark-cli 里的 profile 名**兜底值**(只在"还没有 profile、我们要新建"时用)。
58
+ *
59
+ * lark-cli 自己就按 appId 命名(一个应用一份 profile),所以这里也用它;
60
+ * 但**权威的名字永远来自 `lark-cli profile list`**——用户可能当初起了别的名字。
61
+ *
62
+ * @param appId - 飞书 App ID。
63
+ * @returns profile 名。
64
+ */
65
+ export function profileNameFor(appId) {
66
+ return cleanString(appId) ?? 'unknown';
67
+ }
68
+
69
+ function larkError(code, message, details = {}) {
70
+ const error = new Error(message);
71
+ error.code = code;
72
+ Object.assign(error, details);
73
+ return error;
74
+ }
75
+
76
+ /** 解析 stdout 里的 JSON;不是 JSON 就返回 null(lark-cli 出错时也可能给纯文本)。 */
77
+ function parseJson(text) {
78
+ const raw = typeof text === 'string' ? text.trim() : '';
79
+ if (!raw) return null;
80
+ try {
81
+ return JSON.parse(raw);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 默认执行器:数组形式参数 + 关闭 shell(**没有 shell 注入面**)。
89
+ *
90
+ * @param options - { bin, args, env, cwd, input }。
91
+ * @returns { code, stdout, stderr }。
92
+ */
93
+ function defaultRunner({ bin, args, env, cwd, input }) {
94
+ return new Promise((resolve, reject) => {
95
+ const child = spawn(bin, args, { env, cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
96
+ let stdout = '';
97
+ let stderr = '';
98
+ child.stdout?.setEncoding?.('utf8');
99
+ child.stderr?.setEncoding?.('utf8');
100
+ child.stdout?.on?.('data', (chunk) => { stdout += chunk; });
101
+ child.stderr?.on?.('data', (chunk) => { stderr += chunk; });
102
+ child.on('error', reject);
103
+ child.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
104
+ if (input === undefined || input === null) child.stdin?.end?.();
105
+ else child.stdin?.end?.(input);
106
+ });
107
+ }
108
+
109
+ /**
110
+ * 创建某台机器人的 lark-cli 调用器。
111
+ *
112
+ * @param options - 配置:
113
+ * - `appId`:本机器人的 App ID(必填,形状不对直接拒绝);
114
+ * - `brand`:`feishu` | `lark`(自建 profile 时用);
115
+ * - `secretRef` / `resolveSecret`:自建 profile 时取 App Secret(只经 stdin 传给 lark-cli);
116
+ * - `identityPolicy`:**函数**,每次调用时现读(设置页改完立刻生效,不必重连);
117
+ * - `runner`:执行器(测试注入假替身,**测试绝不真跑 lark-cli**);
118
+ * - `env` / `cwd` / `bin` / `logger`。
119
+ * @returns 调用器:`inspect` / `listProfiles` / `ensureProfile` / `whoami` / `assertIdentity` /
120
+ * `sendMessage` / `replyMessage` / `consumeEvents` / `profileName`。
121
+ */
122
+ export function createLarkCli({
123
+ appId,
124
+ brand = 'feishu',
125
+ secretRef = null,
126
+ resolveSecret = null,
127
+ identityPolicy = () => ({ mode: 'bot-only', userOpenId: null }),
128
+ runner = defaultRunner,
129
+ spawnStream = spawn,
130
+ logger = console,
131
+ env = process.env,
132
+ cwd = undefined,
133
+ bin = 'lark-cli',
134
+ } = {}) {
135
+ const ownAppId = cleanString(appId);
136
+ if (!ownAppId || !APP_ID_PATTERN.test(ownAppId)) {
137
+ throw larkError(
138
+ 'feishu/lark-cli-appid-required',
139
+ `lark-cli 调用器需要一个合法的 App ID(收到 ${JSON.stringify(appId ?? null)})。`,
140
+ );
141
+ }
142
+ const managedName = profileNameFor(ownAppId);
143
+ /** 进程内缓存:解析出的 profile(一个进程只解析一次)。 */
144
+ let resolved = null;
145
+ let resolving = null;
146
+
147
+ /** 现读身份策略:设置页改完这一条立刻生效。 */
148
+ function policy() {
149
+ let value;
150
+ try {
151
+ value = typeof identityPolicy === 'function' ? identityPolicy() : identityPolicy;
152
+ } catch (error) {
153
+ logger.warn?.(`[dsh-chat-feishu] 读取 lark-cli 身份策略失败:${error?.message ?? error}`);
154
+ value = null;
155
+ }
156
+ return {
157
+ mode: normalizeLarkUserIdentity(value?.mode),
158
+ userOpenId: cleanString(value?.userOpenId),
159
+ };
160
+ }
161
+
162
+ /** 子进程环境:剔除会切 workspace 的变量,并关掉两类提示(免得 JSON 里混 `_notice`)。 */
163
+ function childEnv() {
164
+ const next = { ...env };
165
+ for (const key of WORKSPACE_ENV_KEYS) delete next[key];
166
+ next.LARKSUITE_CLI_NO_UPDATE_NOTIFIER = '1';
167
+ next.LARKSUITE_CLI_NO_SKILLS_NOTIFIER = '1';
168
+ return next;
169
+ }
170
+
171
+ /**
172
+ * 跑一条命令(**参数由本模块拼**,调用方没有"传任意 argv"的入口)。
173
+ *
174
+ * @param args - 已经拼好的参数(不含 `--profile`,它由这里统一注入)。
175
+ * @param options - { input, allowPlainText, pin }。`pin: false` **只给发现/创建 profile 用**:
176
+ * 那两条命令必须在"还不知道该用哪个 profile"时跑(`profile list` 是全局列表,
177
+ * `profile add` 是新建),给它们带上一个尚不存在的 profile 名只会被 lark-cli 拒掉。
178
+ * @returns lark-cli 的 JSON 结果。
179
+ */
180
+ async function exec(args, { input = null, allowPlainText = false, pin = true } = {}) {
181
+ // `--profile` 是 lark-cli 的**根持久 flag**:写在子命令前,任何子命令都吃它。
182
+ const argv = pin ? ['--profile', resolved?.name ?? managedName, ...args] : [...args];
183
+ let result;
184
+ try {
185
+ result = await runner({ bin, args: argv, env: childEnv(), cwd, input });
186
+ } catch (error) {
187
+ if (error?.code === 'ENOENT') {
188
+ throw larkError('feishu/lark-cli-missing', `没找到 lark-cli(${bin}),无法完成这次调用。`, { cause: error });
189
+ }
190
+ throw larkError('feishu/lark-cli-failed', `调 lark-cli 失败:${error?.message ?? error}`, { cause: error });
191
+ }
192
+ const payload = parseJson(result?.stdout);
193
+ if (allowPlainText && payload === null && result?.code === 0) {
194
+ return { plain: String(result?.stdout ?? '').trim(), stderr: String(result?.stderr ?? '') };
195
+ }
196
+ if (payload?.ok === false) throw describeFailure(payload.error, result, argv);
197
+ if (result?.code !== 0) {
198
+ const fromStderr = parseJson(result?.stderr);
199
+ throw describeFailure(fromStderr?.error, result, argv);
200
+ }
201
+ if (payload === null) {
202
+ throw larkError('feishu/lark-cli-failed', `lark-cli 没有返回可解析的 JSON(${argv.join(' ')})。`, {
203
+ stderr: String(result?.stderr ?? '').slice(0, 400),
204
+ });
205
+ }
206
+ return payload;
207
+ }
208
+
209
+ /**
210
+ * 把 lark-cli 的错误信封翻译成可读的手册错误。
211
+ *
212
+ * 两条硬规矩:**`--profile` 相关的失败一律不许重试/去掉 flag 再试**;
213
+ * **退出码 10(高风险确认门禁)绝不自动补 `--yes`**——那是用户的决定。
214
+ */
215
+ function describeFailure(error, result, argv) {
216
+ const type = cleanString(error?.type);
217
+ const subtype = cleanString(error?.subtype);
218
+ const hint = cleanString(error?.hint);
219
+ const field = cleanString(error?.field);
220
+ const code = cleanString(error?.message) ?? 'lark-cli 调用失败';
221
+ if (field === '--profile' || subtype === 'not_configured') {
222
+ return larkError(
223
+ 'feishu/lark-cli-profile-unavailable',
224
+ `lark-cli 里没有这台机器人(${ownAppId})对应的 profile:${code}`,
225
+ {
226
+ hint: hint ?? `请先执行 lark-cli profile add --name ${managedName} --app-id ${ownAppId} --app-secret-stdin`,
227
+ appId: ownAppId,
228
+ },
229
+ );
230
+ }
231
+ if (result?.code === 10 || error?.risk === 'high-risk-write') {
232
+ return larkError('feishu/lark-cli-needs-confirmation', `这条 lark-cli 命令需要用户显式确认:${code}`, {
233
+ hint: hint ?? null,
234
+ action: error?.action ?? null,
235
+ });
236
+ }
237
+ return larkError('feishu/lark-cli-failed', `调 lark-cli 失败(${type ?? 'unknown'}${subtype ? `/${subtype}` : ''}):${code}`, {
238
+ hint: hint ?? null,
239
+ argv: argv.join(' '),
240
+ exitCode: result?.code ?? null,
241
+ });
242
+ }
243
+
244
+ /** 列出本机 lark-cli 的全部 profile(只读)。 */
245
+ async function listProfiles() {
246
+ // 注意:`profile list` **没有** `--json`(这版是默认 JSON 输出,加 flag 会报 unknown flag)。
247
+ const payload = await exec(['profile', 'list'], { pin: false });
248
+ const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
249
+ return list.map((item) => ({
250
+ name: cleanString(item?.name),
251
+ appId: cleanString(item?.appId),
252
+ brand: cleanString(item?.brand),
253
+ user: cleanString(item?.user),
254
+ tokenStatus: cleanString(item?.tokenStatus),
255
+ active: item?.active === true,
256
+ effective: item?.effective === true,
257
+ }));
258
+ }
259
+
260
+ /**
261
+ * 找到本应用在 lark-cli 里的那份 profile:**按 appId**(不认名字)。
262
+ *
263
+ * 为什么不能另建一份"插件专用 profile"(真机实测踩到):
264
+ * `lark-cli profile add --app-id <已存在的 appId>` 会被直接拒——
265
+ * `app-id "cli_…" is already used by profile "cli_…"; each profile must have a unique app-id`。
266
+ * 也就是说**一个应用在这个 CLI 里只有一份 profile**,名字是用户当初起的(多数就是 appId)。
267
+ * 于是插件既不能另开一份、也不该去改那一份的 `strict-mode` / `default-as`
268
+ * (用户自己也在用它做别的 user 身份的事),身份只能靠**逐次核对 + 门禁**保证。
269
+ *
270
+ * @returns profile 记录或 null。
271
+ */
272
+ async function findProfile() {
273
+ const list = await listProfiles();
274
+ return list.find((item) => item.appId === ownAppId) ?? null;
275
+ }
276
+
277
+ /**
278
+ * 解析(必要时创建)本机器人专用的 profile。
279
+ *
280
+ * 创建只在**真的要调用**时发生(懒),且:**不带 `--use`**、不碰任何别人的 profile。
281
+ *
282
+ * @returns 解析出的 profile 记录。
283
+ */
284
+ async function ensureProfile() {
285
+ if (resolved) return resolved;
286
+ if (resolving) return resolving;
287
+ resolving = (async () => {
288
+ const found = await findProfile();
289
+ if (found) {
290
+ resolved = found;
291
+ return resolved;
292
+ }
293
+ if (typeof resolveSecret !== 'function' || !cleanString(secretRef)) {
294
+ throw larkError(
295
+ 'feishu/lark-cli-profile-unavailable',
296
+ `lark-cli 里没有 ${ownAppId} 的 profile,且当前拿不到 App Secret,无法为它新建。`,
297
+ { hint: `请执行 lark-cli profile add --name ${ownAppId} --app-id ${ownAppId} --app-secret-stdin` },
298
+ );
299
+ }
300
+ const secret = await resolveSecret(secretRef);
301
+ if (!cleanString(secret)) {
302
+ throw larkError(
303
+ 'feishu/lark-cli-profile-unavailable',
304
+ `lark-cli 里没有 ${ownAppId} 的 profile,且 DSH 里这台机器人的 App Secret 读不到。`,
305
+ { hint: '到设置页重新接入这台机器人(填 App ID + App Secret),或手工 lark-cli profile add。' },
306
+ );
307
+ }
308
+ try {
309
+ // App Secret 只走 stdin;**绝不 `--use`**(那会改全局生效 profile,影响用户别的用法)。
310
+ await exec(
311
+ ['profile', 'add', '--name', managedName, '--app-id', ownAppId, '--brand', brand, '--app-secret-stdin'],
312
+ { input: secret, pin: false },
313
+ );
314
+ } catch (error) {
315
+ // 真机上出现过这一类:lark-cli 说 app-id 已被某个 profile 占用(一个 app 只有一份 profile)。
316
+ // 再查一次:查到了就用它(那本来就是本应用的那一份),查不到才把原错误抛出去。
317
+ const again = await findProfile().catch(() => null);
318
+ if (!again) throw error;
319
+ resolved = again;
320
+ return resolved;
321
+ }
322
+ const created = await findProfile();
323
+ if (!created) {
324
+ throw larkError(
325
+ 'feishu/lark-cli-profile-unavailable',
326
+ `lark-cli 说 profile 建好了,但列表里读不到 ${ownAppId}。`,
327
+ { hint: '查看 lark-cli profile list;若确实是权限/钥匙串问题,请手工执行 profile add。' },
328
+ );
329
+ }
330
+ resolved = created;
331
+ logger.info?.(`[dsh-chat-feishu] lark-cli 里为 ${ownAppId} 新建了 profile ${created.name}`);
332
+ return resolved;
333
+ })();
334
+ try {
335
+ return await resolving;
336
+ } finally {
337
+ resolving = null;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * `whoami`:问 lark-cli"你现在到底是谁"。
343
+ *
344
+ * **故意不走身份策略**:设置页要能先探到"当前登录的是谁",才能把它钉下来;
345
+ * 而且它只读身份信息,不碰任何用户资源(真正代表用户操作的是 intent,那里才判策略)。
346
+ */
347
+ async function whoami({ as = 'bot' } = {}) {
348
+ await ensureProfile();
349
+ return exec(['whoami', '--json', '--as', as]);
350
+ }
351
+
352
+ /**
353
+ * 断言"这次调用真的是以本机器人的身份"。
354
+ *
355
+ * 不满足就抛错,**并且绝不继续执行目标命令**——这是"绝对禁止用别的机器人授权"的落点。
356
+ *
357
+ * @param options - { as }。
358
+ * @returns whoami 的结果。
359
+ */
360
+ async function assertIdentity({ as = 'bot' } = {}) {
361
+ if (as !== 'bot' && as !== 'user') {
362
+ throw larkError('feishu/lark-cli-bad-identity', `身份只能是 bot 或 user(收到 ${JSON.stringify(as)})。`);
363
+ }
364
+ if (as === 'user') {
365
+ const current = policy();
366
+ if (current.mode !== 'user-allowed') {
367
+ throw larkError(
368
+ 'feishu/lark-cli-user-not-allowed',
369
+ '这台机器人没有开启「允许以用户身份调用 lark-cli」,已拒绝这次调用。',
370
+ { hint: '到设置页 → 这台机器人 → 「lark-cli 身份」里开启(需要二次确认)。' },
371
+ );
372
+ }
373
+ if (!current.userOpenId) {
374
+ throw larkError(
375
+ 'feishu/lark-cli-user-not-allowed',
376
+ '这台机器人虽然允许用户身份,但没有钉住具体用户,已拒绝这次调用。',
377
+ { hint: '到设置页重新开启一次「允许用户身份」,让插件记录下当前登录的用户。' },
378
+ );
379
+ }
380
+ await ensureProfile();
381
+ }
382
+ const info = await whoami({ as });
383
+ if (cleanString(info?.appId) !== ownAppId) {
384
+ throw larkError(
385
+ 'feishu/lark-cli-app-mismatch',
386
+ `lark-cli 实际生效的应用是 ${info?.appId ?? '<未知>'},不是本机器人的 ${ownAppId};已拒绝这次调用。`,
387
+ { hint: '检查 lark-cli profile list;本插件只会用 appId 与自身一致的那个 profile。' },
388
+ );
389
+ }
390
+ if (as === 'bot' && cleanString(info?.identity) !== 'bot') {
391
+ throw larkError(
392
+ 'feishu/lark-cli-identity-mismatch',
393
+ `lark-cli 实际身份是 ${info?.identity ?? '<未知>'},不是 bot;已拒绝这次调用。`,
394
+ );
395
+ }
396
+ if (as === 'user') {
397
+ const actual = cleanString(info?.onBehalfOf?.openId);
398
+ const expected = policy().userOpenId;
399
+ if (cleanString(info?.identity) !== 'user' || actual !== expected) {
400
+ throw larkError(
401
+ 'feishu/lark-cli-user-mismatch',
402
+ `lark-cli 里的用户身份是 ${actual ?? '<未知>'},不是这台机器人钉住的 ${expected};已拒绝这次调用。`,
403
+ { hint: '在 lark-cli 里重新登录正确的人,或到设置页重新开启一次「允许用户身份」。' },
404
+ );
405
+ }
406
+ }
407
+ if (info?.available === false) {
408
+ throw larkError(
409
+ 'feishu/lark-cli-identity-unavailable',
410
+ `lark-cli 的 ${as} 身份当前不可用(${cleanString(info?.tokenStatus) ?? '未知状态'})。`,
411
+ );
412
+ }
413
+ return info;
414
+ }
415
+
416
+ /** 只读体检:给设置页看"现在到底会是谁"。**不建 profile、不写任何东西**。 */
417
+ async function inspect() {
418
+ const current = policy();
419
+ const checkedAt = new Date().toISOString();
420
+ let profile = null;
421
+ try {
422
+ const found = await findProfile();
423
+ if (found) {
424
+ // 只读路径也走同一份解析结果,避免设置页看到的状态与真实调用不一致。
425
+ resolved = resolved ?? found;
426
+ profile = found;
427
+ }
428
+ } catch (error) {
429
+ return Object.freeze({
430
+ policy: current,
431
+ profile: null,
432
+ identity: null,
433
+ checkedAt,
434
+ error: { code: error?.code ?? 'feishu/lark-cli-failed', message: error?.message ?? String(error) },
435
+ });
436
+ }
437
+ if (!profile) {
438
+ return Object.freeze({ policy: current, profile: { found: false, name: managedName }, identity: null, checkedAt });
439
+ }
440
+ const identity = { bot: null, user: null };
441
+ for (const as of ['bot', 'user']) {
442
+ try {
443
+ identity[as] = await whoami({ as });
444
+ } catch (error) {
445
+ identity[as] = { error: { code: error?.code ?? 'feishu/lark-cli-failed', message: error?.message ?? String(error) } };
446
+ }
447
+ }
448
+ return Object.freeze({
449
+ policy: current,
450
+ profile: Object.freeze({ found: true, ...profile }),
451
+ identity: Object.freeze(identity),
452
+ checkedAt,
453
+ });
454
+ }
455
+
456
+ /** 只允许 bot/user 两种身份,且 intent 必须显式给。 */
457
+ async function guard(as) {
458
+ return assertIdentity({ as });
459
+ }
460
+
461
+ /**
462
+ * 发消息(`im +messages-send`)。
463
+ *
464
+ * @param params - { chatId, text | markdown | content, msgType, idempotencyKey, dryRun, as }。
465
+ */
466
+ async function sendMessage(params = {}) {
467
+ const chatId = cleanString(params.chatId);
468
+ if (!chatId) throw larkError('feishu/lark-cli-bad-request', 'sendMessage 需要 chatId。');
469
+ const body = contentArgs(params);
470
+ await guard(params.as ?? 'bot');
471
+ const args = ['im', '+messages-send', '--chat-id', chatId, ...body];
472
+ if (cleanString(params.msgType)) args.push('--msg-type', params.msgType);
473
+ if (cleanString(params.idempotencyKey)) args.push('--idempotency-key', params.idempotencyKey);
474
+ if (params.dryRun === true) args.push('--dry-run');
475
+ return exec(withAs(args, params.as ?? 'bot'));
476
+ }
477
+
478
+ /**
479
+ * 回复消息(`im +messages-reply`)。
480
+ *
481
+ * @param params - { messageId, text | markdown | content, replyInThread, dryRun, as }。
482
+ */
483
+ async function replyMessage(params = {}) {
484
+ const messageId = cleanString(params.messageId);
485
+ if (!messageId) throw larkError('feishu/lark-cli-bad-request', 'replyMessage 需要 messageId。');
486
+ const body = contentArgs(params);
487
+ await guard(params.as ?? 'bot');
488
+ const args = ['im', '+messages-reply', '--message-id', messageId, ...body];
489
+ if (params.replyInThread === true) args.push('--reply-in-thread');
490
+ if (params.dryRun === true) args.push('--dry-run');
491
+ return exec(withAs(args, params.as ?? 'bot'));
492
+ }
493
+
494
+ /**
495
+ * 消费事件(`event consume <key>`):**返回子进程句柄**,流式读由调用方负责。
496
+ *
497
+ * 这里仍然把 `--profile` / `--as` 钉死——入站事件也必须来自**本机器人**那条长连接。
498
+ *
499
+ * @param params - { key, maxEvents, timeoutSeconds, as }。
500
+ * @returns 子进程句柄(已带 pin 好的参数与环境)。
501
+ */
502
+ async function consumeEvents(params = {}) {
503
+ const key = cleanString(params.key);
504
+ if (!key || !/^[A-Za-z0-9._]+$/.test(key)) {
505
+ throw larkError('feishu/lark-cli-bad-request', `consumeEvents 的事件名不合法:${JSON.stringify(params.key ?? null)}。`);
506
+ }
507
+ const as = params.as ?? 'bot';
508
+ await guard(as);
509
+ const args = ['event', 'consume', key, '--as', as];
510
+ if (Number.isInteger(params.maxEvents) && params.maxEvents > 0) args.push('--max-events', String(params.maxEvents));
511
+ if (Number.isInteger(params.timeoutSeconds) && params.timeoutSeconds > 0) {
512
+ args.push('--timeout', `${params.timeoutSeconds}s`);
513
+ }
514
+ const argv = ['--profile', (resolved ?? { name: managedName }).name, ...args];
515
+ return spawnStream(bin, argv, { env: childEnv(), cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
516
+ }
517
+
518
+ function withAs(args, as) {
519
+ return [...args, '--as', as];
520
+ }
521
+
522
+ function contentArgs(params) {
523
+ const given = ['text', 'markdown', 'content'].filter((field) => cleanString(params[field]));
524
+ if (given.length !== 1) {
525
+ throw larkError('feishu/lark-cli-bad-request', 'text / markdown / content 必须且只能给一个。');
526
+ }
527
+ const field = given[0];
528
+ return [`--${field}`, params[field]];
529
+ }
530
+
531
+ return Object.freeze({
532
+ appId: ownAppId,
533
+ profileName: managedName,
534
+ inspect,
535
+ listProfiles,
536
+ ensureProfile,
537
+ whoami,
538
+ assertIdentity,
539
+ sendMessage,
540
+ replyMessage,
541
+ consumeEvents,
542
+ });
543
+ }