Newer
Older
WeComCompanyPlugin / index.js
import { AsyncLocalStorage } from "node:async_hooks";
import * as crypto from "node:crypto";
import { logger } from "./logger.js";
import { streamManager } from "./stream-manager.js";
import { WecomWebhook } from "./webhook.js"; // Now acts as parser & reply builder
import {
  streamContext,
  setApi,
  getApi,
  setOpenClawConfig,
  streamMeta,
  responseUrls,
  getActiveStreamContext,
  registerActiveStream,
  unregisterActiveStream,
  handleStreamError,
  processInboundMessage,
  deliverWecomReply,
  getMessageStreamKey,
} from "./wecom-message-processor.js";

const DEFAULT_ACCOUNT_ID = "default";
const THINKING_PLACEHOLDER = "思考中...";

// Webhook targets registry
const webhookTargets = new Map();

function normalizeWebhookPath(raw) {
  const trimmed = (raw || "").trim();
  if (!trimmed) {
    return "/";
  }
  const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
  if (withSlash.length > 1 && withSlash.endsWith("/")) {
    return withSlash.slice(0, -1);
  }
  return withSlash;
}

function registerWebhookTarget(target) {
  const key = normalizeWebhookPath(target.path);
  const entry = { ...target, path: key };
  const existing = webhookTargets.get(key) ?? [];
  webhookTargets.set(key, [...existing, entry]);
  return () => {
    const updated = (webhookTargets.get(key) ?? []).filter((e) => e !== entry);
    if (updated.length > 0) {
      webhookTargets.set(key, updated);
    } else {
      webhookTargets.delete(key);
    }
  };
}

// =============================================================================
// Channel Plugin Definition
// =============================================================================

const wecomChannelPlugin = {
  id: "wecom",
  meta: {
    id: "wecom",
    label: "Enterprise WeChat",
    selectionLabel: "Enterprise WeChat (AI Bot & Self-Built App)",
    blurb: "Enterprise WeChat AI Bot and Self-Built App channel plugin.",
    aliases: ["wecom", "wework"],
  },
  capabilities: {
    chatTypes: ["direct", "group"],
    reactions: false,
    threads: false,
    media: true,
    nativeCommands: false,
    blockStreaming: true, // WeCom AI Bot requires stream-style responses.
  },
  reload: { configPrefixes: ["channels.wecom"] },
  configSchema: {
    schema: {
      $schema: "http://json-schema.org/draft-07/schema#",
      type: "object",
      additionalProperties: false,
      properties: {
        enabled: {
          type: "boolean",
          description: "Enable WeCom channel",
          default: true,
        },
        applications: {
          type: "array",
          description: "List of WeCom applications (AI Bots or Self-Built Apps) to integrate.",
          items: {
            type: "object",
            additionalProperties: false,
            required: ["id", "token", "encodingAesKey"],
            properties: {
              id: {
                type: "string",
                description: "Unique ID for this WeCom application.",
              },
              enabled: {
                type: "boolean",
                description: "Enable this specific WeCom application.",
                default: true,
              },
              token: {
                type: "string",
                description: "WeCom bot token from admin console.",
              },
              encodingAesKey: {
                type: "string",
                description: "WeCom message encryption key (43 characters).",
                minLength: 43,
                maxLength: 43,
              },
              isSelfBuiltApp: {
                type: "boolean",
                description: "Set to true if this is a Self-Built App (uses XML, passive replies). Default is AI Bot (JSON, streaming replies).",
                default: false,
              },
              corpId: {
                type: "string",
                description: "WeCom Corp ID (required for Self-Built Apps to send active replies like 'Thinking...')",
              },
              corpSecret: {
                type: "string",
                description: "WeCom App Secret (required for Self-Built Apps to send active replies)",
              },
              webhookPath: {
                type: "string",
                description: "Unique webhook path for this application (e.g., /webhooks/wecom/my-ai-bot). Defaults to /webhooks/wecom/<id> if not specified.",
              },
            },
          },
        },
      },
    },
    uiHints: {
      applications: {
        label: "WeCom Applications",
        itemLabel: "Application: {{item.id}}",
      },
      token: { sensitive: true, label: "Bot Token" }, // Fallback, prefer item-level
      encodingAesKey: { sensitive: true, label: "Encoding AES Key", help: "43-character encryption key from WeCom admin console" }, // Fallback, prefer item-level
      isSelfBuiltApp: { label: "Self-Built App Mode", help: "Enable for Self-Built Apps (XML input/output). Disable for AI Bots (JSON input/output)." }, // Fallback, prefer item-level
    },
  },
  config: {
    listAccountIds: (cfg) => {
      const wecom = cfg?.channels?.wecom;
      if (!wecom || !wecom.enabled || !Array.isArray(wecom.applications)) {
        return [];
      }
      return wecom.applications.filter(app => app.enabled !== false).map(app => app.id);
    },
    resolveAccount: (cfg, accountId) => {
      const wecom = cfg?.channels?.wecom;
      if (!wecom || !wecom.enabled || !Array.isArray(wecom.applications)) {
        return null;
      }
      const app = wecom.applications.find(app => app.id === accountId && app.enabled !== false);
      if (!app) {
        return null;
      }
      return {
        id: app.id,
        accountId: app.id,
        enabled: app.enabled !== false,
        token: app.token || "",
        encodingAesKey: app.encodingAesKey || "",
        webhookPath: app.webhookPath || normalizeWebhookPath(`/webhooks/wecom/${app.id}`),
        config: app, // Pass the individual app config for `isSelfBuiltApp` etc.
        isSelfBuiltApp: app.isSelfBuiltApp === true,
      };
    },
    defaultAccountId: (cfg) => {
      const wecom = cfg?.channels?.wecom;
      if (!wecom || !wecom.enabled || !Array.isArray(wecom.applications) || wecom.applications.length === 0) {
        return null;
      }
      return wecom.applications[0].id; // Use the first enabled application as default
    },
    setAccountEnabled: ({ cfg, accountId, enabled }) => {
      const wecom = cfg?.channels?.wecom;
      if (!wecom || !Array.isArray(wecom.applications)) {
        return cfg;
      }
      const app = wecom.applications.find(app => app.id === accountId);
      if (app) {
        app.enabled = enabled;
      }
      return cfg;
    },
    deleteAccount: ({ cfg, accountId }) => {
      const wecom = cfg?.channels?.wecom;
      if (wecom && Array.isArray(wecom.applications)) {
        wecom.applications = wecom.applications.filter(app => app.id !== accountId);
      }
      return cfg;
    },
  },
  directory: {
    self: async () => null,
    listPeers: async () => [],
    listGroups: async () => [],
  },
  outbound: {
    sendText: async ({ cfg, to, text, accountId }) => {
      const userId = to.replace(/^wecom:/, "");
      const account = wecomChannelPlugin.config.resolveAccount(cfg, accountId);
      if (!account) {
          logger.error("WeCom outbound: account not found", { accountId, to });
          return { channel: "wecom", messageId: `fake_error_${Date.now()}` };
      }
      const isSelfBuiltAppRequest = account?.isSelfBuiltApp === true;

      const ctx = streamContext.getStore();
      // StreamKey should be derived from the target 'to' or senderId as before
      const streamKey = getMessageStreamKey({ fromUser: userId }); 
      const streamId = ctx?.streamId ?? getActiveStreamContext(streamKey);

      const result = await deliverWecomReply({
        payload: { text },
        senderId: userId,
        streamId: isSelfBuiltAppRequest ? undefined : streamId,
        isSelfBuiltAppRequest,
        originalMessage: { fromUser: userId, chatId: userId, ToUserName: account.id, query: { nonce: crypto.randomBytes(8).toString('hex') } }, // toUser for passive reply is fromUser, FromUser is app.id
        account,
      });

      if (result?.passiveReplyXml) {
          logger.warn("WeCom outbound sendText: Passive XML reply generated, but cannot be returned directly from sendText. This should be handled by httpHandler.", { userId, accountId });
      }

      return {
        channel: "wecom",
        messageId: `msg_${isSelfBuiltAppRequest ? 'passive' : 'stream'}_${Date.now()}`,
      };
    },
    sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
      const userId = to.replace(/^wecom:/, "");
      const account = wecomChannelPlugin.config.resolveAccount(cfg, accountId);
      if (!account) {
          logger.error("WeCom outbound: account not found", { accountId, to });
          return { channel: "wecom", messageId: `fake_error_${Date.now()}` };
      }
      const isSelfBuiltAppRequest = account?.isSelfBuiltApp === true;

      const ctx = streamContext.getStore();
      // StreamKey should be derived from the target 'to' or senderId as before
      const streamKey = getMessageStreamKey({ fromUser: userId });
      const streamId = ctx?.streamId ?? getActiveStreamContext(streamKey);

      const result = await deliverWecomReply({
        payload: { text, mediaUrl },
        senderId: userId,
        streamId: isSelfBuiltAppRequest ? undefined : streamId,
        isSelfBuiltAppRequest,
        originalMessage: { fromUser: userId, chatId: userId, ToUserName: account.id, query: { nonce: crypto.randomBytes(8).toString('hex') } }, // toUser for passive reply is fromUser, FromUser is app.id
        account,
      });

      if (result?.passiveReplyXml) {
          logger.warn("WeCom outbound sendMedia: Passive XML reply generated, but cannot be returned directly from sendMedia. This should be handled by httpHandler.", { userId, accountId });
      }

      return {
        channel: "wecom",
        messageId: `msg_${isSelfBuiltAppRequest ? 'passive_media' : 'stream_media'}_${Date.now()}`,
      };
    },
  },
  gateway: {
    startAccount: async (ctx) => {
      // This startAccount will now be called for each configured application (identified by accountId)
      const appAccount = ctx.account; // 'account' here is already resolved by resolveAccount and contains individual app config

      logger.info("WeCom gateway starting application", {
        appId: appAccount.id,
        webhookPath: appAccount.webhookPath,
        isSelfBuiltApp: appAccount.isSelfBuiltApp,
      });

      const unregister = registerWebhookTarget({
        path: appAccount.webhookPath,
        account: appAccount,
        config: ctx.cfg, // Full OpenClaw config
      });

      return {
        shutdown: async () => {
          logger.info("WeCom gateway shutting down application", { appId: appAccount.id });
          unregister();
        },
      };
    },
  },
};

// =============================================================================
// HTTP Webhook Handler
// =============================================================================

async function wecomHttpHandler(req, res) {
  const url = new URL(req.url || "", "http://localhost");
  const path = normalizeWebhookPath(url.pathname);
  const targets = webhookTargets.get(path);

  if (!targets || targets.length === 0) {
    logger.debug("WeCom HTTP request: no target found for path", { path });
    return false; // Not handled by this plugin
  }

  // In multi-app scenario, there should ideally be only one target per path.
  // If multiple targets map to the same path, we'll use the first one.
  const target = targets[0]; 
  const appAccount = target.account; // This is the specific app's configuration
  const fullConfig = target.config; // The full OpenClaw config

  if (!appAccount || !appAccount.enabled) {
    logger.warn("WeCom HTTP request: target app not found or disabled", { path, appId: appAccount?.id });
    res.writeHead(503, { "Content-Type": "text/plain" });
    res.end("WeCom application not found or disabled");
    return true;
  }

  const query = Object.fromEntries(url.searchParams);
  logger.debug("WeCom HTTP request", { method: req.method, path, appId: appAccount.id });

  const webhook = new WecomWebhook({
    token: appAccount.token,
    encodingAesKey: appAccount.encodingAesKey,
  });

  // GET: URL Verification
  if (req.method === "GET") {
    const echo = webhook.handleVerify(query);
    if (echo) {
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end(echo);
      logger.info("WeCom URL verification successful", { appId: appAccount.id });
      return true;
    }

    res.writeHead(403, { "Content-Type": "text/plain" });
    res.end("Verification failed");
    logger.warn("WeCom URL verification failed", { appId: appAccount.id });
    return true;
  }

  // POST: Message handling
  if (req.method === "POST") {
    const chunks = [];
    for await (const chunk of req) {
      chunks.push(chunk);
    }
    const rawBody = Buffer.concat(chunks).toString("utf-8");
    logger.debug("WeCom message received", { bodyLength: rawBody.length, appId: appAccount.id });

    // handleMessage will parse JSON or XML based on content
    const result = await webhook.handleMessage(query, rawBody);
    if (result === WecomWebhook.DUPLICATE) {
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end("success");
      return true;
    }
    if (!result) {
      res.writeHead(400, { "Content-Type": "text/plain" });
      res.end("Bad Request");
      return true;
    }

    const message = result.message || result; // Normalized message object
    const { timestamp, nonce } = result.query;

    const isAIBot = !appAccount.isSelfBuiltApp; // Use app-specific flag

    if (isAIBot) {
      if (result.stream) { // AI Bot stream refresh
        const streamId = result.stream.id;
        const stream = streamManager.getStream(streamId);

        if (!stream) {
          logger.warn("Stream not found for refresh", { streamId, appId: appAccount.id });
          const streamResponse = webhook.buildStreamResponse(
            streamId,
            "会话已过期",
            true,
            timestamp,
            nonce,
          );
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(streamResponse);
          return true;
        }

        const meta = streamMeta.get(streamId);
        if (meta?.mainResponseDone && !stream.finished) {
          const idleMs = Date.now() - stream.updatedAt;
          if (idleMs > 10000) {
            logger.info("WeCom: closing stream due to idle timeout", { streamId, idleMs, appId: appAccount.id });
            try {
              await streamManager.finishStream(streamId);
            } catch (err) {
              logger.error("WeCom: failed to finish stream", { streamId, error: err.message, appId: appAccount.id });
            }
          }
        }

        const streamResponse = webhook.buildStreamResponse(
          streamId,
          stream.content,
          stream.finished,
          timestamp,
          nonce,
          stream.finished && stream.msgItem.length > 0 ? { msgItem: stream.msgItem } : {},
        );

        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(streamResponse);

        logger.debug("Stream refresh response sent", {
          streamId,
          contentLength: stream.content.length,
          finished: stream.finished,
          appId: appAccount.id,
        });

        if (stream.finished) {
          setTimeout(() => {
            streamManager.deleteStream(streamId);
            streamMeta.delete(streamId);
          }, 30 * 1000);
        }
        return true;

      } else { // AI Bot initial message/event
        const streamId = `stream_${crypto.randomUUID()}`;
        streamManager.createStream(streamId);
        streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
        const streamResponse = webhook.buildStreamResponse(streamId, THINKING_PLACEHOLDER, false, timestamp, nonce);

        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(streamResponse);

        logger.info("Stream initiated (AI Bot)", {
          streamId,
          from: message.fromUser,
          appId: appAccount.id,
        });

        processInboundMessage({
          message,
          streamId,
          timestamp,
          nonce,
          account: appAccount,
          config: fullConfig,
        }).catch(async (err) => {
          logger.error("WeCom AI Bot message processing failed", { error: err.message, appId: appAccount.id });
          await handleStreamError(streamId, getMessageStreamKey(message), "处理消息时出错,请稍后再试。");
        });
        return true;
      }

    } else { // Self-Built App: passive XML reply
        logger.info("Self-Built App message/event received, processing for passive reply", {
          from: message.fromUser,
          appId: appAccount.id,
        });

        const processingResult = await processInboundMessage({
          message,
          streamId: undefined,
          timestamp,
          nonce,
          account: appAccount,
          config: fullConfig,
        }).catch(async (err) => {
          logger.error("WeCom Self-Built App message processing failed", { error: err.message, appId: appAccount.id });
          return { passiveReplyXml: webhook.buildPassiveReplyXml(
              message.fromUser,
              appAccount.id,
              "text",
              "处理消息时出错,请稍后再试。",
              Math.floor(Date.now() / 1000),
              nonce,
          )};
        });

        if (processingResult?.passiveReplyXml) {
            logger.info("WeCom Self-Built: returning passive XML reply", { replyXmlPreview: processingResult.passiveReplyXml.substring(0, 100), appId: appAccount.id });
            res.writeHead(200, { "Content-Type": "application/xml" });
            res.end(processingResult.passiveReplyXml);
            return true;
        } else {
            logger.info("WeCom Self-Built: no immediate passive XML reply, returning success.", { appId: appAccount.id });
            res.writeHead(200, { "Content-Type": "text/plain" });
            res.end("success");
            return true;
        }
    }
  }

  res.writeHead(405, { "Content-Type": "text/plain" });
  res.end("Method Not Allowed");
  return true;
}

// =============================================================================
// Plugin Registration
// =============================================================================

const plugin = {
  id: "wecom",
  name: "Enterprise WeChat",
  description: "Enterprise WeChat AI Bot and Self-Built App channel plugin for OpenClaw (Bone Version)",
  configSchema: { type: "object", additionalProperties: false, properties: {} },
  register(api) {
    logger.info("WeCom plugin registering...");
    console.log('OpenClaw API structure:', JSON.stringify(api, null, 2));
    setApi(api);
    setOpenClawConfig(api.config);

    api.registerChannel({ plugin: wecomChannelPlugin });
    logger.info("WeCom channel registered"); console.log("DEBUG OPENCLAW channelApi.reply keys:", Object.keys(api.runtime?.channel?.reply || api.channel?.reply || {})); console.log("DEBUG OPENCLAW channelApi.text keys:", Object.keys(api.runtime?.channel?.text || api.channel?.text || {}));

    // Register HTTP handler for webhooks. This handler will now dynamically resolve the application.
    api.registerHttpHandler(wecomHttpHandler);
    logger.info("WeCom HTTP handler registered");
  },
};

export default plugin;
export const register = (api) => plugin.register(api);