UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

185 lines (170 loc) 6.14 kB
import { createReplyPrefixContext, createTypingCallbacks, logTypingFailure, type ClawdbotConfig, type RuntimeEnv, type ReplyPayload, } from "openclaw/plugin-sdk"; import type { MentionTarget } from "./mention.js"; import { resolveFeishuAccount } from "./accounts.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; /** * Detect if text contains markdown elements that benefit from card rendering. * Used by auto render mode. */ function shouldUseCard(text: string): boolean { // Code blocks (fenced) if (/```[\s\S]*?```/.test(text)) { return true; } // Tables (at least header + separator row with |) if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) { return true; } return false; } export type CreateFeishuReplyDispatcherParams = { cfg: ClawdbotConfig; agentId: string; runtime: RuntimeEnv; chatId: string; replyToMessageId?: string; /** Mention targets, will be auto-included in replies */ mentionTargets?: MentionTarget[]; /** Account ID for multi-account support */ accountId?: string; }; export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { const core = getFeishuRuntime(); const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params; // Resolve account for config access const account = resolveFeishuAccount({ cfg, accountId }); const prefixContext = createReplyPrefixContext({ cfg, agentId, }); // Feishu doesn't have a native typing indicator API. // We use message reactions as a typing indicator substitute. let typingState: TypingIndicatorState | null = null; const typingCallbacks = createTypingCallbacks({ start: async () => { if (!replyToMessageId) { return; } typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId }); params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`); }, stop: async () => { if (!typingState) { return; } await removeTypingIndicator({ cfg, state: typingState, accountId }); typingState = null; params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`); }, onStartError: (err) => { logTypingFailure({ log: (message) => params.runtime.log?.(message), channel: "feishu", action: "start", error: err, }); }, onStopError: (err) => { logTypingFailure({ log: (message) => params.runtime.log?.(message), channel: "feishu", action: "stop", error: err, }); }, }); const textChunkLimit = core.channel.text.resolveTextChunkLimit({ cfg, channel: "feishu", defaultLimit: 4000, }); const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu"); const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu", }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), onReplyStart: typingCallbacks.onReplyStart, deliver: async (payload: ReplyPayload) => { params.runtime.log?.( `feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`, ); const text = payload.text ?? ""; if (!text.trim()) { params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`); return; } // Check render mode: auto (default), raw, or card const feishuCfg = account.config; const renderMode = feishuCfg?.renderMode ?? "auto"; // Determine if we should use card for this message const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); // Only include @mentions in the first chunk (avoid duplicate @s) let isFirstChunk = true; if (useCard) { // Card mode: send as interactive card with markdown rendering const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); params.runtime.log?.( `feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`, ); for (const chunk of chunks) { await sendMarkdownCardFeishu({ cfg, to: chatId, text: chunk, replyToMessageId, mentions: isFirstChunk ? mentionTargets : undefined, accountId, }); isFirstChunk = false; } } else { // Raw mode: send as plain text with table conversion const converted = core.channel.text.convertMarkdownTables(text, tableMode); const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode); params.runtime.log?.( `feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`, ); for (const chunk of chunks) { await sendMessageFeishu({ cfg, to: chatId, text: chunk, replyToMessageId, mentions: isFirstChunk ? mentionTargets : undefined, accountId, }); isFirstChunk = false; } } }, onError: (err, info) => { params.runtime.error?.( `feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`, ); typingCallbacks.onIdle?.(); }, onIdle: typingCallbacks.onIdle, }); return { dispatcher, replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected, }, markDispatchIdle, }; }