openclaw/src/feishu/monitor.ts
Josh Palmer 4fc4c5256a 🤖 Feishu: expand channel support
What:
- add post parsing, doc link extraction, routing, replies, reactions, typing, and user lookup
- fix media download/send flows and make doc fetches domain-aware
- update Feishu docs and clawtributor credits

Why:
- raise Feishu parity with other channels and avoid dropped group messages
- keep replies threaded while supporting Lark domains
- document new configuration and credit the contributor

Tests:
- pnpm build
- pnpm check
- pnpm test (gateway suite timed out; reran pnpm vitest run --config vitest.gateway.config.ts)

Co-authored-by: 九灵云 <server@jiulingyun.cn>
2026-02-05 12:29:04 -08:00

162 lines
4.6 KiB
TypeScript

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);
}
}
}