@tobeyoureyes/feishu 1.0.0

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,883 @@
1
+ /**
2
+ * Feishu channel plugin implementation
3
+ */
4
+
5
+ import type {
6
+ ChannelPlugin,
7
+ ChannelMeta,
8
+ ChannelCapabilities,
9
+ OpenClawConfig,
10
+ } from "openclaw/plugin-sdk";
11
+ import {
12
+ DEFAULT_ACCOUNT_ID,
13
+ normalizeAccountId,
14
+ formatPairingApproveHint,
15
+ PAIRING_APPROVED_MESSAGE,
16
+ deleteAccountFromConfigSection,
17
+ setAccountEnabledInConfigSection,
18
+ applyAccountNameToChannelSection,
19
+ migrateBaseNameToDefaultAccount,
20
+ loadWebMedia,
21
+ } from "openclaw/plugin-sdk";
22
+
23
+ import type {
24
+ ResolvedFeishuAccount,
25
+ FeishuChannelConfig,
26
+ FeishuAccountConfig,
27
+ FeishuDomain,
28
+ FeishuConnectionMode,
29
+ FeishuRenderMode,
30
+ } from "./types.js";
31
+ import * as api from "./api.js";
32
+ import { hasValidCredentials } from "./auth.js";
33
+ import { createWSClient, type FeishuWSClient } from "./websocket.js";
34
+ import { parseWebhookEvent, type FeishuInboundMessage } from "./webhook.js";
35
+ import { createFeishuDedupeCache } from "./dedupe.js";
36
+ import { createGroupHistoryManager, type HistoryEntry } from "./history.js";
37
+ import { buildFeishuMessageContext } from "./context.js";
38
+ import { dispatchFeishuMessage, createDefaultReplySender, createDefaultMediaSender } from "./dispatch.js";
39
+
40
+ // ============ Helper Functions ============
41
+
42
+ /**
43
+ * Fetch reply context if message is a reply
44
+ */
45
+ async function fetchReplyContext(
46
+ account: ResolvedFeishuAccount,
47
+ inbound: FeishuInboundMessage,
48
+ log?: { warn?: (msg: string) => void },
49
+ ): Promise<FeishuInboundMessage> {
50
+ if (!inbound.parentId) {
51
+ return inbound;
52
+ }
53
+
54
+ try {
55
+ const parentResult = await api.getMessage(account, inbound.parentId);
56
+ if (parentResult.ok && parentResult.message) {
57
+ return {
58
+ ...inbound,
59
+ replyToBody: parentResult.message.body,
60
+ replyToSenderId: parentResult.message.senderId,
61
+ replyToId: inbound.parentId,
62
+ };
63
+ }
64
+ } catch (err) {
65
+ log?.warn?.(`failed to fetch parent message ${inbound.parentId}: ${String(err)}`);
66
+ }
67
+
68
+ return inbound;
69
+ }
70
+
71
+ // Channel metadata
72
+ const meta: ChannelMeta = {
73
+ id: "feishu",
74
+ label: "Feishu",
75
+ selectionLabel: "Feishu (飞书)",
76
+ docsPath: "/channels/feishu",
77
+ docsLabel: "feishu",
78
+ blurb: "Feishu/Lark enterprise messaging platform",
79
+ order: 75,
80
+ quickstartAllowFrom: true,
81
+ };
82
+
83
+ // Channel capabilities
84
+ const capabilities: ChannelCapabilities = {
85
+ chatTypes: ["direct", "group"],
86
+ reactions: false, // Feishu has limited reaction support
87
+ threads: false, // No native thread support in the same way as Slack
88
+ media: true,
89
+ nativeCommands: false,
90
+ };
91
+
92
+ // ============ Account Resolution ============
93
+
94
+ function getFeishuConfig(cfg: OpenClawConfig): FeishuChannelConfig | undefined {
95
+ return (cfg.channels as Record<string, unknown>)?.feishu as FeishuChannelConfig | undefined;
96
+ }
97
+
98
+ function listFeishuAccountIds(cfg: OpenClawConfig): string[] {
99
+ const feishuConfig = getFeishuConfig(cfg);
100
+ if (!feishuConfig) {
101
+ return [];
102
+ }
103
+
104
+ const accountIds: string[] = [];
105
+
106
+ // Check if default account has credentials
107
+ if (feishuConfig.appId || feishuConfig.appSecret) {
108
+ accountIds.push(DEFAULT_ACCOUNT_ID);
109
+ }
110
+
111
+ // Add named accounts
112
+ if (feishuConfig.accounts) {
113
+ for (const accountId of Object.keys(feishuConfig.accounts)) {
114
+ if (!accountIds.includes(accountId)) {
115
+ accountIds.push(accountId);
116
+ }
117
+ }
118
+ }
119
+
120
+ return accountIds;
121
+ }
122
+
123
+ /** Get API base URL for the domain */
124
+ function getApiBase(domain: FeishuDomain): string {
125
+ return domain === "lark"
126
+ ? "https://open.larksuite.com/open-apis"
127
+ : "https://open.feishu.cn/open-apis";
128
+ }
129
+
130
+ /** Get WebSocket URL for the domain */
131
+ function getWsUrl(domain: FeishuDomain): string {
132
+ return domain === "lark"
133
+ ? "wss://open.larksuite.com/open-apis/ws/v1"
134
+ : "wss://open.feishu.cn/open-apis/ws/v1";
135
+ }
136
+
137
+ function resolveFeishuAccount(params: {
138
+ cfg: OpenClawConfig;
139
+ accountId?: string | null;
140
+ }): ResolvedFeishuAccount {
141
+ const { cfg, accountId: rawAccountId } = params;
142
+ const accountId = normalizeAccountId(rawAccountId ?? DEFAULT_ACCOUNT_ID);
143
+ const feishuConfig = getFeishuConfig(cfg);
144
+
145
+ // Get account-specific config
146
+ const accountConfig = feishuConfig?.accounts?.[accountId];
147
+ const isNamedAccount = accountId !== DEFAULT_ACCOUNT_ID;
148
+
149
+ // Resolve credentials - check account config first, then fall back to base config
150
+ const appId = accountConfig?.appId ?? (isNamedAccount ? undefined : feishuConfig?.appId);
151
+ const appSecret = accountConfig?.appSecret ?? (isNamedAccount ? undefined : feishuConfig?.appSecret);
152
+
153
+ // Check environment variables as fallback
154
+ const envAppId = process.env.FEISHU_APP_ID;
155
+ const envAppSecret = process.env.FEISHU_APP_SECRET;
156
+
157
+ const resolvedAppId = appId ?? (accountId === DEFAULT_ACCOUNT_ID ? envAppId : undefined);
158
+ const resolvedAppSecret = appSecret ?? (accountId === DEFAULT_ACCOUNT_ID ? envAppSecret : undefined);
159
+
160
+ // Determine credential sources
161
+ const appIdSource = appId ? "config" : (resolvedAppId ? "env" : "none");
162
+ const appSecretSource = appSecret ? "config" : (resolvedAppSecret ? "env" : "none");
163
+
164
+ // Resolve domain and connection mode
165
+ const domain: FeishuDomain = accountConfig?.domain ?? feishuConfig?.domain ?? "feishu";
166
+ const connectionMode: FeishuConnectionMode = accountConfig?.connectionMode ?? feishuConfig?.connectionMode ?? "websocket";
167
+ const renderMode: FeishuRenderMode = accountConfig?.renderMode ?? feishuConfig?.renderMode ?? "auto";
168
+ const requireMention = accountConfig?.requireMention ?? feishuConfig?.requireMention ?? true;
169
+
170
+ // Merge configuration
171
+ const config: FeishuAccountConfig = {
172
+ appId: resolvedAppId,
173
+ appSecret: resolvedAppSecret,
174
+ domain,
175
+ connectionMode,
176
+ webhookPath: accountConfig?.webhookPath ?? feishuConfig?.webhookPath,
177
+ verificationToken: accountConfig?.verificationToken ?? feishuConfig?.verificationToken,
178
+ encryptKey: accountConfig?.encryptKey ?? feishuConfig?.encryptKey,
179
+ dmPolicy: accountConfig?.dmPolicy ?? feishuConfig?.dmPolicy ?? "pairing",
180
+ allowFrom: accountConfig?.allowFrom ?? feishuConfig?.allowFrom,
181
+ groupPolicy: accountConfig?.groupPolicy ?? feishuConfig?.groupPolicy ?? "allowlist",
182
+ requireMention,
183
+ mediaMaxMb: accountConfig?.mediaMaxMb ?? feishuConfig?.mediaMaxMb ?? 30,
184
+ renderMode,
185
+ groups: accountConfig?.groups ?? feishuConfig?.groups,
186
+ };
187
+
188
+ // Determine if account is enabled
189
+ const enabled = accountConfig?.enabled ?? feishuConfig?.enabled ?? false;
190
+
191
+ // Determine if account is configured
192
+ const configured = Boolean(resolvedAppId && resolvedAppSecret);
193
+
194
+ return {
195
+ accountId,
196
+ name: accountConfig?.name,
197
+ enabled,
198
+ configured,
199
+ appId: resolvedAppId,
200
+ appSecret: resolvedAppSecret,
201
+ appIdSource,
202
+ appSecretSource,
203
+ apiBase: getApiBase(domain),
204
+ wsUrl: getWsUrl(domain),
205
+ domain,
206
+ connectionMode,
207
+ webhookPath: config.webhookPath,
208
+ verificationToken: config.verificationToken,
209
+ encryptKey: config.encryptKey,
210
+ renderMode,
211
+ requireMention,
212
+ config,
213
+ };
214
+ }
215
+
216
+ function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string {
217
+ const accountIds = listFeishuAccountIds(cfg);
218
+ return accountIds.length > 0 ? accountIds[0] : DEFAULT_ACCOUNT_ID;
219
+ }
220
+
221
+ // ============ Media Helpers ============
222
+
223
+ /**
224
+ * Resolve Feishu file type from MIME type
225
+ */
226
+ function resolveFeishuFileType(contentType: string): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
227
+ const mimeMap: Record<string, "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"> = {
228
+ "audio/opus": "opus",
229
+ "audio/ogg": "opus",
230
+ "video/mp4": "mp4",
231
+ "application/pdf": "pdf",
232
+ "application/msword": "doc",
233
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "doc",
234
+ "application/vnd.ms-excel": "xls",
235
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xls",
236
+ "application/vnd.ms-powerpoint": "ppt",
237
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "ppt",
238
+ };
239
+
240
+ return mimeMap[contentType] ?? "stream";
241
+ }
242
+
243
+ // ============ Target Normalization ============
244
+
245
+ function normalizeFeishuMessagingTarget(raw: string): string | undefined {
246
+ if (!raw) {
247
+ return undefined;
248
+ }
249
+
250
+ const trimmed = raw.trim();
251
+
252
+ // Already a chat_id or open_id format
253
+ if (trimmed.startsWith("oc_") || trimmed.startsWith("ou_")) {
254
+ return trimmed;
255
+ }
256
+
257
+ // Handle feishu: prefix
258
+ if (trimmed.toLowerCase().startsWith("feishu:")) {
259
+ return trimmed.slice(7).trim();
260
+ }
261
+
262
+ // Handle chat: prefix
263
+ if (trimmed.toLowerCase().startsWith("chat:")) {
264
+ return trimmed.slice(5).trim();
265
+ }
266
+
267
+ // Handle user: prefix
268
+ if (trimmed.toLowerCase().startsWith("user:")) {
269
+ return trimmed.slice(5).trim();
270
+ }
271
+
272
+ return trimmed;
273
+ }
274
+
275
+ function looksLikeFeishuTargetId(raw: string, normalized?: string): boolean {
276
+ const id = normalized ?? raw;
277
+ // Feishu IDs typically start with oc_ (chat) or ou_ (user)
278
+ return id.startsWith("oc_") || id.startsWith("ou_");
279
+ }
280
+
281
+ // ============ Channel Plugin ============
282
+
283
+ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
284
+ id: "feishu",
285
+ meta,
286
+ capabilities,
287
+ streaming: {
288
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
289
+ },
290
+ reload: { configPrefixes: ["channels.feishu"] },
291
+
292
+ // Configuration adapter
293
+ config: {
294
+ listAccountIds: (cfg) => listFeishuAccountIds(cfg),
295
+ resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
296
+ defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
297
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
298
+ setAccountEnabledInConfigSection({
299
+ cfg,
300
+ sectionKey: "feishu",
301
+ accountId,
302
+ enabled,
303
+ allowTopLevel: true,
304
+ }),
305
+ deleteAccount: ({ cfg, accountId }) =>
306
+ deleteAccountFromConfigSection({
307
+ cfg,
308
+ sectionKey: "feishu",
309
+ accountId,
310
+ clearBaseFields: ["appId", "appSecret", "webhookPath", "verificationToken", "encryptKey", "name"],
311
+ }),
312
+ isConfigured: (account) => account.configured,
313
+ describeAccount: (account) => ({
314
+ accountId: account.accountId,
315
+ name: account.name,
316
+ enabled: account.enabled,
317
+ configured: account.configured,
318
+ appIdSource: account.appIdSource,
319
+ appSecretSource: account.appSecretSource,
320
+ }),
321
+ resolveAllowFrom: ({ cfg, accountId }) =>
322
+ (resolveFeishuAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)),
323
+ formatAllowFrom: ({ allowFrom }) =>
324
+ allowFrom
325
+ .map((entry) => String(entry).trim())
326
+ .filter(Boolean)
327
+ .map((entry) => entry.toLowerCase()),
328
+ },
329
+
330
+ // Pairing adapter
331
+ pairing: {
332
+ idLabel: "feishuUserId",
333
+ normalizeAllowEntry: (entry) => entry.replace(/^feishu:/i, ""),
334
+ notifyApproval: async ({ id, cfg }) => {
335
+ const account = resolveFeishuAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
336
+ if (hasValidCredentials(account)) {
337
+ await api.sendText(account, id, PAIRING_APPROVED_MESSAGE, { receiveIdType: "open_id" });
338
+ }
339
+ },
340
+ },
341
+
342
+ // Security adapter
343
+ security: {
344
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
345
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
346
+ const feishuConfig = getFeishuConfig(cfg);
347
+ const useAccountPath = Boolean(feishuConfig?.accounts?.[resolvedAccountId]);
348
+ const basePath = useAccountPath
349
+ ? `channels.feishu.accounts.${resolvedAccountId}.`
350
+ : "channels.feishu.";
351
+ return {
352
+ policy: account.config.dmPolicy ?? "pairing",
353
+ allowFrom: account.config.allowFrom ?? [],
354
+ policyPath: `${basePath}dmPolicy`,
355
+ allowFromPath: basePath,
356
+ approveHint: formatPairingApproveHint("feishu"),
357
+ normalizeEntry: (raw) => raw.replace(/^feishu:/i, "").trim(),
358
+ };
359
+ },
360
+ collectWarnings: ({ account, cfg }) => {
361
+ const warnings: string[] = [];
362
+ const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }>)?.defaults?.groupPolicy;
363
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
364
+
365
+ if (groupPolicy === "open") {
366
+ warnings.push(
367
+ `- Feishu groups: groupPolicy="open" allows any group member to trigger the bot. Set channels.feishu.groupPolicy="allowlist" to restrict access.`,
368
+ );
369
+ }
370
+
371
+ return warnings;
372
+ },
373
+ },
374
+
375
+ // Messaging adapter
376
+ messaging: {
377
+ normalizeTarget: normalizeFeishuMessagingTarget,
378
+ targetResolver: {
379
+ looksLikeId: looksLikeFeishuTargetId,
380
+ hint: "<chat_id|open_id|feishu:ID>",
381
+ },
382
+ },
383
+
384
+ // Setup adapter
385
+ setup: {
386
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
387
+ applyAccountName: ({ cfg, accountId, name }) =>
388
+ applyAccountNameToChannelSection({
389
+ cfg,
390
+ channelKey: "feishu",
391
+ accountId,
392
+ name,
393
+ }),
394
+ validateInput: ({ input }) => {
395
+ // Check for app credentials
396
+ const hasAppId = Boolean(input.token || input.useEnv);
397
+ const hasAppSecret = Boolean(input.tokenFile || input.useEnv);
398
+
399
+ if (!hasAppId && !hasAppSecret && !input.useEnv) {
400
+ return "Feishu requires --app-id and --app-secret (or --use-env with FEISHU_APP_ID and FEISHU_APP_SECRET).";
401
+ }
402
+ return null;
403
+ },
404
+ applyAccountConfig: ({ cfg, accountId, input }) => {
405
+ const namedConfig = applyAccountNameToChannelSection({
406
+ cfg,
407
+ channelKey: "feishu",
408
+ accountId,
409
+ name: input.name,
410
+ });
411
+ const next =
412
+ accountId !== DEFAULT_ACCOUNT_ID
413
+ ? migrateBaseNameToDefaultAccount({
414
+ cfg: namedConfig,
415
+ channelKey: "feishu",
416
+ })
417
+ : namedConfig;
418
+
419
+ const feishuUpdate: Record<string, unknown> = {
420
+ ...(next.channels as Record<string, unknown>)?.feishu,
421
+ enabled: true,
422
+ };
423
+
424
+ if (!input.useEnv) {
425
+ if (input.token) {
426
+ feishuUpdate.appId = input.token;
427
+ }
428
+ if (input.tokenFile) {
429
+ feishuUpdate.appSecret = input.tokenFile;
430
+ }
431
+ }
432
+
433
+ if (accountId === DEFAULT_ACCOUNT_ID) {
434
+ return {
435
+ ...next,
436
+ channels: {
437
+ ...(next.channels as Record<string, unknown>),
438
+ feishu: feishuUpdate,
439
+ },
440
+ };
441
+ }
442
+
443
+ const existingAccounts = (feishuUpdate as { accounts?: Record<string, unknown> }).accounts;
444
+ return {
445
+ ...next,
446
+ channels: {
447
+ ...(next.channels as Record<string, unknown>),
448
+ feishu: {
449
+ ...feishuUpdate,
450
+ accounts: {
451
+ ...existingAccounts,
452
+ [accountId]: {
453
+ ...existingAccounts?.[accountId],
454
+ enabled: true,
455
+ ...(input.token ? { appId: input.token } : {}),
456
+ ...(input.tokenFile ? { appSecret: input.tokenFile } : {}),
457
+ },
458
+ },
459
+ },
460
+ },
461
+ };
462
+ },
463
+ },
464
+
465
+ // Outbound adapter
466
+ outbound: {
467
+ deliveryMode: "direct",
468
+ chunker: null,
469
+ textChunkLimit: 4000,
470
+ sendText: async ({ to, text, accountId, cfg, replyToId }) => {
471
+ const account = resolveFeishuAccount({ cfg, accountId });
472
+
473
+ if (!hasValidCredentials(account)) {
474
+ return {
475
+ channel: "feishu",
476
+ ok: false,
477
+ error: "Feishu account not configured (missing appId or appSecret)",
478
+ };
479
+ }
480
+
481
+ // Determine receive_id_type based on target format
482
+ const receiveIdType = to.startsWith("ou_") ? "open_id" : "chat_id";
483
+
484
+ const result = await api.sendText(account, to, text, {
485
+ receiveIdType,
486
+ replyToId: replyToId ?? undefined,
487
+ });
488
+
489
+ return {
490
+ channel: "feishu",
491
+ ok: result.ok,
492
+ messageId: result.messageId,
493
+ error: result.error,
494
+ };
495
+ },
496
+ sendMedia: async ({ to, text, mediaUrl, accountId, cfg, replyToId }) => {
497
+ const account = resolveFeishuAccount({ cfg, accountId });
498
+
499
+ if (!hasValidCredentials(account)) {
500
+ return {
501
+ channel: "feishu",
502
+ ok: false,
503
+ error: "Feishu account not configured (missing appId or appSecret)",
504
+ };
505
+ }
506
+
507
+ const receiveIdType = to.startsWith("ou_") ? "open_id" : "chat_id";
508
+ const mediaMaxBytes = (account.config.mediaMaxMb ?? 30) * 1024 * 1024;
509
+
510
+ // If no media URL, just send text
511
+ if (!mediaUrl) {
512
+ const result = await api.sendText(account, to, text, {
513
+ receiveIdType,
514
+ replyToId: replyToId ?? undefined,
515
+ });
516
+ return {
517
+ channel: "feishu",
518
+ ok: result.ok,
519
+ messageId: result.messageId,
520
+ error: result.error,
521
+ };
522
+ }
523
+
524
+ try {
525
+ // Load media from URL or local path
526
+ const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
527
+
528
+ if (!media.ok) {
529
+ // Fallback to sending URL as text
530
+ const messageText = text ? `${text}\n\n${mediaUrl}` : mediaUrl;
531
+ const result = await api.sendText(account, to, messageText, {
532
+ receiveIdType,
533
+ replyToId: replyToId ?? undefined,
534
+ });
535
+ return {
536
+ channel: "feishu",
537
+ ok: result.ok,
538
+ messageId: result.messageId,
539
+ error: media.error,
540
+ };
541
+ }
542
+
543
+ // Determine if it's an image based on content type
544
+ const isImage = media.contentType?.startsWith("image/") ?? false;
545
+ const fileName = media.fileName ?? "file";
546
+
547
+ // Send caption text first if provided
548
+ if (text) {
549
+ await api.sendText(account, to, text, {
550
+ receiveIdType,
551
+ replyToId: replyToId ?? undefined,
552
+ });
553
+ }
554
+
555
+ if (isImage) {
556
+ // Upload and send image
557
+ const uploadResult = await api.uploadImage(account, media.buffer, fileName);
558
+ if (!uploadResult.ok || !uploadResult.imageKey) {
559
+ return {
560
+ channel: "feishu",
561
+ ok: false,
562
+ error: uploadResult.error ?? "Failed to upload image",
563
+ };
564
+ }
565
+
566
+ const sendResult = await api.sendImage(account, to, uploadResult.imageKey, {
567
+ receiveIdType,
568
+ replyToId: text ? undefined : replyToId ?? undefined,
569
+ });
570
+ return {
571
+ channel: "feishu",
572
+ ok: sendResult.ok,
573
+ messageId: sendResult.messageId,
574
+ error: sendResult.error,
575
+ };
576
+ } else {
577
+ // Upload and send file
578
+ // Determine file type for Feishu API
579
+ const fileType = resolveFeishuFileType(media.contentType ?? "application/octet-stream");
580
+ const uploadResult = await api.uploadFile(account, media.buffer, fileName, fileType);
581
+
582
+ if (!uploadResult.ok || !uploadResult.fileKey) {
583
+ return {
584
+ channel: "feishu",
585
+ ok: false,
586
+ error: uploadResult.error ?? "Failed to upload file",
587
+ };
588
+ }
589
+
590
+ const sendResult = await api.sendFile(account, to, uploadResult.fileKey, {
591
+ receiveIdType,
592
+ replyToId: text ? undefined : replyToId ?? undefined,
593
+ });
594
+ return {
595
+ channel: "feishu",
596
+ ok: sendResult.ok,
597
+ messageId: sendResult.messageId,
598
+ error: sendResult.error,
599
+ };
600
+ }
601
+ } catch (err) {
602
+ // Fallback to sending URL as text on error
603
+ const messageText = text ? `${text}\n\n${mediaUrl}` : mediaUrl;
604
+ const result = await api.sendText(account, to, messageText, {
605
+ receiveIdType,
606
+ replyToId: replyToId ?? undefined,
607
+ });
608
+ return {
609
+ channel: "feishu",
610
+ ok: result.ok,
611
+ messageId: result.messageId,
612
+ error: err instanceof Error ? err.message : String(err),
613
+ };
614
+ }
615
+ },
616
+ },
617
+
618
+ // Status adapter
619
+ status: {
620
+ defaultRuntime: {
621
+ accountId: DEFAULT_ACCOUNT_ID,
622
+ running: false,
623
+ lastStartAt: null,
624
+ lastStopAt: null,
625
+ lastError: null,
626
+ },
627
+ collectStatusIssues: (accounts) =>
628
+ accounts.flatMap((account) => {
629
+ const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
630
+ if (!lastError) {
631
+ return [];
632
+ }
633
+ return [
634
+ {
635
+ channel: "feishu",
636
+ accountId: account.accountId,
637
+ kind: "runtime",
638
+ message: `Channel error: ${lastError}`,
639
+ },
640
+ ];
641
+ }),
642
+ buildChannelSummary: ({ snapshot }) => ({
643
+ configured: snapshot.configured ?? false,
644
+ appIdSource: snapshot.appIdSource ?? "none",
645
+ appSecretSource: snapshot.appSecretSource ?? "none",
646
+ running: snapshot.running ?? false,
647
+ lastStartAt: snapshot.lastStartAt ?? null,
648
+ lastStopAt: snapshot.lastStopAt ?? null,
649
+ lastError: snapshot.lastError ?? null,
650
+ probe: snapshot.probe,
651
+ lastProbeAt: snapshot.lastProbeAt ?? null,
652
+ }),
653
+ probeAccount: async ({ account, timeoutMs }) => {
654
+ if (!hasValidCredentials(account)) {
655
+ return { ok: false, error: "missing credentials" };
656
+ }
657
+ return await api.probeFeishu(account, timeoutMs);
658
+ },
659
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
660
+ accountId: account.accountId,
661
+ name: account.name,
662
+ enabled: account.enabled,
663
+ configured: account.configured,
664
+ appIdSource: account.appIdSource,
665
+ appSecretSource: account.appSecretSource,
666
+ running: runtime?.running ?? false,
667
+ lastStartAt: runtime?.lastStartAt ?? null,
668
+ lastStopAt: runtime?.lastStopAt ?? null,
669
+ lastError: runtime?.lastError ?? null,
670
+ probe,
671
+ lastInboundAt: runtime?.lastInboundAt ?? null,
672
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
673
+ }),
674
+ },
675
+
676
+ // Gateway adapter
677
+ gateway: {
678
+ startAccount: async (ctx) => {
679
+ const account = ctx.account;
680
+ const connectionMode = account.connectionMode;
681
+ const logPrefix = `[${account.accountId}]`;
682
+
683
+ ctx.log?.info(`${logPrefix} starting Feishu provider (mode: ${connectionMode})`);
684
+
685
+ // Set initial status
686
+ ctx.setStatus({
687
+ accountId: account.accountId,
688
+ configured: account.configured,
689
+ appIdSource: account.appIdSource,
690
+ appSecretSource: account.appSecretSource,
691
+ connectionMode,
692
+ running: true,
693
+ lastStartAt: Date.now(),
694
+ });
695
+
696
+ if (!hasValidCredentials(account)) {
697
+ ctx.log?.error(`${logPrefix} Feishu credentials not configured`);
698
+ ctx.setStatus({
699
+ ...ctx.getStatus(),
700
+ running: false,
701
+ lastError: "Credentials not configured",
702
+ });
703
+ return;
704
+ }
705
+
706
+ // Probe the API to verify credentials and get bot info
707
+ const probe = await api.probeFeishu(account);
708
+ if (!probe.ok) {
709
+ ctx.log?.error(`${logPrefix} Feishu API probe failed: ${probe.error}`);
710
+ ctx.setStatus({
711
+ ...ctx.getStatus(),
712
+ running: false,
713
+ lastError: probe.error,
714
+ });
715
+ return;
716
+ }
717
+
718
+ // Get bot's open_id for mention detection
719
+ const botOpenId = (probe as { bot?: { open_id?: string } }).bot?.open_id;
720
+
721
+ // Initialize message deduplication cache
722
+ const dedupe = createFeishuDedupeCache();
723
+
724
+ // Initialize group history manager
725
+ const history = createGroupHistoryManager();
726
+
727
+ // Create reply senders
728
+ const sendReply = createDefaultReplySender(account);
729
+ const sendMedia = createDefaultMediaSender(account);
730
+
731
+ /**
732
+ * Handle inbound message - build context, dispatch to agent, send reply
733
+ */
734
+ async function handleInboundMessage(inbound: FeishuInboundMessage): Promise<void> {
735
+ // Deduplication check
736
+ if (dedupe.isProcessed(inbound.messageId)) {
737
+ ctx.log?.debug(`${logPrefix} skipping duplicate message: ${inbound.messageId}`);
738
+ return;
739
+ }
740
+ dedupe.markProcessed(inbound.messageId);
741
+
742
+ ctx.log?.info(`${logPrefix} received: ${inbound.text?.slice(0, 50)}...`);
743
+ ctx.setStatus({ ...ctx.getStatus(), lastInboundAt: Date.now() });
744
+
745
+ // Fetch reply context if this is a reply
746
+ const messageWithContext = await fetchReplyContext(account, inbound, ctx.log);
747
+
748
+ // Prepare group history
749
+ const isGroup = inbound.chatType === "group";
750
+ const historyEntry: HistoryEntry | undefined = isGroup
751
+ ? {
752
+ sender: inbound.senderOpenId ?? inbound.senderId,
753
+ body: inbound.displayText ?? inbound.text ?? "",
754
+ timestamp: inbound.createTime,
755
+ messageId: inbound.messageId,
756
+ }
757
+ : undefined;
758
+
759
+ // Get pending history for context
760
+ const pendingHistory = isGroup ? history.get(inbound.chatId) : [];
761
+
762
+ try {
763
+ // Build message context using local module
764
+ const context = await buildFeishuMessageContext({
765
+ message: messageWithContext,
766
+ account,
767
+ cfg: ctx.cfg,
768
+ botOpenId,
769
+ pendingHistory,
770
+ });
771
+
772
+ if (!context) {
773
+ // Not a direct message to bot - record to history for future context
774
+ if (historyEntry) {
775
+ history.record(inbound.chatId, historyEntry);
776
+ }
777
+ ctx.log?.debug(`${logPrefix} message blocked by policy`);
778
+ return;
779
+ }
780
+
781
+ // Dispatch to agent using local module
782
+ await dispatchFeishuMessage({
783
+ context,
784
+ cfg: ctx.cfg,
785
+ onSendReply: async (params) => {
786
+ const result = await sendReply(params);
787
+ ctx.setStatus({ ...ctx.getStatus(), lastOutboundAt: Date.now() });
788
+ return result;
789
+ },
790
+ onSendMedia: async (params) => {
791
+ const result = await sendMedia(params);
792
+ ctx.setStatus({ ...ctx.getStatus(), lastOutboundAt: Date.now() });
793
+ return result;
794
+ },
795
+ });
796
+
797
+ // Clear group history after bot replies
798
+ if (isGroup) {
799
+ history.clear(inbound.chatId);
800
+ }
801
+ } catch (err) {
802
+ ctx.log?.error(`${logPrefix} message handling failed: ${String(err)}`);
803
+ }
804
+ }
805
+
806
+ // WebSocket mode: establish long connection
807
+ if (connectionMode === "websocket") {
808
+ let wsClient: FeishuWSClient | null = null;
809
+
810
+ try {
811
+ wsClient = await createWSClient({
812
+ account,
813
+ onMessage: async (event) => {
814
+ ctx.log?.debug(`${logPrefix} received message via WebSocket`);
815
+ const result = parseWebhookEvent(event, account);
816
+ if (result.type === "message" && result.message) {
817
+ await handleInboundMessage(result.message as FeishuInboundMessage);
818
+ }
819
+ },
820
+ onStateChange: (state) => {
821
+ ctx.log?.info(`${logPrefix} WebSocket state: ${state}`);
822
+ ctx.setStatus({
823
+ ...ctx.getStatus(),
824
+ wsState: state,
825
+ running: state === "connected" || state === "reconnecting",
826
+ });
827
+ },
828
+ onError: (error) => {
829
+ ctx.log?.error(`${logPrefix} WebSocket error: ${error.message}`);
830
+ },
831
+ });
832
+
833
+ await wsClient.start();
834
+ ctx.log?.info(`${logPrefix} Feishu WebSocket connected`);
835
+
836
+ // Keep running until abort
837
+ return new Promise<void>((resolve) => {
838
+ ctx.abortSignal.addEventListener("abort", async () => {
839
+ ctx.log?.info(`${logPrefix} stopping Feishu WebSocket`);
840
+ if (wsClient) {
841
+ await wsClient.stop();
842
+ }
843
+ ctx.setStatus({
844
+ ...ctx.getStatus(),
845
+ running: false,
846
+ wsState: "disconnected",
847
+ lastStopAt: Date.now(),
848
+ });
849
+ resolve();
850
+ });
851
+ });
852
+ } catch (err) {
853
+ const errorMsg = err instanceof Error ? err.message : String(err);
854
+ ctx.log?.error(`${logPrefix} WebSocket connection failed: ${errorMsg}`);
855
+ ctx.setStatus({
856
+ ...ctx.getStatus(),
857
+ running: false,
858
+ lastError: `WebSocket: ${errorMsg}`,
859
+ });
860
+
861
+ // Fall back to webhook mode if WebSocket fails
862
+ ctx.log?.info(`${logPrefix} falling back to webhook mode`);
863
+ }
864
+ }
865
+
866
+ // Webhook mode: wait for incoming HTTP requests
867
+ ctx.log?.info(`${logPrefix} Feishu provider started in webhook mode`);
868
+
869
+ // Keep the gateway running until abort
870
+ return new Promise<void>((resolve) => {
871
+ ctx.abortSignal.addEventListener("abort", () => {
872
+ ctx.log?.info(`${logPrefix} Feishu provider stopping`);
873
+ ctx.setStatus({
874
+ ...ctx.getStatus(),
875
+ running: false,
876
+ lastStopAt: Date.now(),
877
+ });
878
+ resolve();
879
+ });
880
+ });
881
+ },
882
+ },
883
+ };