UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

698 lines (697 loc) 23.5 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { s as statRegularFileSync } from "./regular-file-BD2zl6_l.js"; import { c as isRecord } from "./utils-CCC-BEJH.js"; import { p as resolvePayloadMediaUrls, v as sendPayloadMediaSequenceAndFinalize, x as sendTextMediaPayload } from "./reply-payload-D-VMfpYI.js"; import { c as normalizeInteractiveReply, f as renderMessagePresentationFallbackText, l as normalizeMessagePresentation, o as interactiveReplyToPresentation, p as resolveInteractiveTextFallback } from "./payload-CZlThETZ.js"; import "./security-runtime-CQm7DD1u.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { t as chunkTextForOutbound } from "./text-chunking-D45TeeEs.js"; import { i as createAttachedChannelResultAdapter, t as attachChannelToResult } from "./channel-send-result-Dn_C6AJS.js"; import { b as parseFeishuCommentTarget, l as resolveFeishuRuntimeAccount, s as resolveFeishuAccount } from "./accounts-BTmGOBDX.js"; import { i as listFeishuDirectoryPeers, n as buildFeishuPresentationCardElements, r as listFeishuDirectoryGroups } from "./presentation-card-BX_vye1k.js"; import { r as createFeishuClient } from "./client-DmDgwXmZ.js"; import { c as getChatInfo, l as getChatMembers, r as cleanupAmbientCommentTypingReaction, t as deliverCommentThreadText, u as getFeishuMemberInfo } from "./drive-CmoJV3Nw.js"; import "./runtime-api-CxVClP-A.js"; import { a as sendCardFeishu, c as sendStructuredCardFeishu, g as shouldSuppressFeishuTextForVoiceMedia, h as sendMediaFeishu, i as resolveFeishuCardTemplate, n as getMessageFeishu, o as sendMarkdownCardFeishu, s as sendMessageFeishu, t as editMessageFeishu } from "./send-DywbiepS.js"; import { n as probeFeishu } from "./probe-CiCSquE9.js"; import path from "node:path"; //#region extensions/feishu/src/directory.ts async function listFeishuDirectoryPeersLive(params) { const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) return listFeishuDirectoryPeers(params); try { const client = createFeishuClient(account); const peers = []; const limit = params.limit ?? 50; const response = await client.contact.user.list({ params: { page_size: Math.min(limit, 50) } }); if (response.code !== 0) throw new Error(response.msg || `code ${response.code}`); const q = normalizeLowercaseStringOrEmpty(params.query); for (const user of response.data?.items ?? []) { if (user.open_id) { const name = user.name || ""; if (!q || normalizeLowercaseStringOrEmpty(user.open_id).includes(q) || normalizeLowercaseStringOrEmpty(name).includes(q)) peers.push({ kind: "user", id: user.open_id, name: name || void 0 }); } if (peers.length >= limit) break; } return peers; } catch (err) { if (params.fallbackToStatic === false) throw err instanceof Error ? err : /* @__PURE__ */ new Error("Feishu live peer lookup failed"); return listFeishuDirectoryPeers(params); } } async function listFeishuDirectoryGroupsLive(params) { const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) return listFeishuDirectoryGroups(params); try { const client = createFeishuClient(account); const groups = []; const limit = params.limit ?? 50; const response = await client.im.chat.list({ params: { page_size: Math.min(limit, 100) } }); if (response.code !== 0) throw new Error(response.msg || `code ${response.code}`); const q = normalizeLowercaseStringOrEmpty(params.query); for (const chat of response.data?.items ?? []) { if (chat.chat_id) { const name = chat.name || ""; if (!q || normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) || normalizeLowercaseStringOrEmpty(name).includes(q)) groups.push({ kind: "group", id: chat.chat_id, name: name || void 0 }); } if (groups.length >= limit) break; } return groups; } catch (err) { if (params.fallbackToStatic === false) throw err instanceof Error ? err : /* @__PURE__ */ new Error("Feishu live group lookup failed"); return listFeishuDirectoryGroups(params); } } //#endregion //#region extensions/feishu/src/outbound.ts const RENDERED_FEISHU_CARD = Symbol("openclaw.renderedFeishuCard"); function normalizePossibleLocalImagePath(text) { const raw = text?.trim(); if (!raw) return null; if (/\s/.test(raw)) return null; if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null; const ext = normalizeLowercaseStringOrEmpty(path.extname(raw)); if (![ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff" ].includes(ext)) return null; if (!path.isAbsolute(raw)) return null; try { if (statRegularFileSync(raw).missing) return null; } catch { return null; } return raw; } function shouldUseCard(text) { return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } function markRenderedFeishuCard(card) { Object.defineProperty(card, RENDERED_FEISHU_CARD, { value: true, enumerable: false }); return card; } function escapeFeishuCardMarkdownText(text) { return text.replace(/[&<>]/g, (char) => { switch (char) { case "&": return "&amp;"; case "<": return "&lt;"; case ">": return "&gt;"; default: return char; } }); } function resolveSafeFeishuButtonUrl(url) { const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed) return; try { const parsed = new URL(trimmed); return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : void 0; } catch { return; } } function sanitizeNativeFeishuButtonBehavior(behavior) { if (!isRecord(behavior)) return; if (behavior.type === "open_url") { const safeUrl = resolveSafeFeishuButtonUrl(behavior.default_url) ?? resolveSafeFeishuButtonUrl(behavior.url); return safeUrl ? { type: "open_url", default_url: safeUrl } : void 0; } if (behavior.type === "callback" && isRecord(behavior.value) && behavior.value.oc === "ocf1") return { type: "callback", value: behavior.value }; } function sanitizeNativeFeishuCardButton(button) { if (!isRecord(button)) return; const text = isRecord(button.text) && typeof button.text.content === "string" ? button.text.content : void 0; if (!text?.trim()) return; const style = button.type === "danger" ? "danger" : button.type === "primary" || button.type === "success" ? "primary" : void 0; const behaviors = Array.isArray(button.behaviors) ? button.behaviors.map((behavior) => sanitizeNativeFeishuButtonBehavior(behavior)).filter((behavior) => Boolean(behavior)) : []; const rootSafeUrl = resolveSafeFeishuButtonUrl(button.url); if (rootSafeUrl) behaviors.push({ type: "open_url", default_url: rootSafeUrl }); if (isRecord(button.value) && button.value.oc === "ocf1") behaviors.push({ type: "callback", value: button.value }); if (behaviors.length === 0) return; return { tag: "button", text: { tag: "plain_text", content: text }, type: style === "danger" ? "danger" : style === "primary" || style === "success" ? "primary" : "default", behaviors }; } function sanitizeNativeFeishuCardElements(element) { if (!isRecord(element) || typeof element.tag !== "string") return []; if (element.tag === "hr") return [{ tag: "hr" }]; if (element.tag === "markdown" && typeof element.content === "string") return [{ tag: "markdown", content: escapeFeishuCardMarkdownText(element.content) }]; if (element.tag === "button") { const button = sanitizeNativeFeishuCardButton(element); return button ? [button] : []; } if (element.tag === "action" && Array.isArray(element.actions)) return element.actions.map((action) => sanitizeNativeFeishuCardButton(action)).filter((action) => Boolean(action)); return []; } function sanitizeNativeFeishuCard(card) { const body = isRecord(card.body) ? card.body : void 0; const elements = (Array.isArray(body?.elements) ? body.elements : []).flatMap((element) => sanitizeNativeFeishuCardElements(element)).filter((element) => Boolean(element)); if (elements.length === 0) return; const header = isRecord(card.header) ? card.header : void 0; const title = isRecord(header?.title) && typeof header.title.content === "string" ? header.title.content : void 0; return markRenderedFeishuCard({ schema: "2.0", config: { width_mode: "fill" }, ...title?.trim() ? { header: { title: { tag: "plain_text", content: title }, template: resolveFeishuCardTemplate(typeof header?.template === "string" ? header.template : void 0) ?? "blue" } } : {}, body: { elements } }); } function readNativeFeishuCard(payload) { const feishuData = payload.channelData?.feishu; if (!isRecord(feishuData)) return; const card = feishuData.card ?? feishuData.interactiveCard; if (!isRecord(card)) return; if (card[RENDERED_FEISHU_CARD] === true) return card; return sanitizeNativeFeishuCard(card); } function buildFeishuPayloadCard(params) { const nativeCard = readNativeFeishuCard(params.payload); if (nativeCard) return nativeCard; const interactive = normalizeInteractiveReply(params.payload.interactive); const presentation = normalizeMessagePresentation(params.payload.presentation) ?? (interactive ? interactiveReplyToPresentation(interactive) : void 0); if (!presentation && !interactive) return; const text = resolveInteractiveTextFallback({ text: params.text ?? params.payload.text, interactive }); const elements = presentation ? buildFeishuPresentationCardElements({ presentation, fallbackText: text }) : [{ tag: "markdown", content: renderMessagePresentationFallbackText({ text, presentation }) }]; const identityTitle = params.identity ? params.identity.emoji ? `${params.identity.emoji} ${params.identity.name ?? ""}`.trim() : params.identity.name ?? "" : ""; const title = presentation?.title ?? identityTitle; const template = resolveFeishuCardTemplate(presentation?.tone === "danger" ? "red" : presentation?.tone === "warning" ? "orange" : presentation?.tone === "success" ? "green" : "blue"); return markRenderedFeishuCard({ schema: "2.0", config: { width_mode: "fill" }, ...title ? { header: { title: { tag: "plain_text", content: title }, template: template ?? "blue" } } : {}, body: { elements } }); } function renderFeishuPresentationPayload({ payload, presentation, ctx }) { const card = buildFeishuPayloadCard({ payload, text: payload.text, identity: ctx.identity }); if (!card) return null; const existingFeishuData = isRecord(payload.channelData?.feishu) ? payload.channelData.feishu : void 0; return { ...payload, text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), channelData: { ...payload.channelData, feishu: { ...existingFeishuData, card } } }; } function resolveReplyToMessageId(params) { const replyToId = params.replyToId?.trim(); if (replyToId) return replyToId; if (params.threadId == null) return; return String(params.threadId).trim() || void 0; } function resolveFeishuMediaReplyMode(params) { const trimmedReplyToId = params.replyToId?.trim() || void 0; return { replyToMessageId: resolveReplyToMessageId(params), replyInThread: params.threadId != null && !trimmedReplyToId }; } async function sendCommentThreadReply(params) { const target = parseFeishuCommentTarget(params.to); if (!target) return null; const client = createFeishuClient(resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId })); const replyId = params.replyId?.trim(); try { const result = await deliverCommentThreadText(client, { file_token: target.fileToken, file_type: target.fileType, comment_id: target.commentId, content: params.text }); return { messageId: typeof result.reply_id === "string" && result.reply_id || typeof result.comment_id === "string" && result.comment_id || "", chatId: target.commentId, result }; } finally { if (replyId) cleanupAmbientCommentTypingReaction({ client, deliveryContext: { channel: "feishu", to: params.to, threadId: replyId } }); } } async function sendOutboundText(params) { const { cfg, to, text, accountId, replyToMessageId, replyInThread } = params; const commentResult = await sendCommentThreadReply({ cfg, to, text, replyId: replyToMessageId, accountId }); if (commentResult) return commentResult; const renderMode = resolveFeishuAccount({ cfg, accountId }).config?.renderMode ?? "auto"; if (renderMode === "card" || renderMode === "auto" && shouldUseCard(text)) return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId, replyInThread }); return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId, replyInThread }); } const feishuOutbound = { deliveryMode: "direct", chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: 4e3, presentationCapabilities: { supported: true, buttons: true, selects: false, context: true, divider: true, limits: { actions: { maxActions: 20, maxActionsPerRow: 5, maxLabelLength: 40, maxValueBytes: 1024 }, text: { maxLength: 4e3, encoding: "characters", markdownDialect: "markdown" } } }, renderPresentation: renderFeishuPresentationPayload, sendPayload: async (ctx) => { const card = buildFeishuPayloadCard({ payload: ctx.payload, text: ctx.text, identity: ctx.identity }); if (!card) return await sendTextMediaPayload({ channel: "feishu", ctx, adapter: feishuOutbound }); const replyToMessageId = resolveReplyToMessageId({ replyToId: ctx.replyToId, threadId: ctx.threadId }); if (parseFeishuCommentTarget(ctx.to)) return await sendTextMediaPayload({ channel: "feishu", ctx: { ...ctx, payload: { ...ctx.payload, text: renderMessagePresentationFallbackText({ text: ctx.payload.text, presentation: normalizeMessagePresentation(ctx.payload.presentation) ?? (() => { const interactive = normalizeInteractiveReply(ctx.payload.interactive); return interactive ? interactiveReplyToPresentation(interactive) : void 0; })() }), interactive: void 0, presentation: void 0, channelData: void 0 } }, adapter: feishuOutbound }); const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(ctx.payload)); return attachChannelToResult("feishu", await sendPayloadMediaSequenceAndFinalize({ text: ctx.payload.text ?? "", mediaUrls, send: async ({ mediaUrl }) => await sendMediaFeishu({ cfg: ctx.cfg, to: ctx.to, mediaUrl, accountId: ctx.accountId ?? void 0, mediaLocalRoots: ctx.mediaLocalRoots, replyToMessageId, ...ctx.payload.audioAsVoice === true || ctx.audioAsVoice === true ? { audioAsVoice: true } : {} }), finalize: async () => await sendCardFeishu({ cfg: ctx.cfg, to: ctx.to, card, replyToMessageId, replyInThread: ctx.threadId != null && !ctx.replyToId, accountId: ctx.accountId ?? void 0 }) })); }, ...createAttachedChannelResultAdapter({ channel: "feishu", sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots, identity }) => { const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({ replyToId, threadId }); const localImagePath = normalizePossibleLocalImagePath(text); if (localImagePath) try { return await sendMediaFeishu({ cfg, to, mediaUrl: localImagePath, accountId: accountId ?? void 0, replyToMessageId, replyInThread, mediaLocalRoots }); } catch (err) { console.error(`[feishu] local image path auto-send failed:`, err); } if (parseFeishuCommentTarget(to)) return await sendOutboundText({ cfg, to, text, accountId: accountId ?? void 0, replyToMessageId, replyInThread }); const renderMode = resolveFeishuAccount({ cfg, accountId: accountId ?? void 0 }).config?.renderMode ?? "auto"; if (renderMode === "card" || renderMode === "auto" && shouldUseCard(text)) { const header = identity ? { title: identity.emoji ? `${identity.emoji} ${identity.name ?? ""}`.trim() : identity.name ?? "", template: "blue" } : void 0; return await sendStructuredCardFeishu({ cfg, to, text, replyToMessageId, replyInThread, accountId: accountId ?? void 0, header: header?.title ? header : void 0 }); } return await sendOutboundText({ cfg, to, text, accountId: accountId ?? void 0, replyToMessageId, replyInThread }); }, sendMedia: async ({ cfg, to, text, mediaUrl, audioAsVoice, accountId, mediaLocalRoots, replyToId, threadId }) => { const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({ replyToId, threadId }); if (parseFeishuCommentTarget(to)) return await sendOutboundText({ cfg, to, text: [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n") || mediaUrl || text || "", accountId: accountId ?? void 0, replyToMessageId, replyInThread }); const suppressTextForVoiceMedia = mediaUrl !== void 0 && shouldSuppressFeishuTextForVoiceMedia({ mediaUrl, audioAsVoice }); if (text?.trim() && !suppressTextForVoiceMedia) await sendOutboundText({ cfg, to, text, accountId: accountId ?? void 0, replyToMessageId, replyInThread }); if (mediaUrl) try { const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId: accountId ?? void 0, mediaLocalRoots, replyToMessageId, replyInThread, ...audioAsVoice === true ? { audioAsVoice: true } : {} }); if (result.voiceIntentDegradedToFile && text?.trim()) await sendOutboundText({ cfg, to, text, accountId: accountId ?? void 0, replyToMessageId, replyInThread }); return result; } catch (err) { console.error(`[feishu] sendMediaFeishu failed:`, err); return await sendOutboundText({ cfg, to, text: [text?.trim(), `📎 ${mediaUrl}`].filter(Boolean).join("\n\n"), accountId: accountId ?? void 0, replyToMessageId, replyInThread }); } return await sendOutboundText({ cfg, to, text: text ?? "", accountId: accountId ?? void 0, replyToMessageId, replyInThread }); } }) }; //#endregion //#region extensions/feishu/src/pins.ts function assertFeishuPinApiSuccess(response, action) { if (response.code !== 0) throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`); } function normalizePin(pin) { return { messageId: pin.message_id, chatId: pin.chat_id, operatorId: pin.operator_id, operatorIdType: pin.operator_id_type, createTime: pin.create_time }; } async function createPinFeishu(params) { const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) throw new Error(`Feishu account "${account.accountId}" not configured`); const response = await createFeishuClient(account).im.pin.create({ data: { message_id: params.messageId } }); assertFeishuPinApiSuccess(response, "pin create"); return response.data?.pin ? normalizePin(response.data.pin) : null; } async function removePinFeishu(params) { const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) throw new Error(`Feishu account "${account.accountId}" not configured`); assertFeishuPinApiSuccess(await createFeishuClient(account).im.pin.delete({ path: { message_id: params.messageId } }), "pin delete"); } async function listPinsFeishu(params) { const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) throw new Error(`Feishu account "${account.accountId}" not configured`); const response = await createFeishuClient(account).im.pin.list({ params: { chat_id: params.chatId, ...params.startTime ? { start_time: params.startTime } : {}, ...params.endTime ? { end_time: params.endTime } : {}, ...typeof params.pageSize === "number" ? { page_size: Math.max(1, Math.min(100, Math.floor(params.pageSize))) } : {}, ...params.pageToken ? { page_token: params.pageToken } : {} } }); assertFeishuPinApiSuccess(response, "pin list"); return { chatId: params.chatId, pins: (response.data?.items ?? []).map(normalizePin), hasMore: response.data?.has_more === true, pageToken: response.data?.page_token }; } //#endregion //#region extensions/feishu/src/reactions.ts function resolveConfiguredFeishuClient(params) { const account = resolveFeishuRuntimeAccount(params); if (!account.configured) throw new Error(`Feishu account "${account.accountId}" not configured`); return createFeishuClient(account); } function assertFeishuReactionApiSuccess(response, action) { if (response.code !== 0) throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`); } /** * Add a reaction (emoji) to a message. * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART" * @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce */ async function addReactionFeishu(params) { const { cfg, messageId, emojiType, accountId } = params; const response = await resolveConfiguredFeishuClient({ cfg, accountId }).im.messageReaction.create({ path: { message_id: messageId }, data: { reaction_type: { emoji_type: emojiType } } }); assertFeishuReactionApiSuccess(response, "add reaction"); 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. */ async function removeReactionFeishu(params) { const { cfg, messageId, reactionId, accountId } = params; assertFeishuReactionApiSuccess(await resolveConfiguredFeishuClient({ cfg, accountId }).im.messageReaction.delete({ path: { message_id: messageId, reaction_id: reactionId } }), "remove reaction"); } /** * List all reactions for a message. */ async function listReactionsFeishu(params) { const { cfg, messageId, emojiType, accountId } = params; const response = await resolveConfiguredFeishuClient({ cfg, accountId }).im.messageReaction.list({ path: { message_id: messageId }, params: emojiType ? { reaction_type: emojiType } : void 0 }); assertFeishuReactionApiSuccess(response, "list reactions"); return (response.data?.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 ?? "" })); } //#endregion //#region extensions/feishu/src/channel.runtime.ts const feishuChannelRuntime = { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive, feishuOutbound: { ...feishuOutbound }, createPinFeishu, listPinsFeishu, removePinFeishu, probeFeishu, addReactionFeishu, listReactionsFeishu, removeReactionFeishu, getChatInfo, getChatMembers, getFeishuMemberInfo, editMessageFeishu, getMessageFeishu, sendCardFeishu, sendMessageFeishu }; //#endregion export { feishuChannelRuntime };