/** * ChannelOutboundAdapter — used by OpenClaw core whenever it wants to * send a message through wechat-agent outside the main inbound/reply * pipeline (e.g. agent-initiated pings, scheduled broadcasts, tool * results that return to the channel, approval notifications). * * For messages generated in response to an inbound (the common path), * the delivery actually happens inside monitor.ts's `deliver` callback, * which bypasses this adapter. Both paths call the same client.sendText * under the hood. */ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; import { getRuntime } from "./runtime.js"; import { createClient } from "./client.js"; import { CHANNEL_ID, getChannelCfg, resolveApiBase } from "./config.js"; /** Strip any provider prefix and return the bare wxid. */ function toBareWxid(target: string): string { return target .replace(/^(?:wxid|wechat-agent|wechat|weixin|user):/i, "") .trim(); } export const outbound: ChannelOutboundAdapter = { deliveryMode: "direct", // Core helpers for text chunking. Using runtime lookup (vs. importing the // utility) keeps us forward-compatible when the SDK changes chunking impls. chunker: (text: string, limit: number) => getRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, sendText: async (ctx) => { const channelCfg = getChannelCfg(ctx.cfg); const apiBase = resolveApiBase(channelCfg); const bare = toBareWxid(ctx.to); if (!bare) { throw new Error( `[${CHANNEL_ID}] invalid target "${ctx.to}" \u2014 expected wxid:` ); } const client = createClient(apiBase); const result = await client.sendText({ toWxid: bare, content: ctx.text, }); if (!result.ok) { throw new Error( `[${CHANNEL_ID}] send-text failed: ${result.msg ?? `ret=${String(result.ret)}`}` ); } return { channel: CHANNEL_ID, messageId: result.syntheticMessageId, conversationId: bare, }; }, sendMedia: async (ctx) => { // MVP: the wechat-agent backend we target does not expose a stable media // endpoint across wcf/xbot builds. Fall back to sending the caption as // text so the user still gets context. Proper image/voice/file support // will land in v0.2 once we fingerprint the backend build. const channelCfg = getChannelCfg(ctx.cfg); const apiBase = resolveApiBase(channelCfg); const bare = toBareWxid(ctx.to); if (!bare) { throw new Error( `[${CHANNEL_ID}] invalid target "${ctx.to}" \u2014 expected wxid:` ); } const caption = ctx.text?.trim() || "[media message \u2014 wechat-agent v0.1 does not yet support media outbound]"; const client = createClient(apiBase); const result = await client.sendText({ toWxid: bare, content: caption, }); if (!result.ok) { throw new Error( `[${CHANNEL_ID}] send-media(fallback-to-text) failed: ${result.msg ?? `ret=${String(result.ret)}`}` ); } return { channel: CHANNEL_ID, messageId: result.syntheticMessageId, conversationId: bare, meta: { mediaFallback: "caption-only" }, }; }, };