wx2tg-puppet 3.0.8 → 6.0.16

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,86 @@
1
+ # wx2tg-puppet
2
+
3
+ 微信机器人核心库,供 `wechat2tg` 调用 `wx2tg-server`。
4
+
5
+ ## v6 配套版本
6
+
7
+ | 仓库 | 维护分支 | 本次 Git 标签 | npm / Docker 版本 |
8
+ | --- | --- | --- | --- |
9
+ | wechat2tg | `v6` | `v6.0.16` | 包版本 `6.0.16`;镜像 `finalpi/wechat2tg:v6.0.16` |
10
+ | wx2tg-puppet | `v6` | `v6.0.16` | npm 格式包 `wx2tg-puppet@6.0.16` |
11
+ | wx2tg-server(fk-zhang) | `v6`,继承 `875` | `v6.0.16` | 镜像 `finalpi/wx2tg-server:v6.0.16` |
12
+
13
+ **只维护 `v6` 分支,开发版和稳定版用 tag 区分,不建立 `v6-dev` 分支。** `v6.X.Y-dev.N` 为开发版,`v6.X.Y` 为稳定版;npm 不带前面的 `v`。三个仓库使用相同配套版本。Docker 的 `v6-dev` 指向开发版、`v6` 指向稳定版,开发构建不得覆盖稳定别名。历史 `3.x`、`875`、`v51.*`、裸 `v6` 标签保留,不移动。
14
+
15
+ 根目录 `VERSION`、`package.json`、`package-lock.json` 必须一致;发布标签必须为 `v${VERSION}`。创建本地标签不代表已推送或已发布 npm。发布前执行:
16
+
17
+ ```sh
18
+ npm ci
19
+ npm run check:version
20
+ npm test
21
+ npm pack
22
+ ```
23
+
24
+ `test.yml` 负责测试和 Actions 制品;`publish.yml` 在完整 `v6.*` 标签触发后重新验证、测试、打包并执行 `npm publish`。正式版使用 npm `latest`,开发版使用 `v6-dev`,不覆盖稳定标签。发布使用仓库 `NPM_TOKEN`,凭据缺失或失效会明确失败;还会检查 registry 确实能读取该版本。当前源码仓库为私有,不声称提供 npm provenance。分支推送和 PR 不发布 npm;可手动重试已有不可变标签。
25
+
26
+ `wechat2tg` 可继续锁定同版本本地制品以保证构建可重现;若切换 registry 来源,必须重新生成 lockfile 并校验制品,不要手工伪造 integrity。
27
+
28
+ ## 消息接收
29
+
30
+ ### polling(保持默认)
31
+
32
+ ```ts
33
+ new WxBot({baseUrl: 'http://wx2tg-server:8058/api', databasePath: 'storage/db/puppet.db',
34
+ deviceType: 'ipad', mode: 'polling'})
35
+ ```
36
+
37
+ server 对应 `syncmessage=false`、`msgpush=false`。每秒拉取 `/Msg/Sync`,同一实例不会并发 Sync;同步进行中又收到通知时,会追加一次拉取。
38
+
39
+ ### callback
40
+
41
+ 设 `mode: 'callback'`、`callbackPort: 8056`;server 的 `syncmessagebusinessuri` 指向 `http://bot:8056/message/callback/{0}`,启用 `msgpush=true`,按需启用 `syncmessage=true` 作为短链兜底。
42
+
43
+ - 带正文回调直接处理 `Data.AddMsgs`,不再丢弃正文后二次拉取。当前 server 同时提供 `Data.Messages` 和兼容的 `Data.AddMsgs`。
44
+ - 兼容空通知和 Go 时间戳通知;它们才触发 Sync。每 5 分钟保留一次兜底 Sync。
45
+ - 64 位消息 ID 无损解析;回调和轮询通过同一队列、同一去重逻辑处理。进程内去重窗口 2 分钟,最多 10,000 项。
46
+ - 回调服务启动成功后发出 `login`;启动/恢复检查取得的消息也会处理,不再直接丢弃。
47
+ - 单个回调正文最大 8 MiB;非法信封 400,错误账号路径 404,超限 413,处理失败 500。单条消息失败最多在本地尝试 3 次,不阻断同批其他消息;部分失败也返回 500,不会回显消息正文。
48
+ - 只允许可信网络访问回调端口。当前 server 没有签名/token,本库不提供公网鉴权。
49
+
50
+ HTTP 200 仅表示 puppet 处理并同步发出了事件,不代表异步业务监听器或 Telegram 已成功处理。进程重启会清空去重与队列;server 的异步消息归档也不是可靠回调重试队列。需要端到端补发时,仍需单独设计持久化投递/游标补偿。
51
+
52
+ ## 公众号识别
53
+
54
+ `Contact.verifyFlag` 保存 `/api/Friend/GetContractDetail` 返回的 `VerifyFlag`,`Contact.isOfficialAccount()` 返回 `true` / `false` / `undefined`。只有品牌位 `(VerifyFlag & 8) !== 0` 才判断为公众号,不根据 `gh_` 前缀,也不把任意非零值当作公众号。例如 `0`、`1`、`2`、`4`、`16` 均不是公众号,`8`、`24`、`136` 是公众号。
55
+
56
+ 根入口导出 `normalizeContactVerifyFlag` 与 `isOfficialAccountVerifyFlag`。字段缺失或非法时保持未知,不能转换成 `0`。旧缓存通过 `Contact.find()` 按需补查,同一账号/联系人共享请求,未知结果有 30 秒冷却。显式 `0` 会更新旧分类,缺失字段不会抹掉已知标志。联系人详情请求有 15 秒超时,不再无限递归;缓存写入使用原子 upsert 避免并发重复主键。
57
+
58
+ 此 SDK 管理 `puppet.db` 的联系人 schema。其他读取连接不要再次用不完整的镜像实体执行 schema synchronize,否则会删除新增字段。服务端目录接口的 `profile.verify_flag` 可用于批量补齐,但必须验证账号范围,不覆盖更新的已知值。
59
+
60
+ ## 接龙消息
61
+
62
+ `Message.Type.Solitaire` 对应外层 `49`、应用子类型 `53`。`Message.text()` 返回接龙完整可见标题、名单和尾注;原始 `Content`、`_text`、`_xml` 保持不变,不把内部 `solitaire_info` 当正文输出。CDATA、实体转义、前导零、Unicode 和换行保留;无法安全解析时返回明确的接龙占位提示。此功能只负责识别/读取,不自动参与、发送或重放历史接龙。
63
+
64
+ ## 对齐 860-v3
65
+
66
+ 当前 v6 源码合入 `860-v3` 的 `7d9590c`。登录恢复和同步互斥已经由 v6 独立覆盖,本轮保留其更完整的回调队列;补齐下载校验、选择性重试及兼容入口。详见 [上游对齐记录](docs/upstream-860-v3.md)。此源码整合不覆盖已发布 `v6.0.15` 制品,后续发布使用新协调版本。
67
+
68
+ ## 文件下载
69
+
70
+ `6.0.13-dev.1` 新增 `toolsApi.DownloadFileBuffer(body): Promise<Buffer>`,从偏移 0 以最多 512 KiB 分片组装完整文件,保留参数和 64 位 ID,供合并的 WebUI / 消息中心使用。原有 v6 `DownloadFile()` 仍返回单片 `AxiosResponse<Buffer>`,没有替换为 npm 3.0.9 的不同契约,也没有回退带正文回调修复。
71
+
72
+ `v6.0.12` 支持 `/Tools/DownloadFile` 的 HTTP 200/206 二进制分片,按实际收到的字节数推进偏移,验证长度/范围,保留 64 位 `NewMsgId`。同时兼容旧 JSON/Base64 成功响应。
73
+
74
+ 每次分片请求超时为 180 秒,以覆盖 server 首次 CDN 回退约 120 秒的预算。明确的暂时性网络错误及 HTTP 429/502/503/504 最多尝试 3 次,按 1/2 秒退避,仍从同一偏移继续;损坏内容、错误范围和确定的业务失败不重试。`FileDownloadError` 提供 `httpStatus`、`businessCode`、`retryable`,但不携带附件密钥、任意 server 正文或 Axios 请求对象。
75
+
76
+ 新增 `DownloadFileBinary` 二进制别名、显式的 `DownloadFileLegacy` JSON/Base64 入口;`dist/utils/fileDownload` 的工具导出与上游兼容,复用同一个校验引擎。保持 v6 `DownloadFile` 返回 `AxiosResponse<Buffer>` 的原契约。
77
+
78
+ 旧 JSON 必须有 `Success=true` 和 `Code=0`;已提供的 native ret、长度、偏移和总长度均需匹配。二进制 `206` 必须提供准确的 `Content-Range`,二进制 `200` 只能是从零开始的完整文件;旧 JSON 分片仍可返回 HTTP 200。响应元数据中的大整数也无损保留。
79
+
80
+ 图片、视频及大于 80 MiB 文件的 `DownloadFileMessage` 路径仍保持原行为。
81
+
82
+ ## 测试与构建
83
+
84
+ `npm test` 先编译,再同时运行 `test/` 与上游 `tests/` 下的 `node:test` 离线回归:回调正文、空通知、大整数、并发去重、登录事件、续登并发保护、二进制/旧 JSON 下载、分片推进、错误响应和不受影响的图片/视频路径。测试只访问本机随机端口,不登录微信或发送真实消息。
85
+
86
+ 保留已发布 `3.0.8` 的单次续登保护及 v6 更完整的串行同步行为,避免上游早退逻辑丢弃待处理通知。仓库既有 `dist` 为被追踪产物,修改源码后需重新构建并一起提交。
package/VERSION ADDED
@@ -0,0 +1 @@
1
+ 6.0.16
@@ -13,7 +13,7 @@ class FriendApi {
13
13
  }
14
14
  /** 获取通讯录好友详情 */
15
15
  GetContractDetail(body) {
16
- return axiosInstance_1.axiosInstance.post('/Friend/GetContractDetail', body);
16
+ return axiosInstance_1.axiosInstance.post('/Friend/GetContractDetail', body, { timeout: 15000 });
17
17
  }
18
18
  /** 获取通讯录好友 */
19
19
  GetContractList(body) {
@@ -8,7 +8,13 @@ declare class ToolsApi {
8
8
  /** 抢红包 */
9
9
  OpenHongBao(body: any): Promise<AxiosResponse<ApiResponse>>;
10
10
  /** 文件下载 */
11
- DownloadFile(body: any): Promise<AxiosResponse<ApiResponse>>;
11
+ DownloadFile(body: any): Promise<AxiosResponse<Buffer>>;
12
+ /** DownloadFile 的二进制别名 */
13
+ DownloadFileBinary(body: any): Promise<AxiosResponse<Buffer>>;
14
+ /** 显式兼容旧版 JSON/Base64 响应结构 */
15
+ DownloadFileLegacy(body: any): Promise<AxiosResponse<ApiResponse>>;
16
+ /** 分段下载完整文件,返回合并后的二进制内容 */
17
+ DownloadFileBuffer(body: any): Promise<Buffer>;
12
18
  /** 文件消息下载 */
13
19
  DownloadFileMessage(body: any): Promise<AxiosResponse<ApiResponse>>;
14
20
  /** 高清图片下载 */
@@ -1,7 +1,18 @@
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 downloadFileSection_1 = require("../utils/downloadFileSection");
15
+ const fileDownload_1 = require("../utils/fileDownload");
5
16
  class ToolsApi {
6
17
  /** 生成支付二维码 */
7
18
  GeneratePayQCode(wxid) {
@@ -17,7 +28,21 @@ class ToolsApi {
17
28
  }
18
29
  /** 文件下载 */
19
30
  DownloadFile(body) {
20
- return axiosInstance_1.axiosInstance.post('/Tools/DownloadFile', body);
31
+ return (0, downloadFileSection_1.downloadFileSection)(body);
32
+ }
33
+ /** DownloadFile 的二进制别名 */
34
+ DownloadFileBinary(body) {
35
+ return (0, downloadFileSection_1.downloadFileSection)(body);
36
+ }
37
+ /** 显式兼容旧版 JSON/Base64 响应结构 */
38
+ DownloadFileLegacy(body) {
39
+ return __awaiter(this, void 0, void 0, function* () {
40
+ return (0, fileDownload_1.legacyFileSection)(yield (0, fileDownload_1.fetchFileSection)(body), body);
41
+ });
42
+ }
43
+ /** 分段下载完整文件,返回合并后的二进制内容 */
44
+ DownloadFileBuffer(body) {
45
+ return (0, fileDownload_1.downloadFileBuffer)(body);
21
46
  }
22
47
  /** 文件消息下载 */
23
48
  DownloadFileMessage(body) {
@@ -10,6 +10,8 @@ export type ContactGender = any;
10
10
  export declare class Contact {
11
11
  get wxid(): string;
12
12
  get nickName(): string;
13
+ get verifyFlag(): number | null;
14
+ isOfficialAccount(): boolean | undefined;
13
15
  /**
14
16
  * Contact id (readonly)
15
17
  */
@@ -27,6 +29,8 @@ export declare class Contact {
27
29
  id?: string;
28
30
  nickName?: string;
29
31
  }): Promise<null | Contact>;
32
+ /** Best-effort backfill for old cache rows; missing flags never imply personal accounts. */
33
+ private static refreshUnknownContact;
30
34
  /**
31
35
  * Find contact by name or alias
32
36
  */
@@ -80,4 +84,5 @@ export declare class Contact {
80
84
  * Fetch and upsert contact details for a given userName and contact.
81
85
  */
82
86
  static fetchAndUpsertContactDetail(Wxid: string, Towxid: string): Promise<ContactEntity>;
87
+ private static loadContactDetail;
83
88
  }
@@ -14,6 +14,11 @@ const contact_service_1 = require("../services/contact.service");
14
14
  const FriendApi_1 = require("../api/FriendApi");
15
15
  const config_service_1 = require("../services/config.service");
16
16
  const common_1 = require("../action/common");
17
+ const ContactClassification_1 = require("../utils/ContactClassification");
18
+ const detailFetches = new Map();
19
+ const unknownRefreshAttempts = new Map();
20
+ const UNKNOWN_REFRESH_INTERVAL_MS = 30000;
21
+ const MAX_UNKNOWN_REFRESH_ENTRIES = 5000;
17
22
  /**
18
23
  * Contact class for handling and interacting with contacts.
19
24
  */
@@ -24,6 +29,13 @@ class Contact {
24
29
  get nickName() {
25
30
  return this._nickName;
26
31
  }
32
+ get verifyFlag() {
33
+ var _a;
34
+ return (0, ContactClassification_1.normalizeContactVerifyFlag)((_a = this.contact) === null || _a === void 0 ? void 0 : _a.verifyFlag);
35
+ }
36
+ isOfficialAccount() {
37
+ return (0, ContactClassification_1.isOfficialAccountVerifyFlag)(this.verifyFlag);
38
+ }
27
39
  constructor(contact) {
28
40
  this.contact = null;
29
41
  this.contact = contact;
@@ -41,7 +53,7 @@ class Contact {
41
53
  if (query.id) {
42
54
  const contact = yield contact_service_1.contactService.getByUserName(query.id);
43
55
  if (contact) {
44
- return new Contact(contact);
56
+ return new Contact(yield Contact.refreshUnknownContact(contact));
45
57
  }
46
58
  else {
47
59
  const config = yield config_service_1.configService.get();
@@ -55,12 +67,43 @@ class Contact {
55
67
  if (query.nickName) {
56
68
  const contact = yield contact_service_1.contactService.searchByNickNameOrRemark(query.nickName);
57
69
  if (contact.length > 0) {
58
- return new Contact(contact[0]);
70
+ return new Contact(yield Contact.refreshUnknownContact(contact[0]));
59
71
  }
60
72
  }
61
73
  return null;
62
74
  });
63
75
  }
76
+ /** Best-effort backfill for old cache rows; missing flags never imply personal accounts. */
77
+ static refreshUnknownContact(contact) {
78
+ return __awaiter(this, void 0, void 0, function* () {
79
+ if ((0, ContactClassification_1.normalizeContactVerifyFlag)(contact.verifyFlag) !== null)
80
+ return contact;
81
+ try {
82
+ const config = yield config_service_1.configService.get();
83
+ if (!(config === null || config === void 0 ? void 0 : config.wxid))
84
+ return contact;
85
+ const key = JSON.stringify([config.wxid, contact.userName]);
86
+ const pending = detailFetches.get(key);
87
+ if (pending)
88
+ return yield pending;
89
+ const now = Date.now();
90
+ const lastAttempt = unknownRefreshAttempts.get(key);
91
+ if (lastAttempt !== undefined && now - lastAttempt < UNKNOWN_REFRESH_INTERVAL_MS)
92
+ return contact;
93
+ // Reinsertion keeps eviction ordered by attempt time; both successes and failures are throttled.
94
+ unknownRefreshAttempts.delete(key);
95
+ unknownRefreshAttempts.set(key, now);
96
+ if (unknownRefreshAttempts.size > MAX_UNKNOWN_REFRESH_ENTRIES) {
97
+ unknownRefreshAttempts.delete(unknownRefreshAttempts.keys().next().value);
98
+ }
99
+ return yield Contact.fetchAndUpsertContactDetail(config.wxid, contact.userName);
100
+ }
101
+ catch (_a) {
102
+ // Transient API/configuration errors must not make a cached contact disappear.
103
+ return contact;
104
+ }
105
+ });
106
+ }
64
107
  /**
65
108
  * Find contact by name or alias
66
109
  */
@@ -169,41 +212,39 @@ class Contact {
169
212
  */
170
213
  static fetchAndUpsertContactDetail(Wxid, Towxid) {
171
214
  return __awaiter(this, void 0, void 0, function* () {
172
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3;
215
+ const key = JSON.stringify([Wxid, Towxid]);
216
+ const pending = detailFetches.get(key);
217
+ if (pending)
218
+ return pending;
219
+ const task = Contact.loadContactDetail(Wxid, Towxid);
220
+ detailFetches.set(key, task);
221
+ try {
222
+ return yield task;
223
+ }
224
+ finally {
225
+ detailFetches.delete(key);
226
+ }
227
+ });
228
+ }
229
+ static loadContactDetail(Wxid, Towxid) {
230
+ return __awaiter(this, void 0, void 0, function* () {
231
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1;
173
232
  const detail = yield FriendApi_1.friendApi.GetContractDetail({
174
233
  "ChatRoom": "",
175
234
  "Towxids": Towxid,
176
235
  "Wxid": Wxid
177
236
  });
178
237
  const detailData = detail.data;
179
- const detailDataSave = (_c = (_b = (_a = detailData.Data) === null || _a === void 0 ? void 0 : _a.ContactList) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : {};
180
- if (!detailDataSave.UserName) {
181
- return this.fetchAndUpsertContactDetail(Wxid, Towxid);
238
+ const detailDataSave = (_c = (_b = (_a = detailData === null || detailData === void 0 ? void 0 : detailData.Data) === null || _a === void 0 ? void 0 : _a.ContactList) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : {};
239
+ const userName = (_d = detailDataSave.UserName) === null || _d === void 0 ? void 0 : _d.string;
240
+ // Fail finitely rather than recursively retrying a missing or unexpected contact.
241
+ if ((detailData === null || detailData === void 0 ? void 0 : detailData.Success) === false || ((detailData === null || detailData === void 0 ? void 0 : detailData.Code) !== undefined && detailData.Code !== 0)
242
+ || typeof userName !== 'string' || !userName || userName !== Towxid) {
243
+ throw new Error('Contact detail response did not contain the requested contact');
182
244
  }
183
- const contact = {
184
- userName: (_e = (_d = detailDataSave.UserName) === null || _d === void 0 ? void 0 : _d.string) !== null && _e !== void 0 ? _e : '',
185
- nickName: (_g = (_f = detailDataSave.NickName) === null || _f === void 0 ? void 0 : _f.string) !== null && _g !== void 0 ? _g : 'no name',
186
- pyInitial: (_j = (_h = detailDataSave.Pyinitial) === null || _h === void 0 ? void 0 : _h.string) !== null && _j !== void 0 ? _j : '',
187
- quanPin: (_l = (_k = detailDataSave.QuanPin) === null || _k === void 0 ? void 0 : _k.string) !== null && _l !== void 0 ? _l : '',
188
- sex: (_m = detailDataSave.Sex) !== null && _m !== void 0 ? _m : null,
189
- remark: (_p = (_o = detailDataSave.Remark) === null || _o === void 0 ? void 0 : _o.string) !== null && _p !== void 0 ? _p : '',
190
- remarkPyInitial: (_r = (_q = detailDataSave.RemarkPyinitial) === null || _q === void 0 ? void 0 : _q.string) !== null && _r !== void 0 ? _r : '',
191
- remarkQuanPin: (_t = (_s = detailDataSave.RemarkQuanPin) === null || _s === void 0 ? void 0 : _s.string) !== null && _t !== void 0 ? _t : '',
192
- signature: (_u = detailDataSave.Signature) !== null && _u !== void 0 ? _u : '',
193
- alias: (_v = detailDataSave.Alias) !== null && _v !== void 0 ? _v : '',
194
- snsBgImg: (_x = (_w = detailDataSave.SnsUserInfo) === null || _w === void 0 ? void 0 : _w.SnsBgimgId) !== null && _x !== void 0 ? _x : '',
195
- country: (_y = detailDataSave.Country) !== null && _y !== void 0 ? _y : '',
196
- bigHeadImgUrl: (_z = detailDataSave.BigHeadImgUrl) !== null && _z !== void 0 ? _z : '',
197
- smallHeadImgUrl: (_0 = detailDataSave.SmallHeadImgUrl) !== null && _0 !== void 0 ? _0 : '',
198
- description: '', // 详情数据中没有明确字段
199
- cardImgUrl: '', // 详情数据中没有明确字段
200
- labelList: '', // 详情数据中没有明确字段
201
- province: (_1 = detailDataSave.Province) !== null && _1 !== void 0 ? _1 : '',
202
- city: (_2 = detailDataSave.City) !== null && _2 !== void 0 ? _2 : '',
203
- phoneNumList: '', // 详情数据中没有明确字段
204
- };
205
- yield contact_service_1.contactService.upsertByUsername((_3 = detailDataSave.UserName) === null || _3 === void 0 ? void 0 : _3.string, contact);
206
- return contact;
245
+ const verifyFlag = (0, ContactClassification_1.normalizeContactVerifyFlag)(detailDataSave.VerifyFlag);
246
+ const contact = Object.assign(Object.assign({ userName }, (verifyFlag === null ? {} : { verifyFlag })), { nickName: (_f = (_e = detailDataSave.NickName) === null || _e === void 0 ? void 0 : _e.string) !== null && _f !== void 0 ? _f : 'no name', pyInitial: (_h = (_g = detailDataSave.Pyinitial) === null || _g === void 0 ? void 0 : _g.string) !== null && _h !== void 0 ? _h : '', quanPin: (_k = (_j = detailDataSave.QuanPin) === null || _j === void 0 ? void 0 : _j.string) !== null && _k !== void 0 ? _k : '', sex: (_l = detailDataSave.Sex) !== null && _l !== void 0 ? _l : null, remark: (_o = (_m = detailDataSave.Remark) === null || _m === void 0 ? void 0 : _m.string) !== null && _o !== void 0 ? _o : '', remarkPyInitial: (_q = (_p = detailDataSave.RemarkPyinitial) === null || _p === void 0 ? void 0 : _p.string) !== null && _q !== void 0 ? _q : '', remarkQuanPin: (_s = (_r = detailDataSave.RemarkQuanPin) === null || _r === void 0 ? void 0 : _r.string) !== null && _s !== void 0 ? _s : '', signature: (_t = detailDataSave.Signature) !== null && _t !== void 0 ? _t : '', alias: (_u = detailDataSave.Alias) !== null && _u !== void 0 ? _u : '', snsBgImg: (_w = (_v = detailDataSave.SnsUserInfo) === null || _v === void 0 ? void 0 : _v.SnsBgimgId) !== null && _w !== void 0 ? _w : '', country: (_x = detailDataSave.Country) !== null && _x !== void 0 ? _x : '', bigHeadImgUrl: (_y = detailDataSave.BigHeadImgUrl) !== null && _y !== void 0 ? _y : '', smallHeadImgUrl: (_z = detailDataSave.SmallHeadImgUrl) !== null && _z !== void 0 ? _z : '', province: (_0 = detailDataSave.Province) !== null && _0 !== void 0 ? _0 : '', city: (_1 = detailDataSave.City) !== null && _1 !== void 0 ? _1 : '' });
247
+ return contact_service_1.contactService.upsertByUsername(userName, contact);
207
248
  });
208
249
  }
209
250
  }
@@ -8,6 +8,7 @@ import * as Axios from 'axios';
8
8
  */
9
9
  export declare class Message {
10
10
  private msg;
11
+ private readonly displayText?;
11
12
  readonly newMsgId: string;
12
13
  readonly fromId: string;
13
14
  readonly _pushContent: string;
@@ -34,6 +35,7 @@ export declare class Message {
34
35
  Link: string;
35
36
  AddFriend: string;
36
37
  Quote: string;
38
+ Solitaire: string;
37
39
  Transfer: string;
38
40
  RedPacket: string;
39
41
  VideoAccount: string;
@@ -52,6 +52,7 @@ const Room_1 = require("./Room");
52
52
  const fast_xml_parser_1 = require("fast-xml-parser");
53
53
  const common_1 = require("../action/common");
54
54
  const ToolsApi_1 = require("../api/ToolsApi");
55
+ const FileDownloadError_1 = require("../utils/FileDownloadError");
55
56
  const file_box_1 = require("file-box");
56
57
  const config_service_1 = require("../services/config.service");
57
58
  const audioUtil_1 = require("../utils/audioUtil");
@@ -61,6 +62,7 @@ const fs = __importStar(require("fs"));
61
62
  const FileChunkHelper_1 = require("../utils/FileChunkHelper");
62
63
  const GroupApi_1 = require("../api/GroupApi");
63
64
  const contact_service_1 = require("../services/contact.service");
65
+ const SolitairePayload_1 = require("../utils/SolitairePayload");
64
66
  const MAX_RETRIES = 3;
65
67
  const RETRY_DELAY = 1000; // 1 second
66
68
  const LARGE_FILE_THRESHOLD = 80 * 1024 * 1024;
@@ -72,18 +74,23 @@ function retryDownload(downloadFn) {
72
74
  for (let i = 0; i < MAX_RETRIES; i++) {
73
75
  try {
74
76
  const response = yield downloadFn();
75
- if (((_c = (_b = (_a = response === null || response === void 0 ? void 0 : response.data) === null || _a === void 0 ? void 0 : _a.Data) === null || _b === void 0 ? void 0 : _b.BaseResponse) === null || _c === void 0 ? void 0 : _c.ret) === 0) {
77
+ if (Buffer.isBuffer(response === null || response === void 0 ? void 0 : response.data) || ((_c = (_b = (_a = response === null || response === void 0 ? void 0 : response.data) === null || _a === void 0 ? void 0 : _a.Data) === null || _b === void 0 ? void 0 : _b.BaseResponse) === null || _c === void 0 ? void 0 : _c.ret) === 0) {
76
78
  return response;
77
79
  }
78
80
  lastError = new Error(`Download failed with ret code: ${(_f = (_e = (_d = response === null || response === void 0 ? void 0 : response.data) === null || _d === void 0 ? void 0 : _d.Data) === null || _e === void 0 ? void 0 : _e.BaseResponse) === null || _f === void 0 ? void 0 : _f.ret}`);
79
81
  }
80
82
  catch (error) {
83
+ if (error instanceof FileDownloadError_1.FileDownloadError && !error.retryable)
84
+ throw error;
81
85
  lastError = error;
82
86
  }
83
87
  if (i < MAX_RETRIES - 1) {
84
- yield new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
88
+ const delay = lastError instanceof FileDownloadError_1.FileDownloadError ? RETRY_DELAY * (i + 1) : RETRY_DELAY;
89
+ yield new Promise(resolve => setTimeout(resolve, delay));
85
90
  }
86
91
  }
92
+ if (lastError instanceof FileDownloadError_1.FileDownloadError)
93
+ throw lastError;
87
94
  throw new Error(`Download failed after ${MAX_RETRIES} attempts. Last error: ${lastError === null || lastError === void 0 ? void 0 : lastError.message}`);
88
95
  });
89
96
  }
@@ -102,7 +109,11 @@ class Message {
102
109
  this._createTime = msg.CreateTime;
103
110
  this._xml = msg.xml;
104
111
  this._text = msg.Content;
105
- if (this.type() === MessageType_1.MessageType.Quote) {
112
+ const messageType = this.type();
113
+ if (messageType === MessageType_1.MessageType.Solitaire) {
114
+ this.displayText = (0, SolitairePayload_1.getSolitaireDisplayText)(msg.Content);
115
+ }
116
+ else if (messageType === MessageType_1.MessageType.Quote) {
106
117
  let obj = Message.getXmlToJson(msg.Content);
107
118
  msg.Content = obj.msg.appmsg.title;
108
119
  this.refer = obj.msg.appmsg.refermsg;
@@ -215,8 +226,9 @@ class Message {
215
226
  * Get the text content of the message.
216
227
  */
217
228
  text() {
229
+ var _a;
218
230
  // TODO: Implement text content retrieval
219
- return this.msg.Content;
231
+ return (_a = this.displayText) !== null && _a !== void 0 ? _a : this.msg.Content;
220
232
  }
221
233
  decodeDownloadBuffer(bufferSource) {
222
234
  if (Buffer.isBuffer(bufferSource)) {
@@ -326,7 +338,12 @@ class Message {
326
338
  return MessageType_1.MessageType.Location;
327
339
  break;
328
340
  case 49:
329
- jObj = Message.getXmlToJson(xml);
341
+ if (typeof xml !== 'string' || /<!\s*(?:DOCTYPE|ENTITY)\b/i.test(xml))
342
+ return MessageType_1.MessageType.Unknown;
343
+ // Classification only needs a numeric subtype. Avoid coercing long
344
+ // titles/record bodies into numbers (which is costly and lossy).
345
+ jObj = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false }).parse(xml);
346
+ jObj.msg.appmsg.type = Number(jObj.msg.appmsg.type);
330
347
  // console.log(jObj)
331
348
  if (jObj.msg.appmsg.type === 5) {
332
349
  if (jObj.msg.appmsg.title === '邀请你加入群聊') {
@@ -351,6 +368,9 @@ class Message {
351
368
  else if (jObj.msg.appmsg.type === 51) {
352
369
  return MessageType_1.MessageType.VideoAccount;
353
370
  }
371
+ else if (jObj.msg.appmsg.type === 53) {
372
+ return (0, SolitairePayload_1.isBoundedAppMessageXml)(xml) ? MessageType_1.MessageType.Solitaire : MessageType_1.MessageType.Unknown;
373
+ }
354
374
  else if (jObj.msg.appmsg.type === 57) {
355
375
  return MessageType_1.MessageType.Quote;
356
376
  }
@@ -454,7 +474,7 @@ class Message {
454
474
  }
455
475
  toFileBox() {
456
476
  return __awaiter(this, void 0, void 0, function* () {
457
- var _a;
477
+ var _a, _b, _c, _d;
458
478
  if (this.type() === MessageType_1.MessageType.Image) {
459
479
  const xml = this.msg.Content;
460
480
  const json = Message.getXmlToJson(xml);
@@ -553,44 +573,34 @@ class Message {
553
573
  }
554
574
  else if (this.type() === MessageType_1.MessageType.File) {
555
575
  const xml = this.msg.Content;
556
- const json = Message.getXmlToJson(xml);
576
+ // Preserve uint64 overwrite_newmsgid values from XML without numeric rounding.
577
+ const json = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false }).parse(xml);
557
578
  const config = yield config_service_1.configService.get();
558
- const totalSize = parseInt(json.msg.appmsg.appattach.totallen);
579
+ const totalSize = Number((_d = (_c = (_b = json === null || json === void 0 ? void 0 : json.msg) === null || _b === void 0 ? void 0 : _b.appmsg) === null || _c === void 0 ? void 0 : _c.appattach) === null || _d === void 0 ? void 0 : _d.totallen);
580
+ if (!Number.isSafeInteger(totalSize) || totalSize <= 0) {
581
+ throw new Error('DownloadFile: invalid total length');
582
+ }
559
583
  if (totalSize > LARGE_FILE_THRESHOLD) {
560
584
  return yield this.downloadFileMessage();
561
585
  }
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
586
+ const chunks = [];
587
+ let position = 0;
588
+ while (position < totalSize) {
582
589
  const resp = yield retryDownload(() => ToolsApi_1.toolsApi.DownloadFile({
583
590
  "AppID": json.msg.appmsg.appid,
584
591
  "AttachId": json.msg.appmsg.appattach.attachid,
585
592
  "DataLen": totalSize,
586
- "Section": chunk,
587
- "UserName": this.msg.FromUserName,
593
+ "Section": { StartPos: position, DataLen: Math.min(512 * 1024, totalSize - position) },
594
+ "NewMsgId": this.msg.NewMsgId && String(this.msg.NewMsgId) !== '0'
595
+ ? this.msg.NewMsgId : json.msg.appmsg.overwrite_newmsgid || 0,
596
+ "Type": 6,
597
+ "UserName": this.msg.RoomId || this.msg.FromUserName,
588
598
  "Wxid": (config === null || config === void 0 ? void 0 : config.wxid) || ''
589
599
  }));
590
- const chunkData = Buffer.from(resp.data.Data.data.buffer, 'base64');
591
- completeBuffer = Buffer.concat([completeBuffer, chunkData]);
600
+ chunks.push(resp.data);
601
+ position += resp.data.length;
592
602
  }
593
- const fileBox = file_box_1.FileBox.fromBuffer(completeBuffer, json.msg.appmsg.title);
603
+ const fileBox = file_box_1.FileBox.fromBuffer(Buffer.concat(chunks, totalSize), json.msg.appmsg.title);
594
604
  return fileBox;
595
605
  }
596
606
  else if (this.type() === MessageType_1.MessageType.Voice) {
@@ -4,6 +4,7 @@ export declare class ContactEntity {
4
4
  pyInitial: string;
5
5
  quanPin: string;
6
6
  sex: number;
7
+ verifyFlag?: number | null;
7
8
  remark: string;
8
9
  remarkPyInitial: string;
9
10
  remarkQuanPin: string;
@@ -35,6 +35,10 @@ __decorate([
35
35
  (0, typeorm_1.Column)('integer', { nullable: true }),
36
36
  __metadata("design:type", Number)
37
37
  ], ContactEntity.prototype, "sex", void 0);
38
+ __decorate([
39
+ (0, typeorm_1.Column)('integer', { nullable: true }),
40
+ __metadata("design:type", Object)
41
+ ], ContactEntity.prototype, "verifyFlag", void 0);
38
42
  __decorate([
39
43
  (0, typeorm_1.Column)('text', { nullable: true }),
40
44
  __metadata("design:type", String)
package/dist/index.d.ts CHANGED
@@ -34,12 +34,14 @@ export declare class WxBot extends EventEmitter {
34
34
  private callbackInterval;
35
35
  private msgInterval;
36
36
  private recentMsgIds;
37
+ private messageQueue;
38
+ private syncTask;
39
+ private syncRequested;
40
+ private loginRecoveryInProgress;
37
41
  private syncFailureCount;
38
42
  private httpServer;
39
43
  private loggingOut;
40
44
  private loggingIn;
41
- private syncInProgress;
42
- private loginRecoveryInProgress;
43
45
  constructor({ baseUrl, databasePath, deviceType, mode, callbackPort, ProxyIp, ProxyPassword, ProxyUser }: WxBotOptions);
44
46
  qrcode(style?: number): Promise<{
45
47
  qrCode: any;
@@ -64,7 +66,9 @@ export declare class WxBot extends EventEmitter {
64
66
  private recoverLogin;
65
67
  private checkSyncLoginStatus;
66
68
  private handleMsg;
67
- private handleMsgOnce;
69
+ private enqueueMessages;
70
+ private processMessages;
71
+ private processMessage;
68
72
  private logOut;
69
73
  /**
70
74
  * 清空登录信息
@@ -81,4 +85,5 @@ export declare class WxBot extends EventEmitter {
81
85
  get Message(): typeof Message;
82
86
  }
83
87
  export { Contact, Room, Message };
88
+ export { normalizeContactVerifyFlag, isOfficialAccountVerifyFlag } from './utils/ContactClassification';
84
89
  export { Emoji, Voice, WeVideo, UrlLink };