outbound.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /**
  2. * ChannelOutboundAdapter — used by OpenClaw core whenever it wants to
  3. * send a message through wechat-agent outside the main inbound/reply
  4. * pipeline (e.g. agent-initiated pings, scheduled broadcasts, tool
  5. * results that return to the channel, approval notifications).
  6. *
  7. * For messages generated in response to an inbound (the common path),
  8. * the delivery actually happens inside monitor.ts's `deliver` callback,
  9. * which bypasses this adapter. Both paths call the same client.sendText
  10. * under the hood.
  11. */
  12. import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
  13. import { getRuntime } from "./runtime.js";
  14. import { createClient } from "./client.js";
  15. import { CHANNEL_ID, getChannelCfg, resolveApiBase } from "./config.js";
  16. /** Strip any provider prefix and return the bare wxid. */
  17. function toBareWxid(target: string): string {
  18. return target
  19. .replace(/^(?:wxid|wechat-agent|wechat|weixin|user):/i, "")
  20. .trim();
  21. }
  22. export const outbound: ChannelOutboundAdapter = {
  23. deliveryMode: "direct",
  24. // Core helpers for text chunking. Using runtime lookup (vs. importing the
  25. // utility) keeps us forward-compatible when the SDK changes chunking impls.
  26. chunker: (text: string, limit: number) =>
  27. getRuntime().channel.text.chunkMarkdownText(text, limit),
  28. chunkerMode: "markdown",
  29. textChunkLimit: 4000,
  30. sendText: async (ctx) => {
  31. const channelCfg = getChannelCfg(ctx.cfg);
  32. const apiBase = resolveApiBase(channelCfg);
  33. const bare = toBareWxid(ctx.to);
  34. if (!bare) {
  35. throw new Error(
  36. `[${CHANNEL_ID}] invalid target "${ctx.to}" \u2014 expected wxid:<wxid>`
  37. );
  38. }
  39. const client = createClient(apiBase);
  40. const result = await client.sendText({
  41. toWxid: bare,
  42. content: ctx.text,
  43. });
  44. if (!result.ok) {
  45. throw new Error(
  46. `[${CHANNEL_ID}] send-text failed: ${result.msg ?? `ret=${String(result.ret)}`}`
  47. );
  48. }
  49. return {
  50. channel: CHANNEL_ID,
  51. messageId: result.syntheticMessageId,
  52. conversationId: bare,
  53. };
  54. },
  55. sendMedia: async (ctx) => {
  56. // MVP: the wechat-agent backend we target does not expose a stable media
  57. // endpoint across wcf/xbot builds. Fall back to sending the caption as
  58. // text so the user still gets context. Proper image/voice/file support
  59. // will land in v0.2 once we fingerprint the backend build.
  60. const channelCfg = getChannelCfg(ctx.cfg);
  61. const apiBase = resolveApiBase(channelCfg);
  62. const bare = toBareWxid(ctx.to);
  63. if (!bare) {
  64. throw new Error(
  65. `[${CHANNEL_ID}] invalid target "${ctx.to}" \u2014 expected wxid:<wxid>`
  66. );
  67. }
  68. const caption =
  69. ctx.text?.trim() ||
  70. "[media message \u2014 wechat-agent v0.1 does not yet support media outbound]";
  71. const client = createClient(apiBase);
  72. const result = await client.sendText({
  73. toWxid: bare,
  74. content: caption,
  75. });
  76. if (!result.ok) {
  77. throw new Error(
  78. `[${CHANNEL_ID}] send-media(fallback-to-text) failed: ${result.msg ?? `ret=${String(result.ret)}`}`
  79. );
  80. }
  81. return {
  82. channel: CHANNEL_ID,
  83. messageId: result.syntheticMessageId,
  84. conversationId: bare,
  85. meta: { mediaFallback: "caption-only" },
  86. };
  87. },
  88. };