diff --git a/CHANGELOG.md b/CHANGELOG.md index 103e4570e..9fefed4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman. - Docs: zh-CN translation polish + pipeline guidance. (#8202, #6995) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan. - Docs: zh-CN translations seed + nav polish + landing notice + typo fix. (#6619, #7242, #7303, #7415) Thanks @joshp123, @lailoo. +- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn). ### Fixes diff --git a/docs/images/feishu-step2-create-app.png b/docs/images/feishu-step2-create-app.png new file mode 100644 index 000000000..c759a8f7e Binary files /dev/null and b/docs/images/feishu-step2-create-app.png differ diff --git a/docs/images/feishu-step3-credentials.png b/docs/images/feishu-step3-credentials.png new file mode 100644 index 000000000..45c69a075 Binary files /dev/null and b/docs/images/feishu-step3-credentials.png differ diff --git a/docs/images/feishu-step4-permissions.png b/docs/images/feishu-step4-permissions.png new file mode 100644 index 000000000..180f83c15 Binary files /dev/null and b/docs/images/feishu-step4-permissions.png differ diff --git a/docs/images/feishu-step5-bot-capability.png b/docs/images/feishu-step5-bot-capability.png new file mode 100644 index 000000000..9bac00c53 Binary files /dev/null and b/docs/images/feishu-step5-bot-capability.png differ diff --git a/docs/images/feishu-step6-event-subscription.png b/docs/images/feishu-step6-event-subscription.png new file mode 100644 index 000000000..a97932d7a Binary files /dev/null and b/docs/images/feishu-step6-event-subscription.png differ diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..adeeba5f6 --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,15 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { feishuPlugin } from "./src/channel.js"; + +const plugin = { + id: "feishu", + name: "Feishu", + description: "Feishu (Lark) channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerChannel({ plugin: feishuPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json new file mode 100644 index 000000000..93fb800f4 --- /dev/null +++ b/extensions/feishu/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "feishu", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22985b90b..0df19dd14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@homebridge/ciao': specifier: ^1.3.4 version: 1.3.4 + '@larksuiteoapi/node-sdk': + specifier: ^1.42.0 + version: 1.58.0 '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 @@ -300,6 +303,12 @@ importers: specifier: workspace:* version: link:../.. + extensions/feishu: + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/google-antigravity-auth: devDependencies: openclaw: @@ -1329,6 +1338,9 @@ packages: peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' + '@larksuiteoapi/node-sdk@1.58.0': + resolution: {integrity: sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==} + '@line/bot-sdk@10.6.0': resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==} engines: {node: '>=20'} @@ -4177,6 +4189,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -4195,9 +4210,15 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -6649,6 +6670,20 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 '@lancedb/lancedb-win32-x64-msvc': 0.23.0 + '@larksuiteoapi/node-sdk@1.58.0': + dependencies: + axios: 1.13.4(debug@4.4.3) + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.5.4 + qs: 6.14.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@line/bot-sdk@10.6.0': dependencies: '@types/node': 24.10.9 @@ -9774,6 +9809,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.identity@3.0.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -9786,8 +9823,12 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.pickby@4.6.0: {} + lodash@4.17.23: {} log-symbols@6.0.0: diff --git a/src/channels/plugins/normalize/feishu.ts b/src/channels/plugins/normalize/feishu.ts new file mode 100644 index 000000000..bd5efae75 --- /dev/null +++ b/src/channels/plugins/normalize/feishu.ts @@ -0,0 +1,5 @@ +export function normalizeFeishuTarget(raw: string): string { + let normalized = raw.replace(/^(feishu|lark):/i, "").trim(); + normalized = normalized.replace(/^(group|chat|user|dm):/i, "").trim(); + return normalized; +} diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index b6319f3a5..f48f51695 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,5 +1,6 @@ import type { GroupPolicy } from "./types.base.js"; import type { DiscordConfig } from "./types.discord.js"; +import type { FeishuConfig } from "./types.feishu.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; import type { MSTeamsConfig } from "./types.msteams.js"; @@ -28,6 +29,7 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + feishu?: FeishuConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.ts b/src/config/types.ts index d14f1178e..ba4ca1d70 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -10,6 +10,7 @@ export * from "./types.channels.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; +export * from "./types.feishu.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; export * from "./types.hooks.js"; diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 44f258914..3f4ed1017 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -53,166 +53,176 @@ beforeEach(() => { __resetDiscordChannelInfoCacheForTest(); }); +const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; + describe("discord tool result dispatch", () => { - it("accepts guild messages when mentionPatterns match", async () => { - const { createDiscordMessageHandler } = await import("./monitor.js"); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: "/tmp/openclaw", + it( + "accepts guild messages when mentionPatterns match", + async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/openclaw", + }, }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { "*": { requireMention: true } }, + session: { store: "/tmp/openclaw-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { "*": { requireMention: true } }, + }, }, - }, - messages: { - responsePrefix: "PFX", - groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, - }, - } as ReturnType; - - const handler = createDiscordMessageHandler({ - cfg, - discordConfig: cfg.channels.discord, - accountId: "default", - token: "token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, }, - }, - botUserId: "bot-id", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off", - dmEnabled: true, - groupDmEnabled: false, - guildEntries: { "*": { requireMention: true } }, - }); + } as ReturnType; - const client = { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.GuildText, - name: "general", - }), - } as unknown as Client; + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.channels.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, + }); - await handler( - { - message: { - id: "m2", - content: "openclaw: hello", - channelId: "c1", - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m2", + content: "openclaw: hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", }, - author: { id: "u1", bot: false, username: "Ada" }, - member: { nickname: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }, - client, - ); + client, + ); - expect(dispatchMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledTimes(1); - }, 20_000); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); + }, + MENTION_PATTERNS_TEST_TIMEOUT_MS, + ); - it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => { - const { createDiscordMessageHandler } = await import("./monitor.js"); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: "/tmp/openclaw", + it( + "accepts guild messages when mentionPatterns match even if another user is mentioned", + async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/openclaw", + }, }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { "*": { requireMention: true } }, + session: { store: "/tmp/openclaw-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { "*": { requireMention: true } }, + }, }, - }, - messages: { - responsePrefix: "PFX", - groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, - }, - } as ReturnType; - - const handler = createDiscordMessageHandler({ - cfg, - discordConfig: cfg.channels.discord, - accountId: "default", - token: "token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, }, - }, - botUserId: "bot-id", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off", - dmEnabled: true, - groupDmEnabled: false, - guildEntries: { "*": { requireMention: true } }, - }); + } as ReturnType; - const client = { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.GuildText, - name: "general", - }), - } as unknown as Client; + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.channels.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, + }); - await handler( - { - message: { - id: "m2", - content: "openclaw: hello", - channelId: "c1", - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }], - mentionedRoles: [], + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m2", + content: "openclaw: hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", }, - author: { id: "u1", bot: false, username: "Ada" }, - member: { nickname: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }, - client, - ); + client, + ); - expect(dispatchMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledTimes(1); - }, 20_000); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); + }, + MENTION_PATTERNS_TEST_TIMEOUT_MS, + ); it("accepts guild reply-to-bot messages as implicit mentions", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); diff --git a/src/feishu/access.ts b/src/feishu/access.ts new file mode 100644 index 000000000..12a0df57d --- /dev/null +++ b/src/feishu/access.ts @@ -0,0 +1,91 @@ +import type { AllowlistMatch } from "../channels/allowlist-match.js"; + +export type NormalizedAllowFrom = { + entries: string[]; + entriesLower: string[]; + hasWildcard: boolean; + hasEntries: boolean; +}; + +export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">; + +/** + * Normalize an allowlist for Feishu. + * Feishu IDs are open_id (ou_xxx) or union_id (on_xxx), no usernames. + */ +export const normalizeAllowFrom = (list?: Array): NormalizedAllowFrom => { + const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean); + const hasWildcard = entries.includes("*"); + // Strip optional "feishu:" prefix + const normalized = entries + .filter((value) => value !== "*") + .map((value) => value.replace(/^(feishu|lark):/i, "")); + const normalizedLower = normalized.map((value) => value.toLowerCase()); + return { + entries: normalized, + entriesLower: normalizedLower, + hasWildcard, + hasEntries: entries.length > 0, + }; +}; + +export const normalizeAllowFromWithStore = (params: { + allowFrom?: Array; + storeAllowFrom?: string[]; +}): NormalizedAllowFrom => { + const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] + .map((value) => String(value).trim()) + .filter(Boolean); + return normalizeAllowFrom(combined); +}; + +export const firstDefined = (...values: Array) => { + for (const value of values) { + if (typeof value !== "undefined") { + return value; + } + } + return undefined; +}; + +/** + * Check if a sender is allowed based on the normalized allowlist. + * Feishu uses open_id (ou_xxx) or union_id (on_xxx) - no usernames. + */ +export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string }) => { + const { allow, senderId } = params; + if (!allow.hasEntries) { + return true; + } + if (allow.hasWildcard) { + return true; + } + if (senderId && allow.entries.includes(senderId)) { + return true; + } + // Also check case-insensitive (though Feishu IDs are typically lowercase) + if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { + return true; + } + return false; +}; + +export const resolveSenderAllowMatch = (params: { + allow: NormalizedAllowFrom; + senderId?: string; +}): AllowFromMatch => { + const { allow, senderId } = params; + if (allow.hasWildcard) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + if (!allow.hasEntries) { + return { allowed: false }; + } + if (senderId && allow.entries.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) { + return { allowed: true, matchKey: senderId.toLowerCase(), matchSource: "id" }; + } + return { allowed: false }; +}; diff --git a/src/feishu/accounts.ts b/src/feishu/accounts.ts new file mode 100644 index 000000000..5b917a7ee --- /dev/null +++ b/src/feishu/accounts.ts @@ -0,0 +1,142 @@ +import fs from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import type { FeishuAccountConfig } from "../config/types.feishu.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export type FeishuTokenSource = "config" | "file" | "env" | "none"; + +export type ResolvedFeishuAccount = { + accountId: string; + config: FeishuAccountConfig; + tokenSource: FeishuTokenSource; + name?: string; + enabled: boolean; +}; + +function readFileIfExists(filePath?: string): string | undefined { + if (!filePath) { + return undefined; + } + try { + return fs.readFileSync(filePath, "utf-8").trim(); + } catch { + return undefined; + } +} + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): FeishuAccountConfig | undefined { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as FeishuAccountConfig | undefined; + if (direct) { + return direct; + } + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined; +} + +function mergeFeishuAccountConfig(cfg: OpenClawConfig, accountId: string): FeishuAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveAppSecret(config?: { appSecret?: string; appSecretFile?: string }): { + value?: string; + source?: Exclude; +} { + const direct = config?.appSecret?.trim(); + if (direct) { + return { value: direct, source: "config" }; + } + const fromFile = readFileIfExists(config?.appSecretFile); + if (fromFile) { + return { value: fromFile, source: "file" }; + } + return {}; +} + +export function listFeishuAccountIds(cfg: OpenClawConfig): string[] { + const feishuCfg = cfg.channels?.feishu; + const accounts = feishuCfg?.accounts; + const ids = new Set(); + + const baseConfigured = Boolean( + feishuCfg?.appId?.trim() && (feishuCfg?.appSecret?.trim() || Boolean(feishuCfg?.appSecretFile)), + ); + const envConfigured = Boolean( + process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(), + ); + if (baseConfigured || envConfigured) { + ids.add(DEFAULT_ACCOUNT_ID); + } + + if (accounts) { + for (const id of Object.keys(accounts)) { + ids.add(normalizeAccountId(id)); + } + } + + return Array.from(ids); +} + +export function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string { + const ids = listFeishuAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveFeishuAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedFeishuAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.feishu?.enabled !== false; + const merged = mergeFeishuAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envAppId = allowEnv ? process.env.FEISHU_APP_ID?.trim() : undefined; + const envAppSecret = allowEnv ? process.env.FEISHU_APP_SECRET?.trim() : undefined; + + const appId = merged.appId?.trim() || envAppId || ""; + const secretResolution = resolveAppSecret(merged); + const appSecret = secretResolution.value ?? envAppSecret ?? ""; + + let tokenSource: FeishuTokenSource = "none"; + if (secretResolution.value) { + tokenSource = secretResolution.source ?? "config"; + } else if (envAppSecret) { + tokenSource = "env"; + } + if (!appId || !appSecret) { + tokenSource = "none"; + } + + const config: FeishuAccountConfig = { + ...merged, + appId, + appSecret, + }; + + const name = config.name?.trim() || config.botName?.trim() || undefined; + + return { + accountId, + config, + tokenSource, + name, + enabled, + }; +} diff --git a/src/feishu/bot.ts b/src/feishu/bot.ts new file mode 100644 index 000000000..c9ba9d872 --- /dev/null +++ b/src/feishu/bot.ts @@ -0,0 +1,58 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { getFeishuClient } from "./client.js"; +import { processFeishuMessage } from "./message.js"; + +const logger = getChildLogger({ module: "feishu-bot" }); + +export type FeishuBotOptions = { + appId: string; + appSecret: string; +}; + +export function createFeishuBot(opts: FeishuBotOptions) { + const { appId, appSecret } = opts; + const client = getFeishuClient(appId, appSecret); + + const eventDispatcher = new Lark.EventDispatcher({}).register({ + "im.message.receive_v1": async (data) => { + try { + await processFeishuMessage(client, data, appId); + } catch (err) { + logger.error(`Error processing Feishu message: ${formatErrorMessage(err)}`); + } + }, + }); + + const wsClient = new Lark.WSClient({ + appId, + appSecret, + logger: { + debug: (...args) => { + logger.debug(args.join(" ")); + }, + info: (...args) => { + logger.info(args.join(" ")); + }, + warn: (...args) => { + logger.warn(args.join(" ")); + }, + error: (...args) => { + logger.error(args.join(" ")); + }, + trace: (...args) => { + logger.silly(args.join(" ")); + }, + }, + }); + + return { client, wsClient, eventDispatcher }; +} + +export async function startFeishuBot(bot: ReturnType) { + logger.info("Starting Feishu bot WS client..."); + await bot.wsClient.start({ + eventDispatcher: bot.eventDispatcher, + }); +} diff --git a/src/feishu/client.ts b/src/feishu/client.ts new file mode 100644 index 000000000..083c01061 --- /dev/null +++ b/src/feishu/client.ts @@ -0,0 +1,134 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import fs from "node:fs"; +import { loadConfig } from "../config/config.js"; +import { getChildLogger } from "../logging.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { normalizeFeishuDomain } from "./domain.js"; + +const logger = getChildLogger({ module: "feishu-client" }); + +function readFileIfExists(filePath?: string): string | undefined { + if (!filePath) { + return undefined; + } + try { + return fs.readFileSync(filePath, "utf-8").trim(); + } catch { + return undefined; + } +} + +function resolveAppSecret(config?: { + appSecret?: string; + appSecretFile?: string; +}): string | undefined { + const direct = config?.appSecret?.trim(); + if (direct) { + return direct; + } + return readFileIfExists(config?.appSecretFile); +} + +export function getFeishuClient(accountIdOrAppId?: string, explicitAppSecret?: string) { + const cfg = loadConfig(); + const feishuCfg = cfg.channels?.feishu; + + let appId: string | undefined; + let appSecret: string | undefined = explicitAppSecret?.trim() || undefined; + let domain: string | undefined; + + // Determine if we received an accountId or an appId + const isAppId = accountIdOrAppId?.startsWith("cli_"); + const accountId = isAppId ? undefined : accountIdOrAppId || DEFAULT_ACCOUNT_ID; + + if (!appSecret && feishuCfg?.accounts) { + if (isAppId) { + // When given an appId, find the account with matching appId + for (const [, acc] of Object.entries(feishuCfg.accounts)) { + if (acc.appId === accountIdOrAppId) { + appId = acc.appId; + appSecret = resolveAppSecret(acc); + domain = acc.domain ?? feishuCfg?.domain; + break; + } + } + // If not found in accounts, use the appId directly (secret from first account as fallback) + if (!appSecret) { + appId = accountIdOrAppId; + const firstKey = Object.keys(feishuCfg.accounts)[0]; + if (firstKey) { + const acc = feishuCfg.accounts[firstKey]; + appSecret = resolveAppSecret(acc); + domain = acc.domain ?? feishuCfg?.domain; + } + } + } else if (accountId && feishuCfg.accounts[accountId]) { + // Try to get from accounts config by accountId + const acc = feishuCfg.accounts[accountId]; + appId = acc.appId; + appSecret = resolveAppSecret(acc); + domain = acc.domain ?? feishuCfg?.domain; + } else if (!accountId) { + // Fallback to first account if accountId is not specified + const firstKey = Object.keys(feishuCfg.accounts)[0]; + if (firstKey) { + const acc = feishuCfg.accounts[firstKey]; + appId = acc.appId; + appSecret = resolveAppSecret(acc); + domain = acc.domain ?? feishuCfg?.domain; + } + } + } + + // Fallback to top-level feishu config (for backward compatibility) + if (!appId && feishuCfg?.appId) { + appId = feishuCfg.appId.trim(); + } + if (!appSecret) { + appSecret = resolveAppSecret(feishuCfg); + } + if (!domain) { + domain = feishuCfg?.domain; + } + + // Environment variables fallback + if (!appId) { + appId = process.env.FEISHU_APP_ID?.trim(); + } + if (!appSecret) { + appSecret = process.env.FEISHU_APP_SECRET?.trim(); + } + + if (!appId || !appSecret) { + throw new Error( + "Feishu app ID/secret not configured. Set channels.feishu.accounts..appId/appSecret (or appSecretFile) or FEISHU_APP_ID/FEISHU_APP_SECRET.", + ); + } + + const resolvedDomain = normalizeFeishuDomain(domain); + + const client = new Lark.Client({ + appId, + appSecret, + ...(resolvedDomain ? { domain: resolvedDomain } : {}), + logger: { + debug: (msg) => { + logger.debug(msg); + }, + info: (msg) => { + logger.info(msg); + }, + warn: (msg) => { + logger.warn(msg); + }, + error: (msg) => { + logger.error(msg); + }, + trace: (msg) => { + logger.silly(msg); + }, + }, + }); + + return client; +} diff --git a/src/feishu/config.ts b/src/feishu/config.ts new file mode 100644 index 000000000..0c82e7740 --- /dev/null +++ b/src/feishu/config.ts @@ -0,0 +1,91 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { DmPolicy, GroupPolicy } from "../config/types.base.js"; +import type { FeishuGroupConfig } from "../config/types.feishu.js"; +import { firstDefined } from "./access.js"; + +export type ResolvedFeishuConfig = { + enabled: boolean; + dmPolicy: DmPolicy; + groupPolicy: GroupPolicy; + allowFrom: string[]; + groupAllowFrom: string[]; + historyLimit: number; + dmHistoryLimit: number; + textChunkLimit: number; + chunkMode: "length" | "newline"; + blockStreaming: boolean; + streaming: boolean; + mediaMaxMb: number; + groups: Record; +}; + +/** + * Resolve effective Feishu configuration for an account. + * Account-level config overrides top-level feishu config, which overrides channel defaults. + */ +export function resolveFeishuConfig(params: { + cfg: OpenClawConfig; + accountId?: string; +}): ResolvedFeishuConfig { + const { cfg, accountId } = params; + const feishuCfg = cfg.channels?.feishu; + const accountCfg = accountId ? feishuCfg?.accounts?.[accountId] : undefined; + const defaults = cfg.channels?.defaults; + + // Merge with precedence: account > feishu top-level > channel defaults > hardcoded defaults + return { + enabled: firstDefined(accountCfg?.enabled, feishuCfg?.enabled, true) ?? true, + dmPolicy: firstDefined(accountCfg?.dmPolicy, feishuCfg?.dmPolicy) ?? "pairing", + groupPolicy: + firstDefined(accountCfg?.groupPolicy, feishuCfg?.groupPolicy, defaults?.groupPolicy) ?? + "open", + allowFrom: (accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []).map(String), + groupAllowFrom: (accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []).map(String), + historyLimit: firstDefined(accountCfg?.historyLimit, feishuCfg?.historyLimit) ?? 10, + dmHistoryLimit: firstDefined(accountCfg?.dmHistoryLimit, feishuCfg?.dmHistoryLimit) ?? 20, + textChunkLimit: firstDefined(accountCfg?.textChunkLimit, feishuCfg?.textChunkLimit) ?? 2000, + chunkMode: firstDefined(accountCfg?.chunkMode, feishuCfg?.chunkMode) ?? "length", + blockStreaming: firstDefined(accountCfg?.blockStreaming, feishuCfg?.blockStreaming) ?? true, + streaming: firstDefined(accountCfg?.streaming, feishuCfg?.streaming) ?? true, + mediaMaxMb: firstDefined(accountCfg?.mediaMaxMb, feishuCfg?.mediaMaxMb) ?? 30, + groups: { ...feishuCfg?.groups, ...accountCfg?.groups }, + }; +} + +/** + * Resolve group-specific configuration for a Feishu chat. + */ +export function resolveFeishuGroupConfig(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): { groupConfig?: FeishuGroupConfig } { + const resolved = resolveFeishuConfig({ cfg: params.cfg, accountId: params.accountId }); + const groupConfig = resolved.groups[params.chatId]; + return { groupConfig }; +} + +/** + * Check if a group requires @mention for the bot to respond. + */ +export function resolveFeishuGroupRequireMention(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): boolean { + const { groupConfig } = resolveFeishuGroupConfig(params); + // Default: require mention in groups + return groupConfig?.requireMention ?? true; +} + +/** + * Check if a group is enabled. + */ +export function resolveFeishuGroupEnabled(params: { + cfg: OpenClawConfig; + accountId?: string; + chatId: string; +}): boolean { + const { groupConfig } = resolveFeishuGroupConfig(params); + return groupConfig?.enabled ?? true; +} diff --git a/src/feishu/domain.ts b/src/feishu/domain.ts new file mode 100644 index 000000000..49c8e593b --- /dev/null +++ b/src/feishu/domain.ts @@ -0,0 +1,31 @@ +export const FEISHU_DOMAIN = "https://open.feishu.cn"; +export const LARK_DOMAIN = "https://open.larksuite.com"; + +export type FeishuDomainInput = string | null | undefined; + +export function normalizeFeishuDomain(value?: FeishuDomainInput): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const lower = trimmed.toLowerCase(); + if (lower === "feishu" || lower === "cn" || lower === "china") { + return FEISHU_DOMAIN; + } + if (lower === "lark" || lower === "global" || lower === "intl" || lower === "international") { + return LARK_DOMAIN; + } + + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const withoutTrailing = withScheme.replace(/\/+$/, ""); + return withoutTrailing.replace(/\/open-apis$/i, ""); +} + +export function resolveFeishuDomain(value?: FeishuDomainInput): string { + return normalizeFeishuDomain(value) ?? FEISHU_DOMAIN; +} + +export function resolveFeishuApiBase(value?: FeishuDomainInput): string { + const base = resolveFeishuDomain(value); + return `${base.replace(/\/+$/, "")}/open-apis`; +} diff --git a/src/feishu/download.ts b/src/feishu/download.ts new file mode 100644 index 000000000..9beccdb67 --- /dev/null +++ b/src/feishu/download.ts @@ -0,0 +1,183 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { saveMediaBuffer } from "../media/store.js"; + +const logger = getChildLogger({ module: "feishu-download" }); + +export type FeishuMediaRef = { + path: string; + contentType?: string; + placeholder: string; +}; + +type FeishuMessagePayload = { + message_type?: string; + message_id?: string; + content?: string; +}; + +/** + * Download a resource from a user message using messageResource.get + * This is the correct API for downloading resources from messages sent by users. + * + * @param type - Resource type: "image", "file", "audio", or "video" + */ +export async function downloadFeishuMessageResource( + client: Client, + messageId: string, + fileKey: string, + type: "image" | "file" | "audio" | "video", + maxBytes: number = 30 * 1024 * 1024, +): Promise { + logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`); + + const res = await client.im.messageResource.get({ + params: { type }, + path: { + message_id: messageId, + file_key: fileKey, + }, + }); + + if (!res) { + throw new Error(`Failed to get ${type} resource: no response`); + } + + const stream = res.getReadableStream(); + const chunks: Buffer[] = []; + let totalSize = 0; + + for await (const chunk of stream) { + totalSize += chunk.length; + if (totalSize > maxBytes) { + throw new Error(`${type} resource exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`); + } + chunks.push(Buffer.from(chunk)); + } + + const buffer = Buffer.concat(chunks); + + // Try to detect content type from headers + const contentType = + res.headers?.["content-type"] ?? res.headers?.["Content-Type"] ?? getDefaultContentType(type); + + const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes); + + return { + path: saved.path, + contentType: saved.contentType, + placeholder: getPlaceholder(type), + }; +} + +function getDefaultContentType(type: string): string { + switch (type) { + case "image": + return "image/jpeg"; + case "audio": + return "audio/ogg"; + case "video": + return "video/mp4"; + default: + return "application/octet-stream"; + } +} + +function getPlaceholder(type: string): string { + switch (type) { + case "image": + return ""; + case "audio": + return ""; + case "video": + return ""; + default: + return ""; + } +} + +/** + * Resolve media from a Feishu message + * Returns the downloaded media reference or null if no media + * + * Uses messageResource.get API to download resources from user messages. + */ +export async function resolveFeishuMedia( + client: Client, + message: FeishuMessagePayload, + maxBytes: number = 30 * 1024 * 1024, +): Promise { + const msgType = message.message_type; + const messageId = message.message_id; + + if (!messageId) { + logger.warn(`Cannot download media: message_id is missing`); + return null; + } + + try { + const rawContent = message.content; + if (!rawContent) { + return null; + } + + if (msgType === "image") { + // Image message: content = { image_key: "..." } + const content = JSON.parse(rawContent); + if (content.image_key) { + return await downloadFeishuMessageResource( + client, + messageId, + content.image_key, + "image", + maxBytes, + ); + } + } else if (msgType === "file") { + // File message: content = { file_key: "...", file_name: "..." } + const content = JSON.parse(rawContent); + if (content.file_key) { + return await downloadFeishuMessageResource( + client, + messageId, + content.file_key, + "file", + maxBytes, + ); + } + } else if (msgType === "audio") { + // Audio message: content = { file_key: "..." } + const content = JSON.parse(rawContent); + if (content.file_key) { + return await downloadFeishuMessageResource( + client, + messageId, + content.file_key, + "audio", + maxBytes, + ); + } + } else if (msgType === "media") { + // Video message: content = { file_key: "...", image_key: "..." (thumbnail) } + const content = JSON.parse(rawContent); + if (content.file_key) { + return await downloadFeishuMessageResource( + client, + messageId, + content.file_key, + "video", + maxBytes, + ); + } + } else if (msgType === "sticker") { + // Sticker - not supported for download via messageResource API + logger.debug(`Sticker messages are not supported for download`); + return null; + } + } catch (err) { + logger.error(`Failed to resolve Feishu media (${msgType}): ${formatErrorMessage(err)}`); + } + + return null; +} diff --git a/src/feishu/format.test.ts b/src/feishu/format.test.ts new file mode 100644 index 000000000..dea45a8d4 --- /dev/null +++ b/src/feishu/format.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { containsMarkdown, markdownToFeishuPost } from "./format.js"; + +describe("containsMarkdown", () => { + it("detects bold text", () => { + expect(containsMarkdown("Hello **world**")).toBe(true); + }); + + it("detects italic text", () => { + expect(containsMarkdown("Hello *world*")).toBe(true); + }); + + it("detects inline code", () => { + expect(containsMarkdown("Run `npm install`")).toBe(true); + }); + + it("detects code blocks", () => { + expect(containsMarkdown("```js\nconsole.log('hi')\n```")).toBe(true); + }); + + it("detects links", () => { + expect(containsMarkdown("Visit [Google](https://google.com)")).toBe(true); + }); + + it("detects headings", () => { + expect(containsMarkdown("# Title")).toBe(true); + }); + + it("returns false for plain text", () => { + expect(containsMarkdown("Hello world")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(containsMarkdown("")).toBe(false); + }); +}); + +describe("markdownToFeishuPost", () => { + it("converts plain text", () => { + const result = markdownToFeishuPost("Hello world"); + expect(result.zh_cn?.content).toBeDefined(); + expect(result.zh_cn?.content[0]).toContainEqual({ + tag: "text", + text: "Hello world", + }); + }); + + it("converts bold text", () => { + const result = markdownToFeishuPost("Hello **bold** text"); + const content = result.zh_cn?.content[0]; + expect(content).toBeDefined(); + // Should have at least one element with bold style + const boldElement = content?.find((el) => el.tag === "text" && el.style?.includes("bold")); + expect(boldElement).toBeDefined(); + }); + + it("converts italic text", () => { + const result = markdownToFeishuPost("Hello *italic* text"); + const content = result.zh_cn?.content[0]; + expect(content).toBeDefined(); + const italicElement = content?.find((el) => el.tag === "text" && el.style?.includes("italic")); + expect(italicElement).toBeDefined(); + }); + + it("converts links", () => { + const result = markdownToFeishuPost("Visit [Google](https://google.com)"); + const content = result.zh_cn?.content[0]; + expect(content).toBeDefined(); + const linkElement = content?.find((el) => el.tag === "a"); + expect(linkElement).toBeDefined(); + if (linkElement && linkElement.tag === "a") { + expect(linkElement.href).toBe("https://google.com"); + expect(linkElement.text).toBe("Google"); + } + }); + + it("handles multi-line text", () => { + const result = markdownToFeishuPost("Line 1\nLine 2\nLine 3"); + expect(result.zh_cn?.content.length).toBe(3); + }); + + it("converts code to code style", () => { + const result = markdownToFeishuPost("Run `npm install`"); + const content = result.zh_cn?.content[0]; + expect(content).toBeDefined(); + const codeElement = content?.find((el) => el.tag === "text" && el.style?.includes("code")); + expect(codeElement).toBeDefined(); + }); + + it("handles empty input", () => { + const result = markdownToFeishuPost(""); + expect(result.zh_cn?.content).toBeDefined(); + }); +}); diff --git a/src/feishu/format.ts b/src/feishu/format.ts new file mode 100644 index 000000000..444af5f79 --- /dev/null +++ b/src/feishu/format.ts @@ -0,0 +1,267 @@ +import type { MarkdownTableMode } from "../config/types.base.js"; +import { + chunkMarkdownIR, + markdownToIR, + type MarkdownIR, + type MarkdownLinkSpan, + type MarkdownStyleSpan, +} from "../markdown/ir.js"; + +/** + * Feishu Post (rich text) format + * Reference: https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json#c9e08671 + */ + +export type FeishuPostElement = + | { tag: "text"; text: string; style?: string[] } + | { tag: "a"; text: string; href: string; style?: string[] } + | { tag: "at"; user_id: string } + | { tag: "img"; image_key: string } + | { tag: "media"; file_key: string } + | { tag: "emotion"; emoji_type: string }; + +export type FeishuPostLine = FeishuPostElement[]; + +export type FeishuPostContent = { + zh_cn?: { + title?: string; + content: FeishuPostLine[]; + }; + en_us?: { + title?: string; + content: FeishuPostLine[]; + }; +}; + +export type FeishuFormattedChunk = { + post: FeishuPostContent; + text: string; +}; + +type StyleState = { + bold: boolean; + italic: boolean; + strikethrough: boolean; + code: boolean; +}; + +/** + * Convert MarkdownIR to Feishu Post format + */ +function renderFeishuPost(ir: MarkdownIR): FeishuPostContent { + const lines: FeishuPostLine[] = []; + const text = ir.text; + + if (!text) { + return { zh_cn: { content: [[{ tag: "text", text: "" }]] } }; + } + + // Build a map of style ranges for quick lookup + const styleRanges = buildStyleRanges(ir.styles, text.length); + const linkMap = buildLinkMap(ir.links); + + // Split text into lines + const textLines = text.split("\n"); + let charIndex = 0; + + for (const line of textLines) { + const lineElements: FeishuPostElement[] = []; + + if (line.length === 0) { + // Empty line - add empty text element + lineElements.push({ tag: "text", text: "" }); + } else { + // Process each character segment with consistent styling + let segmentStart = charIndex; + let currentStyles = getStylesAt(styleRanges, segmentStart); + let currentLink = getLinkAt(linkMap, segmentStart); + + for (let i = 0; i < line.length; i++) { + const pos = charIndex + i; + const newStyles = getStylesAt(styleRanges, pos); + const newLink = getLinkAt(linkMap, pos); + + // Check if style or link changed + const stylesChanged = !stylesEqual(currentStyles, newStyles); + const linkChanged = currentLink !== newLink; + + if (stylesChanged || linkChanged) { + // Emit previous segment + const segmentText = text.slice(segmentStart, pos); + if (segmentText) { + lineElements.push(createPostElement(segmentText, currentStyles, currentLink)); + } + segmentStart = pos; + currentStyles = newStyles; + currentLink = newLink; + } + } + + // Emit final segment of the line + const finalText = text.slice(segmentStart, charIndex + line.length); + if (finalText) { + lineElements.push(createPostElement(finalText, currentStyles, currentLink)); + } + } + + lines.push(lineElements.length > 0 ? lineElements : [{ tag: "text", text: "" }]); + charIndex += line.length + 1; // +1 for newline + } + + return { + zh_cn: { + content: lines, + }, + }; +} + +function buildStyleRanges(styles: MarkdownStyleSpan[], textLength: number): StyleState[] { + const ranges: StyleState[] = Array(textLength) + .fill(null) + .map(() => ({ + bold: false, + italic: false, + strikethrough: false, + code: false, + })); + + for (const span of styles) { + for (let i = span.start; i < span.end && i < textLength; i++) { + switch (span.style) { + case "bold": + ranges[i].bold = true; + break; + case "italic": + ranges[i].italic = true; + break; + case "strikethrough": + ranges[i].strikethrough = true; + break; + case "code": + case "code_block": + ranges[i].code = true; + break; + } + } + } + + return ranges; +} + +function buildLinkMap(links: MarkdownLinkSpan[]): Map { + const map = new Map(); + for (const link of links) { + for (let i = link.start; i < link.end; i++) { + map.set(i, link.href); + } + } + return map; +} + +function getStylesAt(ranges: StyleState[], pos: number): StyleState { + return ranges[pos] ?? { bold: false, italic: false, strikethrough: false, code: false }; +} + +function getLinkAt(linkMap: Map, pos: number): string | undefined { + return linkMap.get(pos); +} + +function stylesEqual(a: StyleState, b: StyleState): boolean { + return ( + a.bold === b.bold && + a.italic === b.italic && + a.strikethrough === b.strikethrough && + a.code === b.code + ); +} + +function createPostElement(text: string, styles: StyleState, link?: string): FeishuPostElement { + const styleArray: string[] = []; + + if (styles.bold) { + styleArray.push("bold"); + } + if (styles.italic) { + styleArray.push("italic"); + } + if (styles.strikethrough) { + styleArray.push("lineThrough"); + } + if (styles.code) { + styleArray.push("code"); + } + + if (link) { + return { + tag: "a", + text, + href: link, + ...(styleArray.length > 0 ? { style: styleArray } : {}), + }; + } + + return { + tag: "text", + text, + ...(styleArray.length > 0 ? { style: styleArray } : {}), + }; +} + +/** + * Convert Markdown to Feishu Post format + */ +export function markdownToFeishuPost( + markdown: string, + options: { tableMode?: MarkdownTableMode } = {}, +): FeishuPostContent { + const ir = markdownToIR(markdown ?? "", { + linkify: true, + headingStyle: "bold", + blockquotePrefix: "| ", + tableMode: options.tableMode, + }); + return renderFeishuPost(ir); +} + +/** + * Convert Markdown to Feishu Post chunks (for long messages) + */ +export function markdownToFeishuChunks( + markdown: string, + limit: number, + options: { tableMode?: MarkdownTableMode } = {}, +): FeishuFormattedChunk[] { + const ir = markdownToIR(markdown ?? "", { + linkify: true, + headingStyle: "bold", + blockquotePrefix: "| ", + tableMode: options.tableMode, + }); + const chunks = chunkMarkdownIR(ir, limit); + return chunks.map((chunk) => ({ + post: renderFeishuPost(chunk), + text: chunk.text, + })); +} + +/** + * Check if text contains Markdown formatting + */ +export function containsMarkdown(text: string): boolean { + if (!text) { + return false; + } + // Check for common Markdown patterns + const markdownPatterns = [ + /\*\*[^*]+\*\*/, // bold + /\*[^*]+\*/, // italic + /~~[^~]+~~/, // strikethrough + /`[^`]+`/, // inline code + /```[\s\S]*```/, // code block + /\[.+\]\(.+\)/, // links + /^#{1,6}\s/m, // headings + /^[-*]\s/m, // unordered list + /^\d+\.\s/m, // ordered list + ]; + return markdownPatterns.some((pattern) => pattern.test(text)); +} diff --git a/src/feishu/index.ts b/src/feishu/index.ts new file mode 100644 index 000000000..1f4aaaeae --- /dev/null +++ b/src/feishu/index.ts @@ -0,0 +1,8 @@ +export * from "./types.js"; +export * from "./client.js"; +export * from "./bot.js"; +export * from "./send.js"; +export * from "./message.js"; +export * from "./probe.js"; +export * from "./accounts.js"; +export * from "./monitor.js"; diff --git a/src/feishu/message.ts b/src/feishu/message.ts new file mode 100644 index 000000000..a3724588c --- /dev/null +++ b/src/feishu/message.ts @@ -0,0 +1,426 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import type { OpenClawConfig } from "../config/config.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { loadConfig } from "../config/config.js"; +import { logVerbose } from "../globals.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js"; +import { + resolveFeishuConfig, + resolveFeishuGroupConfig, + resolveFeishuGroupEnabled, + type ResolvedFeishuConfig, +} from "./config.js"; +import { resolveFeishuMedia, type FeishuMediaRef } from "./download.js"; +import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js"; +import { sendMessageFeishu } from "./send.js"; +import { FeishuStreamingSession } from "./streaming-card.js"; + +const logger = getChildLogger({ module: "feishu-message" }); + +type FeishuSender = { + sender_id?: { + open_id?: string; + user_id?: string; + union_id?: string; + }; +}; + +type FeishuMention = { + key?: string; +}; + +type FeishuMessage = { + chat_id?: string; + chat_type?: string; + message_type?: string; + content?: string; + mentions?: FeishuMention[]; + create_time?: string | number; + message_id?: string; +}; + +type FeishuEventPayload = { + message?: FeishuMessage; + event?: { + message?: FeishuMessage; + sender?: FeishuSender; + }; + sender?: FeishuSender; + mentions?: FeishuMention[]; +}; + +// Supported message types for processing +const SUPPORTED_MSG_TYPES = new Set(["text", "image", "file", "audio", "media", "sticker"]); + +export type ProcessFeishuMessageOptions = { + cfg?: OpenClawConfig; + accountId?: string; + resolvedConfig?: ResolvedFeishuConfig; + /** Feishu app credentials for streaming card API */ + credentials?: { appId: string; appSecret: string; domain?: string }; + /** Bot name for streaming card title (optional, defaults to no title) */ + botName?: string; +}; + +export async function processFeishuMessage( + client: Client, + data: unknown, + appId: string, + options: ProcessFeishuMessageOptions = {}, +) { + const cfg = options.cfg ?? loadConfig(); + const accountId = options.accountId ?? appId; + const feishuCfg = options.resolvedConfig ?? resolveFeishuConfig({ cfg, accountId }); + + const payload = data as FeishuEventPayload; + + // SDK 2.0 schema: data directly contains message, sender, etc. + const message = payload.message ?? payload.event?.message; + const sender = payload.sender ?? payload.event?.sender; + + if (!message) { + logger.warn(`Received event without message field`); + return; + } + + const chatId = message.chat_id; + if (!chatId) { + logger.warn("Received message without chat_id"); + return; + } + const isGroup = message.chat_type === "group"; + const msgType = message.message_type; + const senderId = sender?.sender_id?.open_id || sender?.sender_id?.user_id || "unknown"; + const senderUnionId = sender?.sender_id?.union_id; + const maxMediaBytes = feishuCfg.mediaMaxMb * 1024 * 1024; + + // Check if this is a supported message type + if (!msgType || !SUPPORTED_MSG_TYPES.has(msgType)) { + logger.debug(`Skipping unsupported message type: ${msgType ?? "unknown"}`); + return; + } + + // Load allowlist from store + const storeAllowFrom = await readFeishuAllowFromStore().catch(() => []); + + // ===== Access Control ===== + + // Group access control + if (isGroup) { + // Check if group is enabled + if (!resolveFeishuGroupEnabled({ cfg, accountId, chatId })) { + logVerbose(`Blocked feishu group ${chatId} (group disabled)`); + return; + } + + const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId }); + + // Check group-level allowFrom override + if (groupConfig?.allowFrom) { + const groupAllow = normalizeAllowFromWithStore({ + allowFrom: groupConfig.allowFrom, + storeAllowFrom, + }); + if (!isSenderAllowed({ allow: groupAllow, senderId })) { + logVerbose(`Blocked feishu group sender ${senderId} (group allowFrom override)`); + return; + } + } + + // Apply groupPolicy + const groupPolicy = feishuCfg.groupPolicy; + if (groupPolicy === "disabled") { + logVerbose(`Blocked feishu group message (groupPolicy: disabled)`); + return; + } + + if (groupPolicy === "allowlist") { + const groupAllow = normalizeAllowFromWithStore({ + allowFrom: + feishuCfg.groupAllowFrom.length > 0 ? feishuCfg.groupAllowFrom : feishuCfg.allowFrom, + storeAllowFrom, + }); + if (!groupAllow.hasEntries) { + logVerbose(`Blocked feishu group message (groupPolicy: allowlist, no entries)`); + return; + } + if (!isSenderAllowed({ allow: groupAllow, senderId })) { + logVerbose(`Blocked feishu group sender ${senderId} (groupPolicy: allowlist)`); + return; + } + } + } + + // DM access control + if (!isGroup) { + const dmPolicy = feishuCfg.dmPolicy; + + if (dmPolicy === "disabled") { + logVerbose(`Blocked feishu DM (dmPolicy: disabled)`); + return; + } + + if (dmPolicy !== "open") { + const dmAllow = normalizeAllowFromWithStore({ + allowFrom: feishuCfg.allowFrom, + storeAllowFrom, + }); + const allowMatch = resolveSenderAllowMatch({ allow: dmAllow, senderId }); + const allowed = dmAllow.hasWildcard || (dmAllow.hasEntries && allowMatch.allowed); + + if (!allowed) { + if (dmPolicy === "pairing") { + // Generate pairing code for unknown sender + try { + const { code, created } = await upsertFeishuPairingRequest({ + openId: senderId, + unionId: senderUnionId, + name: sender?.sender_id?.user_id, + }); + if (created) { + logger.info({ openId: senderId, unionId: senderUnionId }, "feishu pairing request"); + await sendMessageFeishu( + client, + senderId, + { + text: [ + "OpenClaw access not configured.", + "", + `Your Feishu Open ID: ${senderId}`, + "", + `Pairing code: ${code}`, + "", + "Ask the OpenClaw admin to approve with:", + `openclaw pairing approve feishu ${code}`, + ].join("\n"), + }, + { receiveIdType: "open_id" }, + ); + } + } catch (err) { + logger.error(`Failed to create pairing request: ${formatErrorMessage(err)}`); + } + return; + } + + // allowlist policy: silently block + logVerbose(`Blocked feishu DM from ${senderId} (dmPolicy: allowlist)`); + return; + } + } + } + + // Handle @mentions for group chats + const mentions = message.mentions ?? payload.mentions ?? []; + const wasMentioned = mentions.length > 0; + + // In group chat, check requireMention setting + if (isGroup) { + const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId }); + const requireMention = groupConfig?.requireMention ?? true; + if (requireMention && !wasMentioned) { + logger.debug(`Ignoring group message without @mention (requireMention: true)`); + return; + } + } + + // Extract text content (for text messages or captions) + let text = ""; + if (msgType === "text") { + try { + if (message.content) { + const content = JSON.parse(message.content); + text = content.text || ""; + } + } catch (err) { + logger.error(`Failed to parse text message content: ${formatErrorMessage(err)}`); + } + } + + // Remove @mention placeholders from text + for (const mention of mentions) { + if (mention.key) { + text = text.replace(mention.key, "").trim(); + } + } + + // Resolve media if present + let media: FeishuMediaRef | null = null; + if (msgType !== "text") { + try { + media = await resolveFeishuMedia(client, message, maxMediaBytes); + } catch (err) { + logger.error(`Failed to download media: ${formatErrorMessage(err)}`); + } + } + + // Build body text + let bodyText = text; + if (!bodyText && media) { + bodyText = media.placeholder; + } + + // Skip if no content + if (!bodyText && !media) { + logger.debug(`Empty message after processing, skipping`); + return; + } + + const senderName = sender?.sender_id?.user_id || "unknown"; + + // Streaming mode support + const streamingEnabled = (feishuCfg.streaming ?? true) && Boolean(options.credentials); + const streamingSession = + streamingEnabled && options.credentials + ? new FeishuStreamingSession(client, options.credentials) + : null; + let streamingStarted = false; + let lastPartialText = ""; + + // Context construction + const ctx = { + Body: bodyText, + RawBody: text || media?.placeholder || "", + From: senderId, + To: chatId, + SenderId: senderId, + SenderName: senderName, + ChatType: isGroup ? "group" : "dm", + Provider: "feishu", + Surface: "feishu", + Timestamp: Number(message.create_time), + MessageSid: message.message_id, + AccountId: accountId, + OriginatingChannel: "feishu", + OriginatingTo: chatId, + // Media fields (similar to Telegram) + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + WasMentioned: isGroup ? wasMentioned : undefined, + }; + + await dispatchReplyWithBufferedBlockDispatcher({ + ctx, + cfg, + dispatcherOptions: { + deliver: async (payload, info) => { + const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0); + if (!payload.text && !hasMedia) { + return; + } + + // Handle block replies - update streaming card with partial text + if (streamingSession?.isActive() && info?.kind === "block" && payload.text) { + logger.debug(`Updating streaming card with block text: ${payload.text.length} chars`); + await streamingSession.update(payload.text); + return; + } + + // If streaming was active, close it with the final text + if (streamingSession?.isActive() && info?.kind === "final") { + await streamingSession.close(payload.text); + streamingStarted = false; + return; // Card already contains the final text + } + + // Handle media URLs + const mediaUrls = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + if (mediaUrls.length > 0) { + // Close streaming session before sending media + if (streamingSession?.isActive()) { + await streamingSession.close(); + streamingStarted = false; + } + // Send each media item + for (let i = 0; i < mediaUrls.length; i++) { + const mediaUrl = mediaUrls[i]; + const caption = i === 0 ? payload.text || "" : ""; + await sendMessageFeishu( + client, + chatId, + { text: caption }, + { + mediaUrl, + receiveIdType: "chat_id", + }, + ); + } + } else if (payload.text) { + // If streaming wasn't used, send as regular message + if (!streamingSession?.isActive()) { + await sendMessageFeishu( + client, + chatId, + { text: payload.text }, + { + msgType: "text", + receiveIdType: "chat_id", + }, + ); + } + } + }, + onError: (err) => { + logger.error(`Reply error: ${formatErrorMessage(err)}`); + // Clean up streaming session on error + if (streamingSession?.isActive()) { + streamingSession.close().catch(() => {}); + } + }, + onReplyStart: async () => { + // Start streaming card when reply generation begins + if (streamingSession && !streamingStarted) { + try { + await streamingSession.start(chatId, "chat_id", options.botName); + streamingStarted = true; + logger.debug(`Started streaming card for chat ${chatId}`); + } catch (err) { + logger.warn(`Failed to start streaming card: ${formatErrorMessage(err)}`); + // Continue without streaming + } + } + }, + }, + replyOptions: { + disableBlockStreaming: !feishuCfg.blockStreaming, + onPartialReply: streamingSession + ? async (payload) => { + if (!streamingSession.isActive() || !payload.text) { + return; + } + if (payload.text === lastPartialText) { + return; + } + lastPartialText = payload.text; + await streamingSession.update(payload.text); + } + : undefined, + onReasoningStream: streamingSession + ? async (payload) => { + // Also update on reasoning stream for extended thinking models + if (!streamingSession.isActive() || !payload.text) { + return; + } + if (payload.text === lastPartialText) { + return; + } + lastPartialText = payload.text; + await streamingSession.update(payload.text); + } + : undefined, + }, + }); + + // Ensure streaming session is closed on completion + if (streamingSession?.isActive()) { + await streamingSession.close(); + } +} diff --git a/src/feishu/pairing-store.ts b/src/feishu/pairing-store.ts new file mode 100644 index 000000000..44f9015de --- /dev/null +++ b/src/feishu/pairing-store.ts @@ -0,0 +1,129 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + addChannelAllowFromStoreEntry, + approveChannelPairingCode, + listChannelPairingRequests, + readChannelAllowFromStore, + upsertChannelPairingRequest, +} from "../pairing/pairing-store.js"; + +export type FeishuPairingListEntry = { + openId: string; + unionId?: string; + name?: string; + code: string; + createdAt: string; + lastSeenAt: string; +}; + +const PROVIDER = "feishu" as const; + +export async function readFeishuAllowFromStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + return readChannelAllowFromStore(PROVIDER, env); +} + +export async function addFeishuAllowFromStoreEntry(params: { + entry: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + return addChannelAllowFromStoreEntry({ + channel: PROVIDER, + entry: params.entry, + env: params.env, + }); +} + +export async function listFeishuPairingRequests( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const list = await listChannelPairingRequests(PROVIDER, env); + return list.map((r) => ({ + openId: r.id, + code: r.code, + createdAt: r.createdAt, + lastSeenAt: r.lastSeenAt, + unionId: r.meta?.unionId, + name: r.meta?.name, + })); +} + +export async function upsertFeishuPairingRequest(params: { + openId: string; + unionId?: string; + name?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + return upsertChannelPairingRequest({ + channel: PROVIDER, + id: params.openId, + env: params.env, + meta: { + unionId: params.unionId, + name: params.name, + }, + }); +} + +export async function approveFeishuPairingCode(params: { + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ openId: string; entry?: FeishuPairingListEntry } | null> { + const res = await approveChannelPairingCode({ + channel: PROVIDER, + code: params.code, + env: params.env, + }); + if (!res) { + return null; + } + const entry = res.entry + ? { + openId: res.entry.id, + code: res.entry.code, + createdAt: res.entry.createdAt, + lastSeenAt: res.entry.lastSeenAt, + unionId: res.entry.meta?.unionId, + name: res.entry.meta?.name, + } + : undefined; + return { openId: res.id, entry }; +} + +export async function resolveFeishuEffectiveAllowFrom(params: { + cfg: OpenClawConfig; + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ dm: string[]; group: string[] }> { + const env = params.env ?? process.env; + const feishuCfg = params.cfg.channels?.feishu; + const accountCfg = params.accountId ? feishuCfg?.accounts?.[params.accountId] : undefined; + + // Account-level config takes precedence over top-level + const allowFrom = accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []; + const groupAllowFrom = accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []; + + const cfgAllowFrom = allowFrom + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^feishu:/i, "")) + .filter((v) => v !== "*"); + + const cfgGroupAllowFrom = groupAllowFrom + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^feishu:/i, "")) + .filter((v) => v !== "*"); + + const storeAllowFrom = await readFeishuAllowFromStore(env); + + const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom])); + const group = Array.from( + new Set([ + ...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom), + ...storeAllowFrom, + ]), + ); + return { dm, group }; +} diff --git a/src/feishu/probe.ts b/src/feishu/probe.ts new file mode 100644 index 000000000..bfe33eab2 --- /dev/null +++ b/src/feishu/probe.ts @@ -0,0 +1,122 @@ +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { resolveFeishuApiBase } from "./domain.js"; + +const logger = getChildLogger({ module: "feishu-probe" }); + +export type FeishuProbe = { + ok: boolean; + error?: string | null; + elapsedMs: number; + bot?: { + appId?: string | null; + appName?: string | null; + avatarUrl?: string | null; + }; +}; + +type TokenResponse = { + code: number; + msg: string; + tenant_access_token?: string; + expire?: number; +}; + +type BotInfoResponse = { + code: number; + msg: string; + bot?: { + app_name?: string; + avatar_url?: string; + open_id?: string; + }; +}; + +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +export async function probeFeishu( + appId: string, + appSecret: string, + timeoutMs: number = 5000, + domain?: string, +): Promise { + const started = Date.now(); + + const result: FeishuProbe = { + ok: false, + error: null, + elapsedMs: 0, + }; + + const apiBase = resolveFeishuApiBase(domain); + + try { + // Step 1: Get tenant_access_token + const tokenRes = await fetchWithTimeout( + `${apiBase}/auth/v3/tenant_access_token/internal`, + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + }, + timeoutMs, + ); + + const tokenJson = (await tokenRes.json()) as TokenResponse; + if (tokenJson.code !== 0 || !tokenJson.tenant_access_token) { + result.error = tokenJson.msg || `Failed to get access token: code ${tokenJson.code}`; + result.elapsedMs = Date.now() - started; + return result; + } + + const accessToken = tokenJson.tenant_access_token; + + // Step 2: Get bot info + const botRes = await fetchWithTimeout( + `${apiBase}/bot/v3/info`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + timeoutMs, + ); + + const botJson = (await botRes.json()) as BotInfoResponse; + if (botJson.code !== 0) { + result.error = botJson.msg || `Failed to get bot info: code ${botJson.code}`; + result.elapsedMs = Date.now() - started; + return result; + } + + result.ok = true; + result.bot = { + appId: appId, + appName: botJson.bot?.app_name ?? null, + avatarUrl: botJson.bot?.avatar_url ?? null, + }; + result.elapsedMs = Date.now() - started; + return result; + } catch (err) { + const errMsg = formatErrorMessage(err); + logger.debug?.(`Feishu probe failed: ${errMsg}`); + return { + ...result, + error: errMsg, + elapsedMs: Date.now() - started, + }; + } +} diff --git a/src/feishu/send.ts b/src/feishu/send.ts new file mode 100644 index 000000000..977e2a107 --- /dev/null +++ b/src/feishu/send.ts @@ -0,0 +1,319 @@ +import type { Client } from "@larksuiteoapi/node-sdk"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getChildLogger } from "../logging.js"; +import { mediaKindFromMime } from "../media/constants.js"; +import { loadWebMedia } from "../web/media.js"; +import { containsMarkdown, markdownToFeishuPost } from "./format.js"; + +const logger = getChildLogger({ module: "feishu-send" }); + +export type FeishuMsgType = "text" | "image" | "file" | "audio" | "media" | "post" | "interactive"; + +export type FeishuSendOpts = { + msgType?: FeishuMsgType; + receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id"; + /** URL of media to upload and send (for image/file/audio/media types) */ + mediaUrl?: string; + /** Max bytes for media download */ + maxBytes?: number; + /** Whether to auto-convert Markdown to rich text (post). Default: true */ + autoRichText?: boolean; +}; + +export type FeishuSendResult = { + message_id?: string; +}; + +type FeishuMessageContent = ({ text?: string } & Record) | string; + +/** + * Upload an image to Feishu and get image_key + */ +export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise { + const res = await client.im.image.create({ + data: { + image_type: "message", + image: imageBuffer, + }, + }); + + if (!res?.image_key) { + throw new Error(`Feishu image upload failed: no image_key returned`); + } + return res.image_key; +} + +/** + * Upload a file to Feishu and get file_key + * @param fileType - opus (audio), mp4 (video), pdf, doc, xls, ppt, stream (other) + */ +export async function uploadFileFeishu( + client: Client, + fileBuffer: Buffer, + fileName: string, + fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream", + duration?: number, +): Promise { + logger.info( + `Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`, + ); + + let res: Awaited>; + try { + res = await client.im.file.create({ + data: { + file_type: fileType, + file_name: fileName, + file: fileBuffer, + ...(duration ? { duration } : {}), + }, + }); + } catch (err) { + const errMsg = formatErrorMessage(err); + // Log the full error details + logger.error(`Feishu file upload exception: ${errMsg}`); + if (err && typeof err === "object") { + const response = (err as { response?: { data?: unknown; status?: number } }).response; + if (response?.data) { + logger.error(`Response data: ${JSON.stringify(response.data)}`); + } + if (response?.status) { + logger.error(`Response status: ${response.status}`); + } + } + throw new Error(`Feishu file upload failed: ${errMsg}`, { cause: err }); + } + + // Log full response for debugging + logger.info(`Feishu file upload response: ${JSON.stringify(res)}`); + + const responseMeta = + res && typeof res === "object" ? (res as { code?: number; msg?: string }) : {}; + // Check for API error code (if provided by SDK) + if (typeof responseMeta.code === "number" && responseMeta.code !== 0) { + const code = responseMeta.code; + const msg = responseMeta.msg || "unknown error"; + logger.error(`Feishu file upload API error: code=${code}, msg=${msg}`); + throw new Error(`Feishu file upload failed: ${msg} (code: ${code})`); + } + + const fileKey = res?.file_key; + if (!fileKey) { + logger.error(`Feishu file upload failed - no file_key in response: ${JSON.stringify(res)}`); + throw new Error(`Feishu file upload failed: no file_key returned`); + } + + logger.info(`Feishu file upload successful: file_key=${fileKey}`); + return fileKey; +} + +/** + * Determine Feishu file_type from content type + */ +function resolveFeishuFileType( + contentType?: string, + fileName?: string, +): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" { + const ct = contentType?.toLowerCase() ?? ""; + const fn = fileName?.toLowerCase() ?? ""; + + // Audio - Feishu only supports opus for audio messages + if (ct.includes("audio/") || fn.endsWith(".opus") || fn.endsWith(".ogg")) { + return "opus"; + } + // Video + if (ct.includes("video/") || fn.endsWith(".mp4") || fn.endsWith(".mov")) { + return "mp4"; + } + // Documents + if (ct.includes("pdf") || fn.endsWith(".pdf")) { + return "pdf"; + } + if ( + ct.includes("msword") || + ct.includes("wordprocessingml") || + fn.endsWith(".doc") || + fn.endsWith(".docx") + ) { + return "doc"; + } + if ( + ct.includes("excel") || + ct.includes("spreadsheetml") || + fn.endsWith(".xls") || + fn.endsWith(".xlsx") + ) { + return "xls"; + } + if ( + ct.includes("powerpoint") || + ct.includes("presentationml") || + fn.endsWith(".ppt") || + fn.endsWith(".pptx") + ) { + return "ppt"; + } + + return "stream"; +} + +/** + * Send a message to Feishu + */ +export async function sendMessageFeishu( + client: Client, + receiveId: string, + content: FeishuMessageContent, + opts: FeishuSendOpts = {}, +): Promise { + const receiveIdType = opts.receiveIdType || "chat_id"; + let msgType = opts.msgType || "text"; + let finalContent = content; + const contentText = + typeof content === "object" && content !== null && "text" in content + ? (content as { text?: string }).text + : undefined; + + // Handle media URL - upload first, then send + if (opts.mediaUrl) { + try { + logger.info(`Loading media from: ${opts.mediaUrl}`); + const media = await loadWebMedia(opts.mediaUrl, opts.maxBytes); + const kind = mediaKindFromMime(media.contentType ?? undefined); + const fileName = media.fileName ?? "file"; + logger.info( + `Media loaded: kind=${kind}, contentType=${media.contentType}, fileName=${fileName}, size=${media.buffer.length}`, + ); + + if (kind === "image") { + // Upload image and send as image message + const imageKey = await uploadImageFeishu(client, media.buffer); + msgType = "image"; + finalContent = { image_key: imageKey }; + } else if (kind === "video") { + // Upload video file and send as media message + const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "mp4"); + msgType = "media"; + finalContent = { file_key: fileKey }; + } else if (kind === "audio") { + // Feishu audio messages (msg_type: "audio") only support opus format + // For other audio formats (mp3, wav, etc.), send as file instead + const isOpus = + media.contentType?.includes("opus") || + media.contentType?.includes("ogg") || + fileName.toLowerCase().endsWith(".opus") || + fileName.toLowerCase().endsWith(".ogg"); + + if (isOpus) { + logger.info(`Uploading opus audio: ${fileName}`); + const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "opus"); + logger.info(`Opus upload successful, file_key: ${fileKey}`); + msgType = "audio"; + finalContent = { file_key: fileKey }; + } else { + // Send non-opus audio as file attachment + logger.info(`Uploading non-opus audio as file: ${fileName}`); + const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "stream"); + logger.info(`File upload successful, file_key: ${fileKey}`); + msgType = "file"; + finalContent = { file_key: fileKey }; + } + } else { + // Upload as file + const fileType = resolveFeishuFileType(media.contentType, fileName); + const fileKey = await uploadFileFeishu(client, media.buffer, fileName, fileType); + msgType = "file"; + finalContent = { file_key: fileKey }; + } + + // If there's text alongside media, we need to send two messages + // First send the media, then send text as a follow-up + if (typeof contentText === "string" && contentText.trim()) { + // Send media first + const mediaRes = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: msgType, + content: JSON.stringify(finalContent), + }, + }); + + if (mediaRes.code !== 0) { + logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`); + throw new Error(`Feishu API Error: ${mediaRes.msg}`); + } + + // Then send text + const textRes = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: "text", + content: JSON.stringify({ text: contentText }), + }, + }); + + return textRes.data ?? null; + } + } catch (err) { + const errMsg = formatErrorMessage(err); + const errStack = err instanceof Error ? err.stack : undefined; + logger.error(`Feishu media upload/send error: ${errMsg}`); + if (errStack) { + logger.error(`Stack: ${errStack}`); + } + // Re-throw the error instead of falling back to text + // This makes debugging easier and prevents silent failures + throw new Error(`Feishu media upload failed: ${errMsg}`, { cause: err }); + } + } + + // Auto-convert Markdown to rich text if enabled and content is text with Markdown + const autoRichText = opts.autoRichText !== false; + const finalText = + typeof finalContent === "object" && finalContent !== null && "text" in finalContent + ? (finalContent as { text?: string }).text + : undefined; + + if ( + autoRichText && + msgType === "text" && + typeof finalText === "string" && + containsMarkdown(finalText) + ) { + try { + const postContent = markdownToFeishuPost(finalText); + msgType = "post"; + finalContent = postContent; + logger.debug(`Converted Markdown to Feishu post format`); + } catch (err) { + logger.warn( + `Failed to convert Markdown to post, falling back to text: ${formatErrorMessage(err)}`, + ); + // Fall back to plain text + } + } + + const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent); + + try { + const res = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: msgType, + content: contentStr, + }, + }); + + if (res.code !== 0) { + logger.error(`Feishu send failed: ${res.code} - ${res.msg}`); + throw new Error(`Feishu API Error: ${res.msg}`); + } + return res.data ?? null; + } catch (err) { + logger.error(`Feishu send error: ${formatErrorMessage(err)}`); + throw err; + } +} diff --git a/src/feishu/streaming-card.ts b/src/feishu/streaming-card.ts new file mode 100644 index 000000000..ecc1c9fa3 --- /dev/null +++ b/src/feishu/streaming-card.ts @@ -0,0 +1,404 @@ +/** + * Feishu Streaming Card Support + * + * Implements typing indicator and streaming text output for Feishu using + * the Card Kit streaming API. + * + * Flow: + * 1. Create a card entity with streaming_mode: true + * 2. Send the card as a message (shows "[Generating...]" in chat preview) + * 3. Stream text updates to the card using the cardkit API + * 4. Close streaming mode when done + */ + +import type { Client } from "@larksuiteoapi/node-sdk"; +import { getChildLogger } from "../logging.js"; +import { resolveFeishuApiBase, resolveFeishuDomain } from "./domain.js"; + +const logger = getChildLogger({ module: "feishu-streaming" }); + +export type FeishuStreamingCredentials = { + appId: string; + appSecret: string; + domain?: string; +}; + +export type FeishuStreamingCardState = { + cardId: string; + messageId: string; + sequence: number; + elementId: string; + currentText: string; +}; + +// Token cache (keyed by domain + appId) +const tokenCache = new Map(); + +const getTokenCacheKey = (credentials: FeishuStreamingCredentials) => + `${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`; + +/** + * Get tenant access token (with caching) + */ +async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise { + const cacheKey = getTokenCacheKey(credentials); + const cached = tokenCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() + 60000) { + return cached.token; + } + + const apiBase = resolveFeishuApiBase(credentials.domain); + const response = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_id: credentials.appId, + app_secret: credentials.appSecret, + }), + }); + + const result = (await response.json()) as { + code: number; + msg: string; + tenant_access_token?: string; + expire?: number; + }; + + if (result.code !== 0 || !result.tenant_access_token) { + throw new Error(`Failed to get tenant access token: ${result.msg}`); + } + + // Cache token (expire 2 hours, we refresh 1 minute early) + tokenCache.set(cacheKey, { + token: result.tenant_access_token, + expiresAt: Date.now() + (result.expire ?? 7200) * 1000, + }); + + return result.tenant_access_token; +} + +/** + * Create a streaming card entity + */ +export async function createStreamingCard( + credentials: FeishuStreamingCredentials, + title?: string, +): Promise<{ cardId: string }> { + const cardJson = { + schema: "2.0", + ...(title + ? { + header: { + title: { + content: title, + tag: "plain_text", + }, + }, + } + : {}), + config: { + streaming_mode: true, + summary: { + content: "[Generating...]", + }, + streaming_config: { + print_frequency_ms: { default: 50 }, + print_step: { default: 2 }, + print_strategy: "fast", + }, + }, + body: { + elements: [ + { + tag: "markdown", + content: "⏳ Thinking...", + element_id: "streaming_content", + }, + ], + }, + }; + + const apiBase = resolveFeishuApiBase(credentials.domain); + const response = await fetch(`${apiBase}/cardkit/v1/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "card_json", + data: JSON.stringify(cardJson), + }), + }); + + const result = (await response.json()) as { + code: number; + msg: string; + data?: { card_id: string }; + }; + + if (result.code !== 0 || !result.data?.card_id) { + throw new Error(`Failed to create streaming card: ${result.msg}`); + } + + logger.debug(`Created streaming card: ${result.data.card_id}`); + return { cardId: result.data.card_id }; +} + +/** + * Send a streaming card as a message + */ +export async function sendStreamingCard( + client: Client, + receiveId: string, + cardId: string, + receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", +): Promise<{ messageId: string }> { + const content = JSON.stringify({ + type: "card", + data: { card_id: cardId }, + }); + + const res = await client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: "interactive", + content, + }, + }); + + if (res.code !== 0 || !res.data?.message_id) { + throw new Error(`Failed to send streaming card: ${res.msg}`); + } + + logger.debug(`Sent streaming card message: ${res.data.message_id}`); + return { messageId: res.data.message_id }; +} + +/** + * Update streaming card text content + */ +export async function updateStreamingCardText( + credentials: FeishuStreamingCredentials, + cardId: string, + elementId: string, + text: string, + sequence: number, +): Promise { + const apiBase = resolveFeishuApiBase(credentials.domain); + const response = await fetch( + `${apiBase}/cardkit/v1/cards/${cardId}/elements/${elementId}/content`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: text, + sequence, + uuid: `stream_${cardId}_${sequence}`, + }), + }, + ); + + const result = (await response.json()) as { code: number; msg: string }; + + if (result.code !== 0) { + logger.warn(`Failed to update streaming card text: ${result.msg}`); + // Don't throw - streaming updates can fail occasionally + } +} + +/** + * Close streaming mode on a card + */ +export async function closeStreamingMode( + credentials: FeishuStreamingCredentials, + cardId: string, + sequence: number, + finalSummary?: string, +): Promise { + // Build config object - summary must be set to clear "[Generating...]" + const configObj: Record = { + streaming_mode: false, + summary: { content: finalSummary || "" }, + }; + + const settings = { config: configObj }; + + const apiBase = resolveFeishuApiBase(credentials.domain); + const response = await fetch(`${apiBase}/cardkit/v1/cards/${cardId}/settings`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${await getTenantAccessToken(credentials)}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + settings: JSON.stringify(settings), + sequence, + uuid: `close_${cardId}_${sequence}`, + }), + }); + + // Check response + const result = (await response.json()) as { code: number; msg: string }; + + if (result.code !== 0) { + logger.warn(`Failed to close streaming mode: ${result.msg}`); + } else { + logger.debug(`Closed streaming mode for card: ${cardId}`); + } +} + +/** + * High-level streaming card manager + */ +export class FeishuStreamingSession { + private client: Client; + private credentials: FeishuStreamingCredentials; + private state: FeishuStreamingCardState | null = null; + private updateQueue: Promise = Promise.resolve(); + private closed = false; + + constructor(client: Client, credentials: FeishuStreamingCredentials) { + this.client = client; + this.credentials = credentials; + } + + /** + * Start a streaming session - creates and sends a streaming card + */ + async start( + receiveId: string, + receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", + title?: string, + ): Promise { + if (this.state) { + logger.warn("Streaming session already started"); + return; + } + + try { + const { cardId } = await createStreamingCard(this.credentials, title); + const { messageId } = await sendStreamingCard(this.client, receiveId, cardId, receiveIdType); + + this.state = { + cardId, + messageId, + sequence: 1, + elementId: "streaming_content", + currentText: "", + }; + + logger.info(`Started streaming session: cardId=${cardId}, messageId=${messageId}`); + } catch (err) { + logger.error(`Failed to start streaming session: ${String(err)}`); + throw err; + } + } + + /** + * Update the streaming card with new text (appends to existing) + */ + async update(text: string): Promise { + if (!this.state || this.closed) { + return; + } + + // Queue updates to ensure order + this.updateQueue = this.updateQueue.then(async () => { + if (!this.state || this.closed) { + return; + } + + this.state.currentText = text; + this.state.sequence += 1; + + try { + await updateStreamingCardText( + this.credentials, + this.state.cardId, + this.state.elementId, + text, + this.state.sequence, + ); + } catch (err) { + logger.debug(`Streaming update failed (will retry): ${String(err)}`); + } + }); + + await this.updateQueue; + } + + /** + * Finalize and close the streaming session + */ + async close(finalText?: string, summary?: string): Promise { + if (!this.state || this.closed) { + return; + } + this.closed = true; + + // Wait for pending updates + await this.updateQueue; + + const text = finalText ?? this.state.currentText; + this.state.sequence += 1; + + try { + // Update final text + if (text) { + await updateStreamingCardText( + this.credentials, + this.state.cardId, + this.state.elementId, + text, + this.state.sequence, + ); + } + + // Close streaming mode + this.state.sequence += 1; + await closeStreamingMode( + this.credentials, + this.state.cardId, + this.state.sequence, + summary ?? truncateForSummary(text), + ); + + logger.info(`Closed streaming session: cardId=${this.state.cardId}`); + } catch (err) { + logger.error(`Failed to close streaming session: ${String(err)}`); + } + } + + /** + * Check if session is active + */ + isActive(): boolean { + return this.state !== null && !this.closed; + } + + /** + * Get the message ID of the streaming card + */ + getMessageId(): string | null { + return this.state?.messageId ?? null; + } +} + +/** + * Truncate text to create a summary for chat preview + */ +function truncateForSummary(text: string, maxLength: number = 50): string { + if (!text) { + return ""; + } + const cleaned = text.replace(/\n/g, " ").trim(); + if (cleaned.length <= maxLength) { + return cleaned; + } + return cleaned.slice(0, maxLength - 3) + "..."; +} diff --git a/src/feishu/types.ts b/src/feishu/types.ts new file mode 100644 index 000000000..32eef7544 --- /dev/null +++ b/src/feishu/types.ts @@ -0,0 +1,14 @@ +import type { FeishuAccountConfig, FeishuConfig } from "../config/types.feishu.js"; + +export type { FeishuConfig, FeishuAccountConfig }; + +export type FeishuContext = { + appId: string; + chatId?: string; + openId?: string; + userId?: string; + messageId?: string; + messageType?: string; + text?: string; + raw?: unknown; +}; diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c31b18aca..c1538b842 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -816,6 +816,61 @@ function resolveTlonSession( }; } +/** + * Feishu ID formats: + * - oc_xxx: chat_id (group chat) + * - ou_xxx: user open_id (DM) + * - on_xxx: user union_id (DM) + * - cli_xxx: app_id (not a valid send target) + */ +function resolveFeishuSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = stripProviderPrefix(params.target, "feishu"); + trimmed = stripProviderPrefix(trimmed, "lark").trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + let isGroup = false; + + if (lower.startsWith("group:") || lower.startsWith("chat:")) { + trimmed = trimmed.replace(/^(group|chat):/i, "").trim(); + isGroup = true; + } else if (lower.startsWith("user:") || lower.startsWith("dm:")) { + trimmed = trimmed.replace(/^(user|dm):/i, "").trim(); + isGroup = false; + } + + const idLower = trimmed.toLowerCase(); + if (idLower.startsWith("oc_")) { + isGroup = true; + } else if (idLower.startsWith("ou_") || idLower.startsWith("on_")) { + isGroup = false; + } + + const peer: RoutePeer = { + kind: isGroup ? "group" : "dm", + id: trimmed, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "feishu", + accountId: params.accountId, + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `feishu:group:${trimmed}` : `feishu:${trimmed}`, + to: trimmed, + }; +} + function resolveFallbackSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { @@ -890,6 +945,8 @@ export async function resolveOutboundSessionRoute( return resolveNostrSession({ ...params, target }); case "tlon": return resolveTlonSession({ ...params, target }); + case "feishu": + return resolveFeishuSession({ ...params, target }); default: return resolveFallbackSession({ ...params, target }); } diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5eb5cbfbe..e010e3749 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -369,5 +369,22 @@ export { } from "../line/markdown-to-line.js"; export type { ProcessedLineMessage } from "../line/markdown-to-line.js"; +// Channel: Feishu +export { + listFeishuAccountIds, + resolveDefaultFeishuAccountId, + resolveFeishuAccount, + type ResolvedFeishuAccount, +} from "../feishu/accounts.js"; +export { + resolveFeishuConfig, + resolveFeishuGroupEnabled, + resolveFeishuGroupRequireMention, +} from "../feishu/config.js"; +export { feishuOutbound } from "../channels/plugins/outbound/feishu.js"; +export { normalizeFeishuTarget } from "../channels/plugins/normalize/feishu.js"; +export { probeFeishu, type FeishuProbe } from "../feishu/probe.js"; +export { monitorFeishuProvider } from "../feishu/monitor.js"; + // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js"; diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index d72534095..5d5a05c8b 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -120,7 +120,7 @@ vi.mock("../auto-reply/reply.js", () => { describe("telegram inbound media", () => { // Parallel vitest shards can make this suite slower than the standalone run. - const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000; + const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000; it( "downloads media via file_path (no file.download)",