wx2tg-puppet 3.0.8 → 3.0.9

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.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # wx2tg-puppet
2
+
3
+ 微信机器人核心库。通过已有的 `WxBot` 配置服务端地址、设备类型和本地数据库。
4
+
5
+ ## 3.0.9:文件下载兼容
6
+
7
+ - 支持服务端 v6.0.10 的 `DownloadFile` 二进制 `200/206` 响应,同时兼容旧版 JSON/Base64 响应。
8
+ - 现有 `toolsApi.DownloadFile()` 仍返回 `Data.data.buffer`(Base64)和 `Data.BaseResponse.ret`,调用方无需为响应格式切换而修改解析逻辑。
9
+ - `toolsApi.DownloadFileBinary()` 返回单个分片的 `Buffer`,保留 HTTP 状态与响应头。
10
+ - `toolsApi.DownloadFileBuffer()` 下载完整文件,默认每次请求 512 KiB,按实际返回字节推进偏移;校验范围、总长度、非空数据和旧版业务状态。
11
+ - `Message.toFileBox()` 的普通文件分片路径使用新下载器;原有超过 80 MiB 的 `DownloadFileMessage` 路径保留。图片、视频和其他 API 的 JSON 解析保持原样。
12
+
13
+ ```ts
14
+ import {toolsApi} from 'wx2tg-puppet/dist/api/ToolsApi';
15
+
16
+ // 使用当前登录账号及原始文件消息中的真实元数据;不要修改文件总长度。
17
+ const buffer = await toolsApi.DownloadFileBuffer({
18
+ Wxid: 'your_logged_in_account',
19
+ UserName: 'original_conversation',
20
+ AttachId: 'original_attachment_id',
21
+ DataLen: originalFileSize,
22
+ });
23
+ ```
24
+
25
+ CDN 回退的首片可能需要等待整份文件接收完成,因此文件分片请求单独使用 180 秒超时;不会修改全局 Axios 配置。完整下载仅对临时网络错误及 HTTP 429/502/503/504 做有限重试,最多三次;业务拒绝、空内容和范围错误立即失败。
26
+
27
+ 失败会抛出 `FileDownloadError`,包含可用的 `httpStatus`、`businessCode` 和 `retryable`。不要把 HTTP 200 或外层 `Success=true` 当作文件下载成功;SDK 会继续检查原生返回码和内容。下载异常不会附带原始请求配置或附件密钥。
28
+
29
+ ## 验证与发布
30
+
31
+ ```sh
32
+ npm run test:download
33
+ npm pack --dry-run
34
+ npm publish
35
+ ```
36
+
37
+ 回归使用本地模拟 HTTP 服务,包括二进制短分片、旧版 JSON、约 26 MiB 文件、范围与业务错误以及 `Message.toFileBox()`,不登录微信或访问真实文件。上述验证不代表真实 CDN 或 home 部署已通过。发布包仅包含 `dist`、README 和 npm 必需元数据。
@@ -7,8 +7,12 @@ declare class ToolsApi {
7
7
  GetRealNameInfo(wxid: string): Promise<AxiosResponse<ApiResponse>>;
8
8
  /** 抢红包 */
9
9
  OpenHongBao(body: any): Promise<AxiosResponse<ApiResponse>>;
10
- /** 文件下载 */
10
+ /** 文件分片:兼容旧 JSON 和 v6.0.10 二进制响应,保留 Data.data.buffer。 */
11
11
  DownloadFile(body: any): Promise<AxiosResponse<ApiResponse>>;
12
+ /** 获取原始二进制分片,HTTP 206 的范围会先被校验。 */
13
+ DownloadFileBinary(body: any): Promise<AxiosResponse<Buffer>>;
14
+ /** 按实际返回字节推进分片并拼接完整文件,不需要传 Section。 */
15
+ DownloadFileBuffer(body: any): Promise<Buffer>;
12
16
  /** 文件消息下载 */
13
17
  DownloadFileMessage(body: any): Promise<AxiosResponse<ApiResponse>>;
14
18
  /** 高清图片下载 */
@@ -1,7 +1,17 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
12
  exports.toolsApi = void 0;
4
13
  const axiosInstance_1 = require("../utils/axiosInstance");
14
+ const fileDownload_1 = require("../utils/fileDownload");
5
15
  class ToolsApi {
6
16
  /** 生成支付二维码 */
7
17
  GeneratePayQCode(wxid) {
@@ -15,9 +25,22 @@ class ToolsApi {
15
25
  OpenHongBao(body) {
16
26
  return axiosInstance_1.axiosInstance.post('/TenPay/OpenHongBao', body);
17
27
  }
18
- /** 文件下载 */
28
+ /** 文件分片:兼容旧 JSON 和 v6.0.10 二进制响应,保留 Data.data.buffer。 */
19
29
  DownloadFile(body) {
20
- return axiosInstance_1.axiosInstance.post('/Tools/DownloadFile', body);
30
+ return __awaiter(this, void 0, void 0, function* () {
31
+ return (0, fileDownload_1.legacyFileSection)(yield (0, fileDownload_1.fetchFileSection)(body), body);
32
+ });
33
+ }
34
+ /** 获取原始二进制分片,HTTP 206 的范围会先被校验。 */
35
+ DownloadFileBinary(body) {
36
+ return __awaiter(this, void 0, void 0, function* () {
37
+ const section = yield (0, fileDownload_1.fetchFileSection)(body);
38
+ return Object.assign(Object.assign({}, section.response), { data: section.buffer });
39
+ });
40
+ }
41
+ /** 按实际返回字节推进分片并拼接完整文件,不需要传 Section。 */
42
+ DownloadFileBuffer(body) {
43
+ return (0, fileDownload_1.downloadFileBuffer)(body);
21
44
  }
22
45
  /** 文件消息下载 */
23
46
  DownloadFileMessage(body) {
@@ -559,37 +559,13 @@ class Message {
559
559
  if (totalSize > LARGE_FILE_THRESHOLD) {
560
560
  return yield this.downloadFileMessage();
561
561
  }
562
- // Get initial chunk size from first request
563
- const firstChunk = FileChunkHelper_1.FileChunkHelper.getChunk(totalSize, 0);
564
- if (!firstChunk)
565
- return null;
566
- // @ts-ignore - Using any type for response due to mixed axios versions
567
- const firstResp = yield retryDownload(() => ToolsApi_1.toolsApi.DownloadFile({
568
- "AppID": json.msg.appmsg.appid,
569
- "AttachId": json.msg.appmsg.appattach.attachid,
570
- "DataLen": totalSize,
571
- "Section": firstChunk,
572
- "UserName": this.msg.FromUserName,
573
- "Wxid": (config === null || config === void 0 ? void 0 : config.wxid) || ''
574
- }));
575
- const downloadedLength = firstResp.data.Data.data.iLen;
576
- const chunks = FileChunkHelper_1.FileChunkHelper.calculateChunks(totalSize, downloadedLength);
577
- let completeBuffer = Buffer.from(firstResp.data.Data.data.buffer, 'base64');
578
- // Start from second chunk since we already have the first one
579
- for (let i = 1; i < chunks.length; i++) {
580
- const chunk = chunks[i];
581
- // @ts-ignore - Using any type for response due to mixed axios versions
582
- const resp = yield retryDownload(() => ToolsApi_1.toolsApi.DownloadFile({
583
- "AppID": json.msg.appmsg.appid,
584
- "AttachId": json.msg.appmsg.appattach.attachid,
585
- "DataLen": totalSize,
586
- "Section": chunk,
587
- "UserName": this.msg.FromUserName,
588
- "Wxid": (config === null || config === void 0 ? void 0 : config.wxid) || ''
589
- }));
590
- const chunkData = Buffer.from(resp.data.Data.data.buffer, 'base64');
591
- completeBuffer = Buffer.concat([completeBuffer, chunkData]);
592
- }
562
+ const completeBuffer = yield ToolsApi_1.toolsApi.DownloadFileBuffer({
563
+ AppID: json.msg.appmsg.appid,
564
+ AttachId: json.msg.appmsg.appattach.attachid,
565
+ DataLen: totalSize,
566
+ UserName: this.msg.FromUserName,
567
+ Wxid: (config === null || config === void 0 ? void 0 : config.wxid) || ''
568
+ });
593
569
  const fileBox = file_box_1.FileBox.fromBuffer(completeBuffer, json.msg.appmsg.title);
594
570
  return fileBox;
595
571
  }
@@ -0,0 +1,20 @@
1
+ import { AxiosResponse } from 'axios';
2
+ import { ApiResponse } from '../types/ApiResponse';
3
+ export declare class FileDownloadError extends Error {
4
+ httpStatus?: number | undefined;
5
+ businessCode?: number | undefined;
6
+ retryable: boolean;
7
+ constructor(message: string, httpStatus?: number | undefined, businessCode?: number | undefined, retryable?: boolean);
8
+ }
9
+ interface FileSection {
10
+ response: AxiosResponse;
11
+ buffer: Buffer;
12
+ legacy?: ApiResponse;
13
+ }
14
+ /** Read bytes without invoking the shared JSON-only Axios transformer. */
15
+ export declare function fetchFileSection(body: any): Promise<FileSection>;
16
+ /** Preserve the existing SDK envelope for consumers using Data.data.buffer. */
17
+ export declare function legacyFileSection(section: FileSection, body: any): AxiosResponse<ApiResponse>;
18
+ /** Advance by actual received bytes; a successful short section is not EOF. */
19
+ export declare function downloadFileBuffer(body: any): Promise<Buffer>;
20
+ export {};
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.FileDownloadError = void 0;
16
+ exports.fetchFileSection = fetchFileSection;
17
+ exports.legacyFileSection = legacyFileSection;
18
+ exports.downloadFileBuffer = downloadFileBuffer;
19
+ const json_bigint_1 = __importDefault(require("json-bigint"));
20
+ const axiosInstance_1 = require("./axiosInstance");
21
+ const parseJSON = (0, json_bigint_1.default)({ storeAsString: true });
22
+ const FILE_TIMEOUT = 180000;
23
+ const CHUNK_SIZE = 512 * 1024;
24
+ class FileDownloadError extends Error {
25
+ constructor(message, httpStatus, businessCode, retryable = false) {
26
+ super(message);
27
+ this.httpStatus = httpStatus;
28
+ this.businessCode = businessCode;
29
+ this.retryable = retryable;
30
+ this.name = 'FileDownloadError';
31
+ }
32
+ }
33
+ exports.FileDownloadError = FileDownloadError;
34
+ function requestRange(body) {
35
+ var _a, _b;
36
+ const total = body === null || body === void 0 ? void 0 : body.DataLen, start = (_a = body === null || body === void 0 ? void 0 : body.Section) === null || _a === void 0 ? void 0 : _a.StartPos, length = (_b = body === null || body === void 0 ? void 0 : body.Section) === null || _b === void 0 ? void 0 : _b.DataLen;
37
+ if (![total, start, length].every(Number.isSafeInteger) || total <= 0 || total > 0xffffffff || start < 0 || start >= total || length <= 0 || length > 0xffffffff) {
38
+ throw new FileDownloadError('Invalid file size or section range');
39
+ }
40
+ return { total, start, length: Math.min(length, total - start) };
41
+ }
42
+ function header(response, name) {
43
+ var _a, _b;
44
+ return String((_b = (_a = response.headers) === null || _a === void 0 ? void 0 : _a[name]) !== null && _b !== void 0 ? _b : '').trim();
45
+ }
46
+ function safeMessage(value, body) {
47
+ if (typeof value !== 'string')
48
+ return '';
49
+ let message = value;
50
+ for (const key of ['AttachId', 'Wxid', 'UserName', 'AppID']) {
51
+ const secret = body === null || body === void 0 ? void 0 : body[key];
52
+ if (typeof secret === 'string' && secret.length > 3)
53
+ message = message.split(secret).join('[redacted]');
54
+ }
55
+ return message.replace(/@cdn_\S+/g, '[redacted]')
56
+ .replace(/https?:\/\/\S+/gi, '[url redacted]')
57
+ .replace(/\b[a-f\d]{24,}\b/gi, '[redacted]')
58
+ .replace(/[A-Za-z\d+/=]{100,}/g, '[data redacted]')
59
+ .replace(/[\r\n]+/g, ' ').slice(0, 400);
60
+ }
61
+ function invalid(message) {
62
+ throw new FileDownloadError(message);
63
+ }
64
+ function decodeLegacy(data, body) {
65
+ var _a, _b;
66
+ const { total, start, length } = requestRange(body);
67
+ const payload = data === null || data === void 0 ? void 0 : data.Data, native = (_a = payload === null || payload === void 0 ? void 0 : payload.BaseResponse) !== null && _a !== void 0 ? _a : payload === null || payload === void 0 ? void 0 : payload.baseResponse;
68
+ if ((data === null || data === void 0 ? void 0 : data.Success) !== true || (data === null || data === void 0 ? void 0 : data.Code) !== 0 || ((native === null || native === void 0 ? void 0 : native.ret) !== undefined && native.ret !== 0)) {
69
+ const code = typeof (data === null || data === void 0 ? void 0 : data.Code) === 'number' ? data.Code : undefined;
70
+ throw new FileDownloadError(`File download rejected${code !== undefined ? ` (Code=${code})` : ''}${(native === null || native === void 0 ? void 0 : native.ret) !== undefined ? ` (ret=${native.ret})` : ''}: ${safeMessage(data === null || data === void 0 ? void 0 : data.Message, body)}`, 200, code);
71
+ }
72
+ const encoded = (_b = payload === null || payload === void 0 ? void 0 : payload.data) === null || _b === void 0 ? void 0 : _b.buffer;
73
+ if (typeof encoded !== 'string' || !encoded.length || !/^[A-Za-z\d+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1) {
74
+ return invalid('File download returned an empty or invalid Base64 section');
75
+ }
76
+ const bytes = Buffer.from(encoded, 'base64');
77
+ if (!bytes.length || bytes.length > length || bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) {
78
+ return invalid('File download returned an invalid section length');
79
+ }
80
+ for (const [value, expected] of [[payload.data.iLen, bytes.length], [payload.dataLen, bytes.length], [payload.totalLen, total], [payload.startPos, start]]) {
81
+ if (value !== undefined && value !== expected)
82
+ return invalid('File download section metadata does not match the requested range');
83
+ }
84
+ return bytes;
85
+ }
86
+ /** Read bytes without invoking the shared JSON-only Axios transformer. */
87
+ function fetchFileSection(body) {
88
+ return __awaiter(this, void 0, void 0, function* () {
89
+ const { total, start, length } = requestRange(body);
90
+ let response;
91
+ try {
92
+ response = yield axiosInstance_1.axiosInstance.post('/Tools/DownloadFile', body, {
93
+ responseType: 'arraybuffer',
94
+ transformResponse: [data => data],
95
+ validateStatus: () => true,
96
+ timeout: FILE_TIMEOUT,
97
+ maxContentLength: Math.ceil(length * 4 / 3) + 64 * 1024,
98
+ });
99
+ }
100
+ catch (error) {
101
+ const errorCode = error === null || error === void 0 ? void 0 : error.code;
102
+ const code = typeof errorCode === 'string' && /^[A-Z_\d]+$/.test(errorCode) ? errorCode : 'NETWORK_ERROR';
103
+ throw new FileDownloadError(`File section request failed (${code})`, undefined, undefined, ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ECONNREFUSED', 'NETWORK_ERROR'].includes(code));
104
+ }
105
+ const contentType = header(response, 'content-type').split(';')[0].toLowerCase();
106
+ const raw = response.data;
107
+ const bytes = Buffer.isBuffer(raw) ? raw : raw instanceof ArrayBuffer ? Buffer.from(raw) :
108
+ ArrayBuffer.isView(raw) ? Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength) :
109
+ typeof raw === 'string' && contentType === 'application/json' ? Buffer.from(raw) : invalid('File download returned an unsupported response');
110
+ let json;
111
+ if (contentType === 'application/json' || (!contentType && bytes[0] === 123)) {
112
+ try {
113
+ json = parseJSON.parse(bytes.toString('utf8'));
114
+ }
115
+ catch (_a) {
116
+ return invalid(`File download returned invalid JSON (HTTP ${response.status})`);
117
+ }
118
+ }
119
+ if (response.status !== 200 && response.status !== 206) {
120
+ const code = typeof (json === null || json === void 0 ? void 0 : json.Code) === 'number' ? json.Code : undefined;
121
+ throw new FileDownloadError(`File download failed (HTTP ${response.status}${code !== undefined ? `, Code=${code}` : ''}): ${safeMessage(json === null || json === void 0 ? void 0 : json.Message, body)}`, response.status, code, [429, 502, 503, 504].includes(response.status));
122
+ }
123
+ if (json)
124
+ return { response, buffer: decodeLegacy(json, body), legacy: json };
125
+ if (contentType !== 'application/octet-stream' || !bytes.length || bytes.length > length) {
126
+ return invalid('File download returned an empty or invalid binary section');
127
+ }
128
+ const declared = header(response, 'content-length');
129
+ if (declared && (!/^\d+$/.test(declared) || Number(declared) !== bytes.length)) {
130
+ return invalid('File download Content-Length does not match received bytes');
131
+ }
132
+ if (response.status === 206) {
133
+ const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header(response, 'content-range'));
134
+ if (!range || Number(range[1]) !== start || Number(range[2]) !== start + bytes.length - 1 || Number(range[3]) !== total) {
135
+ return invalid('File download Content-Range does not match the requested section');
136
+ }
137
+ }
138
+ else if (start !== 0 || bytes.length !== total) {
139
+ return invalid('File download returned HTTP 200 for an incomplete file');
140
+ }
141
+ return { response, buffer: bytes };
142
+ });
143
+ }
144
+ /** Preserve the existing SDK envelope for consumers using Data.data.buffer. */
145
+ function legacyFileSection(section, body) {
146
+ var _a;
147
+ const { response, buffer, legacy } = section;
148
+ return Object.assign(Object.assign({}, response), { data: Object.assign(Object.assign({}, legacy), { Code: 0, Success: true, Message: (legacy === null || legacy === void 0 ? void 0 : legacy.Message) || '成功', Data: Object.assign(Object.assign({}, legacy === null || legacy === void 0 ? void 0 : legacy.Data), { BaseResponse: { ret: 0 }, totalLen: body.DataLen, startPos: body.Section.StartPos, dataLen: buffer.length, data: Object.assign(Object.assign({}, (_a = legacy === null || legacy === void 0 ? void 0 : legacy.Data) === null || _a === void 0 ? void 0 : _a.data), { iLen: buffer.length, buffer: buffer.toString('base64') }) }) }) });
149
+ }
150
+ /** Advance by actual received bytes; a successful short section is not EOF. */
151
+ function downloadFileBuffer(body) {
152
+ return __awaiter(this, void 0, void 0, function* () {
153
+ const total = body === null || body === void 0 ? void 0 : body.DataLen;
154
+ requestRange(Object.assign(Object.assign({}, body), { Section: { StartPos: 0, DataLen: CHUNK_SIZE } }));
155
+ const chunks = [];
156
+ let offset = 0;
157
+ while (offset < total) {
158
+ const request = Object.assign(Object.assign({}, body), { Section: { StartPos: offset, DataLen: Math.min(CHUNK_SIZE, total - offset) } });
159
+ let section;
160
+ for (let attempt = 0;; attempt++) {
161
+ try {
162
+ section = yield fetchFileSection(request);
163
+ break;
164
+ }
165
+ catch (error) {
166
+ if (!(error instanceof FileDownloadError) || !error.retryable || attempt >= 2)
167
+ throw error;
168
+ yield new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
169
+ }
170
+ }
171
+ chunks.push(section.buffer);
172
+ offset += section.buffer.length;
173
+ }
174
+ return Buffer.concat(chunks, total);
175
+ });
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wx2tg-puppet",
3
- "version": "3.0.8",
3
+ "version": "3.0.9",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -9,6 +9,7 @@
9
9
  ],
10
10
  "scripts": {
11
11
  "build": "tsc",
12
+ "test:download": "npm run build && node --test tests/file-download.test.cjs",
12
13
  "start": "node dist/index.js",
13
14
  "dev": "ts-node src/index.ts",
14
15
  "test": "ts-node src/test.ts",