@colin3191/feishu 0.1.2

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,224 @@
1
+ import type { ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "clawdbot/plugin-sdk";
3
+ import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
+ import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js";
5
+ import { feishuOutbound } from "./outbound.js";
6
+ import { probeFeishu } from "./probe.js";
7
+ import { resolveFeishuGroupToolPolicy } from "./policy.js";
8
+ import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
9
+ import { sendMessageFeishu } from "./send.js";
10
+ import {
11
+ listFeishuDirectoryPeers,
12
+ listFeishuDirectoryGroups,
13
+ listFeishuDirectoryPeersLive,
14
+ listFeishuDirectoryGroupsLive,
15
+ } from "./directory.js";
16
+ import { feishuOnboardingAdapter } from "./onboarding.js";
17
+
18
+ const meta = {
19
+ id: "feishu",
20
+ label: "Feishu",
21
+ selectionLabel: "Feishu/Lark (飞书)",
22
+ docsPath: "/channels/feishu",
23
+ docsLabel: "feishu",
24
+ blurb: "飞书/Lark enterprise messaging.",
25
+ aliases: ["lark"],
26
+ order: 70,
27
+ } as const;
28
+
29
+ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
30
+ id: "feishu",
31
+ meta: {
32
+ ...meta,
33
+ },
34
+ pairing: {
35
+ idLabel: "feishuUserId",
36
+ normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
37
+ notifyApproval: async ({ cfg, id }) => {
38
+ await sendMessageFeishu({
39
+ cfg,
40
+ to: id,
41
+ text: PAIRING_APPROVED_MESSAGE,
42
+ });
43
+ },
44
+ },
45
+ capabilities: {
46
+ chatTypes: ["direct", "channel"],
47
+ polls: false,
48
+ threads: true,
49
+ media: true,
50
+ reactions: true,
51
+ edit: true,
52
+ reply: true,
53
+ },
54
+ agentPrompt: {
55
+ messageToolHints: () => [
56
+ "- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
57
+ "- Feishu supports interactive cards for rich messages.",
58
+ ],
59
+ },
60
+ groups: {
61
+ resolveToolPolicy: resolveFeishuGroupToolPolicy,
62
+ },
63
+ reload: { configPrefixes: ["channels.feishu"] },
64
+ configSchema: {
65
+ schema: {
66
+ type: "object",
67
+ additionalProperties: false,
68
+ properties: {
69
+ enabled: { type: "boolean" },
70
+ appId: { type: "string" },
71
+ appSecret: { type: "string" },
72
+ encryptKey: { type: "string" },
73
+ verificationToken: { type: "string" },
74
+ domain: { type: "string", enum: ["feishu", "lark"] },
75
+ connectionMode: { type: "string", enum: ["websocket", "webhook"] },
76
+ webhookPath: { type: "string" },
77
+ webhookPort: { type: "integer", minimum: 1 },
78
+ dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
79
+ allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
80
+ groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
81
+ groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
82
+ requireMention: { type: "boolean" },
83
+ historyLimit: { type: "integer", minimum: 0 },
84
+ dmHistoryLimit: { type: "integer", minimum: 0 },
85
+ textChunkLimit: { type: "integer", minimum: 1 },
86
+ chunkMode: { type: "string", enum: ["length", "newline"] },
87
+ mediaMaxMb: { type: "number", minimum: 0 },
88
+ renderMode: { type: "string", enum: ["auto", "raw", "card"] },
89
+ },
90
+ },
91
+ },
92
+ config: {
93
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
94
+ resolveAccount: (cfg) => resolveFeishuAccount({ cfg }),
95
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
96
+ setAccountEnabled: ({ cfg, enabled }) => ({
97
+ ...cfg,
98
+ channels: {
99
+ ...cfg.channels,
100
+ feishu: {
101
+ ...cfg.channels?.feishu,
102
+ enabled,
103
+ },
104
+ },
105
+ }),
106
+ deleteAccount: ({ cfg }) => {
107
+ const next = { ...cfg } as ClawdbotConfig;
108
+ const nextChannels = { ...cfg.channels };
109
+ delete (nextChannels as Record<string, unknown>).feishu;
110
+ if (Object.keys(nextChannels).length > 0) {
111
+ next.channels = nextChannels;
112
+ } else {
113
+ delete next.channels;
114
+ }
115
+ return next;
116
+ },
117
+ isConfigured: (_account, cfg) =>
118
+ Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)),
119
+ describeAccount: (account) => ({
120
+ accountId: account.accountId,
121
+ enabled: account.enabled,
122
+ configured: account.configured,
123
+ }),
124
+ resolveAllowFrom: ({ cfg }) =>
125
+ (cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [],
126
+ formatAllowFrom: ({ allowFrom }) =>
127
+ allowFrom
128
+ .map((entry) => String(entry).trim())
129
+ .filter(Boolean)
130
+ .map((entry) => entry.toLowerCase()),
131
+ },
132
+ security: {
133
+ collectWarnings: ({ cfg }) => {
134
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
135
+ const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
136
+ const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
137
+ if (groupPolicy !== "open") return [];
138
+ return [
139
+ `- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
140
+ ];
141
+ },
142
+ },
143
+ setup: {
144
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
145
+ applyAccountConfig: ({ cfg }) => ({
146
+ ...cfg,
147
+ channels: {
148
+ ...cfg.channels,
149
+ feishu: {
150
+ ...cfg.channels?.feishu,
151
+ enabled: true,
152
+ },
153
+ },
154
+ }),
155
+ },
156
+ onboarding: feishuOnboardingAdapter,
157
+ messaging: {
158
+ normalizeTarget: normalizeFeishuTarget,
159
+ targetResolver: {
160
+ looksLikeId: looksLikeFeishuId,
161
+ hint: "<chatId|user:openId|chat:chatId>",
162
+ },
163
+ },
164
+ directory: {
165
+ self: async () => null,
166
+ listPeers: async ({ cfg, query, limit }) =>
167
+ listFeishuDirectoryPeers({ cfg, query, limit }),
168
+ listGroups: async ({ cfg, query, limit }) =>
169
+ listFeishuDirectoryGroups({ cfg, query, limit }),
170
+ listPeersLive: async ({ cfg, query, limit }) =>
171
+ listFeishuDirectoryPeersLive({ cfg, query, limit }),
172
+ listGroupsLive: async ({ cfg, query, limit }) =>
173
+ listFeishuDirectoryGroupsLive({ cfg, query, limit }),
174
+ },
175
+ outbound: feishuOutbound,
176
+ status: {
177
+ defaultRuntime: {
178
+ accountId: DEFAULT_ACCOUNT_ID,
179
+ running: false,
180
+ lastStartAt: null,
181
+ lastStopAt: null,
182
+ lastError: null,
183
+ port: null,
184
+ },
185
+ buildChannelSummary: ({ snapshot }) => ({
186
+ configured: snapshot.configured ?? false,
187
+ running: snapshot.running ?? false,
188
+ lastStartAt: snapshot.lastStartAt ?? null,
189
+ lastStopAt: snapshot.lastStopAt ?? null,
190
+ lastError: snapshot.lastError ?? null,
191
+ port: snapshot.port ?? null,
192
+ probe: snapshot.probe,
193
+ lastProbeAt: snapshot.lastProbeAt ?? null,
194
+ }),
195
+ probeAccount: async ({ cfg }) =>
196
+ await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined),
197
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
198
+ accountId: account.accountId,
199
+ enabled: account.enabled,
200
+ configured: account.configured,
201
+ running: runtime?.running ?? false,
202
+ lastStartAt: runtime?.lastStartAt ?? null,
203
+ lastStopAt: runtime?.lastStopAt ?? null,
204
+ lastError: runtime?.lastError ?? null,
205
+ port: runtime?.port ?? null,
206
+ probe,
207
+ }),
208
+ },
209
+ gateway: {
210
+ startAccount: async (ctx) => {
211
+ const { monitorFeishuProvider } = await import("./monitor.js");
212
+ const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined;
213
+ const port = feishuCfg?.webhookPort ?? null;
214
+ ctx.setStatus({ accountId: ctx.accountId, port });
215
+ ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`);
216
+ return monitorFeishuProvider({
217
+ config: ctx.cfg,
218
+ runtime: ctx.runtime,
219
+ abortSignal: ctx.abortSignal,
220
+ accountId: ctx.accountId,
221
+ });
222
+ },
223
+ },
224
+ };
package/src/client.ts ADDED
@@ -0,0 +1,66 @@
1
+ import * as Lark from "@larksuiteoapi/node-sdk";
2
+ import type { FeishuConfig, FeishuDomain } from "./types.js";
3
+ import { resolveFeishuCredentials } from "./accounts.js";
4
+
5
+ let cachedClient: Lark.Client | null = null;
6
+ let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null;
7
+
8
+ function resolveDomain(domain: FeishuDomain) {
9
+ return domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
10
+ }
11
+
12
+ export function createFeishuClient(cfg: FeishuConfig): Lark.Client {
13
+ const creds = resolveFeishuCredentials(cfg);
14
+ if (!creds) {
15
+ throw new Error("Feishu credentials not configured (appId, appSecret required)");
16
+ }
17
+
18
+ if (
19
+ cachedClient &&
20
+ cachedConfig &&
21
+ cachedConfig.appId === creds.appId &&
22
+ cachedConfig.appSecret === creds.appSecret &&
23
+ cachedConfig.domain === creds.domain
24
+ ) {
25
+ return cachedClient;
26
+ }
27
+
28
+ const client = new Lark.Client({
29
+ appId: creds.appId,
30
+ appSecret: creds.appSecret,
31
+ appType: Lark.AppType.SelfBuild,
32
+ domain: resolveDomain(creds.domain),
33
+ });
34
+
35
+ cachedClient = client;
36
+ cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain };
37
+
38
+ return client;
39
+ }
40
+
41
+ export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient {
42
+ const creds = resolveFeishuCredentials(cfg);
43
+ if (!creds) {
44
+ throw new Error("Feishu credentials not configured (appId, appSecret required)");
45
+ }
46
+
47
+ return new Lark.WSClient({
48
+ appId: creds.appId,
49
+ appSecret: creds.appSecret,
50
+ domain: resolveDomain(creds.domain),
51
+ loggerLevel: Lark.LoggerLevel.info,
52
+ });
53
+ }
54
+
55
+ export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher {
56
+ const creds = resolveFeishuCredentials(cfg);
57
+ return new Lark.EventDispatcher({
58
+ encryptKey: creds?.encryptKey,
59
+ verificationToken: creds?.verificationToken,
60
+ });
61
+ }
62
+
63
+ export function clearClientCache() {
64
+ cachedClient = null;
65
+ cachedConfig = null;
66
+ }
@@ -0,0 +1,107 @@
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.enum(["feishu", "lark"]);
7
+ const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
8
+
9
+ const ToolPolicySchema = z
10
+ .object({
11
+ allow: z.array(z.string()).optional(),
12
+ deny: z.array(z.string()).optional(),
13
+ })
14
+ .strict()
15
+ .optional();
16
+
17
+ const DmConfigSchema = z
18
+ .object({
19
+ enabled: z.boolean().optional(),
20
+ systemPrompt: z.string().optional(),
21
+ })
22
+ .strict()
23
+ .optional();
24
+
25
+ const MarkdownConfigSchema = z
26
+ .object({
27
+ mode: z.enum(["native", "escape", "strip"]).optional(),
28
+ tableMode: z.enum(["native", "ascii", "simple"]).optional(),
29
+ })
30
+ .strict()
31
+ .optional();
32
+
33
+ // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
34
+ const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
35
+
36
+ const BlockStreamingCoalesceSchema = z
37
+ .object({
38
+ enabled: z.boolean().optional(),
39
+ minDelayMs: z.number().int().positive().optional(),
40
+ maxDelayMs: z.number().int().positive().optional(),
41
+ })
42
+ .strict()
43
+ .optional();
44
+
45
+ const ChannelHeartbeatVisibilitySchema = z
46
+ .object({
47
+ visibility: z.enum(["visible", "hidden"]).optional(),
48
+ intervalMs: z.number().int().positive().optional(),
49
+ })
50
+ .strict()
51
+ .optional();
52
+
53
+ export const FeishuGroupSchema = z
54
+ .object({
55
+ requireMention: z.boolean().optional(),
56
+ tools: ToolPolicySchema,
57
+ skills: z.array(z.string()).optional(),
58
+ enabled: z.boolean().optional(),
59
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
60
+ systemPrompt: z.string().optional(),
61
+ })
62
+ .strict();
63
+
64
+ export const FeishuConfigSchema = z
65
+ .object({
66
+ enabled: z.boolean().optional(),
67
+ appId: z.string().optional(),
68
+ appSecret: z.string().optional(),
69
+ encryptKey: z.string().optional(),
70
+ verificationToken: z.string().optional(),
71
+ domain: FeishuDomainSchema.optional().default("feishu"),
72
+ connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
73
+ webhookPath: z.string().optional().default("/feishu/events"),
74
+ webhookPort: z.number().int().positive().optional(),
75
+ capabilities: z.array(z.string()).optional(),
76
+ markdown: MarkdownConfigSchema,
77
+ configWrites: z.boolean().optional(),
78
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
79
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
80
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
81
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
82
+ requireMention: z.boolean().optional().default(true),
83
+ groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
84
+ historyLimit: z.number().int().min(0).optional(),
85
+ dmHistoryLimit: z.number().int().min(0).optional(),
86
+ dms: z.record(z.string(), DmConfigSchema).optional(),
87
+ textChunkLimit: z.number().int().positive().optional(),
88
+ chunkMode: z.enum(["length", "newline"]).optional(),
89
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema,
90
+ mediaMaxMb: z.number().positive().optional(),
91
+ heartbeat: ChannelHeartbeatVisibilitySchema,
92
+ renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
93
+ })
94
+ .strict()
95
+ .superRefine((value, ctx) => {
96
+ if (value.dmPolicy === "open") {
97
+ const allowFrom = value.allowFrom ?? [];
98
+ const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
99
+ if (!hasWildcard) {
100
+ ctx.addIssue({
101
+ code: z.ZodIssueCode.custom,
102
+ path: ["allowFrom"],
103
+ message: 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
104
+ });
105
+ }
106
+ }
107
+ });
@@ -0,0 +1,159 @@
1
+ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
2
+ import type { FeishuConfig } from "./types.js";
3
+ import { createFeishuClient } from "./client.js";
4
+ import { normalizeFeishuTarget } from "./targets.js";
5
+
6
+ export type FeishuDirectoryPeer = {
7
+ kind: "user";
8
+ id: string;
9
+ name?: string;
10
+ };
11
+
12
+ export type FeishuDirectoryGroup = {
13
+ kind: "group";
14
+ id: string;
15
+ name?: string;
16
+ };
17
+
18
+ export async function listFeishuDirectoryPeers(params: {
19
+ cfg: ClawdbotConfig;
20
+ query?: string;
21
+ limit?: number;
22
+ }): Promise<FeishuDirectoryPeer[]> {
23
+ const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
24
+ const q = params.query?.trim().toLowerCase() || "";
25
+ const ids = new Set<string>();
26
+
27
+ for (const entry of feishuCfg?.allowFrom ?? []) {
28
+ const trimmed = String(entry).trim();
29
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
30
+ }
31
+
32
+ for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
33
+ const trimmed = userId.trim();
34
+ if (trimmed) ids.add(trimmed);
35
+ }
36
+
37
+ return Array.from(ids)
38
+ .map((raw) => raw.trim())
39
+ .filter(Boolean)
40
+ .map((raw) => normalizeFeishuTarget(raw) ?? raw)
41
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
42
+ .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
43
+ .map((id) => ({ kind: "user" as const, id }));
44
+ }
45
+
46
+ export async function listFeishuDirectoryGroups(params: {
47
+ cfg: ClawdbotConfig;
48
+ query?: string;
49
+ limit?: number;
50
+ }): Promise<FeishuDirectoryGroup[]> {
51
+ const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
52
+ const q = params.query?.trim().toLowerCase() || "";
53
+ const ids = new Set<string>();
54
+
55
+ for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
56
+ const trimmed = groupId.trim();
57
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
58
+ }
59
+
60
+ for (const entry of feishuCfg?.groupAllowFrom ?? []) {
61
+ const trimmed = String(entry).trim();
62
+ if (trimmed && trimmed !== "*") ids.add(trimmed);
63
+ }
64
+
65
+ return Array.from(ids)
66
+ .map((raw) => raw.trim())
67
+ .filter(Boolean)
68
+ .filter((id) => (q ? id.toLowerCase().includes(q) : true))
69
+ .slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
70
+ .map((id) => ({ kind: "group" as const, id }));
71
+ }
72
+
73
+ export async function listFeishuDirectoryPeersLive(params: {
74
+ cfg: ClawdbotConfig;
75
+ query?: string;
76
+ limit?: number;
77
+ }): Promise<FeishuDirectoryPeer[]> {
78
+ const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
79
+ if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
80
+ return listFeishuDirectoryPeers(params);
81
+ }
82
+
83
+ try {
84
+ const client = createFeishuClient(feishuCfg);
85
+ const peers: FeishuDirectoryPeer[] = [];
86
+ const limit = params.limit ?? 50;
87
+
88
+ const response = await client.contact.user.list({
89
+ params: {
90
+ page_size: Math.min(limit, 50),
91
+ },
92
+ });
93
+
94
+ if (response.code === 0 && response.data?.items) {
95
+ for (const user of response.data.items) {
96
+ if (user.open_id) {
97
+ const q = params.query?.trim().toLowerCase() || "";
98
+ const name = user.name || "";
99
+ if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
100
+ peers.push({
101
+ kind: "user",
102
+ id: user.open_id,
103
+ name: name || undefined,
104
+ });
105
+ }
106
+ }
107
+ if (peers.length >= limit) break;
108
+ }
109
+ }
110
+
111
+ return peers;
112
+ } catch {
113
+ return listFeishuDirectoryPeers(params);
114
+ }
115
+ }
116
+
117
+ export async function listFeishuDirectoryGroupsLive(params: {
118
+ cfg: ClawdbotConfig;
119
+ query?: string;
120
+ limit?: number;
121
+ }): Promise<FeishuDirectoryGroup[]> {
122
+ const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
123
+ if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
124
+ return listFeishuDirectoryGroups(params);
125
+ }
126
+
127
+ try {
128
+ const client = createFeishuClient(feishuCfg);
129
+ const groups: FeishuDirectoryGroup[] = [];
130
+ const limit = params.limit ?? 50;
131
+
132
+ const response = await client.im.chat.list({
133
+ params: {
134
+ page_size: Math.min(limit, 100),
135
+ },
136
+ });
137
+
138
+ if (response.code === 0 && response.data?.items) {
139
+ for (const chat of response.data.items) {
140
+ if (chat.chat_id) {
141
+ const q = params.query?.trim().toLowerCase() || "";
142
+ const name = chat.name || "";
143
+ if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
144
+ groups.push({
145
+ kind: "group",
146
+ id: chat.chat_id,
147
+ name: name || undefined,
148
+ });
149
+ }
150
+ }
151
+ if (groups.length >= limit) break;
152
+ }
153
+ }
154
+
155
+ return groups;
156
+ } catch {
157
+ return listFeishuDirectoryGroups(params);
158
+ }
159
+ }