@openclaw-plugins/feishu-plus 0.1.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.
package/src/channel.ts ADDED
@@ -0,0 +1,334 @@
1
+ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
3
+ import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
+ import {
5
+ resolveFeishuAccount,
6
+ resolveFeishuCredentials,
7
+ listFeishuAccountIds,
8
+ resolveDefaultFeishuAccountId,
9
+ } from "./accounts.js";
10
+ import { feishuOutbound } from "./outbound.js";
11
+ import { probeFeishu } from "./probe.js";
12
+ import { resolveFeishuGroupToolPolicy } from "./policy.js";
13
+ import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
14
+ import { sendMessageFeishu } from "./send.js";
15
+ import {
16
+ listFeishuDirectoryPeers,
17
+ listFeishuDirectoryGroups,
18
+ listFeishuDirectoryPeersLive,
19
+ listFeishuDirectoryGroupsLive,
20
+ } from "./directory.js";
21
+ import { feishuOnboardingAdapter } from "./onboarding.js";
22
+
23
+ const meta = {
24
+ id: "feishu",
25
+ label: "Feishu",
26
+ selectionLabel: "Feishu/Lark (飞书)",
27
+ docsPath: "/channels/feishu",
28
+ docsLabel: "feishu",
29
+ blurb: "飞书/Lark enterprise messaging.",
30
+ aliases: ["lark"],
31
+ order: 70,
32
+ } as const;
33
+
34
+ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
35
+ id: "feishu",
36
+ meta: {
37
+ ...meta,
38
+ },
39
+ pairing: {
40
+ idLabel: "feishuUserId",
41
+ normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
42
+ notifyApproval: async ({ cfg, id, accountId }) => {
43
+ await sendMessageFeishu({
44
+ cfg,
45
+ to: id,
46
+ text: PAIRING_APPROVED_MESSAGE,
47
+ accountId,
48
+ });
49
+ },
50
+ },
51
+ capabilities: {
52
+ chatTypes: ["direct", "channel"],
53
+ polls: false,
54
+ threads: true,
55
+ media: true,
56
+ reactions: true,
57
+ edit: true,
58
+ reply: true,
59
+ },
60
+ agentPrompt: {
61
+ messageToolHints: () => [
62
+ "- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
63
+ "- Feishu supports interactive cards for rich messages.",
64
+ ],
65
+ },
66
+ groups: {
67
+ resolveToolPolicy: resolveFeishuGroupToolPolicy,
68
+ },
69
+ reload: { configPrefixes: ["channels.feishu"] },
70
+ configSchema: {
71
+ schema: {
72
+ type: "object",
73
+ additionalProperties: false,
74
+ properties: {
75
+ enabled: { type: "boolean" },
76
+ appId: { type: "string" },
77
+ appSecret: { type: "string" },
78
+ encryptKey: { type: "string" },
79
+ verificationToken: { type: "string" },
80
+ domain: {
81
+ oneOf: [
82
+ { type: "string", enum: ["feishu", "lark"] },
83
+ { type: "string", format: "uri", pattern: "^https://" },
84
+ ],
85
+ },
86
+ connectionMode: { type: "string", enum: ["websocket", "webhook"] },
87
+ webhookPath: { type: "string" },
88
+ webhookPort: { type: "integer", minimum: 1 },
89
+ dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
90
+ allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
91
+ groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
92
+ groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
93
+ requireMention: { type: "boolean" },
94
+ topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
95
+ historyLimit: { type: "integer", minimum: 0 },
96
+ dmHistoryLimit: { type: "integer", minimum: 0 },
97
+ textChunkLimit: { type: "integer", minimum: 1 },
98
+ chunkMode: { type: "string", enum: ["length", "newline"] },
99
+ mediaMaxMb: { type: "number", minimum: 0 },
100
+ renderMode: { type: "string", enum: ["auto", "raw", "card"] },
101
+ accounts: {
102
+ type: "object",
103
+ additionalProperties: {
104
+ type: "object",
105
+ properties: {
106
+ enabled: { type: "boolean" },
107
+ name: { type: "string" },
108
+ appId: { type: "string" },
109
+ appSecret: { type: "string" },
110
+ encryptKey: { type: "string" },
111
+ verificationToken: { type: "string" },
112
+ domain: { type: "string", enum: ["feishu", "lark"] },
113
+ connectionMode: { type: "string", enum: ["websocket", "webhook"] },
114
+ },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ config: {
121
+ listAccountIds: (cfg) => listFeishuAccountIds(cfg),
122
+ resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
123
+ defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
124
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
125
+ const account = resolveFeishuAccount({ cfg, accountId });
126
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
127
+
128
+ if (isDefault) {
129
+ // For default account, set top-level enabled
130
+ return {
131
+ ...cfg,
132
+ channels: {
133
+ ...cfg.channels,
134
+ feishu: {
135
+ ...cfg.channels?.feishu,
136
+ enabled,
137
+ },
138
+ },
139
+ };
140
+ }
141
+
142
+ // For named accounts, set enabled in accounts[accountId]
143
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
144
+ return {
145
+ ...cfg,
146
+ channels: {
147
+ ...cfg.channels,
148
+ feishu: {
149
+ ...feishuCfg,
150
+ accounts: {
151
+ ...feishuCfg?.accounts,
152
+ [accountId]: {
153
+ ...feishuCfg?.accounts?.[accountId],
154
+ enabled,
155
+ },
156
+ },
157
+ },
158
+ },
159
+ };
160
+ },
161
+ deleteAccount: ({ cfg, accountId }) => {
162
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
163
+
164
+ if (isDefault) {
165
+ // Delete entire feishu config
166
+ const next = { ...cfg } as ClawdbotConfig;
167
+ const nextChannels = { ...cfg.channels };
168
+ delete (nextChannels as Record<string, unknown>).feishu;
169
+ if (Object.keys(nextChannels).length > 0) {
170
+ next.channels = nextChannels;
171
+ } else {
172
+ delete next.channels;
173
+ }
174
+ return next;
175
+ }
176
+
177
+ // Delete specific account from accounts
178
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
179
+ const accounts = { ...feishuCfg?.accounts };
180
+ delete accounts[accountId];
181
+
182
+ return {
183
+ ...cfg,
184
+ channels: {
185
+ ...cfg.channels,
186
+ feishu: {
187
+ ...feishuCfg,
188
+ accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
189
+ },
190
+ },
191
+ };
192
+ },
193
+ isConfigured: (account) => account.configured,
194
+ describeAccount: (account) => ({
195
+ accountId: account.accountId,
196
+ enabled: account.enabled,
197
+ configured: account.configured,
198
+ name: account.name,
199
+ appId: account.appId,
200
+ domain: account.domain,
201
+ }),
202
+ resolveAllowFrom: ({ cfg, accountId }) => {
203
+ const account = resolveFeishuAccount({ cfg, accountId });
204
+ return account.config?.allowFrom ?? [];
205
+ },
206
+ formatAllowFrom: ({ allowFrom }) =>
207
+ allowFrom
208
+ .map((entry) => String(entry).trim())
209
+ .filter(Boolean)
210
+ .map((entry) => entry.toLowerCase()),
211
+ },
212
+ security: {
213
+ collectWarnings: ({ cfg, accountId }) => {
214
+ const account = resolveFeishuAccount({ cfg, accountId });
215
+ const feishuCfg = account.config;
216
+ const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
217
+ const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
218
+ if (groupPolicy !== "open") return [];
219
+ return [
220
+ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
221
+ ];
222
+ },
223
+ },
224
+ setup: {
225
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
226
+ applyAccountConfig: ({ cfg, accountId }) => {
227
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
228
+
229
+ if (isDefault) {
230
+ return {
231
+ ...cfg,
232
+ channels: {
233
+ ...cfg.channels,
234
+ feishu: {
235
+ ...cfg.channels?.feishu,
236
+ enabled: true,
237
+ },
238
+ },
239
+ };
240
+ }
241
+
242
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
243
+ return {
244
+ ...cfg,
245
+ channels: {
246
+ ...cfg.channels,
247
+ feishu: {
248
+ ...feishuCfg,
249
+ accounts: {
250
+ ...feishuCfg?.accounts,
251
+ [accountId]: {
252
+ ...feishuCfg?.accounts?.[accountId],
253
+ enabled: true,
254
+ },
255
+ },
256
+ },
257
+ },
258
+ };
259
+ },
260
+ },
261
+ onboarding: feishuOnboardingAdapter,
262
+ messaging: {
263
+ normalizeTarget: normalizeFeishuTarget,
264
+ targetResolver: {
265
+ looksLikeId: looksLikeFeishuId,
266
+ hint: "<chatId|user:openId|chat:chatId>",
267
+ },
268
+ },
269
+ directory: {
270
+ self: async () => null,
271
+ listPeers: async ({ cfg, query, limit, accountId }) =>
272
+ listFeishuDirectoryPeers({ cfg, query, limit, accountId }),
273
+ listGroups: async ({ cfg, query, limit, accountId }) =>
274
+ listFeishuDirectoryGroups({ cfg, query, limit, accountId }),
275
+ listPeersLive: async ({ cfg, query, limit, accountId }) =>
276
+ listFeishuDirectoryPeersLive({ cfg, query, limit, accountId }),
277
+ listGroupsLive: async ({ cfg, query, limit, accountId }) =>
278
+ listFeishuDirectoryGroupsLive({ cfg, query, limit, accountId }),
279
+ },
280
+ outbound: feishuOutbound,
281
+ status: {
282
+ defaultRuntime: {
283
+ accountId: DEFAULT_ACCOUNT_ID,
284
+ running: false,
285
+ lastStartAt: null,
286
+ lastStopAt: null,
287
+ lastError: null,
288
+ port: null,
289
+ },
290
+ buildChannelSummary: ({ snapshot }) => ({
291
+ configured: snapshot.configured ?? false,
292
+ running: snapshot.running ?? false,
293
+ lastStartAt: snapshot.lastStartAt ?? null,
294
+ lastStopAt: snapshot.lastStopAt ?? null,
295
+ lastError: snapshot.lastError ?? null,
296
+ port: snapshot.port ?? null,
297
+ probe: snapshot.probe,
298
+ lastProbeAt: snapshot.lastProbeAt ?? null,
299
+ }),
300
+ probeAccount: async ({ cfg, accountId }) => {
301
+ const account = resolveFeishuAccount({ cfg, accountId });
302
+ return await probeFeishu(account);
303
+ },
304
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
305
+ accountId: account.accountId,
306
+ enabled: account.enabled,
307
+ configured: account.configured,
308
+ name: account.name,
309
+ appId: account.appId,
310
+ domain: account.domain,
311
+ running: runtime?.running ?? false,
312
+ lastStartAt: runtime?.lastStartAt ?? null,
313
+ lastStopAt: runtime?.lastStopAt ?? null,
314
+ lastError: runtime?.lastError ?? null,
315
+ port: runtime?.port ?? null,
316
+ probe,
317
+ }),
318
+ },
319
+ gateway: {
320
+ startAccount: async (ctx) => {
321
+ const { monitorFeishuProvider } = await import("./monitor.js");
322
+ const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
323
+ const port = account.config?.webhookPort ?? null;
324
+ ctx.setStatus({ accountId: ctx.accountId, port });
325
+ ctx.log?.info(`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`);
326
+ return monitorFeishuProvider({
327
+ config: ctx.cfg,
328
+ runtime: ctx.runtime,
329
+ abortSignal: ctx.abortSignal,
330
+ accountId: ctx.accountId,
331
+ });
332
+ },
333
+ },
334
+ };
package/src/client.ts ADDED
@@ -0,0 +1,114 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
3
+
4
+ // Multi-account client cache
5
+ const clientCache = new Map<
6
+ string,
7
+ {
8
+ client: Lark.Client;
9
+ config: { appId: string; appSecret: string; domain?: FeishuDomain };
10
+ }
11
+ >();
12
+
13
+ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
14
+ if (domain === "lark") return Lark.Domain.Lark;
15
+ if (domain === "feishu" || !domain) return Lark.Domain.Feishu;
16
+ return domain.replace(/\/+$/, ""); // Custom URL for private deployment
17
+ }
18
+
19
+ /**
20
+ * Credentials needed to create a Feishu client.
21
+ * Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
22
+ */
23
+ export type FeishuClientCredentials = {
24
+ accountId?: string;
25
+ appId?: string;
26
+ appSecret?: string;
27
+ domain?: FeishuDomain;
28
+ };
29
+
30
+ /**
31
+ * Create or get a cached Feishu client for an account.
32
+ * Accepts any object with appId, appSecret, and optional domain/accountId.
33
+ */
34
+ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
35
+ const { accountId = "default", appId, appSecret, domain } = creds;
36
+
37
+ if (!appId || !appSecret) {
38
+ throw new Error(`Feishu credentials not configured for account "${accountId}"`);
39
+ }
40
+
41
+ // Check cache
42
+ const cached = clientCache.get(accountId);
43
+ if (
44
+ cached &&
45
+ cached.config.appId === appId &&
46
+ cached.config.appSecret === appSecret &&
47
+ cached.config.domain === domain
48
+ ) {
49
+ return cached.client;
50
+ }
51
+
52
+ // Create new client
53
+ const client = new Lark.Client({
54
+ appId,
55
+ appSecret,
56
+ appType: Lark.AppType.SelfBuild,
57
+ domain: resolveDomain(domain),
58
+ });
59
+
60
+ // Cache it
61
+ clientCache.set(accountId, {
62
+ client,
63
+ config: { appId, appSecret, domain },
64
+ });
65
+
66
+ return client;
67
+ }
68
+
69
+ /**
70
+ * Create a Feishu WebSocket client for an account.
71
+ * Note: WSClient is not cached since each call creates a new connection.
72
+ */
73
+ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
74
+ const { accountId, appId, appSecret, domain } = account;
75
+
76
+ if (!appId || !appSecret) {
77
+ throw new Error(`Feishu credentials not configured for account "${accountId}"`);
78
+ }
79
+
80
+ return new Lark.WSClient({
81
+ appId,
82
+ appSecret,
83
+ domain: resolveDomain(domain),
84
+ loggerLevel: Lark.LoggerLevel.info,
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Create an event dispatcher for an account.
90
+ */
91
+ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
92
+ return new Lark.EventDispatcher({
93
+ encryptKey: account.encryptKey,
94
+ verificationToken: account.verificationToken,
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Get a cached client for an account (if exists).
100
+ */
101
+ export function getFeishuClient(accountId: string): Lark.Client | null {
102
+ return clientCache.get(accountId)?.client ?? null;
103
+ }
104
+
105
+ /**
106
+ * Clear client cache for a specific account or all accounts.
107
+ */
108
+ export function clearClientCache(accountId?: string): void {
109
+ if (accountId) {
110
+ clientCache.delete(accountId);
111
+ } else {
112
+ clientCache.clear();
113
+ }
114
+ }
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ export { z };
3
+
4
+ const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
5
+ const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
6
+ const FeishuDomainSchema = z.union([
7
+ z.enum(["feishu", "lark"]),
8
+ z.string().url().startsWith("https://"),
9
+ ]);
10
+ const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
11
+
12
+ const ToolPolicySchema = z
13
+ .object({
14
+ allow: z.array(z.string()).optional(),
15
+ deny: z.array(z.string()).optional(),
16
+ })
17
+ .strict()
18
+ .optional();
19
+
20
+ const DmConfigSchema = z
21
+ .object({
22
+ enabled: z.boolean().optional(),
23
+ systemPrompt: z.string().optional(),
24
+ })
25
+ .strict()
26
+ .optional();
27
+
28
+ const MarkdownConfigSchema = z
29
+ .object({
30
+ mode: z.enum(["native", "escape", "strip"]).optional(),
31
+ tableMode: z.enum(["native", "ascii", "simple"]).optional(),
32
+ })
33
+ .strict()
34
+ .optional();
35
+
36
+ // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
37
+ const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
38
+
39
+ const BlockStreamingCoalesceSchema = z
40
+ .object({
41
+ enabled: z.boolean().optional(),
42
+ minDelayMs: z.number().int().positive().optional(),
43
+ maxDelayMs: z.number().int().positive().optional(),
44
+ })
45
+ .strict()
46
+ .optional();
47
+
48
+ const ChannelHeartbeatVisibilitySchema = z
49
+ .object({
50
+ visibility: z.enum(["visible", "hidden"]).optional(),
51
+ intervalMs: z.number().int().positive().optional(),
52
+ })
53
+ .strict()
54
+ .optional();
55
+
56
+ /**
57
+ * Dynamic agent creation configuration.
58
+ * When enabled, a new agent is created for each unique DM user.
59
+ */
60
+ const DynamicAgentCreationSchema = z
61
+ .object({
62
+ enabled: z.boolean().optional(),
63
+ workspaceTemplate: z.string().optional(),
64
+ agentDirTemplate: z.string().optional(),
65
+ maxAgents: z.number().int().positive().optional(),
66
+ })
67
+ .strict()
68
+ .optional();
69
+
70
+ /**
71
+ * Feishu tools configuration.
72
+ * Controls which tool categories are enabled.
73
+ *
74
+ * Dependencies:
75
+ * - wiki requires doc (wiki content is edited via doc tools)
76
+ * - perm can work independently but is typically used with drive
77
+ */
78
+ const FeishuToolsConfigSchema = z
79
+ .object({
80
+ doc: z.boolean().optional(), // Document operations (default: true)
81
+ wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
82
+ drive: z.boolean().optional(), // Cloud storage operations (default: true)
83
+ perm: z.boolean().optional(), // Permission management (default: false, sensitive)
84
+ scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
85
+ })
86
+ .strict()
87
+ .optional();
88
+
89
+ /**
90
+ * Topic session isolation mode for group chats.
91
+ * - "disabled" (default): All messages in a group share one session
92
+ * - "enabled": Messages in different topics get separate sessions
93
+ *
94
+ * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
95
+ * for messages within a topic thread, allowing isolated conversations.
96
+ */
97
+ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
98
+
99
+ export const FeishuGroupSchema = z
100
+ .object({
101
+ requireMention: z.boolean().optional(),
102
+ tools: ToolPolicySchema,
103
+ skills: z.array(z.string()).optional(),
104
+ enabled: z.boolean().optional(),
105
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
106
+ systemPrompt: z.string().optional(),
107
+ topicSessionMode: TopicSessionModeSchema,
108
+ })
109
+ .strict();
110
+
111
+ /**
112
+ * Per-account configuration.
113
+ * All fields are optional - missing fields inherit from top-level config.
114
+ */
115
+ export const FeishuAccountConfigSchema = z
116
+ .object({
117
+ enabled: z.boolean().optional(),
118
+ name: z.string().optional(), // Display name for this account
119
+ appId: z.string().optional(),
120
+ appSecret: z.string().optional(),
121
+ encryptKey: z.string().optional(),
122
+ verificationToken: z.string().optional(),
123
+ domain: FeishuDomainSchema.optional(),
124
+ connectionMode: FeishuConnectionModeSchema.optional(),
125
+ webhookPath: z.string().optional(),
126
+ webhookPort: z.number().int().positive().optional(),
127
+ capabilities: z.array(z.string()).optional(),
128
+ markdown: MarkdownConfigSchema,
129
+ configWrites: z.boolean().optional(),
130
+ dmPolicy: DmPolicySchema.optional(),
131
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
132
+ groupPolicy: GroupPolicySchema.optional(),
133
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
134
+ requireMention: z.boolean().optional(),
135
+ groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
136
+ historyLimit: z.number().int().min(0).optional(),
137
+ dmHistoryLimit: z.number().int().min(0).optional(),
138
+ dms: z.record(z.string(), DmConfigSchema).optional(),
139
+ textChunkLimit: z.number().int().positive().optional(),
140
+ chunkMode: z.enum(["length", "newline"]).optional(),
141
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema,
142
+ mediaMaxMb: z.number().positive().optional(),
143
+ heartbeat: ChannelHeartbeatVisibilitySchema,
144
+ renderMode: RenderModeSchema,
145
+ tools: FeishuToolsConfigSchema,
146
+ })
147
+ .strict();
148
+
149
+ export const FeishuConfigSchema = z
150
+ .object({
151
+ enabled: z.boolean().optional(),
152
+ // Top-level credentials (backward compatible for single-account mode)
153
+ appId: z.string().optional(),
154
+ appSecret: z.string().optional(),
155
+ encryptKey: z.string().optional(),
156
+ verificationToken: z.string().optional(),
157
+ domain: FeishuDomainSchema.optional().default("feishu"),
158
+ connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
159
+ webhookPath: z.string().optional().default("/feishu/events"),
160
+ webhookPort: z.number().int().positive().optional(),
161
+ capabilities: z.array(z.string()).optional(),
162
+ markdown: MarkdownConfigSchema,
163
+ configWrites: z.boolean().optional(),
164
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
165
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
166
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
167
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
168
+ requireMention: z.boolean().optional().default(true),
169
+ groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
170
+ topicSessionMode: TopicSessionModeSchema,
171
+ historyLimit: z.number().int().min(0).optional(),
172
+ dmHistoryLimit: z.number().int().min(0).optional(),
173
+ dms: z.record(z.string(), DmConfigSchema).optional(),
174
+ textChunkLimit: z.number().int().positive().optional(),
175
+ chunkMode: z.enum(["length", "newline"]).optional(),
176
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema,
177
+ mediaMaxMb: z.number().positive().optional(),
178
+ heartbeat: ChannelHeartbeatVisibilitySchema,
179
+ renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
180
+ tools: FeishuToolsConfigSchema,
181
+ // Dynamic agent creation for DM users
182
+ dynamicAgentCreation: DynamicAgentCreationSchema,
183
+ // Multi-account configuration
184
+ accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
185
+ })
186
+ .strict()
187
+ .superRefine((value, ctx) => {
188
+ if (value.dmPolicy === "open") {
189
+ const allowFrom = value.allowFrom ?? [];
190
+ const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
191
+ if (!hasWildcard) {
192
+ ctx.addIssue({
193
+ code: z.ZodIssueCode.custom,
194
+ path: ["allowFrom"],
195
+ message: 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
196
+ });
197
+ }
198
+ }
199
+ });