📄 outbound.ts

import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/plugin-sdk";

import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
import { resolveWecomAccounts } from "./config/index.js";
import { getWecomRuntime } from "./runtime.js";

import { resolveWecomTarget } from "./target.js";

function resolveAgentConfigOrThrow(cfg: ChannelOutboundContext["cfg"]) {
  const account = resolveWecomAccounts(cfg).agent;
  if (!account?.configured) {
    throw new Error(
      "WeCom outbound requires Agent mode. Configure channels.wecom.agent (corpId/corpSecret/agentId/token/encodingAESKey).",
    );
  }
  // 注意:不要在日志里输出 corpSecret 等敏感信息
  console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}`);
  return account;
}

export const wecomOutbound: ChannelOutboundAdapter = {
  deliveryMode: "direct",
  chunkerMode: "text",
  textChunkLimit: 20480,
  chunker: (text, limit) => {
    try {
      return getWecomRuntime().channel.text.chunkText(text, limit);
    } catch {
      return [text];
    }
  },
  sendText: async ({ cfg, to, text }: ChannelOutboundContext) => {
    // signal removed - not supported in current SDK

    const agent = resolveAgentConfigOrThrow(cfg);
    const target = resolveWecomTarget(to);
    if (!target) {
      throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
    }

    // 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
    // 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
    // 在 WeCom“双模式”场景下,这会造成:
    // - 用户在 Bot 会话发 /new,但却收到一条 Agent 私信回执(双重回复/错会话)。
    // 因此:
    // - Bot 会话目标:抑制该回执(Bot 会话里由 wecom 插件补中文回执)。
    // - Agent 会话目标(wecom-agent:):允许发送,但改写成中文。
    let outgoingText = text;
    const trimmed = String(outgoingText ?? "").trim();
    const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
    const isAgentSessionTarget = rawTo.startsWith("wecom-agent:");
    const looksLikeNewSessionAck =
      /new session started/i.test(trimmed) && /model:/i.test(trimmed);

    if (looksLikeNewSessionAck) {
      if (!isAgentSessionTarget) {
        console.log(`[wecom-outbound] Suppressed command ack to avoid Bot/Agent double-reply (len=${trimmed.length})`);
        return { channel: "wecom", messageId: `suppressed-${Date.now()}`, timestamp: Date.now() };
      }

      const modelLabel = (() => {
        const m = trimmed.match(/model:\s*([^\n()]+)\s*/i);
        return m?.[1]?.trim();
      })();
      const rewritten = modelLabel ? `✅ 已开启新会话(模型:${modelLabel})` : "✅ 已开启新会话。";
      console.log(`[wecom-outbound] Rewrote command ack for agent session (len=${rewritten.length})`);
      outgoingText = rewritten;
    }

    const { touser, toparty, totag, chatid } = target;
    if (chatid) {
      throw new Error(
        `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${chatid})。` +
          `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
          `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
      );
    }
    console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${outgoingText.length})`);

    try {
      await sendAgentText({
        agent,
        toUser: touser,
        toParty: toparty,
        toTag: totag,
        chatId: chatid,
        text: outgoingText,
      });
      console.log(`[wecom-outbound] Successfully sent text to ${JSON.stringify(target)}`);
    } catch (err) {
      console.error(`[wecom-outbound] Failed to send text to ${JSON.stringify(target)}:`, err);
      throw err;
    }

    return {
      channel: "wecom",
      messageId: `agent-${Date.now()}`,
      timestamp: Date.now(),
    };
  },
  sendMedia: async ({ cfg, to, text, mediaUrl }: ChannelOutboundContext) => {
    // signal removed - not supported in current SDK

    const agent = resolveAgentConfigOrThrow(cfg);
    const target = resolveWecomTarget(to);
    if (!target) {
      throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
    }
    if (target.chatid) {
      throw new Error(
        `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
          `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
          `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
      );
    }
    if (!mediaUrl) {
      throw new Error("WeCom outbound requires mediaUrl.");
    }

    let buffer: Buffer;
    let contentType: string;
    let filename: string;

    // 判断是 URL 还是本地文件路径
    const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);

    if (isRemoteUrl) {
      const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
      if (!res.ok) {
        throw new Error(`Failed to download media: ${res.status}`);
      }
      buffer = Buffer.from(await res.arrayBuffer());
      contentType = res.headers.get("content-type") || "application/octet-stream";
      const urlPath = new URL(mediaUrl).pathname;
      filename = urlPath.split("/").pop() || "media";
    } else {
      // 本地文件路径
      const fs = await import("node:fs/promises");
      const path = await import("node:path");

      buffer = await fs.readFile(mediaUrl);
      filename = path.basename(mediaUrl);

      // 根据扩展名推断 content-type
      const ext = path.extname(mediaUrl).slice(1).toLowerCase();
      const mimeTypes: Record<string, string> = {
        jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
        webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
        amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
        docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      };
      contentType = mimeTypes[ext] || "application/octet-stream";
      console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
    }

    let mediaType: "image" | "voice" | "video" | "file" = "file";
    if (contentType.startsWith("image/")) mediaType = "image";
    else if (contentType.startsWith("audio/")) mediaType = "voice";
    else if (contentType.startsWith("video/")) mediaType = "video";

    const mediaId = await uploadMedia({
      agent,
      type: mediaType,
      buffer,
      filename,
    });

    const { touser, toparty, totag, chatid } = target;
    console.log(`[wecom-outbound] Sending media (${mediaType}) to ${JSON.stringify(target)} (mediaId=${mediaId})`);

    try {
      await sendAgentMedia({
        agent,
        toUser: touser,
        toParty: toparty,
        toTag: totag,
        chatId: chatid,
        mediaId,
        mediaType,
        ...(mediaType === "video" && text?.trim()
          ? {
            title: text.trim().slice(0, 64),
            description: text.trim().slice(0, 512),
          }
          : {}),
      });
      console.log(`[wecom-outbound] Successfully sent media to ${JSON.stringify(target)}`);
    } catch (err) {
      console.error(`[wecom-outbound] Failed to send media to ${JSON.stringify(target)}:`, err);
      throw err;
    }

    return {
      channel: "wecom",
      messageId: `agent-media-${Date.now()}`,
      timestamp: Date.now(),
    };
  },
};