| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /**
- * 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<boolean>;
- getSelfWxid(): Promise<string | null>;
- getMessages(opts?: GetMessagesOptions): Promise<RawWechatMessage[]>;
- sendText(opts: SendTextOptions): Promise<SendTextResult>;
- }
- 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<unknown> {
- 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<boolean> {
- 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<string | null> {
- 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<string, unknown>;
- const wxid = dd.wxid ?? dd.self;
- if (typeof wxid === "string" && wxid) return wxid;
- }
- return null;
- } catch {
- return null;
- }
- },
- async getMessages(opts: GetMessagesOptions = {}): Promise<RawWechatMessage[]> {
- 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<SendTextResult> {
- 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<string, unknown>;
- 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<string, unknown>, 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;
- }
|