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,
audio: 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);