@zhin.js/adapter-weixin-ilink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +96 -0
  2. package/client/LoginAssistPanel.tsx +200 -0
  3. package/client/WeixinIlinkManagement.tsx +139 -0
  4. package/client/index.tsx +11 -0
  5. package/client/tsconfig.json +13 -0
  6. package/client/utils/api.ts +30 -0
  7. package/dist/index.js +29 -0
  8. package/lib/adapter.d.ts +11 -0
  9. package/lib/adapter.d.ts.map +1 -0
  10. package/lib/adapter.js +14 -0
  11. package/lib/adapter.js.map +1 -0
  12. package/lib/aes-ecb.d.ts +7 -0
  13. package/lib/aes-ecb.d.ts.map +1 -0
  14. package/lib/aes-ecb.js +19 -0
  15. package/lib/aes-ecb.js.map +1 -0
  16. package/lib/bot.d.ts +37 -0
  17. package/lib/bot.d.ts.map +1 -0
  18. package/lib/bot.js +383 -0
  19. package/lib/bot.js.map +1 -0
  20. package/lib/cdn-upload.d.ts +18 -0
  21. package/lib/cdn-upload.d.ts.map +1 -0
  22. package/lib/cdn-upload.js +73 -0
  23. package/lib/cdn-upload.js.map +1 -0
  24. package/lib/cdn-url.d.ts +14 -0
  25. package/lib/cdn-url.d.ts.map +1 -0
  26. package/lib/cdn-url.js +14 -0
  27. package/lib/cdn-url.js.map +1 -0
  28. package/lib/context-store.d.ts +2 -0
  29. package/lib/context-store.d.ts.map +1 -0
  30. package/lib/context-store.js +2 -0
  31. package/lib/context-store.js.map +1 -0
  32. package/lib/credentials.d.ts +15 -0
  33. package/lib/credentials.d.ts.map +1 -0
  34. package/lib/credentials.js +49 -0
  35. package/lib/credentials.js.map +1 -0
  36. package/lib/ilink-api.d.ts +81 -0
  37. package/lib/ilink-api.d.ts.map +1 -0
  38. package/lib/ilink-api.js +273 -0
  39. package/lib/ilink-api.js.map +1 -0
  40. package/lib/ilink-config-cache.d.ts +19 -0
  41. package/lib/ilink-config-cache.d.ts.map +1 -0
  42. package/lib/ilink-config-cache.js +64 -0
  43. package/lib/ilink-config-cache.js.map +1 -0
  44. package/lib/ilink-logger.d.ts +10 -0
  45. package/lib/ilink-logger.d.ts.map +1 -0
  46. package/lib/ilink-logger.js +27 -0
  47. package/lib/ilink-logger.js.map +1 -0
  48. package/lib/ilink-meta.d.ts +13 -0
  49. package/lib/ilink-meta.d.ts.map +1 -0
  50. package/lib/ilink-meta.js +57 -0
  51. package/lib/ilink-meta.js.map +1 -0
  52. package/lib/ilink-random.d.ts +11 -0
  53. package/lib/ilink-random.d.ts.map +1 -0
  54. package/lib/ilink-random.js +16 -0
  55. package/lib/ilink-random.js.map +1 -0
  56. package/lib/ilink-redact.d.ts +21 -0
  57. package/lib/ilink-redact.d.ts.map +1 -0
  58. package/lib/ilink-redact.js +54 -0
  59. package/lib/ilink-redact.js.map +1 -0
  60. package/lib/ilink-session-guard.d.ts +16 -0
  61. package/lib/ilink-session-guard.d.ts.map +1 -0
  62. package/lib/ilink-session-guard.js +49 -0
  63. package/lib/ilink-session-guard.js.map +1 -0
  64. package/lib/ilink-types.d.ts +233 -0
  65. package/lib/ilink-types.d.ts.map +1 -0
  66. package/lib/ilink-types.js +35 -0
  67. package/lib/ilink-types.js.map +1 -0
  68. package/lib/index.d.ts +20 -0
  69. package/lib/index.d.ts.map +1 -0
  70. package/lib/index.js +38 -0
  71. package/lib/index.js.map +1 -0
  72. package/lib/login-assist-routes.d.ts +7 -0
  73. package/lib/login-assist-routes.d.ts.map +1 -0
  74. package/lib/login-assist-routes.js +29 -0
  75. package/lib/login-assist-routes.js.map +1 -0
  76. package/lib/login.d.ts +6 -0
  77. package/lib/login.d.ts.map +1 -0
  78. package/lib/login.js +121 -0
  79. package/lib/login.js.map +1 -0
  80. package/lib/media-download.d.ts +19 -0
  81. package/lib/media-download.d.ts.map +1 -0
  82. package/lib/media-download.js +95 -0
  83. package/lib/media-download.js.map +1 -0
  84. package/lib/mime.d.ts +9 -0
  85. package/lib/mime.d.ts.map +1 -0
  86. package/lib/mime.js +99 -0
  87. package/lib/mime.js.map +1 -0
  88. package/lib/outbound-media.d.ts +5 -0
  89. package/lib/outbound-media.d.ts.map +1 -0
  90. package/lib/outbound-media.js +122 -0
  91. package/lib/outbound-media.js.map +1 -0
  92. package/lib/pic-decrypt.d.ts +10 -0
  93. package/lib/pic-decrypt.d.ts.map +1 -0
  94. package/lib/pic-decrypt.js +89 -0
  95. package/lib/pic-decrypt.js.map +1 -0
  96. package/lib/routes.d.ts +8 -0
  97. package/lib/routes.d.ts.map +1 -0
  98. package/lib/routes.js +29 -0
  99. package/lib/routes.js.map +1 -0
  100. package/lib/silk-transcode.d.ts +9 -0
  101. package/lib/silk-transcode.d.ts.map +1 -0
  102. package/lib/silk-transcode.js +64 -0
  103. package/lib/silk-transcode.js.map +1 -0
  104. package/lib/types.d.ts +26 -0
  105. package/lib/types.d.ts.map +1 -0
  106. package/lib/types.js +2 -0
  107. package/lib/types.js.map +1 -0
  108. package/lib/typing-indicator.d.ts +32 -0
  109. package/lib/typing-indicator.d.ts.map +1 -0
  110. package/lib/typing-indicator.js +95 -0
  111. package/lib/typing-indicator.js.map +1 -0
  112. package/lib/upload.d.ts +43 -0
  113. package/lib/upload.d.ts.map +1 -0
  114. package/lib/upload.js +106 -0
  115. package/lib/upload.js.map +1 -0
  116. package/lib/weixin-inbound.d.ts +71 -0
  117. package/lib/weixin-inbound.d.ts.map +1 -0
  118. package/lib/weixin-inbound.js +201 -0
  119. package/lib/weixin-inbound.js.map +1 -0
  120. package/lib/weixin-send-media.d.ts +22 -0
  121. package/lib/weixin-send-media.d.ts.map +1 -0
  122. package/lib/weixin-send-media.js +54 -0
  123. package/lib/weixin-send-media.js.map +1 -0
  124. package/lib/weixin-send.d.ts +65 -0
  125. package/lib/weixin-send.d.ts.map +1 -0
  126. package/lib/weixin-send.js +181 -0
  127. package/lib/weixin-send.js.map +1 -0
  128. package/package.json +91 -0
  129. package/src/adapter.ts +16 -0
  130. package/src/aes-ecb.ts +21 -0
  131. package/src/bot.ts +483 -0
  132. package/src/cdn-upload.test.ts +177 -0
  133. package/src/cdn-upload.ts +87 -0
  134. package/src/cdn-url.ts +20 -0
  135. package/src/context-store.ts +8 -0
  136. package/src/credentials.ts +64 -0
  137. package/src/ilink-api.ts +349 -0
  138. package/src/ilink-config-cache.ts +79 -0
  139. package/src/ilink-logger.ts +36 -0
  140. package/src/ilink-meta.ts +60 -0
  141. package/src/ilink-random.ts +17 -0
  142. package/src/ilink-redact.ts +54 -0
  143. package/src/ilink-session-guard.ts +58 -0
  144. package/src/ilink-types.ts +261 -0
  145. package/src/index.ts +60 -0
  146. package/src/login-assist-routes.ts +36 -0
  147. package/src/login.ts +166 -0
  148. package/src/media-download.ts +149 -0
  149. package/src/mime.test.ts +74 -0
  150. package/src/mime.ts +105 -0
  151. package/src/outbound-media.ts +141 -0
  152. package/src/pic-decrypt.ts +101 -0
  153. package/src/routes.ts +39 -0
  154. package/src/silk-transcode.test.ts +58 -0
  155. package/src/silk-transcode.ts +74 -0
  156. package/src/silk-wasm.d.ts +6 -0
  157. package/src/types.ts +26 -0
  158. package/src/typing-indicator.ts +139 -0
  159. package/src/upload.test.ts +305 -0
  160. package/src/upload.ts +157 -0
  161. package/src/weixin-inbound.ts +257 -0
  162. package/src/weixin-send-media.ts +72 -0
  163. package/src/weixin-send.ts +237 -0
@@ -0,0 +1,87 @@
1
+ import { encryptAesEcb } from "./aes-ecb.js";
2
+ import { buildCdnUploadUrl } from "./cdn-url.js";
3
+ import { logger } from "./ilink-logger.js";
4
+ import { redactUrl } from "./ilink-redact.js";
5
+
6
+ /** Maximum retry attempts for CDN upload. */
7
+ const UPLOAD_MAX_RETRIES = 3;
8
+
9
+ /**
10
+ * Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
11
+ * Returns the download encrypted_query_param from the CDN response.
12
+ * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
13
+ */
14
+ export async function uploadBufferToCdn(params: {
15
+ buf: Buffer;
16
+ /** From getUploadUrl.upload_full_url; POST target when set (takes precedence over uploadParam). */
17
+ uploadFullUrl?: string;
18
+ uploadParam?: string;
19
+ filekey: string;
20
+ cdnBaseUrl: string;
21
+ label: string;
22
+ aeskey: Buffer;
23
+ }): Promise<{ downloadParam: string }> {
24
+ const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
25
+ const ciphertext = encryptAesEcb(buf, aeskey);
26
+ const trimmedFull = uploadFullUrl?.trim();
27
+ let cdnUrl: string;
28
+ if (trimmedFull) {
29
+ cdnUrl = trimmedFull;
30
+ } else if (uploadParam) {
31
+ cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
32
+ } else {
33
+ throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);
34
+ }
35
+ logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
36
+
37
+ let downloadParam: string | undefined;
38
+ let lastError: unknown;
39
+
40
+ for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
41
+ try {
42
+ const res = await fetch(cdnUrl, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/octet-stream" },
45
+ body: new Uint8Array(ciphertext),
46
+ });
47
+ if (res.status >= 400 && res.status < 500) {
48
+ const errMsg = res.headers.get("x-error-message") ?? (await res.text());
49
+ logger.error(
50
+ `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
51
+ );
52
+ throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
53
+ }
54
+ if (res.status !== 200) {
55
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
56
+ logger.error(
57
+ `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
58
+ );
59
+ throw new Error(`CDN upload server error: ${errMsg}`);
60
+ }
61
+ downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
62
+ if (!downloadParam) {
63
+ logger.error(
64
+ `${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,
65
+ );
66
+ throw new Error("CDN upload response missing x-encrypted-param header");
67
+ }
68
+ logger.debug(`${label}: CDN upload success attempt=${attempt}`);
69
+ break;
70
+ } catch (err) {
71
+ lastError = err;
72
+ if (err instanceof Error && err.message.includes("client error")) throw err;
73
+ if (attempt < UPLOAD_MAX_RETRIES) {
74
+ logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
75
+ } else {
76
+ logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!downloadParam) {
82
+ throw lastError instanceof Error
83
+ ? lastError
84
+ : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
85
+ }
86
+ return { downloadParam };
87
+ }
package/src/cdn-url.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Unified CDN URL construction for Weixin CDN upload/download.
3
+ */
4
+
5
+ /** 设为 true 时,当服务端未返回 full_url 字段,回退到客户端拼接 URL;false 则直接报错。 */
6
+ export const ENABLE_CDN_URL_FALLBACK = true;
7
+
8
+ /** Build a CDN download URL from encrypt_query_param. */
9
+ export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
10
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
11
+ }
12
+
13
+ /** Build a CDN upload URL from upload_param and filekey. */
14
+ export function buildCdnUploadUrl(params: {
15
+ cdnBaseUrl: string;
16
+ uploadParam: string;
17
+ filekey: string;
18
+ }): string {
19
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
20
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ setContextToken,
3
+ getContextToken,
4
+ restoreContextTokens,
5
+ clearContextTokensForAccount,
6
+ bodyFromItemList,
7
+ isMediaItem,
8
+ } from "./weixin-inbound.js";
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export interface WeixinIlinkCredentials {
5
+ botToken: string;
6
+ ilinkUserId?: string;
7
+ ilinkBotId?: string;
8
+ baseUrl?: string;
9
+ updatedAt?: string;
10
+ }
11
+
12
+ const DEFAULT_DATA_DIR = "./data/weixin-ilink";
13
+
14
+ export function resolveStateDir(): string {
15
+ return process.env.ZHIN_DATA_DIR
16
+ ? path.join(process.env.ZHIN_DATA_DIR, "weixin-ilink")
17
+ : DEFAULT_DATA_DIR;
18
+ }
19
+
20
+ export function credentialsPath(botName: string): string {
21
+ return path.join(resolveStateDir(), `${botName}.json`);
22
+ }
23
+
24
+ export function loadCredentials(botName: string): WeixinIlinkCredentials | null {
25
+ const file = credentialsPath(botName);
26
+ try {
27
+ if (!fs.existsSync(file)) return null;
28
+ const raw = fs.readFileSync(file, "utf-8");
29
+ const data = JSON.parse(raw) as WeixinIlinkCredentials;
30
+ if (!data.botToken?.trim()) return null;
31
+ return data;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function saveCredentials(botName: string, creds: WeixinIlinkCredentials): void {
38
+ const file = credentialsPath(botName);
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ fs.writeFileSync(
41
+ file,
42
+ JSON.stringify({ ...creds, updatedAt: new Date().toISOString() }, null, 2),
43
+ "utf-8",
44
+ );
45
+ }
46
+
47
+ export function syncBufPath(botName: string): string {
48
+ return path.join(resolveStateDir(), `${botName}.sync-buf`);
49
+ }
50
+
51
+ export function loadSyncBuf(botName: string): string {
52
+ const file = syncBufPath(botName);
53
+ try {
54
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "";
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+
60
+ export function saveSyncBuf(botName: string, buf: string): void {
61
+ const file = syncBufPath(botName);
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ fs.writeFileSync(file, buf, "utf-8");
64
+ }
@@ -0,0 +1,349 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { logger } from "./ilink-logger.js";
4
+ import {
5
+ buildBaseInfo,
6
+ ILINK_APP_CLIENT_VERSION,
7
+ ILINK_APP_ID,
8
+ } from "./ilink-meta.js";
9
+ import { redactBody, redactUrl } from "./ilink-redact.js";
10
+
11
+ import type {
12
+ GetUploadUrlReq,
13
+ GetUploadUrlResp,
14
+ GetUpdatesReq,
15
+ GetUpdatesResp,
16
+ NotifyStopResp,
17
+ NotifyStartResp,
18
+ SendMessageReq,
19
+ SendTypingReq,
20
+ GetConfigResp,
21
+ } from "./ilink-types.js";
22
+
23
+ export { buildBaseInfo, sanitizeBotAgent } from "./ilink-meta.js";
24
+
25
+ export type WeixinApiOptions = {
26
+ baseUrl: string;
27
+ token?: string;
28
+ timeoutMs?: number;
29
+ /** Long-poll timeout for getUpdates (server may hold the request up to this). */
30
+ longPollTimeoutMs?: number;
31
+ };
32
+
33
+ /** Default timeout for long-poll getUpdates requests. */
34
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
35
+ /** Default timeout for regular API requests (sendMessage, getUploadUrl). */
36
+ const DEFAULT_API_TIMEOUT_MS = 15_000;
37
+ /** Default timeout for lightweight API requests (getConfig, sendTyping). */
38
+ const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
39
+
40
+ function ensureTrailingSlash(url: string): string {
41
+ return url.endsWith("/") ? url : `${url}/`;
42
+ }
43
+
44
+ /** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
45
+ function randomWechatUin(): string {
46
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
47
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
48
+ }
49
+
50
+ /** Build headers shared by both GET and POST requests. */
51
+ function buildCommonHeaders(): Record<string, string> {
52
+ const headers: Record<string, string> = {
53
+ "iLink-App-Id": ILINK_APP_ID,
54
+ "iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION),
55
+ };
56
+ return headers;
57
+ }
58
+
59
+ function buildHeaders(opts: { token?: string }): Record<string, string> {
60
+ const headers: Record<string, string> = {
61
+ "Content-Type": "application/json",
62
+ AuthorizationType: "ilink_bot_token",
63
+ "X-WECHAT-UIN": randomWechatUin(),
64
+ ...buildCommonHeaders(),
65
+ };
66
+ if (opts.token?.trim()) {
67
+ headers.Authorization = `Bearer ${opts.token.trim()}`;
68
+ }
69
+ logger.debug(
70
+ `requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
71
+ );
72
+ return headers;
73
+ }
74
+
75
+ /**
76
+ * GET fetch wrapper: send a GET request to a Weixin API endpoint.
77
+ * When `timeoutMs` is set, the request is aborted after that many milliseconds.
78
+ * Query parameters should already be encoded in `endpoint`.
79
+ * Returns the raw response text on success; throws on HTTP error or (if used) timeout abort.
80
+ */
81
+ export async function apiGetFetch(params: {
82
+ baseUrl: string;
83
+ endpoint: string;
84
+ timeoutMs?: number;
85
+ label: string;
86
+ }): Promise<string> {
87
+ const base = ensureTrailingSlash(params.baseUrl);
88
+ const url = new URL(params.endpoint, base);
89
+ const hdrs = buildCommonHeaders();
90
+ logger.debug(`GET ${redactUrl(url.toString())}`);
91
+
92
+ const timeoutMs = params.timeoutMs;
93
+ const controller =
94
+ timeoutMs != null && timeoutMs > 0 ? new AbortController() : undefined;
95
+ const t =
96
+ controller != null && timeoutMs != null
97
+ ? setTimeout(() => controller.abort(), timeoutMs)
98
+ : undefined;
99
+ try {
100
+ const res = await fetch(url.toString(), {
101
+ method: "GET",
102
+ headers: hdrs,
103
+ ...(controller ? { signal: controller.signal } : {}),
104
+ });
105
+ if (t !== undefined) clearTimeout(t);
106
+ const rawText = await res.text();
107
+ logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
108
+ if (!res.ok) {
109
+ throw new Error(`${params.label} ${res.status}: ${rawText}`);
110
+ }
111
+ return rawText;
112
+ } catch (err) {
113
+ if (t !== undefined) clearTimeout(t);
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ function combineAbortSignals(
119
+ internal: AbortController | undefined,
120
+ external: AbortSignal | undefined,
121
+ ): { signal?: AbortSignal; cleanup: () => void } {
122
+ if (!internal && !external) return { cleanup: () => {} };
123
+ if (!internal) return { signal: external, cleanup: () => {} };
124
+ if (!external) return { signal: internal.signal, cleanup: () => {} };
125
+
126
+ if (external.aborted) {
127
+ internal.abort();
128
+ return { signal: internal.signal, cleanup: () => {} };
129
+ }
130
+
131
+ const onExternalAbort = () => internal.abort();
132
+ external.addEventListener("abort", onExternalAbort, { once: true });
133
+ return {
134
+ signal: internal.signal,
135
+ cleanup: () => external.removeEventListener("abort", onExternalAbort),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Common fetch wrapper: POST JSON to a Weixin API endpoint.
141
+ * When `timeoutMs` is provided, the request is aborted after that many milliseconds.
142
+ * When omitted, no client-side timeout is applied (relies on OS/TCP stack).
143
+ * When `abortSignal` is provided, an external channel stop also aborts the request.
144
+ * Returns the raw response text on success; throws on HTTP error or timeout.
145
+ */
146
+ export async function apiPostFetch(params: {
147
+ baseUrl: string;
148
+ endpoint: string;
149
+ body: string;
150
+ token?: string;
151
+ timeoutMs?: number;
152
+ label: string;
153
+ abortSignal?: AbortSignal;
154
+ }): Promise<string> {
155
+ const base = ensureTrailingSlash(params.baseUrl);
156
+ const url = new URL(params.endpoint, base);
157
+ const hdrs = buildHeaders({ token: params.token });
158
+ logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
159
+
160
+ const controller =
161
+ params.timeoutMs !== undefined ? new AbortController() : undefined;
162
+ const t =
163
+ controller != null && params.timeoutMs !== undefined
164
+ ? setTimeout(() => controller.abort(), params.timeoutMs)
165
+ : undefined;
166
+ const { signal, cleanup } = combineAbortSignals(controller, params.abortSignal);
167
+ try {
168
+ const res = await fetch(url.toString(), {
169
+ method: "POST",
170
+ headers: hdrs,
171
+ body: params.body,
172
+ ...(signal ? { signal } : {}),
173
+ });
174
+ const rawText = await res.text();
175
+ logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
176
+ if (!res.ok) {
177
+ throw new Error(`${params.label} ${res.status}: ${rawText}`);
178
+ }
179
+ return rawText;
180
+ } catch (err) {
181
+ throw err;
182
+ } finally {
183
+ if (t !== undefined) clearTimeout(t);
184
+ cleanup();
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Long-poll getUpdates. Server should hold the request until new messages or timeout.
190
+ *
191
+ * On client-side timeout (no server response within timeoutMs), returns an empty response
192
+ * with ret=0 so the caller can simply retry. This is normal for long-poll.
193
+ */
194
+ export async function getUpdates(
195
+ params: GetUpdatesReq & {
196
+ baseUrl: string;
197
+ token?: string;
198
+ timeoutMs?: number;
199
+ /**
200
+ * Optional external abort signal (e.g. from the gateway when stopping the
201
+ * channel). When this aborts, the in-flight long-poll is terminated
202
+ * immediately so the monitor loop can exit well within the gateway's
203
+ * channel-stop budget (#141).
204
+ */
205
+ abortSignal?: AbortSignal;
206
+ },
207
+ ): Promise<GetUpdatesResp> {
208
+ const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
209
+ try {
210
+ const rawText = await apiPostFetch({
211
+ baseUrl: params.baseUrl,
212
+ endpoint: "ilink/bot/getupdates",
213
+ body: JSON.stringify({
214
+ get_updates_buf: params.get_updates_buf ?? "",
215
+ base_info: buildBaseInfo(),
216
+ }),
217
+ token: params.token,
218
+ timeoutMs: timeout,
219
+ label: "getUpdates",
220
+ abortSignal: params.abortSignal,
221
+ });
222
+ const resp: GetUpdatesResp = JSON.parse(rawText);
223
+ return resp;
224
+ } catch (err) {
225
+ // Long-poll timeout *or* external abort both surface as AbortError. The caller
226
+ // re-checks `abortSignal?.aborted` after we return; when aborted, it exits
227
+ // the loop. When not aborted (i.e. plain client-side long-poll timeout),
228
+ // returning the empty response lets the caller retry — preserving prior
229
+ // behavior for the normal long-poll case.
230
+ if (err instanceof Error && err.name === "AbortError") {
231
+ if (params.abortSignal?.aborted) {
232
+ logger.debug(`getUpdates: aborted by external signal`);
233
+ } else {
234
+ logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
235
+ }
236
+ return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+
242
+ /** Get a pre-signed CDN upload URL for a file. */
243
+ export async function getUploadUrl(
244
+ params: GetUploadUrlReq & WeixinApiOptions,
245
+ ): Promise<GetUploadUrlResp> {
246
+ const rawText = await apiPostFetch({
247
+ baseUrl: params.baseUrl,
248
+ endpoint: "ilink/bot/getuploadurl",
249
+ body: JSON.stringify({
250
+ filekey: params.filekey,
251
+ media_type: params.media_type,
252
+ to_user_id: params.to_user_id,
253
+ rawsize: params.rawsize,
254
+ rawfilemd5: params.rawfilemd5,
255
+ filesize: params.filesize,
256
+ thumb_rawsize: params.thumb_rawsize,
257
+ thumb_rawfilemd5: params.thumb_rawfilemd5,
258
+ thumb_filesize: params.thumb_filesize,
259
+ no_need_thumb: params.no_need_thumb,
260
+ aeskey: params.aeskey,
261
+ base_info: buildBaseInfo(),
262
+ }),
263
+ token: params.token,
264
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
265
+ label: "getUploadUrl",
266
+ });
267
+ const resp: GetUploadUrlResp = JSON.parse(rawText);
268
+ return resp;
269
+ }
270
+
271
+ /** Send a single message downstream. */
272
+ export async function sendMessage(
273
+ params: WeixinApiOptions & { body: SendMessageReq },
274
+ ): Promise<void> {
275
+ await apiPostFetch({
276
+ baseUrl: params.baseUrl,
277
+ endpoint: "ilink/bot/sendmessage",
278
+ body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
279
+ token: params.token,
280
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
281
+ label: "sendMessage",
282
+ });
283
+ }
284
+
285
+ /** Fetch bot config (includes typing_ticket) for a given user. */
286
+ export async function getConfig(
287
+ params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
288
+ ): Promise<GetConfigResp> {
289
+ const rawText = await apiPostFetch({
290
+ baseUrl: params.baseUrl,
291
+ endpoint: "ilink/bot/getconfig",
292
+ body: JSON.stringify({
293
+ ilink_user_id: params.ilinkUserId,
294
+ context_token: params.contextToken,
295
+ base_info: buildBaseInfo(),
296
+ }),
297
+ token: params.token,
298
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
299
+ label: "getConfig",
300
+ });
301
+ const resp: GetConfigResp = JSON.parse(rawText);
302
+ return resp;
303
+ }
304
+
305
+ /** Send a typing indicator to a user. */
306
+ export async function sendTyping(
307
+ params: WeixinApiOptions & { body: SendTypingReq },
308
+ ): Promise<void> {
309
+ await apiPostFetch({
310
+ baseUrl: params.baseUrl,
311
+ endpoint: "ilink/bot/sendtyping",
312
+ body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
313
+ token: params.token,
314
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
315
+ label: "sendTyping",
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Notify Weixin that this channel client is stopping (gateway shutdown / channel stop).
321
+ * Uses a standalone timeout (not the gateway abort signal) so the request can finish
322
+ * after OpenClaw has already aborted the long-poll.
323
+ */
324
+ export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
325
+ const rawText = await apiPostFetch({
326
+ baseUrl: params.baseUrl,
327
+ endpoint: "ilink/bot/msg/notifystop",
328
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
329
+ token: params.token,
330
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
331
+ label: "notifyStop",
332
+ });
333
+ return JSON.parse(rawText) as NotifyStopResp;
334
+ }
335
+
336
+ /**
337
+ * Notify Weixin that this channel client is starting (gateway startup / channel start).
338
+ */
339
+ export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
340
+ const rawText = await apiPostFetch({
341
+ baseUrl: params.baseUrl,
342
+ endpoint: "ilink/bot/msg/notifystart",
343
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
344
+ token: params.token,
345
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
346
+ label: "notifyStart",
347
+ });
348
+ return JSON.parse(rawText) as NotifyStartResp;
349
+ }
@@ -0,0 +1,79 @@
1
+ import { getConfig } from "./ilink-api.js";
2
+
3
+ /** Subset of getConfig fields that we actually need; add new fields here as needed. */
4
+ export interface CachedConfig {
5
+ typingTicket: string;
6
+ }
7
+
8
+ const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
9
+ const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
10
+ const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
11
+
12
+ interface ConfigCacheEntry {
13
+ config: CachedConfig;
14
+ everSucceeded: boolean;
15
+ nextFetchAt: number;
16
+ retryDelayMs: number;
17
+ }
18
+
19
+ /**
20
+ * Per-user getConfig cache with periodic random refresh (within 24h) and
21
+ * exponential-backoff retry (up to 1h) on failure.
22
+ */
23
+ export class WeixinConfigManager {
24
+ private cache = new Map<string, ConfigCacheEntry>();
25
+
26
+ constructor(
27
+ private apiOpts: { baseUrl: string; token?: string },
28
+ private log: (msg: string) => void,
29
+ ) {}
30
+
31
+ async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
32
+ const now = Date.now();
33
+ const entry = this.cache.get(userId);
34
+ const shouldFetch = !entry || now >= entry.nextFetchAt;
35
+
36
+ if (shouldFetch) {
37
+ let fetchOk = false;
38
+ try {
39
+ const resp = await getConfig({
40
+ baseUrl: this.apiOpts.baseUrl,
41
+ token: this.apiOpts.token,
42
+ ilinkUserId: userId,
43
+ contextToken,
44
+ });
45
+ if (resp.ret === 0) {
46
+ this.cache.set(userId, {
47
+ config: { typingTicket: resp.typing_ticket ?? "" },
48
+ everSucceeded: true,
49
+ nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
50
+ retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
51
+ });
52
+ this.log(
53
+ `[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
54
+ );
55
+ fetchOk = true;
56
+ }
57
+ } catch (err) {
58
+ this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
59
+ }
60
+ if (!fetchOk) {
61
+ const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
62
+ const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
63
+ if (entry) {
64
+ entry.nextFetchAt = now + nextDelay;
65
+ entry.retryDelayMs = nextDelay;
66
+ } else {
67
+ this.cache.set(userId, {
68
+ config: { typingTicket: "" },
69
+ everSucceeded: false,
70
+ nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
71
+ retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ return this.cache.get(userId)?.config ?? { typingTicket: "" };
78
+ }
79
+ }
@@ -0,0 +1,36 @@
1
+ export type IlinkLogger = {
2
+ debug: (msg: string) => void;
3
+ info: (msg: string) => void;
4
+ warn: (msg: string) => void;
5
+ error: (msg: string) => void;
6
+ withAccount: (accountId: string) => IlinkLogger;
7
+ };
8
+
9
+ let sink: IlinkLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: (msg) => console.warn(`[weixin-ilink] ${msg}`),
13
+ error: (msg) => console.error(`[weixin-ilink] ${msg}`),
14
+ withAccount(accountId) {
15
+ const prefix = `[weixin-ilink:${accountId}]`;
16
+ return {
17
+ debug: (msg) => sink.debug(`${prefix} ${msg}`),
18
+ info: (msg) => sink.info(`${prefix} ${msg}`),
19
+ warn: (msg) => sink.warn(`${prefix} ${msg}`),
20
+ error: (msg) => sink.error(`${prefix} ${msg}`),
21
+ withAccount: () => this,
22
+ };
23
+ },
24
+ };
25
+
26
+ export function setIlinkLogger(next: IlinkLogger): void {
27
+ sink = next;
28
+ }
29
+
30
+ export const logger: IlinkLogger = {
31
+ debug: (msg) => sink.debug(msg),
32
+ info: (msg) => sink.info(msg),
33
+ warn: (msg) => sink.warn(msg),
34
+ error: (msg) => sink.error(msg),
35
+ withAccount: (accountId) => sink.withAccount(accountId),
36
+ };