moltbot-channel-feishu 0.0.8

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,378 @@
1
+ /**
2
+ * Onboarding wizard for Feishu channel configuration.
3
+ */
4
+
5
+ import type {
6
+ ChannelOnboardingAdapter,
7
+ ChannelOnboardingDmPolicy,
8
+ ClawdbotConfig,
9
+ DmPolicy,
10
+ WizardPrompter,
11
+ } from "clawdbot/plugin-sdk";
12
+ import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "clawdbot/plugin-sdk";
13
+
14
+ import type { Config } from "../config/schema.js";
15
+ import { resolveCredentials } from "../config/schema.js";
16
+ import { probeConnection } from "../api/client.js";
17
+
18
+ const channel = "feishu" as const;
19
+
20
+ // ============================================================================
21
+ // Config Helpers
22
+ // ============================================================================
23
+
24
+ function setDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
25
+ const existingAllowFrom = cfg.channels?.feishu?.allowFrom as (string | number)[] | undefined;
26
+ const allowFrom =
27
+ dmPolicy === "open"
28
+ ? addWildcardAllowFrom(existingAllowFrom)?.map((entry) => String(entry))
29
+ : undefined;
30
+
31
+ return {
32
+ ...cfg,
33
+ channels: {
34
+ ...cfg.channels,
35
+ feishu: {
36
+ ...cfg.channels?.feishu,
37
+ dmPolicy,
38
+ ...(allowFrom ? { allowFrom } : {}),
39
+ },
40
+ },
41
+ };
42
+ }
43
+
44
+ function setAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
45
+ return {
46
+ ...cfg,
47
+ channels: {
48
+ ...cfg.channels,
49
+ feishu: { ...cfg.channels?.feishu, allowFrom },
50
+ },
51
+ };
52
+ }
53
+
54
+ function setGroupPolicy(
55
+ cfg: ClawdbotConfig,
56
+ groupPolicy: "open" | "allowlist" | "disabled"
57
+ ): ClawdbotConfig {
58
+ return {
59
+ ...cfg,
60
+ channels: {
61
+ ...cfg.channels,
62
+ feishu: { ...cfg.channels?.feishu, enabled: true, groupPolicy },
63
+ },
64
+ };
65
+ }
66
+
67
+ function setGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
68
+ return {
69
+ ...cfg,
70
+ channels: {
71
+ ...cfg.channels,
72
+ feishu: { ...cfg.channels?.feishu, groupAllowFrom },
73
+ },
74
+ };
75
+ }
76
+
77
+ function parseAllowFromInput(raw: string): string[] {
78
+ return raw
79
+ .split(/[\n,;]+/g)
80
+ .map((entry) => entry.trim())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ // ============================================================================
85
+ // Prompts
86
+ // ============================================================================
87
+
88
+ async function promptAllowFrom(params: {
89
+ cfg: ClawdbotConfig;
90
+ prompter: WizardPrompter;
91
+ }): Promise<ClawdbotConfig> {
92
+ const existing = (params.cfg.channels?.feishu?.allowFrom ?? []) as (string | number)[];
93
+
94
+ await params.prompter.note(
95
+ [
96
+ "Allowlist Feishu DMs by open_id or user_id.",
97
+ "Find user open_id in Feishu admin console or via API.",
98
+ "Examples:",
99
+ "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
100
+ "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
101
+ ].join("\n"),
102
+ "Feishu allowlist"
103
+ );
104
+
105
+ while (true) {
106
+ const entry = await params.prompter.text({
107
+ message: "Feishu allowFrom (user open_ids)",
108
+ placeholder: "ou_xxxxx, ou_yyyyy",
109
+ initialValue: existing.length > 0 ? String(existing[0]) : undefined,
110
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
111
+ });
112
+
113
+ const parts = parseAllowFromInput(String(entry));
114
+ if (parts.length === 0) {
115
+ await params.prompter.note("Enter at least one user.", "Feishu allowlist");
116
+ continue;
117
+ }
118
+
119
+ const unique = [
120
+ ...new Set([
121
+ ...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
122
+ ...parts,
123
+ ]),
124
+ ];
125
+ return setAllowFrom(params.cfg, unique);
126
+ }
127
+ }
128
+
129
+ async function showCredentialHelp(prompter: WizardPrompter): Promise<void> {
130
+ await prompter.note(
131
+ [
132
+ "1) Go to Feishu Open Platform (open.feishu.cn)",
133
+ "2) Create a self-built app",
134
+ "3) Get App ID and App Secret from Credentials page",
135
+ "4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
136
+ "5) Publish the app or add it to a test group",
137
+ "Tip: set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
138
+ `Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
139
+ ].join("\n"),
140
+ "Feishu credentials"
141
+ );
142
+ }
143
+
144
+ // ============================================================================
145
+ // DM Policy Adapter
146
+ // ============================================================================
147
+
148
+ const dmPolicy: ChannelOnboardingDmPolicy = {
149
+ label: "Feishu",
150
+ channel,
151
+ policyKey: "channels.feishu.dmPolicy",
152
+ allowFromKey: "channels.feishu.allowFrom",
153
+ getCurrent: (cfg) => (cfg.channels?.feishu as Config | undefined)?.dmPolicy ?? "pairing",
154
+ setPolicy: (cfg, policy) => setDmPolicy(cfg, policy),
155
+ promptAllowFrom,
156
+ };
157
+
158
+ // ============================================================================
159
+ // Onboarding Adapter
160
+ // ============================================================================
161
+
162
+ export const feishuOnboarding: ChannelOnboardingAdapter = {
163
+ channel,
164
+
165
+ getStatus: async ({ cfg }) => {
166
+ const feishuCfg = cfg.channels?.feishu as Config | undefined;
167
+ const configured = Boolean(resolveCredentials(feishuCfg));
168
+
169
+ let probeResult = null;
170
+ if (configured && feishuCfg) {
171
+ try {
172
+ probeResult = await probeConnection(feishuCfg);
173
+ } catch {
174
+ // Ignore probe errors
175
+ }
176
+ }
177
+
178
+ const statusLines: string[] = [];
179
+ if (!configured) {
180
+ statusLines.push("Feishu: needs app credentials");
181
+ } else if (probeResult?.ok) {
182
+ statusLines.push(
183
+ `Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`
184
+ );
185
+ } else {
186
+ statusLines.push("Feishu: configured (connection not verified)");
187
+ }
188
+
189
+ return {
190
+ channel,
191
+ configured,
192
+ statusLines,
193
+ selectionHint: configured ? "configured" : "needs app creds",
194
+ quickstartScore: configured ? 2 : 0,
195
+ };
196
+ },
197
+
198
+ configure: async ({ cfg, prompter }) => {
199
+ const feishuCfg = cfg.channels?.feishu as Config | undefined;
200
+ const resolved = resolveCredentials(feishuCfg);
201
+ const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
202
+ const canUseEnv = Boolean(
203
+ !hasConfigCreds &&
204
+ process.env["FEISHU_APP_ID"]?.trim() &&
205
+ process.env["FEISHU_APP_SECRET"]?.trim()
206
+ );
207
+
208
+ let next = cfg;
209
+ let appId: string | null = null;
210
+ let appSecret: string | null = null;
211
+
212
+ if (!resolved) {
213
+ await showCredentialHelp(prompter);
214
+ }
215
+
216
+ // Check for env vars
217
+ if (canUseEnv) {
218
+ const keepEnv = await prompter.confirm({
219
+ message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
220
+ initialValue: true,
221
+ });
222
+ if (keepEnv) {
223
+ next = {
224
+ ...next,
225
+ channels: {
226
+ ...next.channels,
227
+ feishu: { ...next.channels?.feishu, enabled: true },
228
+ },
229
+ };
230
+ } else {
231
+ appId = String(
232
+ await prompter.text({
233
+ message: "Enter Feishu App ID",
234
+ validate: (value) => (value?.trim() ? undefined : "Required"),
235
+ })
236
+ ).trim();
237
+ appSecret = String(
238
+ await prompter.text({
239
+ message: "Enter Feishu App Secret",
240
+ validate: (value) => (value?.trim() ? undefined : "Required"),
241
+ })
242
+ ).trim();
243
+ }
244
+ } else if (hasConfigCreds) {
245
+ const keep = await prompter.confirm({
246
+ message: "Feishu credentials already configured. Keep them?",
247
+ initialValue: true,
248
+ });
249
+ if (!keep) {
250
+ appId = String(
251
+ await prompter.text({
252
+ message: "Enter Feishu App ID",
253
+ validate: (value) => (value?.trim() ? undefined : "Required"),
254
+ })
255
+ ).trim();
256
+ appSecret = String(
257
+ await prompter.text({
258
+ message: "Enter Feishu App Secret",
259
+ validate: (value) => (value?.trim() ? undefined : "Required"),
260
+ })
261
+ ).trim();
262
+ }
263
+ } else {
264
+ appId = String(
265
+ await prompter.text({
266
+ message: "Enter Feishu App ID",
267
+ validate: (value) => (value?.trim() ? undefined : "Required"),
268
+ })
269
+ ).trim();
270
+ appSecret = String(
271
+ await prompter.text({
272
+ message: "Enter Feishu App Secret",
273
+ validate: (value) => (value?.trim() ? undefined : "Required"),
274
+ })
275
+ ).trim();
276
+ }
277
+
278
+ // Apply credentials
279
+ if (appId && appSecret) {
280
+ next = {
281
+ ...next,
282
+ channels: {
283
+ ...next.channels,
284
+ feishu: {
285
+ ...next.channels?.feishu,
286
+ enabled: true,
287
+ appId,
288
+ appSecret,
289
+ },
290
+ },
291
+ };
292
+
293
+ // Test connection
294
+ const testCfg = next.channels?.feishu as Config;
295
+ try {
296
+ const probe = await probeConnection(testCfg);
297
+ if (probe.ok) {
298
+ await prompter.note(
299
+ `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
300
+ "Feishu connection test"
301
+ );
302
+ } else {
303
+ await prompter.note(
304
+ `Connection failed: ${probe.error ?? "unknown error"}`,
305
+ "Feishu connection test"
306
+ );
307
+ }
308
+ } catch (err) {
309
+ await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
310
+ }
311
+ }
312
+
313
+ // Domain selection
314
+ const currentDomain = (next.channels?.feishu as Config | undefined)?.domain ?? "feishu";
315
+ const domain = await prompter.select({
316
+ message: "Which Feishu domain?",
317
+ options: [
318
+ { value: "feishu", label: "Feishu (feishu.cn) - China" },
319
+ { value: "lark", label: "Lark (larksuite.com) - International" },
320
+ ],
321
+ initialValue: currentDomain,
322
+ });
323
+ if (domain) {
324
+ next = {
325
+ ...next,
326
+ channels: {
327
+ ...next.channels,
328
+ feishu: {
329
+ ...next.channels?.feishu,
330
+ domain: domain as "feishu" | "lark",
331
+ },
332
+ },
333
+ };
334
+ }
335
+
336
+ // Group policy
337
+ const groupPolicy = await prompter.select({
338
+ message: "Group chat policy",
339
+ options: [
340
+ { value: "allowlist", label: "Allowlist - only respond in specific groups" },
341
+ { value: "open", label: "Open - respond in all groups (requires mention)" },
342
+ { value: "disabled", label: "Disabled - don't respond in groups" },
343
+ ],
344
+ initialValue: (next.channels?.feishu as Config | undefined)?.groupPolicy ?? "allowlist",
345
+ });
346
+ if (groupPolicy) {
347
+ next = setGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
348
+ }
349
+
350
+ // Group allowlist
351
+ if (groupPolicy === "allowlist") {
352
+ const existing = (next.channels?.feishu as Config | undefined)?.groupAllowFrom ?? [];
353
+ const entry = await prompter.text({
354
+ message: "Group chat allowlist (chat_ids)",
355
+ placeholder: "oc_xxxxx, oc_yyyyy",
356
+ initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
357
+ });
358
+ if (entry) {
359
+ const parts = parseAllowFromInput(String(entry));
360
+ if (parts.length > 0) {
361
+ next = setGroupAllowFrom(next, parts);
362
+ }
363
+ }
364
+ }
365
+
366
+ return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
367
+ },
368
+
369
+ dmPolicy,
370
+
371
+ disable: (cfg) => ({
372
+ ...cfg,
373
+ channels: {
374
+ ...cfg.channels,
375
+ feishu: { ...cfg.channels?.feishu, enabled: false },
376
+ },
377
+ }),
378
+ };