@m1heng-clawd/feishu 0.1.9 → 0.1.11

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/src/media.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { createFeishuClient } from "./client.js";
3
3
  import { resolveFeishuAccount } from "./accounts.js";
4
+ import { getFeishuRuntime } from "./runtime.js";
4
5
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
5
6
  import fs from "fs";
6
7
  import path from "path";
@@ -353,10 +354,12 @@ export async function sendFileFeishu(params: {
353
354
  cfg: ClawdbotConfig;
354
355
  to: string;
355
356
  fileKey: string;
357
+ msgType?: "file" | "media";
356
358
  replyToMessageId?: string;
357
359
  accountId?: string;
358
360
  }): Promise<SendMediaResult> {
359
361
  const { cfg, to, fileKey, replyToMessageId, accountId } = params;
362
+ const msgType = params.msgType ?? "file";
360
363
  const account = resolveFeishuAccount({ cfg, accountId });
361
364
  if (!account.configured) {
362
365
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -376,7 +379,7 @@ export async function sendFileFeishu(params: {
376
379
  path: { message_id: replyToMessageId },
377
380
  data: {
378
381
  content,
379
- msg_type: "file",
382
+ msg_type: msgType,
380
383
  },
381
384
  });
382
385
 
@@ -395,7 +398,7 @@ export async function sendFileFeishu(params: {
395
398
  data: {
396
399
  receive_id: receiveId,
397
400
  content,
398
- msg_type: "file",
401
+ msg_type: msgType,
399
402
  },
400
403
  });
401
404
 
@@ -440,23 +443,6 @@ export function detectFileType(
440
443
  }
441
444
  }
442
445
 
443
- /**
444
- * Check if a string is a local file path (not a URL)
445
- */
446
- function isLocalPath(urlOrPath: string): boolean {
447
- // Starts with / or ~ or drive letter (Windows)
448
- if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
449
- return true;
450
- }
451
- // Try to parse as URL - if it fails or has no protocol, it's likely a local path
452
- try {
453
- const url = new URL(urlOrPath);
454
- return url.protocol === "file:";
455
- } catch {
456
- return true; // Not a valid URL, treat as local path
457
- }
458
- }
459
-
460
446
  /**
461
447
  * Upload and send media (image or file) from URL, local path, or buffer
462
448
  */
@@ -470,6 +456,11 @@ export async function sendMediaFeishu(params: {
470
456
  accountId?: string;
471
457
  }): Promise<SendMediaResult> {
472
458
  const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
459
+ const account = resolveFeishuAccount({ cfg, accountId });
460
+ if (!account.configured) {
461
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
462
+ }
463
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
473
464
 
474
465
  let buffer: Buffer;
475
466
  let name: string;
@@ -478,26 +469,12 @@ export async function sendMediaFeishu(params: {
478
469
  buffer = mediaBuffer;
479
470
  name = fileName ?? "file";
480
471
  } else if (mediaUrl) {
481
- if (isLocalPath(mediaUrl)) {
482
- // Local file path - read directly
483
- const filePath = mediaUrl.startsWith("~")
484
- ? mediaUrl.replace("~", process.env.HOME ?? "")
485
- : mediaUrl.replace("file://", "");
486
-
487
- if (!fs.existsSync(filePath)) {
488
- throw new Error(`Local file not found: ${filePath}`);
489
- }
490
- buffer = fs.readFileSync(filePath);
491
- name = fileName ?? path.basename(filePath);
492
- } else {
493
- // Remote URL - fetch
494
- const response = await fetch(mediaUrl);
495
- if (!response.ok) {
496
- throw new Error(`Failed to fetch media from URL: ${response.status}`);
497
- }
498
- buffer = Buffer.from(await response.arrayBuffer());
499
- name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
500
- }
472
+ const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
473
+ maxBytes: mediaMaxBytes,
474
+ optimizeImages: false,
475
+ });
476
+ buffer = loaded.buffer;
477
+ name = fileName ?? loaded.fileName ?? "file";
501
478
  } else {
502
479
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
503
480
  }
@@ -518,6 +495,14 @@ export async function sendMediaFeishu(params: {
518
495
  fileType,
519
496
  accountId,
520
497
  });
521
- return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId });
498
+ const isMedia = fileType === "mp4" || fileType === "opus";
499
+ return sendFileFeishu({
500
+ cfg,
501
+ to,
502
+ fileKey,
503
+ msgType: isMedia ? "media" : "file",
504
+ replyToMessageId,
505
+ accountId,
506
+ });
522
507
  }
523
508
  }
package/src/monitor.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import * as http from "http";
3
- import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
3
+ import {
4
+ type ClawdbotConfig,
5
+ type RuntimeEnv,
6
+ type HistoryEntry,
7
+ installRequestBodyLimitGuard,
8
+ } from "openclaw/plugin-sdk";
4
9
  import type { ResolvedFeishuAccount } from "./types.js";
5
10
  import { createFeishuWSClient, createEventDispatcher } from "./client.js";
6
11
  import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
@@ -18,6 +23,8 @@ export type MonitorFeishuOpts = {
18
23
  const wsClients = new Map<string, Lark.WSClient>();
19
24
  const httpServers = new Map<string, http.Server>();
20
25
  const botOpenIds = new Map<string, string>();
26
+ const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
+ const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
21
28
 
22
29
  async function fetchBotOpenId(
23
30
  account: ResolvedFeishuAccount,
@@ -191,7 +198,27 @@ async function monitorWebhook({ params, accountId, eventDispatcher }: Connection
191
198
  log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
192
199
 
193
200
  const server = http.createServer();
194
- server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
201
+ const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
202
+ server.on("request", (req, res) => {
203
+ const guard = installRequestBodyLimitGuard(req, res, {
204
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
205
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
206
+ responseFormat: "text",
207
+ });
208
+ if (guard.isTripped()) {
209
+ return;
210
+ }
211
+
212
+ void Promise.resolve(webhookHandler(req, res))
213
+ .catch((err) => {
214
+ if (!guard.isTripped()) {
215
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
216
+ }
217
+ })
218
+ .finally(() => {
219
+ guard.dispose();
220
+ });
221
+ });
195
222
  httpServers.set(accountId, server);
196
223
 
197
224
  return new Promise((resolve, reject) => {
package/src/onboarding.ts CHANGED
@@ -5,45 +5,104 @@ import type {
5
5
  DmPolicy,
6
6
  WizardPrompter,
7
7
  } from "openclaw/plugin-sdk";
8
- import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
8
+ import {
9
+ addWildcardAllowFrom,
10
+ DEFAULT_ACCOUNT_ID,
11
+ formatDocsLink,
12
+ normalizeAccountId,
13
+ promptAccountId,
14
+ } from "openclaw/plugin-sdk";
9
15
 
10
- import { resolveFeishuCredentials } from "./accounts.js";
16
+ import {
17
+ listFeishuAccountIds,
18
+ resolveDefaultFeishuAccountId,
19
+ resolveFeishuAccount,
20
+ resolveFeishuCredentials,
21
+ } from "./accounts.js";
11
22
  import { probeFeishu } from "./probe.js";
12
23
  import type { FeishuConfig } from "./types.js";
13
24
 
14
25
  const channel = "feishu" as const;
26
+ let onboardingDmPolicyAccountId: string | undefined;
15
27
 
16
- function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
17
- const allowFrom =
18
- dmPolicy === "open"
19
- ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
20
- : undefined;
21
- return {
22
- ...cfg,
23
- channels: {
24
- ...cfg.channels,
25
- feishu: {
26
- ...cfg.channels?.feishu,
27
- dmPolicy,
28
- ...(allowFrom ? { allowFrom } : {}),
29
- },
30
- },
31
- };
28
+ function resolveOnboardingAccountId(cfg: ClawdbotConfig, accountId?: string | null): string {
29
+ const raw = accountId?.trim();
30
+ if (raw) {
31
+ return normalizeAccountId(raw);
32
+ }
33
+ return resolveDefaultFeishuAccountId(cfg);
34
+ }
35
+
36
+ function resolveDmPolicyAccountId(cfg: ClawdbotConfig, accountId?: string | null): string {
37
+ return resolveOnboardingAccountId(cfg, accountId ?? onboardingDmPolicyAccountId);
32
38
  }
33
39
 
34
- function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
40
+ function upsertFeishuAccountConfig(
41
+ cfg: ClawdbotConfig,
42
+ accountId: string,
43
+ patch: Partial<FeishuConfig>,
44
+ ): ClawdbotConfig {
45
+ const normalizedAccountId = normalizeAccountId(accountId);
46
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
47
+
48
+ if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
49
+ return {
50
+ ...cfg,
51
+ channels: {
52
+ ...cfg.channels,
53
+ feishu: {
54
+ ...feishuCfg,
55
+ ...patch,
56
+ },
57
+ },
58
+ };
59
+ }
60
+
61
+ const existingAccount = feishuCfg?.accounts?.[normalizedAccountId];
35
62
  return {
36
63
  ...cfg,
37
64
  channels: {
38
65
  ...cfg.channels,
39
66
  feishu: {
40
- ...cfg.channels?.feishu,
41
- allowFrom,
67
+ ...feishuCfg,
68
+ enabled: true,
69
+ accounts: {
70
+ ...feishuCfg?.accounts,
71
+ [normalizedAccountId]: {
72
+ ...existingAccount,
73
+ enabled: existingAccount?.enabled ?? true,
74
+ ...patch,
75
+ },
76
+ },
42
77
  },
43
78
  },
44
79
  };
45
80
  }
46
81
 
82
+ function setFeishuDmPolicy(
83
+ cfg: ClawdbotConfig,
84
+ dmPolicy: DmPolicy,
85
+ accountId?: string,
86
+ ): ClawdbotConfig {
87
+ const resolvedAccountId = resolveDmPolicyAccountId(cfg, accountId);
88
+ const account = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
89
+ // Feishu channel config does not support "disabled" as a dmPolicy value.
90
+ const effectiveDmPolicy = dmPolicy === "disabled" ? "pairing" : dmPolicy;
91
+ const allowFrom =
92
+ effectiveDmPolicy === "open"
93
+ ? addWildcardAllowFrom(account.config.allowFrom)?.map((entry) => String(entry))
94
+ : undefined;
95
+ return upsertFeishuAccountConfig(cfg, resolvedAccountId, {
96
+ dmPolicy: effectiveDmPolicy,
97
+ ...(allowFrom ? { allowFrom } : {}),
98
+ });
99
+ }
100
+
101
+ function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[], accountId?: string): ClawdbotConfig {
102
+ const resolvedAccountId = resolveOnboardingAccountId(cfg, accountId);
103
+ return upsertFeishuAccountConfig(cfg, resolvedAccountId, { allowFrom });
104
+ }
105
+
47
106
  function parseAllowFromInput(raw: string): string[] {
48
107
  return raw
49
108
  .split(/[\n,;]+/g)
@@ -54,10 +113,14 @@ function parseAllowFromInput(raw: string): string[] {
54
113
  async function promptFeishuAllowFrom(params: {
55
114
  cfg: ClawdbotConfig;
56
115
  prompter: WizardPrompter;
116
+ accountId?: string;
57
117
  }): Promise<ClawdbotConfig> {
58
- const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
118
+ const accountId = resolveDmPolicyAccountId(params.cfg, params.accountId);
119
+ const existing = resolveFeishuAccount({ cfg: params.cfg, accountId }).config.allowFrom ?? [];
120
+ const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
59
121
  await params.prompter.note(
60
122
  [
123
+ `Account: ${accountLabel}`,
61
124
  "Allowlist Feishu DMs by open_id or user_id.",
62
125
  "You can find user open_id in Feishu admin console or via API.",
63
126
  "Examples:",
@@ -83,7 +146,7 @@ async function promptFeishuAllowFrom(params: {
83
146
  const unique = [
84
147
  ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]),
85
148
  ];
86
- return setFeishuAllowFrom(params.cfg, unique);
149
+ return setFeishuAllowFrom(params.cfg, unique, accountId);
87
150
  }
88
151
  }
89
152
 
@@ -105,31 +168,24 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
105
168
  function setFeishuGroupPolicy(
106
169
  cfg: ClawdbotConfig,
107
170
  groupPolicy: "open" | "allowlist" | "disabled",
171
+ accountId?: string,
108
172
  ): ClawdbotConfig {
109
- return {
110
- ...cfg,
111
- channels: {
112
- ...cfg.channels,
113
- feishu: {
114
- ...cfg.channels?.feishu,
115
- enabled: true,
116
- groupPolicy,
117
- },
118
- },
119
- };
173
+ const resolvedAccountId = resolveOnboardingAccountId(cfg, accountId);
174
+ return upsertFeishuAccountConfig(cfg, resolvedAccountId, { enabled: true, groupPolicy });
120
175
  }
121
176
 
122
- function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
123
- return {
124
- ...cfg,
125
- channels: {
126
- ...cfg.channels,
127
- feishu: {
128
- ...cfg.channels?.feishu,
129
- groupAllowFrom,
130
- },
131
- },
132
- };
177
+ function setFeishuGroupAllowFrom(
178
+ cfg: ClawdbotConfig,
179
+ groupAllowFrom: string[],
180
+ accountId?: string,
181
+ ): ClawdbotConfig {
182
+ const resolvedAccountId = resolveOnboardingAccountId(cfg, accountId);
183
+ return upsertFeishuAccountConfig(cfg, resolvedAccountId, { groupAllowFrom });
184
+ }
185
+
186
+ function setFeishuDomain(cfg: ClawdbotConfig, domain: "feishu" | "lark", accountId?: string): ClawdbotConfig {
187
+ const resolvedAccountId = resolveOnboardingAccountId(cfg, accountId);
188
+ return upsertFeishuAccountConfig(cfg, resolvedAccountId, { domain });
133
189
  }
134
190
 
135
191
  const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -137,22 +193,30 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
137
193
  channel,
138
194
  policyKey: "channels.feishu.dmPolicy",
139
195
  allowFromKey: "channels.feishu.allowFrom",
140
- getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
141
- setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
196
+ getCurrent: (cfg) => {
197
+ const accountId = resolveDmPolicyAccountId(cfg);
198
+ return resolveFeishuAccount({ cfg, accountId }).config.dmPolicy ?? "pairing";
199
+ },
200
+ setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy, onboardingDmPolicyAccountId),
142
201
  promptAllowFrom: promptFeishuAllowFrom,
143
202
  };
144
203
 
145
204
  export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
146
205
  channel,
147
- getStatus: async ({ cfg }) => {
148
- const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
149
- const configured = Boolean(resolveFeishuCredentials(feishuCfg));
206
+ getStatus: async ({ cfg, accountOverrides }) => {
207
+ const override = accountOverrides?.feishu?.trim();
208
+ const accountIds = override
209
+ ? [resolveOnboardingAccountId(cfg, override)]
210
+ : listFeishuAccountIds(cfg);
211
+ const accounts = accountIds.map((accountId) => resolveFeishuAccount({ cfg, accountId }));
212
+ const configuredAccounts = accounts.filter((account) => account.configured);
213
+ const configured = configuredAccounts.length > 0;
150
214
 
151
215
  // Try to probe if configured
152
216
  let probeResult = null;
153
- if (configured && feishuCfg) {
217
+ if (configuredAccounts[0]) {
154
218
  try {
155
- probeResult = await probeFeishu(feishuCfg);
219
+ probeResult = await probeFeishu(configuredAccounts[0]);
156
220
  } catch {
157
221
  // Ignore probe errors
158
222
  }
@@ -162,7 +226,16 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
162
226
  if (!configured) {
163
227
  statusLines.push("Feishu: needs app credentials");
164
228
  } else if (probeResult?.ok) {
165
- statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`);
229
+ const probeAccountId = configuredAccounts[0]?.accountId ?? DEFAULT_ACCOUNT_ID;
230
+ const accountNote = probeAccountId === DEFAULT_ACCOUNT_ID ? "" : ` [${probeAccountId}]`;
231
+ statusLines.push(
232
+ `Feishu${accountNote}: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
233
+ );
234
+ if (!override && configuredAccounts.length > 1) {
235
+ statusLines.push(`Feishu: ${configuredAccounts.length} account(s) configured`);
236
+ }
237
+ } else if (!override && configuredAccounts.length > 1) {
238
+ statusLines.push(`Feishu: configured (${configuredAccounts.length} account(s), connection not verified)`);
166
239
  } else {
167
240
  statusLines.push("Feishu: configured (connection not verified)");
168
241
  }
@@ -176,11 +249,31 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
176
249
  };
177
250
  },
178
251
 
179
- configure: async ({ cfg, prompter }) => {
180
- const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
181
- const resolved = resolveFeishuCredentials(feishuCfg);
182
- const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
252
+ configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
253
+ const feishuOverride = accountOverrides?.feishu?.trim();
254
+ const defaultFeishuAccountId = resolveDefaultFeishuAccountId(cfg);
255
+ let feishuAccountId = feishuOverride
256
+ ? normalizeAccountId(feishuOverride)
257
+ : defaultFeishuAccountId;
258
+ if (shouldPromptAccountIds && !feishuOverride) {
259
+ feishuAccountId = await promptAccountId({
260
+ cfg,
261
+ prompter,
262
+ label: "Feishu",
263
+ currentId: feishuAccountId,
264
+ listAccountIds: listFeishuAccountIds,
265
+ defaultAccountId: defaultFeishuAccountId,
266
+ });
267
+ }
268
+ onboardingDmPolicyAccountId = feishuAccountId;
269
+ const accountLabel = feishuAccountId === DEFAULT_ACCOUNT_ID ? "default" : feishuAccountId;
270
+ const currentAccount = resolveFeishuAccount({ cfg, accountId: feishuAccountId });
271
+ const resolved = resolveFeishuCredentials(currentAccount.config);
272
+ const hasConfigCreds = Boolean(
273
+ currentAccount.config.appId?.trim() && currentAccount.config.appSecret?.trim(),
274
+ );
183
275
  const canUseEnv = Boolean(
276
+ feishuAccountId === DEFAULT_ACCOUNT_ID &&
184
277
  !hasConfigCreds &&
185
278
  process.env.FEISHU_APP_ID?.trim() &&
186
279
  process.env.FEISHU_APP_SECRET?.trim(),
@@ -189,6 +282,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
189
282
  let next = cfg;
190
283
  let appId: string | null = null;
191
284
  let appSecret: string | null = null;
285
+ const appIdPrompt =
286
+ feishuAccountId === DEFAULT_ACCOUNT_ID
287
+ ? "Enter Feishu App ID"
288
+ : `Enter Feishu App ID for account "${accountLabel}"`;
289
+ const appSecretPrompt =
290
+ feishuAccountId === DEFAULT_ACCOUNT_ID
291
+ ? "Enter Feishu App Secret"
292
+ : `Enter Feishu App Secret for account "${accountLabel}"`;
192
293
 
193
294
  if (!resolved) {
194
295
  await noteFeishuCredentialHelp(prompter);
@@ -200,42 +301,36 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
200
301
  initialValue: true,
201
302
  });
202
303
  if (keepEnv) {
203
- next = {
204
- ...next,
205
- channels: {
206
- ...next.channels,
207
- feishu: { ...next.channels?.feishu, enabled: true },
208
- },
209
- };
304
+ next = upsertFeishuAccountConfig(next, feishuAccountId, { enabled: true });
210
305
  } else {
211
306
  appId = String(
212
307
  await prompter.text({
213
- message: "Enter Feishu App ID",
308
+ message: appIdPrompt,
214
309
  validate: (value) => (value?.trim() ? undefined : "Required"),
215
310
  }),
216
311
  ).trim();
217
312
  appSecret = String(
218
313
  await prompter.text({
219
- message: "Enter Feishu App Secret",
314
+ message: appSecretPrompt,
220
315
  validate: (value) => (value?.trim() ? undefined : "Required"),
221
316
  }),
222
317
  ).trim();
223
318
  }
224
319
  } else if (hasConfigCreds) {
225
320
  const keep = await prompter.confirm({
226
- message: "Feishu credentials already configured. Keep them?",
321
+ message: `Feishu credentials already configured for account "${accountLabel}". Keep them?`,
227
322
  initialValue: true,
228
323
  });
229
324
  if (!keep) {
230
325
  appId = String(
231
326
  await prompter.text({
232
- message: "Enter Feishu App ID",
327
+ message: appIdPrompt,
233
328
  validate: (value) => (value?.trim() ? undefined : "Required"),
234
329
  }),
235
330
  ).trim();
236
331
  appSecret = String(
237
332
  await prompter.text({
238
- message: "Enter Feishu App Secret",
333
+ message: appSecretPrompt,
239
334
  validate: (value) => (value?.trim() ? undefined : "Required"),
240
335
  }),
241
336
  ).trim();
@@ -243,54 +338,50 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
243
338
  } else {
244
339
  appId = String(
245
340
  await prompter.text({
246
- message: "Enter Feishu App ID",
341
+ message: appIdPrompt,
247
342
  validate: (value) => (value?.trim() ? undefined : "Required"),
248
343
  }),
249
344
  ).trim();
250
345
  appSecret = String(
251
346
  await prompter.text({
252
- message: "Enter Feishu App Secret",
347
+ message: appSecretPrompt,
253
348
  validate: (value) => (value?.trim() ? undefined : "Required"),
254
349
  }),
255
350
  ).trim();
256
351
  }
257
352
 
258
353
  if (appId && appSecret) {
259
- next = {
260
- ...next,
261
- channels: {
262
- ...next.channels,
263
- feishu: {
264
- ...next.channels?.feishu,
265
- enabled: true,
266
- appId,
267
- appSecret,
268
- },
269
- },
270
- };
354
+ next = upsertFeishuAccountConfig(next, feishuAccountId, {
355
+ enabled: true,
356
+ appId,
357
+ appSecret,
358
+ });
271
359
 
272
360
  // Test connection
273
- const testCfg = next.channels?.feishu as FeishuConfig;
361
+ const testAccount = resolveFeishuAccount({ cfg: next, accountId: feishuAccountId });
274
362
  try {
275
- const probe = await probeFeishu(testCfg);
363
+ const probe = await probeFeishu(testAccount);
276
364
  if (probe.ok) {
277
365
  await prompter.note(
278
366
  `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
279
- "Feishu connection test",
367
+ `Feishu connection test (${accountLabel})`,
280
368
  );
281
369
  } else {
282
370
  await prompter.note(
283
371
  `Connection failed: ${probe.error ?? "unknown error"}`,
284
- "Feishu connection test",
372
+ `Feishu connection test (${accountLabel})`,
285
373
  );
286
374
  }
287
375
  } catch (err) {
288
- await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
376
+ await prompter.note(
377
+ `Connection test failed: ${String(err)}`,
378
+ `Feishu connection test (${accountLabel})`,
379
+ );
289
380
  }
290
381
  }
291
382
 
292
383
  // Domain selection
293
- const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
384
+ const currentDomain = resolveFeishuAccount({ cfg: next, accountId: feishuAccountId }).config.domain ?? "feishu";
294
385
  const domain = await prompter.select({
295
386
  message: "Which Feishu domain?",
296
387
  options: [
@@ -300,19 +391,11 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
300
391
  initialValue: currentDomain,
301
392
  });
302
393
  if (domain) {
303
- next = {
304
- ...next,
305
- channels: {
306
- ...next.channels,
307
- feishu: {
308
- ...next.channels?.feishu,
309
- domain: domain as "feishu" | "lark",
310
- },
311
- },
312
- };
394
+ next = setFeishuDomain(next, domain as "feishu" | "lark", feishuAccountId);
313
395
  }
314
396
 
315
397
  // Group policy
398
+ const groupPolicyAccount = resolveFeishuAccount({ cfg: next, accountId: feishuAccountId });
316
399
  const groupPolicy = await prompter.select({
317
400
  message: "Group chat policy",
318
401
  options: [
@@ -320,16 +403,20 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
320
403
  { value: "open", label: "Open - respond in all groups (requires mention)" },
321
404
  { value: "disabled", label: "Disabled - don't respond in groups" },
322
405
  ],
323
- initialValue:
324
- (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
406
+ initialValue: groupPolicyAccount.config.groupPolicy ?? "allowlist",
325
407
  });
326
408
  if (groupPolicy) {
327
- next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
409
+ next = setFeishuGroupPolicy(
410
+ next,
411
+ groupPolicy as "open" | "allowlist" | "disabled",
412
+ feishuAccountId,
413
+ );
328
414
  }
329
415
 
330
416
  // Group allowlist if needed
331
417
  if (groupPolicy === "allowlist") {
332
- const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
418
+ const existing =
419
+ resolveFeishuAccount({ cfg: next, accountId: feishuAccountId }).config.groupAllowFrom ?? [];
333
420
  const entry = await prompter.text({
334
421
  message: "Group chat allowlist (chat_ids)",
335
422
  placeholder: "oc_xxxxx, oc_yyyyy",
@@ -338,16 +425,20 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
338
425
  if (entry) {
339
426
  const parts = parseAllowFromInput(String(entry));
340
427
  if (parts.length > 0) {
341
- next = setFeishuGroupAllowFrom(next, parts);
428
+ next = setFeishuGroupAllowFrom(next, parts, feishuAccountId);
342
429
  }
343
430
  }
344
431
  }
345
432
 
346
- return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
433
+ return { cfg: next, accountId: feishuAccountId };
347
434
  },
348
435
 
349
436
  dmPolicy,
350
437
 
438
+ onAccountRecorded: (accountId) => {
439
+ onboardingDmPolicyAccountId = normalizeAccountId(accountId);
440
+ },
441
+
351
442
  disable: (cfg) => ({
352
443
  ...cfg,
353
444
  channels: {