mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 13:01:42 +03:00
feat(feishu): replace built-in SDK with community plugin
Replace the built-in Feishu SDK with the community-maintained clawdbot-feishu plugin by @m1heng. Changes: - Remove src/feishu/ directory (19 files) - Remove src/channels/plugins/outbound/feishu.ts - Remove src/channels/plugins/normalize/feishu.ts - Remove src/config/types.feishu.ts - Remove feishu exports from plugin-sdk/index.ts - Remove FeishuConfig from types.channels.ts New features in community plugin: - Document tools (read/create/edit Feishu docs) - Wiki tools (navigate/manage knowledge base) - Drive tools (folder/file management) - Bitable tools (read/write table records) - Permission tools (collaborator management) - Emoji reactions support - Typing indicators - Rich media support (bidirectional image/file transfer) - @mention handling - Skills for feishu-doc, feishu-wiki, feishu-drive, feishu-perm Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
|
||||
import { getFeishuClient } from "../../../feishu/client.js";
|
||||
import { sendMessageFeishu } from "../../../feishu/send.js";
|
||||
|
||||
function resolveReceiveIdType(target: string): "open_id" | "union_id" | "chat_id" {
|
||||
const trimmed = target.trim().toLowerCase();
|
||||
if (trimmed.startsWith("ou_")) {
|
||||
return "open_id";
|
||||
}
|
||||
if (trimmed.startsWith("on_")) {
|
||||
return "union_id";
|
||||
}
|
||||
return "chat_id";
|
||||
}
|
||||
|
||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
const client = getFeishuClient(accountId ?? undefined);
|
||||
const result = await sendMessageFeishu(
|
||||
client,
|
||||
to,
|
||||
{ text },
|
||||
{
|
||||
receiveIdType: resolveReceiveIdType(to),
|
||||
},
|
||||
);
|
||||
return {
|
||||
channel: "feishu",
|
||||
messageId: result?.message_id || "unknown",
|
||||
chatId: to,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||
const client = getFeishuClient(accountId ?? undefined);
|
||||
const result = await sendMessageFeishu(
|
||||
client,
|
||||
to,
|
||||
{ text: text || "" },
|
||||
{ mediaUrl, receiveIdType: resolveReceiveIdType(to) },
|
||||
);
|
||||
return {
|
||||
channel: "feishu",
|
||||
messageId: result?.message_id || "unknown",
|
||||
chatId: to,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
@@ -29,7 +28,6 @@ export type ChannelsConfig = {
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
feishu?: FeishuConfig;
|
||||
googlechat?: GoogleChatConfig;
|
||||
slack?: SlackConfig;
|
||||
signal?: SignalConfig;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { DmPolicy, GroupPolicy, MarkdownConfig, OutboundRetryConfig } from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type FeishuGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this group. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this group. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this group. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for group senders (open_ids). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this group. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type FeishuAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Feishu app ID (cli_xxx). */
|
||||
appId?: string;
|
||||
/** Feishu app secret. */
|
||||
appSecret?: string;
|
||||
/** Path to file containing app secret (for secret managers). */
|
||||
appSecretFile?: string;
|
||||
/** API domain override: "feishu" (default), "lark" (global), or full https:// domain. */
|
||||
domain?: string;
|
||||
/** Bot display name (used for streaming card title). */
|
||||
botName?: string;
|
||||
/** If false, do not start this Feishu account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Feishu (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/**
|
||||
* Controls how Feishu direct chats (DMs) are handled:
|
||||
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
||||
* - "allowlist": only allow senders in allowFrom (or paired allow store)
|
||||
* - "open": allow all inbound DMs (requires allowFrom to include "*")
|
||||
* - "disabled": ignore all inbound DMs
|
||||
*/
|
||||
dmPolicy?: DmPolicy;
|
||||
/**
|
||||
* Controls how group messages are handled:
|
||||
* - "open": groups bypass allowFrom, only mention-gating applies
|
||||
* - "disabled": block all group messages entirely
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Allowlist for DM senders (open_id or union_id). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional allowlist for Feishu group senders. */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
dmHistoryLimit?: number;
|
||||
/** Per-DM config overrides keyed by user open_id. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Per-group config keyed by chat_id (oc_xxx). */
|
||||
groups?: Record<string, FeishuGroupConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 2000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/**
|
||||
* Enable streaming card mode for replies (shows typing indicator).
|
||||
* When true, replies are streamed via Feishu's CardKit API with typewriter effect.
|
||||
* Default: true.
|
||||
*/
|
||||
streaming?: boolean;
|
||||
/** Media max size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Retry policy for outbound Feishu API calls. */
|
||||
retry?: OutboundRetryConfig;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type FeishuConfig = {
|
||||
/** Optional per-account Feishu configuration (multi-account). */
|
||||
accounts?: Record<string, FeishuAccountConfig>;
|
||||
/** Top-level app ID (alternative to accounts). */
|
||||
appId?: string;
|
||||
/** Top-level app secret (alternative to accounts). */
|
||||
appSecret?: string;
|
||||
/** Top-level app secret file (alternative to accounts). */
|
||||
appSecretFile?: string;
|
||||
} & Omit<FeishuAccountConfig, "appId" | "appSecret" | "appSecretFile">;
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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<string | number>): 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<string | number>;
|
||||
storeAllowFrom?: string[];
|
||||
}): NormalizedAllowFrom => {
|
||||
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean);
|
||||
return normalizeAllowFrom(combined);
|
||||
};
|
||||
|
||||
export const firstDefined = <T>(...values: Array<T | undefined>) => {
|
||||
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 };
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
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<FeishuTokenSource, "env" | "none">;
|
||||
} {
|
||||
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<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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<typeof createFeishuBot>) {
|
||||
logger.info("Starting Feishu bot WS client...");
|
||||
await bot.wsClient.start({
|
||||
eventDispatcher: bot.eventDispatcher,
|
||||
});
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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.<id>.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;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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<string, FeishuGroupConfig>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractDocRefsFromText, extractDocRefsFromPost } from "./docs.js";
|
||||
|
||||
describe("extractDocRefsFromText", () => {
|
||||
it("should extract docx URL", () => {
|
||||
const text = "Check this document https://example.feishu.cn/docx/B4EPdAYx8oi8HRxgPQQb";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docToken).toBe("B4EPdAYx8oi8HRxgPQQb");
|
||||
expect(refs[0].docType).toBe("docx");
|
||||
});
|
||||
|
||||
it("should extract wiki URL", () => {
|
||||
const text = "Wiki link: https://company.feishu.cn/wiki/WikiTokenExample123";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("wiki");
|
||||
expect(refs[0].docToken).toBe("WikiTokenExample123");
|
||||
});
|
||||
|
||||
it("should extract sheet URL", () => {
|
||||
const text = "Sheet URL https://open.larksuite.com/sheets/SheetToken1234567890";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("sheet");
|
||||
});
|
||||
|
||||
it("should extract bitable/base URL", () => {
|
||||
const text = "Bitable https://abc.feishu.cn/base/BitableToken1234567890";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("bitable");
|
||||
});
|
||||
|
||||
it("should extract multiple URLs", () => {
|
||||
const text = `
|
||||
Doc 1: https://example.feishu.cn/docx/Doc1Token12345678901
|
||||
Doc 2: https://example.feishu.cn/wiki/Wiki1Token12345678901
|
||||
`;
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should deduplicate same token", () => {
|
||||
const text = `
|
||||
https://example.feishu.cn/docx/SameToken123456789012
|
||||
https://example.feishu.cn/docx/SameToken123456789012
|
||||
`;
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array for text without URLs", () => {
|
||||
const text = "This is plain text without any document links";
|
||||
const refs = extractDocRefsFromText(text);
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDocRefsFromPost", () => {
|
||||
it("should extract URL from link element", () => {
|
||||
const content = {
|
||||
title: "Test rich text",
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "a",
|
||||
text: "API Documentation",
|
||||
href: "https://example.feishu.cn/docx/ApiDocToken123456789",
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].title).toBe("API Documentation");
|
||||
expect(refs[0].docToken).toBe("ApiDocToken123456789");
|
||||
});
|
||||
|
||||
it("should extract URL from title", () => {
|
||||
const content = {
|
||||
title: "See https://example.feishu.cn/docx/TitleDocToken1234567",
|
||||
content: [],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should extract URL from text element", () => {
|
||||
const content = {
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "text",
|
||||
text: "Visit https://example.feishu.cn/wiki/TextWikiToken12345678",
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].docType).toBe("wiki");
|
||||
});
|
||||
|
||||
it("should handle stringified JSON", () => {
|
||||
const content = JSON.stringify({
|
||||
title: "Document Share",
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "a",
|
||||
text: "Click to view",
|
||||
href: "https://example.feishu.cn/docx/JsonDocToken123456789",
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array for post without doc links", () => {
|
||||
const content = {
|
||||
title: "Normal title",
|
||||
content: [
|
||||
[
|
||||
{ tag: "text", text: "Normal text" },
|
||||
{ tag: "a", text: "Normal link", href: "https://example.com" },
|
||||
],
|
||||
],
|
||||
};
|
||||
const refs = extractDocRefsFromPost(content);
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,456 +0,0 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveFeishuApiBase } from "./domain.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-docs" });
|
||||
|
||||
type FeishuApiResponse<T> = {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
type FeishuRequestClient = {
|
||||
request: <T>(params: {
|
||||
method: string;
|
||||
url: string;
|
||||
params?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}) => Promise<FeishuApiResponse<T>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Document token info extracted from a Feishu/Lark document URL or message
|
||||
*/
|
||||
export type FeishuDocRef = {
|
||||
docToken: string;
|
||||
docType: "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide";
|
||||
url: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Regex patterns to extract doc_token from various Feishu/Lark URLs
|
||||
*
|
||||
* Supported URL formats:
|
||||
* - https://xxx.feishu.cn/docx/xxxxx
|
||||
* - https://xxx.feishu.cn/wiki/xxxxx
|
||||
* - https://xxx.feishu.cn/sheets/xxxxx
|
||||
* - https://xxx.feishu.cn/base/xxxxx (bitable)
|
||||
* - https://xxx.larksuite.com/docx/xxxxx
|
||||
* etc.
|
||||
*/
|
||||
/* eslint-disable no-useless-escape */
|
||||
const DOC_URL_PATTERNS = [
|
||||
// docx (new version document) - token is typically 22-27 chars
|
||||
/https?:\/\/[^\/]+\/(docx)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// doc (legacy document)
|
||||
/https?:\/\/[^\/]+\/(doc)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// wiki
|
||||
/https?:\/\/[^\/]+\/(wiki)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// sheets
|
||||
/https?:\/\/[^\/]+\/(sheets?)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// bitable (base)
|
||||
/https?:\/\/[^\/]+\/(base|bitable)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// mindnote
|
||||
/https?:\/\/[^\/]+\/(mindnote)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// file
|
||||
/https?:\/\/[^\/]+\/(file)\/([A-Za-z0-9_-]{15,35})/,
|
||||
// slide
|
||||
/https?:\/\/[^\/]+\/(slides?)\/([A-Za-z0-9_-]{15,35})/,
|
||||
];
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
/**
|
||||
* Extract document references from text content
|
||||
* Looks for Feishu/Lark document URLs and extracts doc tokens
|
||||
*/
|
||||
export function extractDocRefsFromText(text: string): FeishuDocRef[] {
|
||||
const refs: FeishuDocRef[] = [];
|
||||
const seenTokens = new Set<string>();
|
||||
|
||||
for (const pattern of DOC_URL_PATTERNS) {
|
||||
const regex = new RegExp(pattern, "g");
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const [url, typeStr, token] = match;
|
||||
const docType = normalizeDocType(typeStr);
|
||||
|
||||
if (!seenTokens.has(token)) {
|
||||
seenTokens.add(token);
|
||||
refs.push({
|
||||
docToken: token,
|
||||
docType,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract document references from a rich text (post) message content
|
||||
*/
|
||||
export function extractDocRefsFromPost(content: unknown): FeishuDocRef[] {
|
||||
const refs: FeishuDocRef[] = [];
|
||||
const seenTokens = new Set<string>();
|
||||
|
||||
try {
|
||||
// Post content structure: { title, content: [[{tag, ...}]] }
|
||||
const postContent = typeof content === "string" ? JSON.parse(content) : content;
|
||||
|
||||
// Check title for links
|
||||
if (postContent.title) {
|
||||
const titleRefs = extractDocRefsFromText(postContent.title);
|
||||
for (const ref of titleRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check content elements
|
||||
if (Array.isArray(postContent.content)) {
|
||||
for (const line of postContent.content) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of line) {
|
||||
// Check hyperlinks
|
||||
if (element.tag === "a" && element.href) {
|
||||
const linkRefs = extractDocRefsFromText(element.href);
|
||||
for (const ref of linkRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
// Use the link text as title if available
|
||||
ref.title = element.text || undefined;
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check text content for inline URLs
|
||||
if (element.tag === "text" && element.text) {
|
||||
const textRefs = extractDocRefsFromText(element.text);
|
||||
for (const ref of textRefs) {
|
||||
if (!seenTokens.has(ref.docToken)) {
|
||||
seenTokens.add(ref.docToken);
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.debug(`Failed to parse post content: ${String(err)}`);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function normalizeDocType(
|
||||
typeStr: string,
|
||||
): "docx" | "doc" | "sheet" | "bitable" | "wiki" | "mindnote" | "file" | "slide" {
|
||||
switch (typeStr.toLowerCase()) {
|
||||
case "docx":
|
||||
return "docx";
|
||||
case "doc":
|
||||
return "doc";
|
||||
case "sheet":
|
||||
case "sheets":
|
||||
return "sheet";
|
||||
case "base":
|
||||
case "bitable":
|
||||
return "bitable";
|
||||
case "wiki":
|
||||
return "wiki";
|
||||
case "mindnote":
|
||||
return "mindnote";
|
||||
case "file":
|
||||
return "file";
|
||||
case "slide":
|
||||
case "slides":
|
||||
return "slide";
|
||||
default:
|
||||
return "docx";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wiki node info to resolve the actual document token
|
||||
*
|
||||
* Wiki documents have a node_token that needs to be resolved to the actual obj_token
|
||||
*
|
||||
* API: GET https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node
|
||||
* Required permission: wiki:wiki:readonly or wiki:wiki
|
||||
*/
|
||||
async function resolveWikiNode(
|
||||
client: Client,
|
||||
nodeToken: string,
|
||||
apiBase: string,
|
||||
): Promise<{ objToken: string; objType: string; title?: string } | null> {
|
||||
try {
|
||||
logger.debug(`Resolving wiki node: ${nodeToken}`);
|
||||
|
||||
const response = await (client as FeishuRequestClient).request<{
|
||||
node?: { obj_token?: string; obj_type?: string; title?: string };
|
||||
}>({
|
||||
method: "GET",
|
||||
url: `${apiBase}/wiki/v2/spaces/get_node`,
|
||||
params: {
|
||||
token: nodeToken,
|
||||
obj_type: "wiki",
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.code !== 0) {
|
||||
const errMsg = response?.msg || "Unknown error";
|
||||
logger.warn(`Failed to resolve wiki node: ${errMsg} (code: ${response?.code})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = response.data?.node;
|
||||
if (!node?.obj_token || !node?.obj_type) {
|
||||
logger.warn(`Wiki node response missing obj_token or obj_type`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
objToken: node.obj_token,
|
||||
objType: node.obj_type,
|
||||
title: node.title,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Error resolving wiki node: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the content of a Feishu document
|
||||
*
|
||||
* Supports:
|
||||
* - docx (new version documents) - direct content fetch
|
||||
* - wiki (knowledge base nodes) - first resolve to actual document, then fetch
|
||||
*
|
||||
* Other document types return a placeholder message.
|
||||
*
|
||||
* API: GET https://open.feishu.cn/open-apis/docs/v1/content
|
||||
* Docs: https://open.feishu.cn/document/server-docs/docs/content/get
|
||||
*
|
||||
* Required permissions:
|
||||
* - docs:document.content:read (for docx)
|
||||
* - wiki:wiki:readonly or wiki:wiki (for wiki)
|
||||
*/
|
||||
export async function fetchFeishuDocContent(
|
||||
client: Client,
|
||||
docRef: FeishuDocRef,
|
||||
options: {
|
||||
maxLength?: number;
|
||||
lang?: "zh" | "en" | "ja";
|
||||
apiBase?: string;
|
||||
} = {},
|
||||
): Promise<{ content: string; truncated: boolean } | null> {
|
||||
const { maxLength = 50000, lang = "zh", apiBase } = options;
|
||||
const resolvedApiBase = apiBase ?? resolveFeishuApiBase();
|
||||
|
||||
// For wiki type, first resolve the node to get the actual document token
|
||||
let targetToken = docRef.docToken;
|
||||
let targetType = docRef.docType;
|
||||
let resolvedTitle = docRef.title;
|
||||
|
||||
if (docRef.docType === "wiki") {
|
||||
const wikiNode = await resolveWikiNode(client, docRef.docToken, resolvedApiBase);
|
||||
if (!wikiNode) {
|
||||
return {
|
||||
content: `[Feishu Wiki Document: ${docRef.title || docRef.docToken}]\nLink: ${docRef.url}\n\n(Unable to access wiki node info. Please ensure the bot has been added as a wiki space member)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
targetToken = wikiNode.objToken;
|
||||
targetType = wikiNode.objType as FeishuDocRef["docType"];
|
||||
resolvedTitle = wikiNode.title || docRef.title;
|
||||
|
||||
logger.debug(`Wiki node resolved: ${docRef.docToken} -> ${targetToken} (${targetType})`);
|
||||
}
|
||||
|
||||
// Only docx is supported for content fetching
|
||||
if (targetType !== "docx") {
|
||||
logger.debug(`Document type ${targetType} is not supported for content fetching`);
|
||||
return {
|
||||
content: `[Feishu ${getDocTypeName(targetType)} Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(This document type does not support content extraction. Please access the link directly)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Fetching document content: ${targetToken} (${targetType})`);
|
||||
|
||||
// Use native HTTP request since SDK may not have this endpoint
|
||||
// The API endpoint is: GET /open-apis/docs/v1/content
|
||||
const response = await (client as FeishuRequestClient).request<{
|
||||
content?: string;
|
||||
}>({
|
||||
method: "GET",
|
||||
url: `${resolvedApiBase}/docs/v1/content`,
|
||||
params: {
|
||||
doc_token: targetToken,
|
||||
doc_type: "docx",
|
||||
content_type: "markdown",
|
||||
lang,
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.code !== 0) {
|
||||
const errMsg = response?.msg || "Unknown error";
|
||||
logger.warn(`Failed to fetch document content: ${errMsg} (code: ${response?.code})`);
|
||||
|
||||
// Check for common errors
|
||||
if (response?.code === 2889902) {
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(No permission to access this document. Please ensure the bot has been added as a document collaborator)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Failed to fetch document content: ${errMsg})`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
let content = response.data?.content || "";
|
||||
let truncated = false;
|
||||
|
||||
// Truncate if too long
|
||||
if (content.length > maxLength) {
|
||||
content = content.substring(0, maxLength) + "\n\n... (Content truncated due to length)";
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Add document header
|
||||
const header = resolvedTitle
|
||||
? `[Feishu Document: ${resolvedTitle}]\nLink: ${docRef.url}\n\n---\n\n`
|
||||
: `[Feishu Document]\nLink: ${docRef.url}\n\n---\n\n`;
|
||||
|
||||
return {
|
||||
content: header + content,
|
||||
truncated,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
logger.error(`Error fetching document content: ${String(err)}`);
|
||||
return {
|
||||
content: `[Feishu Document: ${resolvedTitle || targetToken}]\nLink: ${docRef.url}\n\n(Error occurred while fetching document content)`,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDocTypeName(docType: FeishuDocRef["docType"]): string {
|
||||
switch (docType) {
|
||||
case "docx":
|
||||
case "doc":
|
||||
return "";
|
||||
case "sheet":
|
||||
return "Sheet";
|
||||
case "bitable":
|
||||
return "Bitable";
|
||||
case "wiki":
|
||||
return "Wiki";
|
||||
case "mindnote":
|
||||
return "Mindnote";
|
||||
case "file":
|
||||
return "File";
|
||||
case "slide":
|
||||
return "Slide";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve document content from a message
|
||||
* Extracts document links and fetches their content
|
||||
*
|
||||
* @returns Combined document content string, or null if no documents found
|
||||
*/
|
||||
export async function resolveFeishuDocsFromMessage(
|
||||
client: Client,
|
||||
message: { message_type?: string; content?: string },
|
||||
options: {
|
||||
maxDocsPerMessage?: number;
|
||||
maxTotalLength?: number;
|
||||
domain?: string;
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
const { maxDocsPerMessage = 3, maxTotalLength = 100000 } = options;
|
||||
const apiBase = resolveFeishuApiBase(options.domain);
|
||||
|
||||
const msgType = message.message_type;
|
||||
let docRefs: FeishuDocRef[] = [];
|
||||
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
|
||||
if (msgType === "text" && content.text) {
|
||||
// Extract from plain text
|
||||
docRefs = extractDocRefsFromText(content.text);
|
||||
} else if (msgType === "post") {
|
||||
// Extract from rich text - handle locale wrapper
|
||||
let postData = content;
|
||||
if (content.post && typeof content.post === "object") {
|
||||
const localeKey = Object.keys(content.post).find(
|
||||
(key) => content.post[key]?.content || content.post[key]?.title,
|
||||
);
|
||||
if (localeKey) {
|
||||
postData = content.post[localeKey];
|
||||
}
|
||||
}
|
||||
docRefs = extractDocRefsFromPost(postData);
|
||||
}
|
||||
// TODO: Handle interactive (card) messages with document links
|
||||
} catch (err: unknown) {
|
||||
logger.debug(`Failed to parse message content for document extraction: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (docRefs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Limit number of documents to process
|
||||
const refsToProcess = docRefs.slice(0, maxDocsPerMessage);
|
||||
|
||||
logger.debug(`Found ${docRefs.length} document(s), processing ${refsToProcess.length}`);
|
||||
|
||||
const contents: string[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (const ref of refsToProcess) {
|
||||
const result = await fetchFeishuDocContent(client, ref, {
|
||||
maxLength: Math.min(50000, maxTotalLength - totalLength),
|
||||
apiBase,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
contents.push(result.content);
|
||||
totalLength += result.content.length;
|
||||
|
||||
if (totalLength >= maxTotalLength) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contents.join("\n\n---\n\n");
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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" or "file" only (per Feishu API docs)
|
||||
* Audio/video must use type="file" despite being different media types.
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message/get-2
|
||||
*/
|
||||
export async function downloadFeishuMessageResource(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
fileKey: string,
|
||||
type: "image" | "file",
|
||||
maxBytes: number = 30 * 1024 * 1024,
|
||||
): Promise<FeishuMediaRef> {
|
||||
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 "<media:image>";
|
||||
case "audio":
|
||||
return "<media:audio>";
|
||||
case "video":
|
||||
return "<media:video>";
|
||||
default:
|
||||
return "<media:document>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FeishuMediaRef | null> {
|
||||
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: "..." }
|
||||
// Note: Feishu API only supports type="image" or type="file" for messageResource.get
|
||||
// Audio must be downloaded using type="file" per official docs:
|
||||
// https://open.feishu.cn/document/server-docs/im-v1/message/get-2
|
||||
const content = JSON.parse(rawContent);
|
||||
if (content.file_key) {
|
||||
const result = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
content.file_key,
|
||||
"file", // Use "file" type for audio download (API limitation)
|
||||
maxBytes,
|
||||
);
|
||||
// Override placeholder to indicate audio content
|
||||
return {
|
||||
...result,
|
||||
placeholder: "<media:audio>",
|
||||
};
|
||||
}
|
||||
} else if (msgType === "media") {
|
||||
// Video message: content = { file_key: "...", image_key: "..." (thumbnail) }
|
||||
// Note: Video must also be downloaded using type="file" per Feishu API docs
|
||||
const content = JSON.parse(rawContent);
|
||||
if (content.file_key) {
|
||||
const result = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
content.file_key,
|
||||
"file", // Use "file" type for video download (API limitation)
|
||||
maxBytes,
|
||||
);
|
||||
// Override placeholder to indicate video content
|
||||
return {
|
||||
...result,
|
||||
placeholder: "<media:video>",
|
||||
};
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image keys from post (rich text) message content
|
||||
* Post content structure: { post: { locale: { content: [[{ tag: "img", image_key: "..." }]] } } }
|
||||
*/
|
||||
export function extractPostImageKeys(content: unknown): string[] {
|
||||
const imageKeys: string[] = [];
|
||||
|
||||
if (!content || typeof content !== "object") {
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
const obj = content as Record<string, unknown>;
|
||||
|
||||
// Handle locale-wrapped format: { post: { zh_cn: { content: [...] } } }
|
||||
let postData = obj;
|
||||
if (obj.post && typeof obj.post === "object") {
|
||||
const post = obj.post as Record<string, unknown>;
|
||||
const localeKey = Object.keys(post).find((key) => post[key] && typeof post[key] === "object");
|
||||
if (localeKey) {
|
||||
postData = post[localeKey] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image_key from content elements
|
||||
const contentArray = postData.content;
|
||||
if (!Array.isArray(contentArray)) {
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
for (const line of contentArray) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
for (const element of line) {
|
||||
if (
|
||||
element &&
|
||||
typeof element === "object" &&
|
||||
(element as Record<string, unknown>).tag === "img" &&
|
||||
typeof (element as Record<string, unknown>).image_key === "string"
|
||||
) {
|
||||
imageKeys.push((element as Record<string, unknown>).image_key as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download embedded images from a post (rich text) message
|
||||
*/
|
||||
export async function downloadPostImages(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
imageKeys: string[],
|
||||
maxBytes: number = 30 * 1024 * 1024,
|
||||
maxImages: number = 5,
|
||||
): Promise<FeishuMediaRef[]> {
|
||||
const results: FeishuMediaRef[] = [];
|
||||
|
||||
for (const imageKey of imageKeys.slice(0, maxImages)) {
|
||||
try {
|
||||
const media = await downloadFeishuMessageResource(
|
||||
client,
|
||||
messageId,
|
||||
imageKey,
|
||||
"image",
|
||||
maxBytes,
|
||||
);
|
||||
results.push(media);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to download post image ${imageKey}: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
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<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
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<number, string>, 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));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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";
|
||||
@@ -1,619 +0,0 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js";
|
||||
import {
|
||||
resolveFeishuConfig,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuGroupEnabled,
|
||||
type ResolvedFeishuConfig,
|
||||
} from "./config.js";
|
||||
import { resolveFeishuDocsFromMessage } from "./docs.js";
|
||||
import {
|
||||
downloadPostImages,
|
||||
extractPostImageKeys,
|
||||
resolveFeishuMedia,
|
||||
type FeishuMediaRef,
|
||||
} from "./download.js";
|
||||
import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { FeishuStreamingSession } from "./streaming-card.js";
|
||||
import { createTypingIndicatorCallbacks } from "./typing.js";
|
||||
import { getFeishuUserDisplayName } from "./user.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-message" });
|
||||
|
||||
type FeishuSender = {
|
||||
sender_id?: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type FeishuMention = {
|
||||
key?: string;
|
||||
id?: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type FeishuMessage = {
|
||||
chat_id?: string;
|
||||
chat_type?: string;
|
||||
message_type?: string;
|
||||
content?: string;
|
||||
mentions?: FeishuMention[];
|
||||
create_time?: string | number;
|
||||
message_id?: string;
|
||||
parent_id?: string;
|
||||
root_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", "post", "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;
|
||||
/** Bot's open_id for detecting bot mentions in groups */
|
||||
botOpenId?: 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;
|
||||
|
||||
// Resolve agent route for multi-agent support
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? chatId : senderId,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 ?? [];
|
||||
// Check if the bot itself was mentioned, not just any user
|
||||
const botOpenId = options.botOpenId?.trim();
|
||||
const wasMentioned = botOpenId
|
||||
? mentions.some((m) => m.id?.open_id === botOpenId || m.id?.user_id === botOpenId)
|
||||
: false;
|
||||
|
||||
// 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)}`);
|
||||
}
|
||||
} else if (msgType === "post") {
|
||||
// Post (rich text) message parsing
|
||||
// Feishu post content can have two formats:
|
||||
// Format 1: { post: { zh_cn: { title, content } } } (locale-wrapped)
|
||||
// Format 2: { title, content } (direct)
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
const parts: string[] = [];
|
||||
|
||||
// Try to find the actual post content
|
||||
let postData = content;
|
||||
if (content.post && typeof content.post === "object") {
|
||||
// Find the first locale key (zh_cn, en_us, etc.)
|
||||
const localeKey = Object.keys(content.post).find(
|
||||
(key) => content.post[key]?.content || content.post[key]?.title,
|
||||
);
|
||||
if (localeKey) {
|
||||
postData = content.post[localeKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Include title if present
|
||||
if (postData.title) {
|
||||
parts.push(postData.title);
|
||||
}
|
||||
|
||||
// Extract text from content elements
|
||||
if (Array.isArray(postData.content)) {
|
||||
for (const line of postData.content) {
|
||||
if (!Array.isArray(line)) {
|
||||
continue;
|
||||
}
|
||||
const lineParts: string[] = [];
|
||||
for (const element of line) {
|
||||
if (element.tag === "text" && element.text) {
|
||||
lineParts.push(element.text);
|
||||
} else if (element.tag === "a" && element.text) {
|
||||
lineParts.push(element.text);
|
||||
} else if (element.tag === "at" && element.user_name) {
|
||||
lineParts.push(`@${element.user_name}`);
|
||||
}
|
||||
}
|
||||
if (lineParts.length > 0) {
|
||||
parts.push(lineParts.join(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text = parts.join("\n");
|
||||
} catch (err) {
|
||||
logger.error(`Failed to parse post 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;
|
||||
let postImages: FeishuMediaRef[] = [];
|
||||
|
||||
if (msgType === "post") {
|
||||
// Extract and download embedded images from post message
|
||||
try {
|
||||
const content = JSON.parse(message.content ?? "{}");
|
||||
const imageKeys = extractPostImageKeys(content);
|
||||
if (imageKeys.length > 0 && message.message_id) {
|
||||
postImages = await downloadPostImages(
|
||||
client,
|
||||
message.message_id,
|
||||
imageKeys,
|
||||
maxMediaBytes,
|
||||
5, // max 5 images per post
|
||||
);
|
||||
logger.debug(
|
||||
`Downloaded ${postImages.length}/${imageKeys.length} images from post message`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to download post images: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
} else if (msgType !== "text") {
|
||||
try {
|
||||
media = await resolveFeishuMedia(client, message, maxMediaBytes);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to download media: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve document content if message contains Feishu doc links
|
||||
let docContent: string | null = null;
|
||||
if (msgType === "text" || msgType === "post") {
|
||||
try {
|
||||
docContent = await resolveFeishuDocsFromMessage(client, message, {
|
||||
maxDocsPerMessage: 3,
|
||||
maxTotalLength: 100000,
|
||||
domain: options.credentials?.domain,
|
||||
});
|
||||
if (docContent) {
|
||||
logger.debug(`Resolved ${docContent.length} chars of document content`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to resolve document content: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build body text
|
||||
let bodyText = text;
|
||||
if (!bodyText && media) {
|
||||
bodyText = media.placeholder;
|
||||
}
|
||||
|
||||
// Append document content if available
|
||||
if (docContent) {
|
||||
bodyText = bodyText ? `${bodyText}\n\n${docContent}` : docContent;
|
||||
}
|
||||
|
||||
// Skip if no content
|
||||
if (!bodyText && !media && postImages.length === 0) {
|
||||
logger.debug(`Empty message after processing, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get sender display name (try to fetch from contact API, fallback to user_id)
|
||||
const fallbackName = sender?.sender_id?.user_id || "unknown";
|
||||
const senderName = await getFeishuUserDisplayName(client, senderId, fallbackName);
|
||||
|
||||
// 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 = "";
|
||||
|
||||
// Typing indicator callbacks (for non-streaming mode)
|
||||
const typingCallbacks = createTypingIndicatorCallbacks(client, message.message_id);
|
||||
|
||||
// Use first post image as primary media if no other media
|
||||
const primaryMedia = media ?? (postImages.length > 0 ? postImages[0] : null);
|
||||
const additionalMediaPaths = postImages.length > 1 ? postImages.slice(1).map((m) => m.path) : [];
|
||||
|
||||
// Reply/Thread metadata for inbound messages
|
||||
const replyToId = message.parent_id ?? message.root_id;
|
||||
const messageThreadId = message.root_id ?? undefined;
|
||||
|
||||
// Context construction
|
||||
const ctx = {
|
||||
Body: bodyText,
|
||||
RawBody: text || primaryMedia?.placeholder || "",
|
||||
From: senderId,
|
||||
To: chatId,
|
||||
SessionKey: route.sessionKey,
|
||||
SenderId: senderId,
|
||||
SenderName: senderName,
|
||||
ChatType: isGroup ? "group" : "dm",
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
Timestamp: Number(message.create_time),
|
||||
MessageSid: message.message_id,
|
||||
AccountId: route.accountId,
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: chatId,
|
||||
// Media fields (similar to Telegram)
|
||||
MediaPath: primaryMedia?.path,
|
||||
MediaType: primaryMedia?.contentType,
|
||||
MediaUrl: primaryMedia?.path,
|
||||
// Additional images from post messages
|
||||
MediaUrls: additionalMediaPaths.length > 0 ? additionalMediaPaths : undefined,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
// Reply/thread metadata when the inbound message is a reply
|
||||
MessageThreadId: messageThreadId,
|
||||
ReplyToId: replyToId,
|
||||
// Command authorization - if message reached here, sender passed access control
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const agentId = resolveSessionAgentId({ config: cfg });
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
});
|
||||
|
||||
await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
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",
|
||||
// Only reply to the first media item to avoid spamming quote replies
|
||||
replyToMessageId: i === 0 ? payload.replyToId : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
} 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",
|
||||
replyToMessageId: payload.replyToId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = formatErrorMessage(err);
|
||||
if (
|
||||
msg.includes("permission") ||
|
||||
msg.includes("forbidden") ||
|
||||
msg.includes("code: 99991660")
|
||||
) {
|
||||
logger.error(
|
||||
`Reply error: ${msg} (Check if "im:message" or "im:resource" permissions are enabled in Feishu Console)`,
|
||||
);
|
||||
} else {
|
||||
logger.error(`Reply error: ${msg}`);
|
||||
}
|
||||
// Clean up streaming session on error
|
||||
if (streamingSession?.isActive()) {
|
||||
streamingSession.close().catch(() => {});
|
||||
}
|
||||
// Clean up typing indicator on error
|
||||
typingCallbacks.onIdle().catch(() => {});
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
// Add typing indicator reaction (for non-streaming fallback)
|
||||
if (!streamingSession) {
|
||||
await typingCallbacks.onReplyStart();
|
||||
}
|
||||
// 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) {
|
||||
const msg = formatErrorMessage(err);
|
||||
if (msg.includes("permission") || msg.includes("forbidden")) {
|
||||
logger.warn(
|
||||
`Failed to start streaming card: ${msg} (Check if "im:resource:msg:send" or card permissions are enabled)`,
|
||||
);
|
||||
} else {
|
||||
logger.warn(`Failed to start streaming card: ${msg}`);
|
||||
}
|
||||
// Continue without streaming
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming: !feishuCfg.blockStreaming,
|
||||
onModelSelected,
|
||||
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();
|
||||
}
|
||||
|
||||
// Clean up typing indicator
|
||||
await typingCallbacks.onIdle();
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { resolveFeishuConfig } from "./config.js";
|
||||
import { normalizeFeishuDomain } from "./domain.js";
|
||||
import { processFeishuMessage } from "./message.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-monitor" });
|
||||
|
||||
export type MonitorFeishuOpts = {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveFeishuAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const appId = opts.appId?.trim() || account.config.appId;
|
||||
const appSecret = opts.appSecret?.trim() || account.config.appSecret;
|
||||
const domain = normalizeFeishuDomain(account.config.domain);
|
||||
const accountId = account.accountId;
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error(
|
||||
`Feishu app ID/secret missing for account "${accountId}" (set channels.feishu.accounts.${accountId}.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve effective config for this account
|
||||
const feishuCfg = resolveFeishuConfig({ cfg, accountId });
|
||||
|
||||
// Check if account is enabled
|
||||
if (!feishuCfg.enabled) {
|
||||
logger.info(`Feishu account "${accountId}" is disabled, skipping monitor`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Lark client for API calls
|
||||
const client = new Lark.Client({
|
||||
appId,
|
||||
appSecret,
|
||||
...(domain ? { domain } : {}),
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get bot's open_id for detecting mentions in group chats
|
||||
const probeResult = await probeFeishu(appId, appSecret, 5000, domain);
|
||||
const botOpenId = probeResult.bot?.openId ?? undefined;
|
||||
if (!botOpenId) {
|
||||
logger.warn(`Could not get bot open_id, group mention detection may not work correctly`);
|
||||
}
|
||||
|
||||
// Create event dispatcher
|
||||
const eventDispatcher = new Lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
logger.info(`Received Feishu message event`);
|
||||
try {
|
||||
await processFeishuMessage(client, data, appId, {
|
||||
cfg,
|
||||
accountId,
|
||||
resolvedConfig: feishuCfg,
|
||||
credentials: { appId, appSecret, domain },
|
||||
botName: account.name,
|
||||
botOpenId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Error processing Feishu message: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Create WebSocket client
|
||||
const wsClient = new Lark.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
...(domain ? { domain } : {}),
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle abort signal
|
||||
const handleAbort = () => {
|
||||
logger.info("Stopping Feishu WS client...");
|
||||
// WSClient doesn't have a stop method exposed, but it should handle disconnection
|
||||
// We'll let the process handle cleanup
|
||||
};
|
||||
|
||||
if (opts.abortSignal) {
|
||||
opts.abortSignal.addEventListener("abort", handleAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Starting Feishu WebSocket client...");
|
||||
await wsClient.start({ eventDispatcher });
|
||||
logger.info("Feishu WebSocket connection established");
|
||||
|
||||
// The WSClient.start() should keep running until disconnected
|
||||
// If it returns, we need to keep the process alive
|
||||
// Wait for abort signal
|
||||
if (opts.abortSignal) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (opts.abortSignal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
} else {
|
||||
// If no abort signal, wait indefinitely
|
||||
await new Promise<void>(() => {});
|
||||
}
|
||||
} finally {
|
||||
if (opts.abortSignal) {
|
||||
opts.abortSignal.removeEventListener("abort", handleAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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<string[]> {
|
||||
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<FeishuPairingListEntry[]> {
|
||||
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 };
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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;
|
||||
openId?: 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<Response> {
|
||||
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<FeishuProbe> {
|
||||
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,
|
||||
openId: botJson.bot?.open_id ?? 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
|
||||
/**
|
||||
* Reaction info returned from Feishu API
|
||||
*/
|
||||
export type FeishuReaction = {
|
||||
reactionId: string;
|
||||
emojiType: string;
|
||||
operatorType: "app" | "user";
|
||||
operatorId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a reaction (emoji) to a message.
|
||||
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART", "Typing"
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export async function addReactionFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
emojiType: string,
|
||||
): Promise<{ reactionId: string }> {
|
||||
const response = (await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
reaction_type: {
|
||||
emoji_type: emojiType,
|
||||
},
|
||||
},
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: { reaction_id?: string };
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const reactionId = response.data?.reaction_id;
|
||||
if (!reactionId) {
|
||||
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
||||
}
|
||||
|
||||
return { reactionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message.
|
||||
*/
|
||||
export async function removeReactionFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
reactionId: string,
|
||||
): Promise<void> {
|
||||
const response = (await client.im.messageReaction.delete({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
reaction_id: reactionId,
|
||||
},
|
||||
})) as { code?: number; msg?: string };
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all reactions for a message.
|
||||
*/
|
||||
export async function listReactionsFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
emojiType?: string,
|
||||
): Promise<FeishuReaction[]> {
|
||||
const response = (await client.im.messageReaction.list({
|
||||
path: { message_id: messageId },
|
||||
params: emojiType ? { reaction_type: emojiType } : undefined,
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
reaction_id?: string;
|
||||
reaction_type?: { emoji_type?: string };
|
||||
operator_type?: string;
|
||||
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const items = response.data?.items ?? [];
|
||||
return items.map((item) => ({
|
||||
reactionId: item.reaction_id ?? "",
|
||||
emojiType: item.reaction_type?.emoji_type ?? "",
|
||||
operatorType: item.operator_type === "app" ? "app" : "user",
|
||||
operatorId:
|
||||
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Feishu emoji types for convenience.
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export const FeishuEmoji = {
|
||||
// Common reactions
|
||||
THUMBSUP: "THUMBSUP",
|
||||
THUMBSDOWN: "THUMBSDOWN",
|
||||
HEART: "HEART",
|
||||
SMILE: "SMILE",
|
||||
GRINNING: "GRINNING",
|
||||
LAUGHING: "LAUGHING",
|
||||
CRY: "CRY",
|
||||
ANGRY: "ANGRY",
|
||||
SURPRISED: "SURPRISED",
|
||||
THINKING: "THINKING",
|
||||
CLAP: "CLAP",
|
||||
OK: "OK",
|
||||
FIST: "FIST",
|
||||
PRAY: "PRAY",
|
||||
FIRE: "FIRE",
|
||||
PARTY: "PARTY",
|
||||
CHECK: "CHECK",
|
||||
CROSS: "CROSS",
|
||||
QUESTION: "QUESTION",
|
||||
EXCLAMATION: "EXCLAMATION",
|
||||
// Special typing indicator
|
||||
TYPING: "Typing",
|
||||
} as const;
|
||||
|
||||
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
||||
@@ -1,374 +0,0 @@
|
||||
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;
|
||||
/** Message ID to reply to (uses reply API instead of create) */
|
||||
replyToMessageId?: string;
|
||||
/** Whether to reply in thread mode. Default: false */
|
||||
replyInThread?: boolean;
|
||||
};
|
||||
|
||||
export type FeishuSendResult = {
|
||||
message_id?: string;
|
||||
};
|
||||
|
||||
type FeishuMessageContent = ({ text?: string } & Record<string, unknown>) | string;
|
||||
|
||||
/**
|
||||
* Upload an image to Feishu and get image_key
|
||||
*/
|
||||
export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise<string> {
|
||||
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<string> {
|
||||
logger.info(
|
||||
`Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`,
|
||||
);
|
||||
|
||||
let res: Awaited<ReturnType<typeof client.im.file.create>>;
|
||||
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<FeishuSendResult | null> {
|
||||
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 mediaContent = JSON.stringify(finalContent);
|
||||
if (opts.replyToMessageId) {
|
||||
await replyMessageFeishu(client, opts.replyToMessageId, mediaContent, msgType, {
|
||||
replyInThread: opts.replyInThread,
|
||||
});
|
||||
} else {
|
||||
const mediaRes = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: mediaContent,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Use reply API if replyToMessageId is provided
|
||||
if (opts.replyToMessageId) {
|
||||
return replyMessageFeishu(client, opts.replyToMessageId, contentStr, msgType, {
|
||||
replyInThread: opts.replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeishuReplyOpts = {
|
||||
/** Whether to reply in thread mode. Default: false */
|
||||
replyInThread?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reply to a specific message in Feishu
|
||||
* Uses the Feishu reply API: POST /open-apis/im/v1/messages/:message_id/reply
|
||||
*/
|
||||
export async function replyMessageFeishu(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
content: string,
|
||||
msgType: FeishuMsgType,
|
||||
opts: FeishuReplyOpts = {},
|
||||
): Promise<FeishuSendResult | null> {
|
||||
try {
|
||||
const res = await client.im.message.reply({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
msg_type: msgType,
|
||||
content: content,
|
||||
reply_in_thread: opts.replyInThread ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.code !== 0) {
|
||||
logger.error(`Feishu reply failed: ${res.code} - ${res.msg}`);
|
||||
throw new Error(`Feishu API Error: ${res.msg}`);
|
||||
}
|
||||
return res.data ?? null;
|
||||
} catch (err) {
|
||||
logger.error(`Feishu reply error: ${formatErrorMessage(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
/**
|
||||
* 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<string, { token: string; expiresAt: number }>();
|
||||
|
||||
const getTokenCacheKey = (credentials: FeishuStreamingCredentials) =>
|
||||
`${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`;
|
||||
|
||||
/**
|
||||
* Get tenant access token (with caching)
|
||||
*/
|
||||
async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// Build config object - summary must be set to clear "[Generating...]"
|
||||
const configObj: Record<string, unknown> = {
|
||||
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<void> = 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) + "...";
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { addReactionFeishu, removeReactionFeishu, FeishuEmoji } from "./reactions.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-typing" });
|
||||
|
||||
/**
|
||||
* Typing indicator state
|
||||
*/
|
||||
export type TypingIndicatorState = {
|
||||
messageId: string;
|
||||
reactionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a typing indicator (reaction) to a message.
|
||||
*
|
||||
* Feishu doesn't have a native typing indicator API, so we use emoji reactions
|
||||
* as a visual substitute. The "Typing" emoji provides immediate feedback to users.
|
||||
*
|
||||
* Requires permission: im:message.reaction:read_write
|
||||
*/
|
||||
export async function addTypingIndicator(
|
||||
client: Client,
|
||||
messageId: string,
|
||||
): Promise<TypingIndicatorState> {
|
||||
try {
|
||||
const { reactionId } = await addReactionFeishu(client, messageId, FeishuEmoji.TYPING);
|
||||
logger.debug(`Added typing indicator reaction: ${reactionId}`);
|
||||
return { messageId, reactionId };
|
||||
} catch (err) {
|
||||
// Silently fail - typing indicator is not critical
|
||||
logger.debug(`Failed to add typing indicator: ${formatErrorMessage(err)}`);
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a typing indicator (reaction) from a message.
|
||||
*/
|
||||
export async function removeTypingIndicator(
|
||||
client: Client,
|
||||
state: TypingIndicatorState,
|
||||
): Promise<void> {
|
||||
if (!state.reactionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeReactionFeishu(client, state.messageId, state.reactionId);
|
||||
logger.debug(`Removed typing indicator reaction: ${state.reactionId}`);
|
||||
} catch (err) {
|
||||
// Silently fail - cleanup is not critical
|
||||
logger.debug(`Failed to remove typing indicator: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create typing indicator callbacks for use with reply dispatchers.
|
||||
* These callbacks automatically manage the typing indicator lifecycle.
|
||||
*/
|
||||
export function createTypingIndicatorCallbacks(
|
||||
client: Client,
|
||||
messageId: string | undefined,
|
||||
): {
|
||||
state: { current: TypingIndicatorState | null };
|
||||
onReplyStart: () => Promise<void>;
|
||||
onIdle: () => Promise<void>;
|
||||
} {
|
||||
const state: { current: TypingIndicatorState | null } = { current: null };
|
||||
|
||||
return {
|
||||
state,
|
||||
onReplyStart: async () => {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
state.current = await addTypingIndicator(client, messageId);
|
||||
},
|
||||
onIdle: async () => {
|
||||
if (!state.current) {
|
||||
return;
|
||||
}
|
||||
await removeTypingIndicator(client, state.current);
|
||||
state.current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
|
||||
const logger = getChildLogger({ module: "feishu-user" });
|
||||
|
||||
export type FeishuUserInfo = {
|
||||
openId: string;
|
||||
name?: string;
|
||||
enName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
// Simple in-memory cache for user info (expires after 1 hour)
|
||||
const userCache = new Map<string, { info: FeishuUserInfo; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Get user information from Feishu
|
||||
* Uses the contact API: GET /open-apis/contact/v3/users/:user_id
|
||||
* Requires permission: contact:user.base:readonly or contact:contact:readonly_as_app
|
||||
*/
|
||||
export async function getFeishuUserInfo(
|
||||
client: Client,
|
||||
openId: string,
|
||||
): Promise<FeishuUserInfo | null> {
|
||||
// Check cache first
|
||||
const cached = userCache.get(openId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.info;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await client.contact.user.get({
|
||||
path: { user_id: openId },
|
||||
params: { user_id_type: "open_id" },
|
||||
});
|
||||
|
||||
if (res.code !== 0) {
|
||||
logger.debug(`Failed to get user info for ${openId}: ${res.code} - ${res.msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = res.data?.user;
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info: FeishuUserInfo = {
|
||||
openId,
|
||||
name: user.name,
|
||||
enName: user.en_name,
|
||||
avatar: user.avatar?.avatar_240,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
userCache.set(openId, {
|
||||
info,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
return info;
|
||||
} catch (err) {
|
||||
// Gracefully handle permission errors - just log and return null
|
||||
logger.debug(`Error getting user info for ${openId}: ${formatErrorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a user
|
||||
* Falls back to openId if name is not available
|
||||
*/
|
||||
export async function getFeishuUserDisplayName(
|
||||
client: Client,
|
||||
openId: string,
|
||||
fallback?: string,
|
||||
): Promise<string> {
|
||||
const info = await getFeishuUserInfo(client, openId);
|
||||
return info?.name || info?.enName || fallback || openId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired entries from the cache
|
||||
*/
|
||||
export function cleanupUserCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of userCache) {
|
||||
if (value.expiresAt < now) {
|
||||
userCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,22 +370,5 @@ 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";
|
||||
|
||||
Reference in New Issue
Block a user