@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
package/src/login.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { Plugin } from "zhin.js";
2
+ import { apiGetFetch, apiPostFetch } from "./ilink-api.js";
3
+ import { buildBaseInfo, DEFAULT_API_BASE_URL } from "./ilink-meta.js";
4
+ import { logger } from "./ilink-logger.js";
5
+ import {
6
+ loadCredentials,
7
+ saveCredentials,
8
+ type WeixinIlinkCredentials,
9
+ } from "./credentials.js";
10
+ import type { WeixinIlinkBotConfig } from "./types.js";
11
+
12
+ const DEFAULT_ILINK_BOT_TYPE = "3";
13
+ const QR_LONG_POLL_TIMEOUT_MS = 35_000;
14
+ const LOGIN_TIMEOUT_MS = 480_000;
15
+
16
+ interface QRCodeResponse {
17
+ qrcode: string;
18
+ qrcode_img_content: string;
19
+ }
20
+
21
+ interface StatusResponse {
22
+ status:
23
+ | "wait"
24
+ | "scaned"
25
+ | "confirmed"
26
+ | "expired"
27
+ | "scaned_but_redirect"
28
+ | "need_verifycode"
29
+ | "verify_code_blocked"
30
+ | "binded_redirect";
31
+ bot_token?: string;
32
+ ilink_bot_id?: string;
33
+ baseurl?: string;
34
+ ilink_user_id?: string;
35
+ redirect_host?: string;
36
+ }
37
+
38
+ function getLocalBotTokenList(): string[] {
39
+ return [];
40
+ }
41
+
42
+ async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
43
+ const rawText = await apiPostFetch({
44
+ baseUrl: apiBaseUrl,
45
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
46
+ body: JSON.stringify({ local_token_list: getLocalBotTokenList(), base_info: buildBaseInfo() }),
47
+ label: "fetchQRCode",
48
+ });
49
+ return JSON.parse(rawText) as QRCodeResponse;
50
+ }
51
+
52
+ async function pollQRStatus(
53
+ apiBaseUrl: string,
54
+ qrcode: string,
55
+ ): Promise<StatusResponse> {
56
+ try {
57
+ const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
58
+ const rawText = await apiGetFetch({
59
+ baseUrl: apiBaseUrl,
60
+ endpoint,
61
+ timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
62
+ label: "pollQRStatus",
63
+ });
64
+ return JSON.parse(rawText) as StatusResponse;
65
+ } catch (err) {
66
+ if (err instanceof Error && err.name === "AbortError") {
67
+ return { status: "wait" };
68
+ }
69
+ logger.warn(`pollQRStatus error: ${String(err)}`);
70
+ return { status: "wait" };
71
+ }
72
+ }
73
+
74
+ export async function resolveCredentials(
75
+ plugin: Plugin,
76
+ config: WeixinIlinkBotConfig,
77
+ ): Promise<WeixinIlinkCredentials> {
78
+ const envToken = process.env.WEIXIN_ILINK_TOKEN?.trim() || config.botToken?.trim();
79
+ if (envToken) {
80
+ const stored = loadCredentials(config.name);
81
+ return {
82
+ botToken: envToken,
83
+ ilinkUserId: stored?.ilinkUserId,
84
+ ilinkBotId: stored?.ilinkBotId,
85
+ baseUrl: config.baseUrl ?? stored?.baseUrl ?? DEFAULT_API_BASE_URL,
86
+ };
87
+ }
88
+
89
+ const stored = loadCredentials(config.name);
90
+ if (stored?.botToken) {
91
+ return {
92
+ ...stored,
93
+ baseUrl: config.baseUrl ?? stored.baseUrl ?? DEFAULT_API_BASE_URL,
94
+ };
95
+ }
96
+
97
+ return loginWithQr(plugin, config);
98
+ }
99
+
100
+ export async function loginWithQr(
101
+ plugin: Plugin,
102
+ config: WeixinIlinkBotConfig,
103
+ ): Promise<WeixinIlinkCredentials> {
104
+ const apiBaseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL;
105
+ const qr = await fetchQRCode(apiBaseUrl, DEFAULT_ILINK_BOT_TYPE);
106
+ const qrcodeUrl = qr.qrcode_img_content || qr.qrcode;
107
+
108
+ const loginAssist = plugin.inject("loginAssist");
109
+ if (!loginAssist) {
110
+ throw new Error("loginAssist 服务不可用,请确保已加载 zhin 核心服务");
111
+ }
112
+ void loginAssist.waitForInput("weixin-ilink", config.name, "qrcode", {
113
+ message: "请用微信扫描 ClawBot 二维码完成登录",
114
+ image: qrcodeUrl.startsWith("data:") ? qrcodeUrl : undefined,
115
+ url: qrcodeUrl.startsWith("http") ? qrcodeUrl : undefined,
116
+ qrcode: qr.qrcode,
117
+ });
118
+ const assistTask = loginAssist
119
+ .listPending()
120
+ .find((t) => t.adapter === "weixin-ilink" && t.botId === config.name);
121
+
122
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
123
+ let currentBaseUrl = apiBaseUrl;
124
+
125
+ try {
126
+ while (Date.now() < deadline) {
127
+ const status = await pollQRStatus(currentBaseUrl, qr.qrcode);
128
+
129
+ if (status.status === "scaned_but_redirect" && status.redirect_host) {
130
+ currentBaseUrl = status.redirect_host.startsWith("http")
131
+ ? status.redirect_host
132
+ : `https://${status.redirect_host}`;
133
+ }
134
+
135
+ if (status.status === "confirmed" || status.status === "binded_redirect") {
136
+ if (!status.bot_token && status.status !== "binded_redirect") {
137
+ throw new Error("扫码成功但未返回 bot_token");
138
+ }
139
+ const creds: WeixinIlinkCredentials = {
140
+ botToken: status.bot_token ?? loadCredentials(config.name)?.botToken ?? "",
141
+ ilinkUserId: status.ilink_user_id,
142
+ ilinkBotId: status.ilink_bot_id,
143
+ baseUrl: status.baseurl ?? currentBaseUrl,
144
+ };
145
+ if (!creds.botToken) {
146
+ throw new Error("binded_redirect 但本地无有效 bot_token");
147
+ }
148
+ saveCredentials(config.name, creds);
149
+ if (assistTask) loginAssist.submit(assistTask.id, "confirmed");
150
+ return creds;
151
+ }
152
+
153
+ if (status.status === "expired") {
154
+ if (assistTask) loginAssist.cancel(assistTask.id, "expired");
155
+ throw new Error("二维码已过期,请重启 bot 重新登录");
156
+ }
157
+
158
+ await new Promise((r) => setTimeout(r, 500));
159
+ }
160
+ if (assistTask) loginAssist.cancel(assistTask.id, "timeout");
161
+ throw new Error("微信扫码登录超时");
162
+ } catch (err) {
163
+ if (assistTask) loginAssist.cancel(assistTask.id, String(err));
164
+ throw err;
165
+ }
166
+ }
@@ -0,0 +1,149 @@
1
+ import type { WeixinInboundMediaOpts } from "./weixin-inbound.js";
2
+ import { logger } from "./ilink-logger.js";
3
+ import { getMimeFromFilename } from "./mime.js";
4
+ import {
5
+ downloadAndDecryptBuffer,
6
+ downloadPlainCdnBuffer,
7
+ } from "./pic-decrypt.js";
8
+ import { silkToWav } from "./silk-transcode.js";
9
+ import type { WeixinMessage } from "./ilink-types.js";
10
+ import { MessageItemType } from "./ilink-types.js";
11
+
12
+ const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
13
+
14
+ /** Persist a buffer via the framework's unified media store. */
15
+ type SaveMediaFn = (
16
+ buffer: Buffer,
17
+ contentType?: string,
18
+ subdir?: string,
19
+ maxBytes?: number,
20
+ originalFilename?: string,
21
+ ) => Promise<{ path: string }>;
22
+
23
+ /**
24
+ * Download and decrypt media from a single MessageItem.
25
+ * Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure.
26
+ */
27
+ export async function downloadMediaFromItem(
28
+ item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never,
29
+ deps: {
30
+ cdnBaseUrl: string;
31
+ saveMedia: SaveMediaFn;
32
+ log: (msg: string) => void;
33
+ errLog: (msg: string) => void;
34
+ label: string;
35
+ },
36
+ ): Promise<WeixinInboundMediaOpts> {
37
+ const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
38
+ const result: WeixinInboundMediaOpts = {};
39
+
40
+ if (item.type === MessageItemType.IMAGE) {
41
+ const img = item.image_item;
42
+ if (!img?.media?.encrypt_query_param && !img?.media?.full_url) return result;
43
+ const aesKeyBase64 = img.aeskey
44
+ ? Buffer.from(img.aeskey, "hex").toString("base64")
45
+ : img.media.aes_key;
46
+ logger.debug(
47
+ `${label} image: encrypt_query_param=${(img.media.encrypt_query_param ?? "").slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"} full_url=${Boolean(img.media.full_url)}`,
48
+ );
49
+ try {
50
+ const buf = aesKeyBase64
51
+ ? await downloadAndDecryptBuffer(
52
+ img.media.encrypt_query_param ?? "",
53
+ aesKeyBase64,
54
+ cdnBaseUrl,
55
+ `${label} image`,
56
+ img.media.full_url,
57
+ )
58
+ : await downloadPlainCdnBuffer(
59
+ img.media.encrypt_query_param ?? "",
60
+ cdnBaseUrl,
61
+ `${label} image-plain`,
62
+ img.media.full_url,
63
+ );
64
+ const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
65
+ result.decryptedPicPath = saved.path;
66
+ logger.debug(`${label} image saved: ${saved.path}`);
67
+ } catch (err) {
68
+ logger.error(`${label} image download/decrypt failed: ${String(err)}`);
69
+ errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`);
70
+ }
71
+ } else if (item.type === MessageItemType.VOICE) {
72
+ const voice = item.voice_item;
73
+ if ((!voice?.media?.encrypt_query_param && !voice?.media?.full_url) || !voice?.media?.aes_key)
74
+ return result;
75
+ try {
76
+ const silkBuf = await downloadAndDecryptBuffer(
77
+ voice.media.encrypt_query_param ?? "",
78
+ voice.media.aes_key,
79
+ cdnBaseUrl,
80
+ `${label} voice`,
81
+ voice.media.full_url,
82
+ );
83
+ logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);
84
+ const wavBuf = await silkToWav(silkBuf);
85
+ if (wavBuf) {
86
+ const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
87
+ result.decryptedVoicePath = saved.path;
88
+ result.voiceMediaType = "audio/wav";
89
+ logger.debug(`${label} voice: saved WAV to ${saved.path}`);
90
+ } else {
91
+ const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
92
+ result.decryptedVoicePath = saved.path;
93
+ result.voiceMediaType = "audio/silk";
94
+ logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`);
95
+ }
96
+ } catch (err) {
97
+ logger.error(`${label} voice download/transcode failed: ${String(err)}`);
98
+ errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`);
99
+ }
100
+ } else if (item.type === MessageItemType.FILE) {
101
+ const fileItem = item.file_item;
102
+ if ((!fileItem?.media?.encrypt_query_param && !fileItem?.media?.full_url) || !fileItem?.media?.aes_key)
103
+ return result;
104
+ try {
105
+ const buf = await downloadAndDecryptBuffer(
106
+ fileItem.media.encrypt_query_param ?? "",
107
+ fileItem.media.aes_key,
108
+ cdnBaseUrl,
109
+ `${label} file`,
110
+ fileItem.media.full_url,
111
+ );
112
+ const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin");
113
+ const saved = await saveMedia(
114
+ buf,
115
+ mime,
116
+ "inbound",
117
+ WEIXIN_MEDIA_MAX_BYTES,
118
+ fileItem.file_name ?? undefined,
119
+ );
120
+ result.decryptedFilePath = saved.path;
121
+ result.fileMediaType = mime;
122
+ logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);
123
+ } catch (err) {
124
+ logger.error(`${label} file download failed: ${String(err)}`);
125
+ errLog(`weixin ${label} file download failed: ${String(err)}`);
126
+ }
127
+ } else if (item.type === MessageItemType.VIDEO) {
128
+ const videoItem = item.video_item;
129
+ if ((!videoItem?.media?.encrypt_query_param && !videoItem?.media?.full_url) || !videoItem?.media?.aes_key)
130
+ return result;
131
+ try {
132
+ const buf = await downloadAndDecryptBuffer(
133
+ videoItem.media.encrypt_query_param ?? "",
134
+ videoItem.media.aes_key,
135
+ cdnBaseUrl,
136
+ `${label} video`,
137
+ videoItem.media.full_url,
138
+ );
139
+ const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES);
140
+ result.decryptedVideoPath = saved.path;
141
+ logger.debug(`${label} video: saved to ${saved.path}`);
142
+ } catch (err) {
143
+ logger.error(`${label} video download failed: ${String(err)}`);
144
+ errLog(`weixin ${label} video download failed: ${String(err)}`);
145
+ }
146
+ }
147
+
148
+ return result;
149
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getMimeFromFilename, getExtensionFromMime, getExtensionFromContentTypeOrUrl } from "./mime.js";
3
+
4
+ describe("getMimeFromFilename", () => {
5
+ it("returns correct MIME for known extensions", () => {
6
+ expect(getMimeFromFilename("photo.png")).toBe("image/png");
7
+ expect(getMimeFromFilename("doc.pdf")).toBe("application/pdf");
8
+ expect(getMimeFromFilename("video.mp4")).toBe("video/mp4");
9
+ expect(getMimeFromFilename("audio.mp3")).toBe("audio/mpeg");
10
+ expect(getMimeFromFilename("image.jpg")).toBe("image/jpeg");
11
+ expect(getMimeFromFilename("image.jpeg")).toBe("image/jpeg");
12
+ expect(getMimeFromFilename("data.csv")).toBe("text/csv");
13
+ });
14
+
15
+ it("is case-insensitive", () => {
16
+ expect(getMimeFromFilename("PHOTO.PNG")).toBe("image/png");
17
+ expect(getMimeFromFilename("file.PDF")).toBe("application/pdf");
18
+ });
19
+
20
+ it("returns application/octet-stream for unknown extensions", () => {
21
+ expect(getMimeFromFilename("file.xyz")).toBe("application/octet-stream");
22
+ expect(getMimeFromFilename("noext")).toBe("application/octet-stream");
23
+ });
24
+
25
+ it("handles paths with directories", () => {
26
+ expect(getMimeFromFilename("/path/to/photo.png")).toBe("image/png");
27
+ });
28
+ });
29
+
30
+ describe("getExtensionFromMime", () => {
31
+ it("returns correct extension for known MIME types", () => {
32
+ expect(getExtensionFromMime("image/jpeg")).toBe(".jpg");
33
+ expect(getExtensionFromMime("image/png")).toBe(".png");
34
+ expect(getExtensionFromMime("video/mp4")).toBe(".mp4");
35
+ expect(getExtensionFromMime("application/pdf")).toBe(".pdf");
36
+ });
37
+
38
+ it("handles MIME with parameters", () => {
39
+ expect(getExtensionFromMime("image/jpeg; charset=utf-8")).toBe(".jpg");
40
+ expect(getExtensionFromMime("text/plain; boundary=something")).toBe(".txt");
41
+ });
42
+
43
+ it("returns .bin for unknown MIME types", () => {
44
+ expect(getExtensionFromMime("application/unknown")).toBe(".bin");
45
+ expect(getExtensionFromMime("x-custom/type")).toBe(".bin");
46
+ });
47
+
48
+ it("handles image/jpg alias", () => {
49
+ expect(getExtensionFromMime("image/jpg")).toBe(".jpg");
50
+ });
51
+ });
52
+
53
+ describe("getExtensionFromContentTypeOrUrl", () => {
54
+ it("prefers Content-Type when it resolves to a known extension", () => {
55
+ expect(getExtensionFromContentTypeOrUrl("image/png", "https://example.com/file.jpg")).toBe(".png");
56
+ });
57
+
58
+ it("falls back to URL extension when Content-Type is unknown", () => {
59
+ expect(getExtensionFromContentTypeOrUrl("application/unknown", "https://example.com/file.jpg")).toBe(".jpg");
60
+ });
61
+
62
+ it("falls back to URL extension when Content-Type is null", () => {
63
+ expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file.mp4")).toBe(".mp4");
64
+ });
65
+
66
+ it("returns .bin when neither Content-Type nor URL has a known extension", () => {
67
+ expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file")).toBe(".bin");
68
+ expect(getExtensionFromContentTypeOrUrl("application/unknown", "https://example.com/file.xyz")).toBe(".bin");
69
+ });
70
+
71
+ it("handles URL with query parameters", () => {
72
+ expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file.png?token=abc")).toBe(".png");
73
+ });
74
+ });
package/src/mime.ts ADDED
@@ -0,0 +1,105 @@
1
+ import path from "node:path";
2
+
3
+ const EXTENSION_TO_MIME: Record<string, string> = {
4
+ ".pdf": "application/pdf",
5
+ ".doc": "application/msword",
6
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
7
+ ".xls": "application/vnd.ms-excel",
8
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
9
+ ".ppt": "application/vnd.ms-powerpoint",
10
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
11
+ ".txt": "text/plain",
12
+ ".csv": "text/csv",
13
+ ".zip": "application/zip",
14
+ ".tar": "application/x-tar",
15
+ ".gz": "application/gzip",
16
+ ".mp3": "audio/mpeg",
17
+ ".ogg": "audio/ogg",
18
+ ".wav": "audio/wav",
19
+ ".mp4": "video/mp4",
20
+ ".mov": "video/quicktime",
21
+ ".webm": "video/webm",
22
+ ".mkv": "video/x-matroska",
23
+ ".avi": "video/x-msvideo",
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".bmp": "image/bmp",
30
+ };
31
+
32
+ const MIME_TO_EXTENSION: Record<string, string> = {
33
+ "image/jpeg": ".jpg",
34
+ "image/jpg": ".jpg",
35
+ "image/png": ".png",
36
+ "image/gif": ".gif",
37
+ "image/webp": ".webp",
38
+ "image/bmp": ".bmp",
39
+ "video/mp4": ".mp4",
40
+ "video/quicktime": ".mov",
41
+ "video/webm": ".webm",
42
+ "video/x-matroska": ".mkv",
43
+ "video/x-msvideo": ".avi",
44
+ "audio/mpeg": ".mp3",
45
+ "audio/ogg": ".ogg",
46
+ "audio/wav": ".wav",
47
+ "application/pdf": ".pdf",
48
+ "application/zip": ".zip",
49
+ "application/x-tar": ".tar",
50
+ "application/gzip": ".gz",
51
+ "text/plain": ".txt",
52
+ "text/csv": ".csv",
53
+ };
54
+
55
+ /** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */
56
+ export function getMimeFromFilename(filename: string): string {
57
+ const ext = path.extname(filename).toLowerCase();
58
+ return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
59
+ }
60
+
61
+ /** Get file extension from MIME type. Returns ".bin" for unknown types. */
62
+ export function getExtensionFromMime(mimeType: string): string {
63
+ const ct = mimeType.split(";")[0].trim().toLowerCase();
64
+ return MIME_TO_EXTENSION[ct] ?? ".bin";
65
+ }
66
+
67
+ /** Sniff common image/video MIME from magic bytes. */
68
+ export function sniffMimeFromBuffer(buf: Buffer): string | undefined {
69
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
70
+ return "image/jpeg";
71
+ }
72
+ if (
73
+ buf.length >= 8 &&
74
+ buf[0] === 0x89 &&
75
+ buf[1] === 0x50 &&
76
+ buf[2] === 0x4e &&
77
+ buf[3] === 0x47
78
+ ) {
79
+ return "image/png";
80
+ }
81
+ if (buf.length >= 6 && buf.slice(0, 6).toString("ascii") === "GIF87a") {
82
+ return "image/gif";
83
+ }
84
+ if (buf.length >= 6 && buf.slice(0, 6).toString("ascii") === "GIF89a") {
85
+ return "image/gif";
86
+ }
87
+ if (buf.length >= 12 && buf.slice(0, 4).toString("ascii") === "RIFF" && buf.slice(8, 12).toString("ascii") === "WEBP") {
88
+ return "image/webp";
89
+ }
90
+ if (buf.length >= 8 && buf.slice(4, 8).toString("ascii") === "ftyp") {
91
+ return "video/mp4";
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */
97
+ export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
98
+ if (contentType) {
99
+ const ext = getExtensionFromMime(contentType);
100
+ if (ext !== ".bin") return ext;
101
+ }
102
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
103
+ const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
104
+ return knownExts.has(ext) ? ext : ".bin";
105
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 微信 iLink 出站媒体:base64 / 远程 URL 须落盘后再走 CDN 上传。
3
+ */
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import type { MessageSegment, SendContent } from "zhin.js";
8
+ import { downloadRemoteImageToTemp } from "./upload.js";
9
+ import { getExtensionFromMime } from "./mime.js";
10
+
11
+ const MEDIA_TYPES = new Set(["image", "video", "file", "record", "audio"]);
12
+
13
+ function isRemoteUrl(value: string): boolean {
14
+ return /^https?:\/\//i.test(value);
15
+ }
16
+
17
+ function isInlineMediaRef(value: string): boolean {
18
+ return value.startsWith("base64://") || /^data:[^/]+\/[^;]+;base64,/i.test(value);
19
+ }
20
+
21
+ function normalizeLocalPath(value: string): string {
22
+ return value.replace(/^file:\/\//, "");
23
+ }
24
+
25
+ export function segmentMediaRef(seg: MessageSegment): string | undefined {
26
+ const data = seg.data as Record<string, unknown>;
27
+ const candidates = [data.file, data.path, data.url].filter((v): v is string => typeof v === "string" && Boolean(v));
28
+ return candidates[0];
29
+ }
30
+
31
+ function extractBase64Buffer(data: Record<string, unknown>): Buffer | undefined {
32
+ const url = typeof data.url === "string" ? data.url : undefined;
33
+ const file = typeof data.file === "string" ? data.file : undefined;
34
+ const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
35
+
36
+ if (url?.startsWith("base64://")) {
37
+ return Buffer.from(url.slice(9), "base64");
38
+ }
39
+ if (url && /^data:[^/]+\/[^;]+;base64,/i.test(url)) {
40
+ return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ""), "base64");
41
+ }
42
+ if (file?.startsWith("base64://")) {
43
+ return Buffer.from(file.slice(9), "base64");
44
+ }
45
+ if (base64) {
46
+ const raw = base64.startsWith("base64://") ? base64.slice(9) : base64;
47
+ return Buffer.from(raw, "base64");
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function resolveExt(seg: MessageSegment, mime?: string): string {
53
+ const data = seg.data as Record<string, unknown>;
54
+ const name = typeof data.name === "string" ? data.name : undefined;
55
+ if (name) {
56
+ const ext = path.extname(name);
57
+ if (ext) return ext;
58
+ }
59
+ const mimeHint = mime ?? (typeof data.mime === "string" ? data.mime : typeof data.mimeType === "string" ? data.mimeType : undefined);
60
+ if (mimeHint) {
61
+ return getExtensionFromMime(mimeHint) ?? ".bin";
62
+ }
63
+ if (seg.type === "image") return ".png";
64
+ if (seg.type === "video") return ".mp4";
65
+ if (seg.type === "record" || seg.type === "audio") return ".mp3";
66
+ return ".bin";
67
+ }
68
+
69
+ function withLocalFile(seg: MessageSegment, filePath: string): MessageSegment {
70
+ const data = { ...seg.data, file: filePath } as Record<string, unknown>;
71
+ delete data.base64;
72
+ return { type: seg.type, data } as MessageSegment;
73
+ }
74
+
75
+ function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: MessageSegment): string {
76
+ fs.mkdirSync(outboundDir, { recursive: true });
77
+ const ext = resolveExt(seg);
78
+ const filePath = path.join(
79
+ outboundDir,
80
+ `out-${seg.type}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`,
81
+ );
82
+ fs.writeFileSync(filePath, buffer);
83
+ return filePath;
84
+ }
85
+
86
+ async function materializeSegment(
87
+ seg: MessageSegment,
88
+ outboundDir: string,
89
+ ): Promise<MessageSegment> {
90
+ if (!MEDIA_TYPES.has(seg.type)) return seg;
91
+
92
+ const ref = segmentMediaRef(seg);
93
+ if (ref && !isInlineMediaRef(ref)) {
94
+ const local = normalizeLocalPath(ref);
95
+ if (isRemoteUrl(local)) {
96
+ const downloaded = await downloadRemoteImageToTemp(local, outboundDir);
97
+ return withLocalFile(seg, downloaded);
98
+ }
99
+ if (fs.existsSync(local)) {
100
+ return withLocalFile(seg, local);
101
+ }
102
+ }
103
+
104
+ const buffer = extractBase64Buffer(seg.data as Record<string, unknown>);
105
+ if (buffer) {
106
+ return withLocalFile(seg, writeOutboundBuffer(buffer, outboundDir, seg));
107
+ }
108
+
109
+ return seg;
110
+ }
111
+
112
+ function asSegments(content: SendContent): MessageSegment[] {
113
+ if (typeof content === "string") {
114
+ return [{ type: "text", data: { text: content } }];
115
+ }
116
+ if (!Array.isArray(content)) {
117
+ return typeof content === "object" && content !== null && "type" in content
118
+ ? [content as MessageSegment]
119
+ : [];
120
+ }
121
+ return content.flatMap((item) => {
122
+ if (typeof item === "string") return [{ type: "text", data: { text: item } } as MessageSegment];
123
+ return typeof item === "object" && item !== null && "type" in item ? [item as MessageSegment] : [];
124
+ });
125
+ }
126
+
127
+ /** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
128
+ export async function materializeOutboundMedia(
129
+ content: SendContent,
130
+ outboundDir: string,
131
+ ): Promise<SendContent> {
132
+ const segments = asSegments(content);
133
+ const out = await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir)));
134
+ if (typeof content === "string") {
135
+ return out[0]?.type === "text" ? (out[0].data as { text: string }).text : content;
136
+ }
137
+ if (!Array.isArray(content)) {
138
+ return out[0] ?? content;
139
+ }
140
+ return out;
141
+ }