client.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * HTTP client for the third-party wechat-agent backend.
  3. *
  4. * Zero external deps — uses Node 20+ global fetch. Keeps tgz tiny.
  5. *
  6. * Backend contract (verified against the customer's production daemon
  7. * @../../../daemon/auto-reply-daemon.js on 2026-04-18):
  8. * POST /login/check-online -> { data: true } OR { ret: 200, data: { online: true } }
  9. * GET /messages?direction=received -> { data: [RawMsg, ...] } (ret optional)
  10. * POST /message/send-text -> { ret: 200, msg?: string }
  11. *
  12. * Both response shapes for check-online are tolerated because older/newer
  13. * backend builds (wcf vs xbot vs custom Express wrapper) differ here.
  14. *
  15. * RawMsg fields we expect (best-effort, unknown fields tolerated):
  16. * msgId | id, fromWxid | from, toWxid | to, fromName | senderName,
  17. * type, content | text | body, timestamp | time, isGroup, chatroomId,
  18. * chatroomMemberWxid
  19. */
  20. import type { RawWechatMessage, SendTextResult } from "./types.js";
  21. export interface WechatAgentClient {
  22. readonly apiBase: string;
  23. checkOnline(): Promise<boolean>;
  24. getSelfWxid(): Promise<string | null>;
  25. getMessages(opts?: GetMessagesOptions): Promise<RawWechatMessage[]>;
  26. sendText(opts: SendTextOptions): Promise<SendTextResult>;
  27. }
  28. export type GetMessagesOptions = {
  29. /** Max messages to return. Default 50. */
  30. limit?: number;
  31. /** "received" = inbound only (what we want for auto-reply). */
  32. direction?: "received" | "sent" | "all";
  33. /** Abort signal for graceful shutdown. */
  34. signal?: AbortSignal;
  35. /** Per-request timeout (ms). Default 15s. */
  36. timeoutMs?: number;
  37. /** Optional incremental cursor fields (backend may ignore unknown params). */
  38. sinceTimestampMs?: number;
  39. sinceMsgId?: string;
  40. };
  41. export type SendTextOptions = {
  42. toWxid: string;
  43. content: string;
  44. /** Optional @mentions wxid list, comma-separated (for group chats). */
  45. ats?: string;
  46. signal?: AbortSignal;
  47. timeoutMs?: number;
  48. };
  49. export function createClient(apiBase: string): WechatAgentClient {
  50. const base = apiBase.replace(/\/+$/, "");
  51. async function request(
  52. path: string,
  53. init: RequestInit & { timeoutMs?: number } = {}
  54. ): Promise<unknown> {
  55. const { timeoutMs = 15000, signal: userSignal, ...rest } = init;
  56. const controller = new AbortController();
  57. const timeout = setTimeout(() => controller.abort(new Error("timeout")), timeoutMs);
  58. // Chain user-provided signal so caller abort propagates.
  59. if (userSignal) {
  60. if (userSignal.aborted) {
  61. controller.abort(userSignal.reason);
  62. } else {
  63. userSignal.addEventListener("abort", () => controller.abort(userSignal.reason), {
  64. once: true,
  65. });
  66. }
  67. }
  68. try {
  69. const resp = await fetch(`${base}${path}`, {
  70. ...rest,
  71. signal: controller.signal,
  72. });
  73. const text = await resp.text();
  74. if (!resp.ok) {
  75. throw new Error(`wechat-agent ${path} HTTP ${resp.status}: ${text.slice(0, 200)}`);
  76. }
  77. if (!text) return null;
  78. try {
  79. return JSON.parse(text);
  80. } catch {
  81. throw new Error(
  82. `wechat-agent ${path} returned non-JSON: ${text.slice(0, 200)}`
  83. );
  84. }
  85. } finally {
  86. clearTimeout(timeout);
  87. }
  88. }
  89. return {
  90. apiBase: base,
  91. async checkOnline(): Promise<boolean> {
  92. try {
  93. const body = (await request("/login/check-online", {
  94. method: "POST",
  95. headers: { "content-type": "application/json" },
  96. body: "{}",
  97. })) as { ret?: number; data?: unknown } | null;
  98. if (!body) return false;
  99. if (body.ret !== undefined && body.ret !== 200) return false;
  100. // Shape A (production daemon): { data: true }
  101. if (body.data === true) return true;
  102. // Shape B (alt backends): { data: { online: true } }
  103. if (body.data && typeof body.data === "object") {
  104. return Boolean((body.data as { online?: boolean }).online);
  105. }
  106. return false;
  107. } catch {
  108. return false;
  109. }
  110. },
  111. async getSelfWxid(): Promise<string | null> {
  112. try {
  113. const body = (await request("/login/check-online", {
  114. method: "POST",
  115. headers: { "content-type": "application/json" },
  116. body: "{}",
  117. })) as { ret?: number; data?: unknown } | null;
  118. const d = body?.data;
  119. if (d && typeof d === "object") {
  120. const dd = d as Record<string, unknown>;
  121. const wxid = dd.wxid ?? dd.self;
  122. if (typeof wxid === "string" && wxid) return wxid;
  123. }
  124. return null;
  125. } catch {
  126. return null;
  127. }
  128. },
  129. async getMessages(opts: GetMessagesOptions = {}): Promise<RawWechatMessage[]> {
  130. const limit = opts.limit ?? 50;
  131. const direction = opts.direction ?? "received";
  132. const qs = new URLSearchParams({ limit: String(limit), direction });
  133. if (typeof opts.sinceTimestampMs === "number" && Number.isFinite(opts.sinceTimestampMs)) {
  134. qs.set("sinceTimestampMs", String(Math.floor(opts.sinceTimestampMs)));
  135. }
  136. if (opts.sinceMsgId?.trim()) {
  137. qs.set("sinceMsgId", opts.sinceMsgId.trim());
  138. }
  139. const body = (await request(`/messages?${qs.toString()}`, {
  140. method: "GET",
  141. signal: opts.signal,
  142. timeoutMs: opts.timeoutMs,
  143. })) as { ret?: number; data?: unknown[] } | null;
  144. if (!body) return [];
  145. if (body.ret !== undefined && body.ret !== 200) return [];
  146. const items = Array.isArray(body.data) ? body.data : [];
  147. return items.map(normalizeMessage).filter((m): m is RawWechatMessage => m !== null);
  148. },
  149. async sendText(opts: SendTextOptions): Promise<SendTextResult> {
  150. const payload = {
  151. toWxid: opts.toWxid,
  152. content: opts.content,
  153. ats: opts.ats ?? "",
  154. };
  155. const syntheticMessageId = `wx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  156. try {
  157. const body = (await request("/message/send-text", {
  158. method: "POST",
  159. headers: { "content-type": "application/json" },
  160. body: JSON.stringify(payload),
  161. signal: opts.signal,
  162. timeoutMs: opts.timeoutMs,
  163. })) as { ret?: number; msg?: string } | null;
  164. const ret = body?.ret;
  165. const ok = ret === 200 || ret === undefined;
  166. return {
  167. ok,
  168. ret,
  169. msg: body?.msg,
  170. syntheticMessageId,
  171. };
  172. } catch (err) {
  173. return {
  174. ok: false,
  175. msg: err instanceof Error ? err.message : String(err),
  176. syntheticMessageId,
  177. };
  178. }
  179. },
  180. };
  181. }
  182. /** Best-effort normalization of the backend's message payload. */
  183. function normalizeMessage(item: unknown): RawWechatMessage | null {
  184. if (!item || typeof item !== "object") return null;
  185. const o = item as Record<string, unknown>;
  186. const msgId = pickString(o, ["msgId", "id", "messageId", "msg_id"]);
  187. if (!msgId) return null;
  188. const fromWxid = pickString(o, ["fromWxid", "from", "fromId", "sender", "from_wxid"]);
  189. if (!fromWxid) return null;
  190. const toWxid =
  191. pickString(o, ["toWxid", "to", "toId", "receiver", "to_wxid"]) ?? "";
  192. const fromName = pickString(o, ["fromName", "senderName", "nickName", "fromNick"]);
  193. const type = pickString(o, ["type", "msgType"]) ?? "text";
  194. const content =
  195. pickString(o, ["content", "text", "body", "message"]) ?? "";
  196. const timestamp = (o.timestamp ?? o.time ?? o.createTime ?? Date.now()) as
  197. | string
  198. | number;
  199. const chatroomId = pickString(o, ["chatroomId", "roomId", "groupId"]);
  200. const isGroup = Boolean(chatroomId) || Boolean(o.isGroup);
  201. const chatroomMemberWxid = isGroup
  202. ? pickString(o, ["chatroomMemberWxid", "memberWxid", "groupMember"])
  203. : undefined;
  204. return {
  205. msgId,
  206. fromWxid,
  207. toWxid,
  208. fromName,
  209. type: String(type),
  210. content: String(content),
  211. timestamp,
  212. isGroup,
  213. chatroomId,
  214. chatroomMemberWxid,
  215. raw: o,
  216. };
  217. }
  218. function pickString(o: Record<string, unknown>, keys: string[]): string | undefined {
  219. for (const k of keys) {
  220. const v = o[k];
  221. if (typeof v === "string" && v.length > 0) return v;
  222. if (typeof v === "number") return String(v);
  223. }
  224. return undefined;
  225. }