/** * HTTP client for the third-party wechat-agent backend. * * Zero external deps — uses Node 20+ global fetch. Keeps tgz tiny. * * Backend contract (verified against the customer's production daemon * @../../../daemon/auto-reply-daemon.js on 2026-04-18): * POST /login/check-online -> { data: true } OR { ret: 200, data: { online: true } } * GET /messages?direction=received -> { data: [RawMsg, ...] } (ret optional) * POST /message/send-text -> { ret: 200, msg?: string } * * Both response shapes for check-online are tolerated because older/newer * backend builds (wcf vs xbot vs custom Express wrapper) differ here. * * RawMsg fields we expect (best-effort, unknown fields tolerated): * msgId | id, fromWxid | from, toWxid | to, fromName | senderName, * type, content | text | body, timestamp | time, isGroup, chatroomId, * chatroomMemberWxid */ import type { RawWechatMessage, SendTextResult } from "./types.js"; export interface WechatAgentClient { readonly apiBase: string; checkOnline(): Promise; getSelfWxid(): Promise; getMessages(opts?: GetMessagesOptions): Promise; sendText(opts: SendTextOptions): Promise; } export type GetMessagesOptions = { /** Max messages to return. Default 50. */ limit?: number; /** "received" = inbound only (what we want for auto-reply). */ direction?: "received" | "sent" | "all"; /** Abort signal for graceful shutdown. */ signal?: AbortSignal; /** Per-request timeout (ms). Default 15s. */ timeoutMs?: number; /** Optional incremental cursor fields (backend may ignore unknown params). */ sinceTimestampMs?: number; sinceMsgId?: string; }; export type SendTextOptions = { toWxid: string; content: string; /** Optional @mentions wxid list, comma-separated (for group chats). */ ats?: string; signal?: AbortSignal; timeoutMs?: number; }; export function createClient(apiBase: string): WechatAgentClient { const base = apiBase.replace(/\/+$/, ""); async function request( path: string, init: RequestInit & { timeoutMs?: number } = {} ): Promise { const { timeoutMs = 15000, signal: userSignal, ...rest } = init; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new Error("timeout")), timeoutMs); // Chain user-provided signal so caller abort propagates. if (userSignal) { if (userSignal.aborted) { controller.abort(userSignal.reason); } else { userSignal.addEventListener("abort", () => controller.abort(userSignal.reason), { once: true, }); } } try { const resp = await fetch(`${base}${path}`, { ...rest, signal: controller.signal, }); const text = await resp.text(); if (!resp.ok) { throw new Error(`wechat-agent ${path} HTTP ${resp.status}: ${text.slice(0, 200)}`); } if (!text) return null; try { return JSON.parse(text); } catch { throw new Error( `wechat-agent ${path} returned non-JSON: ${text.slice(0, 200)}` ); } } finally { clearTimeout(timeout); } } return { apiBase: base, async checkOnline(): Promise { try { const body = (await request("/login/check-online", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", })) as { ret?: number; data?: unknown } | null; if (!body) return false; if (body.ret !== undefined && body.ret !== 200) return false; // Shape A (production daemon): { data: true } if (body.data === true) return true; // Shape B (alt backends): { data: { online: true } } if (body.data && typeof body.data === "object") { return Boolean((body.data as { online?: boolean }).online); } return false; } catch { return false; } }, async getSelfWxid(): Promise { try { const body = (await request("/login/check-online", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", })) as { ret?: number; data?: unknown } | null; const d = body?.data; if (d && typeof d === "object") { const dd = d as Record; const wxid = dd.wxid ?? dd.self; if (typeof wxid === "string" && wxid) return wxid; } return null; } catch { return null; } }, async getMessages(opts: GetMessagesOptions = {}): Promise { const limit = opts.limit ?? 50; const direction = opts.direction ?? "received"; const qs = new URLSearchParams({ limit: String(limit), direction }); if (typeof opts.sinceTimestampMs === "number" && Number.isFinite(opts.sinceTimestampMs)) { qs.set("sinceTimestampMs", String(Math.floor(opts.sinceTimestampMs))); } if (opts.sinceMsgId?.trim()) { qs.set("sinceMsgId", opts.sinceMsgId.trim()); } const body = (await request(`/messages?${qs.toString()}`, { method: "GET", signal: opts.signal, timeoutMs: opts.timeoutMs, })) as { ret?: number; data?: unknown[] } | null; if (!body) return []; if (body.ret !== undefined && body.ret !== 200) return []; const items = Array.isArray(body.data) ? body.data : []; return items.map(normalizeMessage).filter((m): m is RawWechatMessage => m !== null); }, async sendText(opts: SendTextOptions): Promise { const payload = { toWxid: opts.toWxid, content: opts.content, ats: opts.ats ?? "", }; const syntheticMessageId = `wx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { const body = (await request("/message/send-text", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), signal: opts.signal, timeoutMs: opts.timeoutMs, })) as { ret?: number; msg?: string } | null; const ret = body?.ret; const ok = ret === 200 || ret === undefined; return { ok, ret, msg: body?.msg, syntheticMessageId, }; } catch (err) { return { ok: false, msg: err instanceof Error ? err.message : String(err), syntheticMessageId, }; } }, }; } /** Best-effort normalization of the backend's message payload. */ function normalizeMessage(item: unknown): RawWechatMessage | null { if (!item || typeof item !== "object") return null; const o = item as Record; const msgId = pickString(o, ["msgId", "id", "messageId", "msg_id"]); if (!msgId) return null; const fromWxid = pickString(o, ["fromWxid", "from", "fromId", "sender", "from_wxid"]); if (!fromWxid) return null; const toWxid = pickString(o, ["toWxid", "to", "toId", "receiver", "to_wxid"]) ?? ""; const fromName = pickString(o, ["fromName", "senderName", "nickName", "fromNick"]); const type = pickString(o, ["type", "msgType"]) ?? "text"; const content = pickString(o, ["content", "text", "body", "message"]) ?? ""; const timestamp = (o.timestamp ?? o.time ?? o.createTime ?? Date.now()) as | string | number; const chatroomId = pickString(o, ["chatroomId", "roomId", "groupId"]); const isGroup = Boolean(chatroomId) || Boolean(o.isGroup); const chatroomMemberWxid = isGroup ? pickString(o, ["chatroomMemberWxid", "memberWxid", "groupMember"]) : undefined; return { msgId, fromWxid, toWxid, fromName, type: String(type), content: String(content), timestamp, isGroup, chatroomId, chatroomMemberWxid, raw: o, }; } function pickString(o: Record, keys: string[]): string | undefined { for (const k of keys) { const v = o[k]; if (typeof v === "string" && v.length > 0) return v; if (typeof v === "number") return String(v); } return undefined; }