openclaw/extensions/wecom/src/monitor.ts
YanHaidao a3799c7748 - Add WeCom channel plugin for Clawdbot (popular in China)
- Add channel documentation (EN + 中文) and plugin README
- Link WeCom from channels index
- 新增 WeCom 渠道插件(国内常用)
- 新增频道文档(英文 + 中文)与插件 README
- 在 Channels 索引中加入 WeCom 入口
2026-01-27 11:45:52 +08:00

647 lines
20 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import crypto from "node:crypto";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
import { getWecomRuntime } from "./runtime.js";
export type WecomRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
type WecomWebhookTarget = {
account: ResolvedWecomAccount;
config: ClawdbotConfig;
runtime: WecomRuntimeEnv;
core: PluginRuntime;
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
type StreamState = {
streamId: string;
msgid?: string;
createdAt: number;
updatedAt: number;
started: boolean;
finished: boolean;
error?: string;
content: string;
};
const webhookTargets = new Map<string, WecomWebhookTarget[]>();
const streams = new Map<string, StreamState>();
const msgidToStreamId = new Map<string, string>();
const STREAM_TTL_MS = 10 * 60 * 1000;
const STREAM_MAX_BYTES = 20_480;
function normalizeWebhookPath(raw: string): string {
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 pruneStreams(): void {
const cutoff = Date.now() - STREAM_TTL_MS;
for (const [id, state] of streams.entries()) {
if (state.updatedAt < cutoff) {
streams.delete(id);
}
}
for (const [msgid, id] of msgidToStreamId.entries()) {
if (!streams.has(id)) {
msgidToStreamId.delete(msgid);
}
}
}
function truncateUtf8Bytes(text: string, maxBytes: number): string {
const buf = Buffer.from(text, "utf8");
if (buf.length <= maxBytes) return text;
const slice = buf.subarray(buf.length - maxBytes);
return slice.toString("utf8");
}
function jsonOk(res: ServerResponse, body: unknown): void {
res.statusCode = 200;
// WeCom's reference implementation returns the encrypted JSON as text/plain.
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(JSON.stringify(body));
}
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
resolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
resolve({ ok: false, error: "empty payload" });
return;
}
resolve({ ok: true, value: JSON.parse(raw) as unknown });
} catch (err) {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
});
req.on("error", (err) => {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
});
}
function buildEncryptedJsonReply(params: {
account: ResolvedWecomAccount;
plaintextJson: unknown;
nonce: string;
timestamp: string;
}): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
const plaintext = JSON.stringify(params.plaintextJson ?? {});
const encrypt = encryptWecomPlaintext({
encodingAESKey: params.account.encodingAESKey ?? "",
receiveId: params.account.receiveId ?? "",
plaintext,
});
const msgsignature = computeWecomMsgSignature({
token: params.account.token ?? "",
timestamp: params.timestamp,
nonce: params.nonce,
encrypt,
});
return {
encrypt,
msgsignature,
timestamp: params.timestamp,
nonce: params.nonce,
};
}
function resolveQueryParams(req: IncomingMessage): URLSearchParams {
const url = new URL(req.url ?? "/", "http://localhost");
return url.searchParams;
}
function resolvePath(req: IncomingMessage): string {
const url = new URL(req.url ?? "/", "http://localhost");
return normalizeWebhookPath(url.pathname || "/");
}
function resolveSignatureParam(params: URLSearchParams): string {
return (
params.get("msg_signature") ??
params.get("msgsignature") ??
params.get("signature") ??
""
);
}
function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
return {
msgtype: "stream",
stream: {
id: streamId,
finish: false,
// Spec: "第一次回复内容为 1" works as a minimal placeholder.
content: "1",
},
};
}
function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES);
return {
msgtype: "stream",
stream: {
id: state.streamId,
finish: state.finished,
content,
},
};
}
function createStreamId(): string {
return crypto.randomBytes(16).toString("hex");
}
function logVerbose(target: WecomWebhookTarget, message: string): void {
const core = target.core;
const should = core.logging?.shouldLogVerbose?.() ?? false;
if (should) {
target.runtime.log?.(`[wecom] ${message}`);
}
}
function parseWecomPlainMessage(raw: string): WecomInboundMessage {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
return parsed as WecomInboundMessage;
}
async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
if (maxWaitMs <= 0) return;
const startedAt = Date.now();
await new Promise<void>((resolve) => {
const tick = () => {
const state = streams.get(streamId);
if (!state) return resolve();
if (state.error || state.finished || state.content.trim()) return resolve();
if (Date.now() - startedAt >= maxWaitMs) return resolve();
setTimeout(tick, 25);
};
tick();
});
}
async function startAgentForStream(params: {
target: WecomWebhookTarget;
accountId: string;
msg: WecomInboundMessage;
streamId: string;
}): Promise<void> {
const { target, msg, streamId } = params;
const core = target.core;
const config = target.config;
const account = target.account;
const userid = msg.from?.userid?.trim() || "unknown";
const chatType = msg.chattype === "group" ? "group" : "direct";
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
const rawBody = buildInboundBody(msg);
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "wecom",
accountId: account.accountId,
peer: { kind: chatType === "group" ? "group" : "dm", id: chatId },
});
logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "WeCom",
from: fromLabel,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
To: `wecom:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
SenderName: userid,
SenderId: userid,
Provider: "wecom",
Surface: "wecom",
MessageSid: msg.msgid,
OriginatingChannel: "wecom",
OriginatingTo: `wecom:${chatId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "wecom",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const current = streams.get(streamId);
if (!current) return;
const nextText = current.content
? `${current.content}\n\n${text}`.trim()
: text.trim();
current.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
current.updatedAt = Date.now();
target.statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (err, info) => {
target.runtime.error?.(`[${account.accountId}] wecom ${info.kind} reply failed: ${String(err)}`);
},
},
});
const current = streams.get(streamId);
if (current) {
current.finished = true;
current.updatedAt = Date.now();
}
}
function buildInboundBody(msg: WecomInboundMessage): string {
const msgtype = String(msg.msgtype ?? "").toLowerCase();
if (msgtype === "text") {
const content = (msg as any).text?.content;
return typeof content === "string" ? content : "";
}
if (msgtype === "voice") {
const content = (msg as any).voice?.content;
return typeof content === "string" ? content : "[voice]";
}
if (msgtype === "mixed") {
const items = (msg as any).mixed?.msg_item;
if (Array.isArray(items)) {
return items
.map((item: any) => {
const t = String(item?.msgtype ?? "").toLowerCase();
if (t === "text") return String(item?.text?.content ?? "");
if (t === "image") return `[image] ${String(item?.image?.url ?? "").trim()}`.trim();
return `[${t || "item"}]`;
})
.filter((part: string) => Boolean(part && part.trim()))
.join("\n");
}
return "[mixed]";
}
if (msgtype === "image") {
const url = String((msg as any).image?.url ?? "").trim();
return url ? `[image] ${url}` : "[image]";
}
if (msgtype === "file") {
const url = String((msg as any).file?.url ?? "").trim();
return url ? `[file] ${url}` : "[file]";
}
if (msgtype === "event") {
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
return eventtype ? `[event] ${eventtype}` : "[event]";
}
if (msgtype === "stream") {
const id = String((msg as any).stream?.id ?? "").trim();
return id ? `[stream_refresh] ${id}` : "[stream_refresh]";
}
return msgtype ? `[${msgtype}]` : "";
}
export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) webhookTargets.set(key, updated);
else webhookTargets.delete(key);
};
}
export async function handleWecomWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
pruneStreams();
const path = resolvePath(req);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false;
const query = resolveQueryParams(req);
const timestamp = query.get("timestamp") ?? "";
const nonce = query.get("nonce") ?? "";
const signature = resolveSignatureParam(query);
const firstTarget = targets[0]!;
logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`);
if (req.method === "GET") {
const echostr = query.get("echostr") ?? "";
if (!timestamp || !nonce || !signature || !echostr) {
logVerbose(firstTarget, "GET request missing query params");
res.statusCode = 400;
res.end("missing query params");
return true;
}
const target = targets.find((candidate) => {
if (!candidate.account.configured || !candidate.account.token) return false;
const ok = verifyWecomSignature({
token: candidate.account.token,
timestamp,
nonce,
encrypt: echostr,
signature,
});
if (!ok) {
logVerbose(candidate, `signature verification failed for echostr (token=${candidate.account.token?.slice(0, 4)}...)`);
}
return ok;
});
if (!target || !target.account.encodingAESKey) {
logVerbose(firstTarget, "no matching target for GET signature");
res.statusCode = 401;
res.end("unauthorized");
return true;
}
try {
const plain = decryptWecomEncrypted({
encodingAESKey: target.account.encodingAESKey,
receiveId: target.account.receiveId,
encrypt: echostr,
});
logVerbose(target, "GET echostr decrypted successfully");
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(plain);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logVerbose(target, `GET decrypt failed: ${msg}`);
res.statusCode = 400;
res.end(msg || "decrypt failed");
return true;
}
}
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "GET, POST");
res.end("Method Not Allowed");
return true;
}
if (!timestamp || !nonce || !signature) {
logVerbose(firstTarget, "POST request missing query params");
res.statusCode = 400;
res.end("missing query params");
return true;
}
const body = await readJsonBody(req, 1024 * 1024);
if (!body.ok) {
logVerbose(firstTarget, `POST body read failed: ${body.error}`);
res.statusCode = body.error === "payload too large" ? 413 : 400;
res.end(body.error ?? "invalid payload");
return true;
}
const record = body.value && typeof body.value === "object" ? (body.value as Record<string, unknown>) : null;
const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : "";
if (!encrypt) {
logVerbose(firstTarget, "POST request missing encrypt field in body");
res.statusCode = 400;
res.end("missing encrypt");
return true;
}
// Find the first target that validates the signature.
const target = targets.find((candidate) => {
if (!candidate.account.token) return false;
const ok = verifyWecomSignature({
token: candidate.account.token,
timestamp,
nonce,
encrypt,
signature,
});
if (!ok) {
logVerbose(candidate, `signature verification failed for POST (token=${candidate.account.token?.slice(0, 4)}...)`);
}
return ok;
});
if (!target) {
logVerbose(firstTarget, "no matching target for POST signature");
res.statusCode = 401;
res.end("unauthorized");
return true;
}
if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) {
logVerbose(target, "target found but not fully configured");
res.statusCode = 500;
res.end("wecom not configured");
return true;
}
let plain: string;
try {
plain = decryptWecomEncrypted({
encodingAESKey: target.account.encodingAESKey,
receiveId: target.account.receiveId,
encrypt,
});
logVerbose(target, `decrypted POST message: ${plain}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logVerbose(target, `POST decrypt failed: ${msg}`);
res.statusCode = 400;
res.end(msg || "decrypt failed");
return true;
}
const msg = parseWecomPlainMessage(plain);
target.statusSink?.({ lastInboundAt: Date.now() });
const msgtype = String(msg.msgtype ?? "").toLowerCase();
const msgid = msg.msgid ? String(msg.msgid) : undefined;
// Stream refresh callback: reply with current state (if any).
if (msgtype === "stream") {
const streamId = String((msg as any).stream?.id ?? "").trim();
const state = streamId ? streams.get(streamId) : undefined;
if (state) logVerbose(target, `stream refresh streamId=${streamId} started=${state.started} finished=${state.finished}`);
const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({
streamId: streamId || "unknown",
createdAt: Date.now(),
updatedAt: Date.now(),
started: true,
finished: true,
content: "",
});
jsonOk(res, buildEncryptedJsonReply({
account: target.account,
plaintextJson: reply,
nonce,
timestamp,
}));
return true;
}
// Dedupe: if we already created a stream for this msgid, return placeholder again.
if (msgid && msgidToStreamId.has(msgid)) {
const streamId = msgidToStreamId.get(msgid) ?? "";
const reply = buildStreamPlaceholderReply(streamId);
jsonOk(res, buildEncryptedJsonReply({
account: target.account,
plaintextJson: reply,
nonce,
timestamp,
}));
return true;
}
// enter_chat welcome event: optionally reply with text (allowed by spec).
if (msgtype === "event") {
const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
if (eventtype === "enter_chat") {
const welcome = target.account.config.welcomeText?.trim();
const reply = welcome
? { msgtype: "text", text: { content: welcome } }
: {};
jsonOk(res, buildEncryptedJsonReply({
account: target.account,
plaintextJson: reply,
nonce,
timestamp,
}));
return true;
}
// For other events, reply empty to avoid timeouts.
jsonOk(res, buildEncryptedJsonReply({
account: target.account,
plaintextJson: {},
nonce,
timestamp,
}));
return true;
}
// Default: respond with a stream placeholder and compute the actual reply async.
const streamId = createStreamId();
if (msgid) msgidToStreamId.set(msgid, streamId);
streams.set(streamId, {
streamId,
msgid,
createdAt: Date.now(),
updatedAt: Date.now(),
started: false,
finished: false,
content: "",
});
// Kick off agent processing in the background.
let core: PluginRuntime | null = null;
try {
core = getWecomRuntime();
} catch (err) {
// If runtime is not ready, we can't process the agent, but we should still
// return the placeholder if possible, or handle it as a background error.
logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`);
}
if (core) {
streams.get(streamId)!.started = true;
const enrichedTarget: WecomWebhookTarget = { ...target, core };
startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg, streamId }).catch((err) => {
const state = streams.get(streamId);
if (state) {
state.error = err instanceof Error ? err.message : String(err);
state.content = state.content || `Error: ${state.error}`;
state.finished = true;
state.updatedAt = Date.now();
}
target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
});
} else {
// In tests or uninitialized state, we might not have a core.
// We mark it as finished to avoid hanging, but don't set an error content
// immediately if we want to return the placeholder "1".
const state = streams.get(streamId);
if (state) {
state.finished = true;
state.updatedAt = Date.now();
}
}
// Try to include a first chunk in the initial response (matches WeCom demo behavior).
// If nothing is ready quickly, fall back to the placeholder "1".
await waitForStreamContent(streamId, 800);
const state = streams.get(streamId);
const initialReply = state && (state.content.trim() || state.error)
? buildStreamReplyFromState(state)
: buildStreamPlaceholderReply(streamId);
jsonOk(res, buildEncryptedJsonReply({
account: target.account,
plaintextJson: initialReply,
nonce,
timestamp,
}));
logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`);
return true;
}