wx2tg-puppet 6.0.16 → 6.0.18-dev.1

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 CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  微信机器人核心库,供 `wechat2tg` 调用 `wx2tg-server`。
4
4
 
5
- ## v6 配套版本
5
+ ## v6 当前稳定配套版本
6
6
 
7
- | 仓库 | 维护分支 | 本次 Git 标签 | npm / Docker 版本 |
7
+ | 仓库 | 维护分支 | 当前稳定 Git 标签 | npm / Docker 版本 |
8
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` |
9
+ | wechat2tg | `v6` | `v6.0.39` | 包版本 `6.0.39`;镜像 `finalpi/wechat2tg:v6.0.39` |
10
+ | wx2tg-puppet | `v6` | `v6.0.17` | npm 格式包 `wx2tg-puppet@6.0.17` |
11
11
  | wx2tg-server(fk-zhang) | `v6`,继承 `875` | `v6.0.16` | 镜像 `finalpi/wx2tg-server:v6.0.16` |
12
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` 标签保留,不移动。
13
+ **只维护 `v6` 分支,开发版和稳定版用 tag 区分,不建立 `v6-dev` 分支。** `v6.X.Y-dev.N` 为开发版,`v6.X.Y` 为稳定版;npm 不带前面的 `v`。三个组件在 v6 大版本内独立发布,不要求补丁版本号相同;本次 server 保持 v6.0.16。Docker 的 `v6-dev` 指向开发版、`v6` 指向稳定版,开发构建不得覆盖稳定别名。历史 `3.x`、`875`、`v51.*`、裸 `v6` 标签保留,不移动。
14
14
 
15
15
  根目录 `VERSION`、`package.json`、`package-lock.json` 必须一致;发布标签必须为 `v${VERSION}`。创建本地标签不代表已推送或已发布 npm。发布前执行:
16
16
 
@@ -25,6 +25,23 @@ npm pack
25
25
 
26
26
  `wechat2tg` 可继续锁定同版本本地制品以保证构建可重现;若切换 registry 来源,必须重新生成 lockfile 并校验制品,不要手工伪造 integrity。
27
27
 
28
+ ## v6.0.18-dev.1:登录不再等待全量联系人(开发预览)
29
+
30
+ - 修复 `loginSuccess()` 等待所有联系人详情后才启动消息接收、发出登录事件的时序问题。恢复登录和扫码登录先启动接收与心跳,目录任务在后台处理;启动 Sync 取得的消息继续分发,不丢弃。
31
+ - `start()` / `loginTwice()` 完成不再代表全量目录同步结束。依赖初次完整目录的调用方可显式 `await bot.waitForContactSync()`;这是等待当前轮次,不保证失败项已成功,也不启动额外同步。
32
+ - `contacts-updated` 事件通知当前目录轮次结束(也包括后续分页失败后的部分更新),供调用方重新读取目录;不重新触发登录事件。
33
+ - 保留每页最多 4 个详情请求、逐项失败隔离及分页等待。跨登录任务串行衔接,旧任务不再启动剩余详情 / 下一页或发出完成通知。
34
+ - 联系人接口失败不主动退出账号;会话恢复仍由消息 Sync 的原有单次恢复逻辑负责。清缓存和切换扫码账号前等待旧在途写入结束,避免清空后被旧任务重新写入。
35
+ - 208 项离线测试通过,包括安全菜单元数据、联系人保持挂起时接收已就绪、积压消息不丢、退出 / 重新登录、失败处理和缓存清理顺序。未访问真实账号,不代表线上延迟已验收。
36
+ - 此开发包供 Bot `v6.0.40-dev.4` 使用;dev tag 只发布 npm `v6-dev` 通道,以发布工作流成功为准。不替换已发布的 `6.0.17` 制品,也不代表线上完整验收。
37
+
38
+ ## v6.0.17:联系人初始化修复
39
+
40
+ - 修复初始化时异步详情查询未等待、未捕获,导致 `Contact detail response did not contain the requested contact` 等异常刷屏。
41
+ - 每页最多 4 个并发详情请求,等待本页完成后再翻页;单项失败不会中断其他联系人或群聊。
42
+ - 逐批记录成功 / 失败数量,不记录联系人 ID、接口原始数据或凭据。缺失详情仍明确计入失败,不伪造成功或删除缓存。
43
+ - 183 项测试通过;对应 Bot 修复已通过用户的临时部署验收。
44
+
28
45
  ## 消息接收
29
46
 
30
47
  ### polling(保持默认)
@@ -57,6 +74,18 @@ HTTP 200 仅表示 puppet 处理并同步发出了事件,不代表异步业务
57
74
 
58
75
  此 SDK 管理 `puppet.db` 的联系人 schema。其他读取连接不要再次用不完整的镜像实体执行 schema synchronize,否则会删除新增字段。服务端目录接口的 `profile.verify_flag` 可用于批量补齐,但必须验证账号范围,不覆盖更新的已知值。
59
76
 
77
+ ## 公众号类型与只读菜单元数据(6.0.18-dev.1 开发预览)
78
+
79
+ - `Contact.serviceType: number | null` 保留 `ContactList[0].CustomizedInfo.ExternalInfo` JSON 字符串中**顶层** `ServiceType` 的 uint32 原值(也接受规范十进制字符串)。仅详情本次明确带品牌位 8 的 `VerifyFlag` 时解析;不根据名称、ID、`BrandFlag` 或任意嵌套 `Type` 猜测。缺失/非法表示未知或保留旧值,绝不推断为订阅号/type 0。
80
+ - `Contact.officialMenu: OfficialMenu | null` 返回经过重新校验的菜单。根入口导出 `OfficialMenu`、`OfficialMenuButton` 类型及纯函数 `parseStoredOfficialMenu(value: unknown): OfficialMenu | null`,供其他只读 DB 连接解析 TEXT 缓存;每次返回新的安全对象。无法解析是 `null`;明确空菜单为 `{buttons: []}`,两者不可混同。
81
+ - `OfficialMenu` 为 `{buttons: OfficialMenuButton[]}`,按钮仅含 `name`、`kind: group | url | unsupported`,以及相应的 `children`、`url` 或 `reason: event | miniapp | wechat | unknown`。菜单组来自 type 0;普通网址只读取 type 2 的 **value**,不把 opaque key 当网址。type 1 事件和 type 5 小程序显示为不可执行项,不提供点击、授权或小程序执行 API。
82
+ - 仅允许无凭据/控制字符的 HTTP(S) 地址;微信协议、脚本、data 及 OAuth/授权地址均变成 `unsupported/wechat`,缓存中的 URL 同样重新验证。不保存原始 key、uin、appid、native_url、版本或其他任意字段。
83
+ - JSON/规范化结果最多 64 KiB、菜单最多 3 层/100 个节点、名称最多 256 字符、URL 最多 4096 字符。原始按钮 ID 可能全部相同,因此不作为身份或执行依据且不保存;超限、相同同级按钮、歧义子树、非法结构均拒绝整个菜单,不截断后冒充完整数据。缺失或非法菜单不覆盖已知缓存;显式空 `button_list` 才清除旧菜单。
84
+
85
+ Schema 仍由 SDK 管理:`contact.serviceType INTEGER NULL` 与 `contact.officialMenu TEXT NULL` 都无默认值。迁移顺序为已有 `contact-verify-flag.sql` schema → `OfficialAccountMetadata1760000000000`(`src/database/migrations/1760000000000-OfficialAccountMetadata.ts`)→ 原有 TypeORM synchronize。新迁移检查现有列并直接执行 additive `ALTER TABLE ADD COLUMN`,避免 SQLite 的 addColumn helper 重建旧联系人表;fresh DB 无 contact 表时跳过 ALTER,由后续 synchronize 创建完整 schema。已执行迁移记入 TypeORM migrations 表,重启不重复;不提供会丢失元数据的自动 down。`migrations/contact-official-metadata.sql` 仅为对应增量的版本控制参考,不需手工修改远程 DB。Bot 镜像连接必须保持 `synchronize=false`。
86
+
87
+ 离线测试 `test/official-account-metadata.test.cjs` 使用合成数据覆盖真实详情入库路径、未知字段保留、菜单/URL 安全、隐私字段剔除、边界,以及旧/新 SQLite 初始化和旧行无重建保留。
88
+
60
89
  ## 接龙消息
61
90
 
62
91
  `Message.Type.Solitaire` 对应外层 `49`、应用子类型 `53`。`Message.text()` 返回接龙完整可见标题、名单和尾注;原始 `Content`、`_text`、`_xml` 保持不变,不把内部 `solitaire_info` 当正文输出。CDATA、实体转义、前导零、Unicode 和换行保留;无法安全解析时返回明确的接龙占位提示。此功能只负责识别/读取,不自动参与、发送或重放历史接龙。
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.0.16
1
+ 6.0.18-dev.1
@@ -1,5 +1,6 @@
1
1
  import { ContactEntity } from "../entities/ContactEntity";
2
2
  import { ResponseMsg } from "./Message";
3
+ import { OfficialMenu } from '../utils/OfficialAccountMetadata';
3
4
  export type Message = any;
4
5
  export type FileBox = any;
5
6
  export type ContactType = any;
@@ -12,6 +13,8 @@ export declare class Contact {
12
13
  get nickName(): string;
13
14
  get verifyFlag(): number | null;
14
15
  isOfficialAccount(): boolean | undefined;
16
+ get serviceType(): number | null;
17
+ get officialMenu(): OfficialMenu | null;
15
18
  /**
16
19
  * Contact id (readonly)
17
20
  */
@@ -15,6 +15,7 @@ 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
17
  const ContactClassification_1 = require("../utils/ContactClassification");
18
+ const OfficialAccountMetadata_1 = require("../utils/OfficialAccountMetadata");
18
19
  const detailFetches = new Map();
19
20
  const unknownRefreshAttempts = new Map();
20
21
  const UNKNOWN_REFRESH_INTERVAL_MS = 30000;
@@ -36,6 +37,14 @@ class Contact {
36
37
  isOfficialAccount() {
37
38
  return (0, ContactClassification_1.isOfficialAccountVerifyFlag)(this.verifyFlag);
38
39
  }
40
+ get serviceType() {
41
+ var _a;
42
+ return this.isOfficialAccount() === true ? (0, ContactClassification_1.normalizeContactVerifyFlag)((_a = this.contact) === null || _a === void 0 ? void 0 : _a.serviceType) : null;
43
+ }
44
+ get officialMenu() {
45
+ var _a;
46
+ return this.isOfficialAccount() === true ? (0, OfficialAccountMetadata_1.parseStoredOfficialMenu)((_a = this.contact) === null || _a === void 0 ? void 0 : _a.officialMenu) : null;
47
+ }
39
48
  constructor(contact) {
40
49
  this.contact = null;
41
50
  this.contact = contact;
@@ -228,7 +237,7 @@ class Contact {
228
237
  }
229
238
  static loadContactDetail(Wxid, Towxid) {
230
239
  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;
240
+ 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;
232
241
  const detail = yield FriendApi_1.friendApi.GetContractDetail({
233
242
  "ChatRoom": "",
234
243
  "Towxids": Towxid,
@@ -243,7 +252,8 @@ class Contact {
243
252
  throw new Error('Contact detail response did not contain the requested contact');
244
253
  }
245
254
  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 : '' });
255
+ const metadata = (0, OfficialAccountMetadata_1.decodeOfficialAccountMetadata)((_e = detailDataSave.CustomizedInfo) === null || _e === void 0 ? void 0 : _e.ExternalInfo, verifyFlag);
256
+ const contact = Object.assign(Object.assign(Object.assign(Object.assign({ userName }, (verifyFlag === null ? {} : { verifyFlag })), (metadata.serviceType === undefined ? {} : { serviceType: metadata.serviceType })), (metadata.officialMenu === undefined ? {} : { officialMenu: JSON.stringify(metadata.officialMenu) })), { nickName: (_g = (_f = detailDataSave.NickName) === null || _f === void 0 ? void 0 : _f.string) !== null && _g !== void 0 ? _g : 'no name', pyInitial: (_j = (_h = detailDataSave.Pyinitial) === null || _h === void 0 ? void 0 : _h.string) !== null && _j !== void 0 ? _j : '', quanPin: (_l = (_k = detailDataSave.QuanPin) === null || _k === void 0 ? void 0 : _k.string) !== null && _l !== void 0 ? _l : '', sex: (_m = detailDataSave.Sex) !== null && _m !== void 0 ? _m : null, remark: (_p = (_o = detailDataSave.Remark) === null || _o === void 0 ? void 0 : _o.string) !== null && _p !== void 0 ? _p : '', remarkPyInitial: (_r = (_q = detailDataSave.RemarkPyinitial) === null || _q === void 0 ? void 0 : _q.string) !== null && _r !== void 0 ? _r : '', remarkQuanPin: (_t = (_s = detailDataSave.RemarkQuanPin) === null || _s === void 0 ? void 0 : _s.string) !== null && _t !== void 0 ? _t : '', signature: (_u = detailDataSave.Signature) !== null && _u !== void 0 ? _u : '', alias: (_v = detailDataSave.Alias) !== null && _v !== void 0 ? _v : '', snsBgImg: (_x = (_w = detailDataSave.SnsUserInfo) === null || _w === void 0 ? void 0 : _w.SnsBgimgId) !== null && _x !== void 0 ? _x : '', country: (_y = detailDataSave.Country) !== null && _y !== void 0 ? _y : '', bigHeadImgUrl: (_z = detailDataSave.BigHeadImgUrl) !== null && _z !== void 0 ? _z : '', smallHeadImgUrl: (_0 = detailDataSave.SmallHeadImgUrl) !== null && _0 !== void 0 ? _0 : '', province: (_1 = detailDataSave.Province) !== null && _1 !== void 0 ? _1 : '', city: (_2 = detailDataSave.City) !== null && _2 !== void 0 ? _2 : '' });
247
257
  return contact_service_1.contactService.upsertByUsername(userName, contact);
248
258
  });
249
259
  }
@@ -8,11 +8,15 @@ const config_entity_1 = require("../entities/config.entity");
8
8
  const ContactEntity_1 = require("../entities/ContactEntity");
9
9
  const RoomEntity_1 = require("../entities/RoomEntity");
10
10
  const MessageEntity_1 = require("../entities/MessageEntity");
11
+ const _1760000000000_OfficialAccountMetadata_1 = require("./migrations/1760000000000-OfficialAccountMetadata");
11
12
  function createAppDataSource(databasePath) {
12
13
  exports.AppDataSource = new typeorm_1.DataSource({
13
14
  type: 'sqlite',
14
15
  database: databasePath,
15
16
  synchronize: true,
17
+ // TypeORM runs migrations before synchronize: upgrade old caches in place.
18
+ migrationsRun: true,
19
+ migrations: [_1760000000000_OfficialAccountMetadata_1.OfficialAccountMetadata1760000000000],
16
20
  logging: false,
17
21
  entities: [config_entity_1.Config, ContactEntity_1.ContactEntity, RoomEntity_1.RoomEntity, MessageEntity_1.MessageEntity],
18
22
  });
@@ -0,0 +1,6 @@
1
+ import { MigrationInterface, QueryRunner } from 'typeorm';
2
+ /** Add columns in place: TypeORM's SQLite addColumn helper rebuilds tables. */
3
+ export declare class OfficialAccountMetadata1760000000000 implements MigrationInterface {
4
+ up(queryRunner: QueryRunner): Promise<void>;
5
+ down(): Promise<void>;
6
+ }
@@ -0,0 +1,34 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.OfficialAccountMetadata1760000000000 = void 0;
13
+ /** Add columns in place: TypeORM's SQLite addColumn helper rebuilds tables. */
14
+ class OfficialAccountMetadata1760000000000 {
15
+ up(queryRunner) {
16
+ return __awaiter(this, void 0, void 0, function* () {
17
+ if (!(yield queryRunner.hasTable('contact')))
18
+ return; // Fresh SDK schema is created by synchronize afterwards.
19
+ const columns = yield queryRunner.query('PRAGMA table_info("contact")');
20
+ if (!columns.some(column => column.name === 'serviceType')) {
21
+ yield queryRunner.query('ALTER TABLE "contact" ADD COLUMN "serviceType" integer');
22
+ }
23
+ if (!columns.some(column => column.name === 'officialMenu')) {
24
+ yield queryRunner.query('ALTER TABLE "contact" ADD COLUMN "officialMenu" text');
25
+ }
26
+ });
27
+ }
28
+ down() {
29
+ return __awaiter(this, void 0, void 0, function* () {
30
+ throw new Error('Additive contact metadata migration cannot be reverted without losing cached metadata');
31
+ });
32
+ }
33
+ }
34
+ exports.OfficialAccountMetadata1760000000000 = OfficialAccountMetadata1760000000000;
@@ -5,6 +5,8 @@ export declare class ContactEntity {
5
5
  quanPin: string;
6
6
  sex: number;
7
7
  verifyFlag?: number | null;
8
+ serviceType?: number | null;
9
+ officialMenu?: string | null;
8
10
  remark: string;
9
11
  remarkPyInitial: string;
10
12
  remarkQuanPin: string;
@@ -39,6 +39,14 @@ __decorate([
39
39
  (0, typeorm_1.Column)('integer', { nullable: true }),
40
40
  __metadata("design:type", Object)
41
41
  ], ContactEntity.prototype, "verifyFlag", void 0);
42
+ __decorate([
43
+ (0, typeorm_1.Column)('integer', { nullable: true }),
44
+ __metadata("design:type", Object)
45
+ ], ContactEntity.prototype, "serviceType", void 0);
46
+ __decorate([
47
+ (0, typeorm_1.Column)('text', { nullable: true }),
48
+ __metadata("design:type", Object)
49
+ ], ContactEntity.prototype, "officialMenu", void 0);
42
50
  __decorate([
43
51
  (0, typeorm_1.Column)('text', { nullable: true }),
44
52
  __metadata("design:type", String)
package/dist/index.d.ts CHANGED
@@ -38,6 +38,8 @@ export declare class WxBot extends EventEmitter {
38
38
  private syncTask;
39
39
  private syncRequested;
40
40
  private loginRecoveryInProgress;
41
+ private contactSyncTask;
42
+ private contactSyncGeneration;
41
43
  private syncFailureCount;
42
44
  private httpServer;
43
45
  private loggingOut;
@@ -58,6 +60,9 @@ export declare class WxBot extends EventEmitter {
58
60
  * @param currentChatroomSeq 当前群聊联系人序列号
59
61
  */
60
62
  private fetchAllContacts;
63
+ private startContactSync;
64
+ /** Explicit directory barrier for callers that need the current background pass. */
65
+ waitForContactSync(): Promise<void>;
61
66
  private loginSuccess;
62
67
  private startCallbackServer;
63
68
  private handleCallbackMessage;
@@ -86,4 +91,6 @@ export declare class WxBot extends EventEmitter {
86
91
  }
87
92
  export { Contact, Room, Message };
88
93
  export { normalizeContactVerifyFlag, isOfficialAccountVerifyFlag } from './utils/ContactClassification';
94
+ export { parseStoredOfficialMenu } from './utils/OfficialAccountMetadata';
95
+ export type { OfficialMenu, OfficialMenuButton } from './utils/OfficialAccountMetadata';
89
96
  export { Emoji, Voice, WeVideo, UrlLink };
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
42
42
  });
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.UrlLink = exports.WeVideo = exports.Voice = exports.Emoji = exports.isOfficialAccountVerifyFlag = exports.normalizeContactVerifyFlag = exports.Message = exports.Room = exports.Contact = exports.WxBot = exports.AppDataSource = void 0;
45
+ exports.UrlLink = exports.WeVideo = exports.Voice = exports.Emoji = exports.parseStoredOfficialMenu = exports.isOfficialAccountVerifyFlag = exports.normalizeContactVerifyFlag = exports.Message = exports.Room = exports.Contact = exports.WxBot = exports.AppDataSource = void 0;
46
46
  require("reflect-metadata");
47
47
  const axiosInstance_1 = require("./utils/axiosInstance");
48
48
  const data_source_1 = require("./database/data-source");
@@ -87,6 +87,8 @@ class WxBot extends events_1.EventEmitter {
87
87
  this.syncTask = null;
88
88
  this.syncRequested = false;
89
89
  this.loginRecoveryInProgress = false;
90
+ this.contactSyncTask = null;
91
+ this.contactSyncGeneration = 0;
90
92
  this.syncFailureCount = 0; // Sync failure counter
91
93
  this.httpServer = null; // HTTP回调服务器
92
94
  this.loggingOut = false; // Flag to prevent concurrent logOut calls
@@ -256,13 +258,18 @@ class WxBot extends events_1.EventEmitter {
256
258
  const statusData = statusResp.data;
257
259
  if (statusData.Success) {
258
260
  if (statusData.Message === '登录成功') {
261
+ // Drain only old in-flight detail requests before changing accounts.
262
+ this.contactSyncGeneration++;
263
+ yield this.waitForContactSync();
259
264
  yield config_service_1.configService.save({
260
265
  uuid: this.uuid,
261
266
  wxid: statusData.Data.acctSectResp.userName,
262
267
  });
263
- this.loginSuccess(statusData.Data.acctSectResp.userName);
264
- if (this.interval)
268
+ if (this.interval) {
265
269
  clearInterval(this.interval);
270
+ this.interval = null;
271
+ }
272
+ yield this.loginSuccess(statusData.Data.acctSectResp.userName);
266
273
  }
267
274
  }
268
275
  else if (statusData.Message.includes('数据不存在') || statusData.Message.includes('未知的错误')) {
@@ -294,6 +301,8 @@ class WxBot extends events_1.EventEmitter {
294
301
  });
295
302
  }
296
303
  clearAllIntervals() {
304
+ // In-flight requests may settle, but old sessions cannot schedule more work.
305
+ this.contactSyncGeneration++;
297
306
  if (this.interval) {
298
307
  clearInterval(this.interval);
299
308
  this.interval = null;
@@ -322,34 +331,58 @@ class WxBot extends events_1.EventEmitter {
322
331
  * @param currentChatroomSeq 当前群聊联系人序列号
323
332
  */
324
333
  fetchAllContacts(userName_1) {
325
- return __awaiter(this, arguments, void 0, function* (userName, currentWxSeq = 0, currentChatroomSeq = 0) {
334
+ return __awaiter(this, arguments, void 0, function* (userName, currentWxSeq = 0, currentChatroomSeq = 0, generation = this.contactSyncGeneration) {
335
+ if (generation !== this.contactSyncGeneration)
336
+ return false;
326
337
  try {
327
338
  const res = yield FriendApi_1.friendApi.GetContractList({
328
339
  "Wxid": userName,
329
340
  "CurrentChatRoomContactSeq": currentChatroomSeq,
330
341
  "CurrentWxcontactSeq": currentWxSeq,
331
342
  });
343
+ if (generation !== this.contactSyncGeneration)
344
+ return false;
332
345
  if (res.data.Message === '用户可能退出') {
333
- yield this.logOut();
334
- yield this.login();
335
- return;
346
+ // Optional directory work is not the session authority. Message Sync
347
+ // owns recovery; starting it here could wait on this very task on reset.
348
+ console.error('联系人接口提示会话可能失效,交由消息同步检查');
349
+ return false;
336
350
  }
337
351
  const data = res.data;
338
352
  if (!data.Success) {
339
- console.error('获取联系人列表失败:', data.Message);
340
- return;
353
+ console.error('获取联系人列表失败,保留已有目录');
354
+ return false;
341
355
  }
342
356
  // 处理当前批次的联系人
343
357
  if (data.Data && data.Data.ContactUsernameList) {
344
358
  console.log(`获取到 ${data.Data.ContactUsernameList.length} 个联系人`);
345
- data.Data.ContactUsernameList.forEach((contact) => {
346
- if (!contact.endsWith('@chatroom')) {
347
- Contact_1.Contact.fetchAndUpsertContactDetail(userName, contact);
348
- }
349
- else {
350
- Room_1.Room.find({ id: contact });
359
+ const contacts = data.Data.ContactUsernameList;
360
+ let nextIndex = 0;
361
+ let succeeded = 0;
362
+ let failed = 0;
363
+ // Bound detail requests and finish this page before requesting the next.
364
+ const worker = () => __awaiter(this, void 0, void 0, function* () {
365
+ while (generation === this.contactSyncGeneration && nextIndex < contacts.length) {
366
+ const contact = contacts[nextIndex++];
367
+ try {
368
+ if (!contact.endsWith('@chatroom')) {
369
+ yield Contact_1.Contact.fetchAndUpsertContactDetail(userName, contact);
370
+ }
371
+ else {
372
+ yield Room_1.Room.find({ id: contact });
373
+ }
374
+ succeeded++;
375
+ }
376
+ catch (_a) {
377
+ // A missing/failed item must not abort the page or leak private data.
378
+ failed++;
379
+ }
351
380
  }
352
381
  });
382
+ yield Promise.all(Array.from({ length: Math.min(4, contacts.length) }, () => worker()));
383
+ if (generation !== this.contactSyncGeneration)
384
+ return false;
385
+ console.log(`联系人批次处理完成: 成功 ${succeeded},失败 ${failed}`);
353
386
  }
354
387
  // 检查是否还有更多联系人需要获取
355
388
  // CountinueFlag 为 1 表示还有更多数据
@@ -358,27 +391,60 @@ class WxBot extends events_1.EventEmitter {
358
391
  const nextChatroomSeq = data.Data.CurrentChatRoomContactSeq || 0;
359
392
  console.log(`还有更多联系人,继续获取... (WxSeq: ${nextWxSeq}, ChatroomSeq: ${nextChatroomSeq})`);
360
393
  // 递归获取下一批联系人
361
- yield this.fetchAllContacts(userName, nextWxSeq, nextChatroomSeq);
394
+ return yield this.fetchAllContacts(userName, nextWxSeq, nextChatroomSeq, generation);
362
395
  }
363
396
  else {
364
397
  console.log('联系人列表获取完成');
398
+ return true;
365
399
  }
366
400
  }
367
- catch (error) {
368
- console.error('获取联系人列表时发生错误:', error);
401
+ catch (_a) {
402
+ if (generation === this.contactSyncGeneration)
403
+ console.error('获取联系人列表时发生错误,保留已有目录');
404
+ return false;
369
405
  }
370
406
  });
371
407
  }
408
+ startContactSync(userName) {
409
+ const generation = this.contactSyncGeneration;
410
+ const previous = this.contactSyncTask;
411
+ const task = Promise.resolve(previous).then(() => __awaiter(this, void 0, void 0, function* () {
412
+ if (generation !== this.contactSyncGeneration)
413
+ return;
414
+ console.log('开始后台同步联系人,不阻塞消息接收');
415
+ const complete = yield this.fetchAllContacts(userName, 0, 0, generation);
416
+ if (generation !== this.contactSyncGeneration)
417
+ return;
418
+ console.log(complete ? '联系人后台同步批次处理完成' : '联系人后台同步未完成,已有目录仍可使用');
419
+ // Also invalidate a partially updated directory after a later page fails.
420
+ this.emit('contacts-updated');
421
+ })).catch(() => {
422
+ if (generation === this.contactSyncGeneration)
423
+ console.error('联系人后台同步失败');
424
+ }).finally(() => {
425
+ if (this.contactSyncTask === task)
426
+ this.contactSyncTask = null;
427
+ });
428
+ this.contactSyncTask = task;
429
+ }
430
+ /** Explicit directory barrier for callers that need the current background pass. */
431
+ waitForContactSync() {
432
+ return __awaiter(this, void 0, void 0, function* () {
433
+ yield this.contactSyncTask;
434
+ });
435
+ }
372
436
  loginSuccess(userName) {
373
437
  return __awaiter(this, void 0, void 0, function* () {
374
- contact_service_1.contactService.clearRoom();
375
- this.clearAllIntervals(); // 确保清理所有定时器
438
+ this.clearAllIntervals(); // 确保清理所有定时器,取消旧目录任务
439
+ const generation = this.contactSyncGeneration;
440
+ yield contact_service_1.contactService.clearRoom();
441
+ if (generation !== this.contactSyncGeneration)
442
+ return false;
376
443
  this.syncFailureCount = 0; // 重置失败计数器
377
- // 分页获取所有联系人
378
- yield this.fetchAllContacts(userName, 0, 0);
379
- console.log('所有联系人获取完成');
380
444
  // 设置设备信息
381
445
  const cacheInfo = yield LoginApi_1.loginApi.GetCacheInfo(userName);
446
+ if (generation !== this.contactSyncGeneration)
447
+ return false;
382
448
  if (cacheInfo.data.Message === '成功') {
383
449
  yield config_service_1.configService.save({
384
450
  deviceId: cacheInfo.data.Data.Deviceid_str,
@@ -386,10 +452,16 @@ class WxBot extends events_1.EventEmitter {
386
452
  DeviceType: this.deviceType,
387
453
  });
388
454
  }
455
+ if (generation !== this.contactSyncGeneration)
456
+ return false;
389
457
  // 先监听再启动心跳;不再通过 Sync 清空并丢弃登录期间的消息。
390
458
  if (this.mode === 'callback') {
391
459
  yield this.startCallbackServer(userName);
460
+ if (generation !== this.contactSyncGeneration)
461
+ return false;
392
462
  this.emit('login', userName);
463
+ if (generation !== this.contactSyncGeneration)
464
+ return false;
393
465
  this.callbackInterval = setInterval(() => {
394
466
  void this.handleMsg(userName).catch(() => console.error('回调兜底同步失败'));
395
467
  }, 1000 * 60 * 5);
@@ -397,7 +469,13 @@ class WxBot extends events_1.EventEmitter {
397
469
  else {
398
470
  this.startPollingMode(userName);
399
471
  }
472
+ if (generation !== this.contactSyncGeneration)
473
+ return false;
400
474
  yield this.setupHeartbeat(userName);
475
+ if (generation !== this.contactSyncGeneration)
476
+ return false;
477
+ this.startContactSync(userName);
478
+ return true;
401
479
  });
402
480
  }
403
481
  startCallbackServer(userName) {
@@ -711,15 +789,17 @@ class WxBot extends events_1.EventEmitter {
711
789
  */
712
790
  clearCache() {
713
791
  return __awaiter(this, void 0, void 0, function* () {
714
- this.logOut();
792
+ yield this.logOut();
793
+ // Never let an old request repopulate the cache after an account reset.
794
+ yield this.waitForContactSync();
715
795
  const config = yield config_service_1.configService.get();
716
796
  if (config) {
717
797
  const res = yield LoginApi_1.loginApi.LogOut(config.wxid);
718
798
  }
719
- config_service_1.configService.clear();
720
- room_service_1.roomService.clear();
721
- contact_service_1.contactService.clear();
722
- message_service_1.messageService.clear();
799
+ yield config_service_1.configService.clear();
800
+ yield room_service_1.roomService.clear();
801
+ yield contact_service_1.contactService.clear();
802
+ yield message_service_1.messageService.clear();
723
803
  });
724
804
  }
725
805
  login() {
@@ -757,8 +837,8 @@ class WxBot extends events_1.EventEmitter {
757
837
  "Synckey": ""
758
838
  });
759
839
  if (resp.data.Code === 0) {
760
- yield this.loginSuccess(config.wxid);
761
- yield this.enqueueMessages(config.wxid, resp.data);
840
+ if (yield this.loginSuccess(config.wxid))
841
+ yield this.enqueueMessages(config.wxid, resp.data);
762
842
  }
763
843
  else {
764
844
  yield this.login();
@@ -787,7 +867,8 @@ class WxBot extends events_1.EventEmitter {
787
867
  });
788
868
  yield LoginApi_1.loginApi.LoginTwiceAutoAuth(config === null || config === void 0 ? void 0 : config.wxid);
789
869
  if (yield this.checkSyncLoginStatus(resp.data, config === null || config === void 0 ? void 0 : config.wxid)) {
790
- yield this.loginSuccess(config.wxid);
870
+ if (!(yield this.loginSuccess(config.wxid)))
871
+ return false;
791
872
  yield this.enqueueMessages(config.wxid, resp.data);
792
873
  return true;
793
874
  }
@@ -819,3 +900,5 @@ exports.WxBot = WxBot;
819
900
  var ContactClassification_1 = require("./utils/ContactClassification");
820
901
  Object.defineProperty(exports, "normalizeContactVerifyFlag", { enumerable: true, get: function () { return ContactClassification_1.normalizeContactVerifyFlag; } });
821
902
  Object.defineProperty(exports, "isOfficialAccountVerifyFlag", { enumerable: true, get: function () { return ContactClassification_1.isOfficialAccountVerifyFlag; } });
903
+ var OfficialAccountMetadata_1 = require("./utils/OfficialAccountMetadata");
904
+ Object.defineProperty(exports, "parseStoredOfficialMenu", { enumerable: true, get: function () { return OfficialAccountMetadata_1.parseStoredOfficialMenu; } });
@@ -0,0 +1,17 @@
1
+ export interface OfficialMenu {
2
+ buttons: OfficialMenuButton[];
3
+ }
4
+ export interface OfficialMenuButton {
5
+ name: string;
6
+ kind: 'group' | 'url' | 'unsupported';
7
+ url?: string;
8
+ reason?: 'event' | 'miniapp' | 'wechat' | 'unknown';
9
+ children?: OfficialMenuButton[];
10
+ }
11
+ /** Revalidate untrusted DB JSON (or an object) and return a fresh, allowlisted tree. */
12
+ export declare function parseStoredOfficialMenu(value: unknown): OfficialMenu | null;
13
+ /** Missing/invalid fields are omitted so an atomic partial upsert preserves known metadata. */
14
+ export declare function decodeOfficialAccountMetadata(externalInfo: unknown, verifyFlag: unknown): {
15
+ serviceType?: number;
16
+ officialMenu?: OfficialMenu;
17
+ };
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStoredOfficialMenu = parseStoredOfficialMenu;
4
+ exports.decodeOfficialAccountMetadata = decodeOfficialAccountMetadata;
5
+ const ContactClassification_1 = require("./ContactClassification");
6
+ // Reject the entire tree at a limit, rather than displaying a truncated menu as complete.
7
+ const MAX_JSON_BYTES = 64 * 1024;
8
+ const MAX_DEPTH = 3;
9
+ const MAX_NODES = 100;
10
+ const MAX_NAME_LENGTH = 256;
11
+ const MAX_URL_LENGTH = 4096;
12
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
13
+ function record(value) {
14
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
15
+ return false;
16
+ const prototype = Object.getPrototypeOf(value);
17
+ return prototype === Object.prototype || prototype === null;
18
+ }
19
+ // Never use inherited values or invoke getters on caller-supplied cached objects.
20
+ function own(value, key) {
21
+ var _a;
22
+ return (_a = Object.getOwnPropertyDescriptor(value, key)) === null || _a === void 0 ? void 0 : _a.value;
23
+ }
24
+ function json(value) {
25
+ if (typeof value !== 'string' || value.length > MAX_JSON_BYTES
26
+ || Buffer.byteLength(value, 'utf8') > MAX_JSON_BYTES)
27
+ return null;
28
+ return JSON.parse(value);
29
+ }
30
+ function safeUrl(value) {
31
+ if (typeof value !== 'string' || !value || value.length > MAX_URL_LENGTH
32
+ || /[\s\\]/u.test(value) || CONTROL.test(value) || !/^https?:\/\//i.test(value))
33
+ return null;
34
+ const url = new URL(value);
35
+ if (!url.hostname || url.username || url.password)
36
+ return null;
37
+ // Encoded authorization links and control characters must not bypass the check.
38
+ let decoded = value;
39
+ for (let i = 0; i < 4; i++) {
40
+ if (CONTROL.test(decoded) || /oauth|authori[sz]|snsapi_|connect\/qrconnect|(?:[?&#])(?:response_type|redirect_uri)=/i.test(decoded)) {
41
+ return null;
42
+ }
43
+ const next = decodeURIComponent(decoded);
44
+ if (next === decoded)
45
+ return value;
46
+ decoded = next;
47
+ }
48
+ return null;
49
+ }
50
+ function normalizeMenu(value, raw) {
51
+ if (!record(value))
52
+ return null;
53
+ let nodes = 0;
54
+ let textSize = 0;
55
+ const seen = new WeakSet();
56
+ function buttons(value, depth) {
57
+ if (!Array.isArray(value) || depth > MAX_DEPTH || value.length > MAX_NODES)
58
+ return null;
59
+ if (seen.has(value))
60
+ return null;
61
+ seen.add(value);
62
+ const result = [];
63
+ const siblings = new Set();
64
+ for (let i = 0; i < value.length; i++) {
65
+ const item = own(value, String(i));
66
+ if (!record(item) || seen.has(item) || ++nodes > MAX_NODES)
67
+ return null;
68
+ seen.add(item);
69
+ const name = own(item, 'name');
70
+ if (typeof name !== 'string' || !name.trim() || name.length > MAX_NAME_LENGTH || CONTROL.test(name))
71
+ return null;
72
+ textSize += Buffer.byteLength(name, 'utf8');
73
+ // Live menus may reuse the same placeholder id for every button. IDs and
74
+ // event keys are neither exposed nor used to execute actions here.
75
+ const type = own(item, raw ? 'type' : 'kind');
76
+ if (raw && (typeof type !== 'number' || !Number.isInteger(type) || type < 0 || type > 0xffffffff))
77
+ return null;
78
+ const children = own(item, raw ? 'sub_button_list' : 'children');
79
+ let button;
80
+ if (type === (raw ? 0 : 'group')) {
81
+ const nested = buttons(children, depth + 1);
82
+ if (!nested || !nested.length)
83
+ return null;
84
+ button = { name, kind: 'group', children: nested };
85
+ }
86
+ else {
87
+ // A leaf with children is ambiguous; do not silently discard a subtree.
88
+ if (children !== undefined && !(raw && children === null)
89
+ && (!Array.isArray(children) || children.length !== 0))
90
+ return null;
91
+ if (type === (raw ? 2 : 'url')) {
92
+ const candidate = own(item, raw ? 'value' : 'url');
93
+ if (typeof candidate !== 'string')
94
+ return null;
95
+ let url;
96
+ try {
97
+ url = safeUrl(candidate);
98
+ }
99
+ catch (_a) {
100
+ url = null;
101
+ }
102
+ if (url) {
103
+ textSize += Buffer.byteLength(url, 'utf8');
104
+ button = { name, kind: 'url', url };
105
+ }
106
+ else {
107
+ button = { name, kind: 'unsupported', reason: 'wechat' };
108
+ }
109
+ }
110
+ else if (raw) {
111
+ button = { name, kind: 'unsupported', reason: type === 1 ? 'event' : type === 5 ? 'miniapp' : 'unknown' };
112
+ }
113
+ else if (type === 'unsupported') {
114
+ const reason = own(item, 'reason');
115
+ if (reason !== 'event' && reason !== 'miniapp' && reason !== 'wechat' && reason !== 'unknown')
116
+ return null;
117
+ button = { name, kind: 'unsupported', reason };
118
+ }
119
+ else {
120
+ return null;
121
+ }
122
+ }
123
+ if (textSize > MAX_JSON_BYTES)
124
+ return null;
125
+ const identity = JSON.stringify(button);
126
+ if (siblings.has(identity))
127
+ return null;
128
+ siblings.add(identity);
129
+ result.push(button);
130
+ }
131
+ return result;
132
+ }
133
+ const normalized = buttons(own(value, raw ? 'button_list' : 'buttons'), 1);
134
+ if (!normalized)
135
+ return null;
136
+ const menu = { buttons: normalized };
137
+ return Buffer.byteLength(JSON.stringify(menu), 'utf8') <= MAX_JSON_BYTES ? menu : null;
138
+ }
139
+ /** Revalidate untrusted DB JSON (or an object) and return a fresh, allowlisted tree. */
140
+ function parseStoredOfficialMenu(value) {
141
+ try {
142
+ return normalizeMenu(typeof value === 'string' ? json(value) : value, false);
143
+ }
144
+ catch (_a) {
145
+ return null;
146
+ }
147
+ }
148
+ /** Missing/invalid fields are omitted so an atomic partial upsert preserves known metadata. */
149
+ function decodeOfficialAccountMetadata(externalInfo, verifyFlag) {
150
+ const result = {};
151
+ if ((0, ContactClassification_1.isOfficialAccountVerifyFlag)(verifyFlag) !== true)
152
+ return result;
153
+ try {
154
+ const external = json(externalInfo);
155
+ if (!record(external))
156
+ return result;
157
+ const serviceType = (0, ContactClassification_1.normalizeContactVerifyFlag)(own(external, 'ServiceType'));
158
+ if (serviceType !== null)
159
+ result.serviceType = serviceType;
160
+ const officialMenu = normalizeMenu(own(external, 'MMBizMenu'), true);
161
+ if (officialMenu)
162
+ result.officialMenu = officialMenu;
163
+ }
164
+ catch (_a) {
165
+ // Malformed ExternalInfo is not evidence that the account has no menu.
166
+ }
167
+ return result;
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wx2tg-puppet",
3
- "version": "6.0.16",
3
+ "version": "6.0.18-dev.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [