@openclaw/feishu 2026.3.2 → 2026.3.7

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.
Files changed (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +1 -1
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
@@ -1,12 +1,45 @@
1
1
  import { vi } from "vitest";
2
2
 
3
- export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.fn();
3
+ export function createFeishuClientMockModule(): {
4
+ createFeishuWSClient: () => { start: () => void };
5
+ createEventDispatcher: () => { register: () => void };
6
+ } {
7
+ return {
8
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
9
+ createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
10
+ };
11
+ }
4
12
 
5
- vi.mock("./probe.js", () => ({
6
- probeFeishu: probeFeishuMock,
7
- }));
8
-
9
- vi.mock("./client.js", () => ({
10
- createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
11
- createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
12
- }));
13
+ export function createFeishuRuntimeMockModule(): {
14
+ getFeishuRuntime: () => {
15
+ channel: {
16
+ debounce: {
17
+ resolveInboundDebounceMs: () => number;
18
+ createInboundDebouncer: () => {
19
+ enqueue: () => Promise<void>;
20
+ flushKey: () => Promise<void>;
21
+ };
22
+ };
23
+ text: {
24
+ hasControlCommand: () => boolean;
25
+ };
26
+ };
27
+ };
28
+ } {
29
+ return {
30
+ getFeishuRuntime: () => ({
31
+ channel: {
32
+ debounce: {
33
+ resolveInboundDebounceMs: () => 0,
34
+ createInboundDebouncer: () => ({
35
+ enqueue: async () => {},
36
+ flushKey: async () => {},
37
+ }),
38
+ },
39
+ text: {
40
+ hasControlCommand: () => false,
41
+ },
42
+ },
43
+ }),
44
+ };
45
+ }
@@ -4,9 +4,10 @@ import {
4
4
  applyBasicWebhookRequestGuards,
5
5
  type RuntimeEnv,
6
6
  installRequestBodyLimitGuard,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/feishu";
8
8
  import { createFeishuWSClient } from "./client.js";
9
9
  import {
10
+ botNames,
10
11
  botOpenIds,
11
12
  FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
12
13
  FEISHU_WEBHOOK_MAX_BODY_BYTES,
@@ -42,6 +43,7 @@ export async function monitorWebSocket({
42
43
  const cleanup = () => {
43
44
  wsClients.delete(accountId);
44
45
  botOpenIds.delete(accountId);
46
+ botNames.delete(accountId);
45
47
  };
46
48
 
47
49
  const handleAbort = () => {
@@ -134,6 +136,7 @@ export async function monitorWebhook({
134
136
  server.close();
135
137
  httpServers.delete(accountId);
136
138
  botOpenIds.delete(accountId);
139
+ botNames.delete(accountId);
137
140
  };
138
141
 
139
142
  const handleAbort = () => {
package/src/monitor.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
2
  import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
3
3
  import {
4
4
  monitorSingleAccount,
5
5
  resolveReactionSyntheticEvent,
6
6
  type FeishuReactionCreatedEvent,
7
7
  } from "./monitor.account.js";
8
- import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
8
+ import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
9
9
  import {
10
10
  clearFeishuWebhookRateLimitStateForTest,
11
11
  getFeishuWebhookRateLimitStateSizeForTest,
@@ -66,7 +66,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
66
66
  }
67
67
 
68
68
  // Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
69
- const botOpenId = await fetchBotOpenIdForMonitor(account, {
69
+ const { botOpenId, botName } = await fetchBotIdentityForMonitor(account, {
70
70
  runtime: opts.runtime,
71
71
  abortSignal: opts.abortSignal,
72
72
  });
@@ -82,7 +82,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
82
82
  account,
83
83
  runtime: opts.runtime,
84
84
  abortSignal: opts.abortSignal,
85
- botOpenIdSource: { kind: "prefetched", botOpenId },
85
+ botOpenIdSource: { kind: "prefetched", botOpenId, botName },
86
86
  }),
87
87
  );
88
88
  }
@@ -1,7 +1,11 @@
1
1
  import { createServer } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
3
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ createFeishuClientMockModule,
7
+ createFeishuRuntimeMockModule,
8
+ } from "./monitor.test-mocks.js";
5
9
 
6
10
  const probeFeishuMock = vi.hoisted(() => vi.fn());
7
11
 
@@ -9,27 +13,8 @@ vi.mock("./probe.js", () => ({
9
13
  probeFeishu: probeFeishuMock,
10
14
  }));
11
15
 
12
- vi.mock("./client.js", () => ({
13
- createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
14
- createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
15
- }));
16
-
17
- vi.mock("./runtime.js", () => ({
18
- getFeishuRuntime: () => ({
19
- channel: {
20
- debounce: {
21
- resolveInboundDebounceMs: () => 0,
22
- createInboundDebouncer: () => ({
23
- enqueue: async () => {},
24
- flushKey: async () => {},
25
- }),
26
- },
27
- text: {
28
- hasControlCommand: () => false,
29
- },
30
- },
31
- }),
32
- }));
16
+ vi.mock("./client.js", () => createFeishuClientMockModule());
17
+ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
33
18
 
34
19
  vi.mock("@larksuiteoapi/node-sdk", () => ({
35
20
  adaptDefault: vi.fn(
@@ -88,7 +73,7 @@ function buildConfig(params: {
88
73
  [params.accountId]: {
89
74
  enabled: true,
90
75
  appId: "cli_test",
91
- appSecret: "secret_test",
76
+ appSecret: "secret_test", // pragma: allowlist secret
92
77
  connectionMode: "webhook",
93
78
  webhookHost: "127.0.0.1",
94
79
  webhookPort: params.port,
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { feishuOnboardingAdapter } from "./onboarding.js";
4
4
 
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./probe.js", () => ({
4
+ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
5
+ }));
6
+
7
+ import { feishuOnboardingAdapter } from "./onboarding.js";
8
+
9
+ const baseConfigureContext = {
10
+ runtime: {} as never,
11
+ accountOverrides: {},
12
+ shouldPromptAccountIds: false,
13
+ forceAllowFrom: false,
14
+ };
15
+
16
+ const baseStatusContext = {
17
+ accountOverrides: {},
18
+ };
19
+
20
+ async function withEnvVars(values: Record<string, string | undefined>, run: () => Promise<void>) {
21
+ const previous = new Map<string, string | undefined>();
22
+ for (const [key, value] of Object.entries(values)) {
23
+ previous.set(key, process.env[key]);
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ } else {
27
+ process.env[key] = value;
28
+ }
29
+ }
30
+
31
+ try {
32
+ await run();
33
+ } finally {
34
+ for (const [key, prior] of previous.entries()) {
35
+ if (prior === undefined) {
36
+ delete process.env[key];
37
+ } else {
38
+ process.env[key] = prior;
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
45
+ return await feishuOnboardingAdapter.getStatus({
46
+ cfg: {
47
+ channels: {
48
+ feishu: {
49
+ appId: { source: "env", id: params.appIdKey, provider: "default" },
50
+ appSecret: { source: "env", id: params.appSecretKey, provider: "default" },
51
+ },
52
+ },
53
+ } as never,
54
+ ...baseStatusContext,
55
+ });
56
+ }
57
+
58
+ describe("feishuOnboardingAdapter.configure", () => {
59
+ it("does not throw when config appId/appSecret are SecretRef objects", async () => {
60
+ const text = vi
61
+ .fn()
62
+ .mockResolvedValueOnce("cli_from_prompt")
63
+ .mockResolvedValueOnce("secret_from_prompt")
64
+ .mockResolvedValueOnce("oc_group_1");
65
+
66
+ const prompter = {
67
+ note: vi.fn(async () => undefined),
68
+ text,
69
+ confirm: vi.fn(async () => true),
70
+ select: vi.fn(
71
+ async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
72
+ ),
73
+ } as never;
74
+
75
+ await expect(
76
+ feishuOnboardingAdapter.configure({
77
+ cfg: {
78
+ channels: {
79
+ feishu: {
80
+ appId: { source: "env", id: "FEISHU_APP_ID", provider: "default" },
81
+ appSecret: { source: "env", id: "FEISHU_APP_SECRET", provider: "default" },
82
+ },
83
+ },
84
+ } as never,
85
+ prompter,
86
+ ...baseConfigureContext,
87
+ }),
88
+ ).resolves.toBeTruthy();
89
+ });
90
+ });
91
+
92
+ describe("feishuOnboardingAdapter.getStatus", () => {
93
+ it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
94
+ const status = await feishuOnboardingAdapter.getStatus({
95
+ cfg: {
96
+ channels: {
97
+ feishu: {
98
+ appId: "top_level_app",
99
+ accounts: {
100
+ main: {
101
+ appId: "",
102
+ appSecret: "sample-app-credential", // pragma: allowlist secret
103
+ },
104
+ },
105
+ },
106
+ },
107
+ } as never,
108
+ ...baseStatusContext,
109
+ });
110
+
111
+ expect(status.configured).toBe(false);
112
+ });
113
+
114
+ it("treats env SecretRef appId as not configured when env var is missing", async () => {
115
+ const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST";
116
+ const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret
117
+ await withEnvVars(
118
+ {
119
+ [appIdKey]: undefined,
120
+ [appSecretKey]: "env-credential-456", // pragma: allowlist secret
121
+ },
122
+ async () => {
123
+ const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
124
+ expect(status.configured).toBe(false);
125
+ },
126
+ );
127
+ });
128
+
129
+ it("treats env SecretRef appId/appSecret as configured in status", async () => {
130
+ const appIdKey = "FEISHU_APP_ID_STATUS_TEST";
131
+ const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_TEST"; // pragma: allowlist secret
132
+ await withEnvVars(
133
+ {
134
+ [appIdKey]: "cli_env_123",
135
+ [appSecretKey]: "env-credential-456", // pragma: allowlist secret
136
+ },
137
+ async () => {
138
+ const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
139
+ expect(status.configured).toBe(true);
140
+ },
141
+ );
142
+ });
143
+ });
package/src/onboarding.ts CHANGED
@@ -5,56 +5,47 @@ import type {
5
5
  DmPolicy,
6
6
  SecretInput,
7
7
  WizardPrompter,
8
- } from "openclaw/plugin-sdk";
8
+ } from "openclaw/plugin-sdk/feishu";
9
9
  import {
10
- addWildcardAllowFrom,
10
+ buildSingleChannelSecretPromptState,
11
11
  DEFAULT_ACCOUNT_ID,
12
12
  formatDocsLink,
13
13
  hasConfiguredSecretInput,
14
+ mergeAllowFromEntries,
14
15
  promptSingleChannelSecretInput,
15
- } from "openclaw/plugin-sdk";
16
+ setTopLevelChannelAllowFrom,
17
+ setTopLevelChannelDmPolicyWithAllowFrom,
18
+ setTopLevelChannelGroupPolicy,
19
+ splitOnboardingEntries,
20
+ } from "openclaw/plugin-sdk/feishu";
16
21
  import { resolveFeishuCredentials } from "./accounts.js";
17
22
  import { probeFeishu } from "./probe.js";
18
23
  import type { FeishuConfig } from "./types.js";
19
24
 
20
25
  const channel = "feishu" as const;
21
26
 
22
- function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
23
- const allowFrom =
24
- dmPolicy === "open"
25
- ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
26
- : undefined;
27
- return {
28
- ...cfg,
29
- channels: {
30
- ...cfg.channels,
31
- feishu: {
32
- ...cfg.channels?.feishu,
33
- dmPolicy,
34
- ...(allowFrom ? { allowFrom } : {}),
35
- },
36
- },
37
- };
27
+ function normalizeString(value: unknown): string | undefined {
28
+ if (typeof value !== "string") {
29
+ return undefined;
30
+ }
31
+ const trimmed = value.trim();
32
+ return trimmed || undefined;
38
33
  }
39
34
 
40
- function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
41
- return {
42
- ...cfg,
43
- channels: {
44
- ...cfg.channels,
45
- feishu: {
46
- ...cfg.channels?.feishu,
47
- allowFrom,
48
- },
49
- },
50
- };
35
+ function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
36
+ return setTopLevelChannelDmPolicyWithAllowFrom({
37
+ cfg,
38
+ channel: "feishu",
39
+ dmPolicy,
40
+ }) as ClawdbotConfig;
51
41
  }
52
42
 
53
- function parseAllowFromInput(raw: string): string[] {
54
- return raw
55
- .split(/[\n,;]+/g)
56
- .map((entry) => entry.trim())
57
- .filter(Boolean);
43
+ function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
44
+ return setTopLevelChannelAllowFrom({
45
+ cfg,
46
+ channel: "feishu",
47
+ allowFrom,
48
+ }) as ClawdbotConfig;
58
49
  }
59
50
 
60
51
  async function promptFeishuAllowFrom(params: {
@@ -80,18 +71,13 @@ async function promptFeishuAllowFrom(params: {
80
71
  initialValue: existing[0] ? String(existing[0]) : undefined,
81
72
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
82
73
  });
83
- const parts = parseAllowFromInput(String(entry));
74
+ const parts = splitOnboardingEntries(String(entry));
84
75
  if (parts.length === 0) {
85
76
  await params.prompter.note("Enter at least one user.", "Feishu allowlist");
86
77
  continue;
87
78
  }
88
79
 
89
- const unique = [
90
- ...new Set([
91
- ...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
92
- ...parts,
93
- ]),
94
- ];
80
+ const unique = mergeAllowFromEntries(existing, parts);
95
81
  return setFeishuAllowFrom(params.cfg, unique);
96
82
  }
97
83
  }
@@ -129,17 +115,12 @@ function setFeishuGroupPolicy(
129
115
  cfg: ClawdbotConfig,
130
116
  groupPolicy: "open" | "allowlist" | "disabled",
131
117
  ): ClawdbotConfig {
132
- return {
133
- ...cfg,
134
- channels: {
135
- ...cfg.channels,
136
- feishu: {
137
- ...cfg.channels?.feishu,
138
- enabled: true,
139
- groupPolicy,
140
- },
141
- },
142
- };
118
+ return setTopLevelChannelGroupPolicy({
119
+ cfg,
120
+ channel: "feishu",
121
+ groupPolicy,
122
+ enabled: true,
123
+ }) as ClawdbotConfig;
143
124
  }
144
125
 
145
126
  function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
@@ -169,20 +150,43 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
169
150
  channel,
170
151
  getStatus: async ({ cfg }) => {
171
152
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
153
+
154
+ const isAppIdConfigured = (value: unknown): boolean => {
155
+ const asString = normalizeString(value);
156
+ if (asString) {
157
+ return true;
158
+ }
159
+ if (!value || typeof value !== "object") {
160
+ return false;
161
+ }
162
+ const rec = value as Record<string, unknown>;
163
+ const source = normalizeString(rec.source)?.toLowerCase();
164
+ const id = normalizeString(rec.id);
165
+ if (source === "env" && id) {
166
+ return Boolean(normalizeString(process.env[id]));
167
+ }
168
+ return hasConfiguredSecretInput(value);
169
+ };
170
+
172
171
  const topLevelConfigured = Boolean(
173
- feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret),
172
+ isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
174
173
  );
174
+
175
175
  const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
176
176
  if (!account || typeof account !== "object") {
177
177
  return false;
178
178
  }
179
- const accountAppId =
180
- typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim();
181
- const accountSecretConfigured =
182
- hasConfiguredSecretInput(account.appSecret) ||
183
- hasConfiguredSecretInput(feishuCfg?.appSecret);
184
- return Boolean(accountAppId && accountSecretConfigured);
179
+ const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
180
+ const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
181
+ const accountAppIdConfigured = hasOwnAppId
182
+ ? isAppIdConfigured((account as Record<string, unknown>).appId)
183
+ : isAppIdConfigured(feishuCfg?.appId);
184
+ const accountSecretConfigured = hasOwnAppSecret
185
+ ? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
186
+ : hasConfiguredSecretInput(feishuCfg?.appSecret);
187
+ return Boolean(accountAppIdConfigured && accountSecretConfigured);
185
188
  });
189
+
186
190
  const configured = topLevelConfigured || accountConfigured;
187
191
  const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
188
192
  allowUnresolvedSecretRef: true,
@@ -224,10 +228,15 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
224
228
  allowUnresolvedSecretRef: true,
225
229
  });
226
230
  const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
227
- const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret);
228
- const canUseEnv = Boolean(
229
- !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
231
+ const hasConfigCreds = Boolean(
232
+ typeof feishuCfg?.appId === "string" && feishuCfg.appId.trim() && hasConfigSecret,
230
233
  );
234
+ const appSecretPromptState = buildSingleChannelSecretPromptState({
235
+ accountConfigured: Boolean(resolved),
236
+ hasConfigToken: hasConfigSecret,
237
+ allowEnv: !hasConfigCreds && Boolean(process.env.FEISHU_APP_ID?.trim()),
238
+ envValue: process.env.FEISHU_APP_SECRET,
239
+ });
231
240
 
232
241
  let next = cfg;
233
242
  let appId: string | null = null;
@@ -243,9 +252,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
243
252
  prompter,
244
253
  providerHint: "feishu",
245
254
  credentialLabel: "App Secret",
246
- accountConfigured: Boolean(resolved),
247
- canUseEnv,
248
- hasConfigToken: hasConfigSecret,
255
+ accountConfigured: appSecretPromptState.accountConfigured,
256
+ canUseEnv: appSecretPromptState.canUseEnv,
257
+ hasConfigToken: appSecretPromptState.hasConfigToken,
249
258
  envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
250
259
  keepPrompt: "Feishu App Secret already configured. Keep it?",
251
260
  inputPrompt: "Enter Feishu App Secret",
@@ -265,7 +274,8 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
265
274
  appSecretProbeValue = appSecretResult.resolvedValue;
266
275
  appId = await promptFeishuAppId({
267
276
  prompter,
268
- initialValue: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(),
277
+ initialValue:
278
+ normalizeString(feishuCfg?.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
269
279
  });
270
280
  }
271
281
 
@@ -330,14 +340,19 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
330
340
  if (connectionMode === "webhook") {
331
341
  const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
332
342
  ?.verificationToken;
343
+ const verificationTokenPromptState = buildSingleChannelSecretPromptState({
344
+ accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
345
+ hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
346
+ allowEnv: false,
347
+ });
333
348
  const verificationTokenResult = await promptSingleChannelSecretInput({
334
349
  cfg: next,
335
350
  prompter,
336
351
  providerHint: "feishu-webhook",
337
352
  credentialLabel: "verification token",
338
- accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
339
- canUseEnv: false,
340
- hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
353
+ accountConfigured: verificationTokenPromptState.accountConfigured,
354
+ canUseEnv: verificationTokenPromptState.canUseEnv,
355
+ hasConfigToken: verificationTokenPromptState.hasConfigToken,
341
356
  envPrompt: "",
342
357
  keepPrompt: "Feishu verification token already configured. Keep it?",
343
358
  inputPrompt: "Enter Feishu verification token",
@@ -421,7 +436,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
421
436
  initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
422
437
  });
423
438
  if (entry) {
424
- const parts = parseAllowFromInput(String(entry));
439
+ const parts = splitOnboardingEntries(String(entry));
425
440
  if (parts.length > 0) {
426
441
  next = setFeishuGroupAllowFrom(next, parts);
427
442
  }