tca-js 0.0.0-stage → 0.3.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.
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * Hằng giao thức IM của TikTok Web — dựng lại từ lưu lượng `tiktok.com/business-suite/messages`
4
+ * (09/11/2026). TikTok đổi phía họ thì sửa ở đây; ghi lại cách dò ra ở README.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.IMAGEX_VERSION = exports.IMAGEX_SERVICE = exports.IMAGEX_REGION = exports.REACTION_HEART = exports.REACTION_LOVE_KEY = exports.REACTION_PREFIX = exports.PropertyOp = exports.MsgF = exports.Resp = exports.ConversationType = exports.MessageType = exports.MARK_READ_BODY_FIELD = exports.Cmd = exports.FRONTIER_SALT = exports.FRONTIER_APP_KEY = exports.FRONTIER_FPID = exports.BUILD_NUMBER = exports.SDK_VERSION = exports.AID_FRONTIER = exports.AID_WEB = exports.DEFAULT_USER_AGENT = exports.WEB = exports.IM_WS = exports.IM_API = void 0;
8
+ exports.IM_API = 'https://im-api-sg.tiktok.com';
9
+ exports.IM_WS = 'wss://im-ws-sg.tiktok.com/ws/v2';
10
+ exports.WEB = 'https://www.tiktok.com';
11
+ exports.DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36';
12
+ /** App id của TikTok Web (HTTP IM). */
13
+ exports.AID_WEB = '1988';
14
+ /** App id kênh "frontier" — websocket đẩy tin mới. */
15
+ exports.AID_FRONTIER = '1459';
16
+ exports.SDK_VERSION = '1.8.5';
17
+ exports.BUILD_NUMBER = 'feffaa1:master';
18
+ /** `access_key` của websocket = md5(FPID + APP_KEY + device_id + SALT). */
19
+ exports.FRONTIER_FPID = '9';
20
+ exports.FRONTIER_APP_KEY = 'e1bd35ec9db7b8d846de66ed140b1ad9';
21
+ exports.FRONTIER_SALT = 'f8a69f1719916z';
22
+ /** Lệnh IM (`Request.cmd`) — đồng thời là số trường của body trong `RequestBody`. */
23
+ exports.Cmd = {
24
+ SendMessage: 100,
25
+ GetMessagesByUserInit: 203,
26
+ GetMessagesByConversation: 301,
27
+ /** Websocket đẩy tin mới. */
28
+ NewMessage: 500,
29
+ /** Thả / gỡ reaction (`property` của tin) — `/v1/message/set_property`. */
30
+ SetMessageProperty: 705,
31
+ GetConversationInfo: 610,
32
+ MarkRead: 2002,
33
+ UploadConfig: 2059,
34
+ };
35
+ /** Body của MarkRead nằm ở trường 604, không trùng số lệnh. */
36
+ exports.MARK_READ_BODY_FIELD = 604;
37
+ /** `MessageBody.message_type`. */
38
+ exports.MessageType = {
39
+ System: 1,
40
+ Text: 7,
41
+ ShareVideo: 8,
42
+ PrivateImage: 1802,
43
+ /** Sticker (nhãn dán) — ảnh công khai trên CDN sticker, không mã hoá. */
44
+ Sticker: 1805,
45
+ /** Lệnh ẩn (đã đọc…) — không hiện trong hội thoại, websocket vẫn đẩy. */
46
+ Command: 50001,
47
+ };
48
+ exports.ConversationType = { OneToOne: 1 };
49
+ /** Trường của `Response` (cả HTTP lẫn payload websocket). */
50
+ exports.Resp = { cmd: 1, seq: 2, status: 3, error: 4, body: 6, logId: 7 };
51
+ /** Trường của `MessageBody`. */
52
+ exports.MsgF = {
53
+ conversationId: 1,
54
+ conversationType: 2,
55
+ serverMessageId: 3,
56
+ indexInConversation: 4,
57
+ conversationShortId: 5,
58
+ type: 6,
59
+ sender: 7,
60
+ content: 8,
61
+ ext: 9,
62
+ createTime: 10,
63
+ /** `property_list` — reaction: `{1 khoá "e:<emoji>", 2 {1 PropertyItem[]}}`. */
64
+ propertyList: 15,
65
+ attachments: 20,
66
+ tag: 21,
67
+ };
68
+ /** Thao tác trên `property` của tin (`ModifyPropertyContent.operation`). */
69
+ exports.PropertyOp = { Add: 0, Remove: 1 };
70
+ /** Khoá reaction = `e:<emoji>`. App điện thoại thả ❤️ còn ghi thêm khoá cũ `e:love` — coi như ❤️. */
71
+ exports.REACTION_PREFIX = 'e:';
72
+ exports.REACTION_LOVE_KEY = 'e:love';
73
+ exports.REACTION_HEART = '\u2764\uFE0F';
74
+ /** ImageX — nơi tải ảnh lên (ký kiểu AWS SigV4). */
75
+ exports.IMAGEX_REGION = 'cn-north-1';
76
+ exports.IMAGEX_SERVICE = 'imagex';
77
+ exports.IMAGEX_VERSION = '2018-08-01';
@@ -0,0 +1,32 @@
1
+ import { Msg } from './proto';
2
+ import type { Credentials, TikTokOptions } from './types';
3
+ /** Cookie dạng `name → value` từ mọi kiểu đầu vào của `Credentials.cookies`. */
4
+ export declare function normalizeCookies(input: Credentials['cookies']): Map<string, string>;
5
+ /** Trạng thái dùng chung của một phiên: cookie, mã máy, uid, gọi HTTP. */
6
+ export declare class Context {
7
+ readonly options: TikTokOptions;
8
+ readonly cookies: Map<string, string>;
9
+ /**
10
+ * Mã máy IM = `wid` TikTok cấp theo cookie `ttwid` (`login` tự hỏi). Websocket chỉ nhận đúng
11
+ * mã này — mã tự bịa bị 400 (HTTP IM thì không kén).
12
+ */
13
+ deviceId: string;
14
+ readonly userAgent: string;
15
+ ownId: string;
16
+ private seq;
17
+ constructor(creds: Credentials, options?: TikTokOptions);
18
+ cookieHeader(): string;
19
+ log(level: 'debug' | 'warn', msg: string): void;
20
+ /** `fetch` kèm cookie + UA + hạn chờ. */
21
+ fetch(url: string, init?: RequestInit & {
22
+ headers?: Record<string, string>;
23
+ }): Promise<Response>;
24
+ getJson<T>(url: string): Promise<T>;
25
+ /** Vỏ `Request` của IM: lệnh + body + đầu mục chung. */
26
+ buildRequest(cmd: number, bodyField: number, body: Buffer): Buffer;
27
+ /**
28
+ * Gọi một lệnh IM qua HTTP. Trả body đã bóc (`ResponseBody[cmd]`).
29
+ * KHÔNG cần chữ ký X-Gnarly/X-Bogus/msToken — server IM chỉ xét cookie (thử 09/11/2026).
30
+ */
31
+ im(path: string, cmd: number, body: Buffer, bodyField?: number): Promise<Msg>;
32
+ }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Context = void 0;
4
+ exports.normalizeCookies = normalizeCookies;
5
+ const constants_1 = require("./constants");
6
+ const errors_1 = require("./errors");
7
+ const proto_1 = require("./proto");
8
+ /** Cookie dạng `name → value` từ mọi kiểu đầu vào của `Credentials.cookies`. */
9
+ function normalizeCookies(input) {
10
+ const jar = new Map();
11
+ if (typeof input === 'string') {
12
+ for (const part of input.split(';')) {
13
+ const i = part.indexOf('=');
14
+ if (i > 0)
15
+ jar.set(part.slice(0, i).trim(), part.slice(i + 1).trim());
16
+ }
17
+ }
18
+ else if (Array.isArray(input)) {
19
+ for (const c of input)
20
+ if (c?.name)
21
+ jar.set(c.name, String(c.value ?? ''));
22
+ }
23
+ else {
24
+ for (const [k, v] of Object.entries(input ?? {}))
25
+ jar.set(k, String(v));
26
+ }
27
+ return jar;
28
+ }
29
+ /** Trạng thái dùng chung của một phiên: cookie, mã máy, uid, gọi HTTP. */
30
+ class Context {
31
+ options;
32
+ cookies;
33
+ /**
34
+ * Mã máy IM = `wid` TikTok cấp theo cookie `ttwid` (`login` tự hỏi). Websocket chỉ nhận đúng
35
+ * mã này — mã tự bịa bị 400 (HTTP IM thì không kén).
36
+ */
37
+ deviceId;
38
+ userAgent;
39
+ ownId = '';
40
+ seq = 10_000;
41
+ constructor(creds, options = {}) {
42
+ this.options = options;
43
+ this.cookies = normalizeCookies(creds.cookies);
44
+ this.deviceId = creds.deviceId ?? '';
45
+ this.userAgent = creds.userAgent || constants_1.DEFAULT_USER_AGENT;
46
+ }
47
+ cookieHeader() {
48
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
49
+ }
50
+ log(level, msg) {
51
+ this.options.logger?.(level, msg);
52
+ }
53
+ /** `fetch` kèm cookie + UA + hạn chờ. */
54
+ async fetch(url, init = {}) {
55
+ try {
56
+ return await fetch(url, {
57
+ ...init,
58
+ headers: {
59
+ 'User-Agent': this.userAgent,
60
+ Referer: `${constants_1.WEB}/`,
61
+ Origin: constants_1.WEB,
62
+ Cookie: this.cookieHeader(),
63
+ ...init.headers,
64
+ },
65
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? 20_000),
66
+ });
67
+ }
68
+ catch (err) {
69
+ throw new errors_1.TikTokError('HTTP_ERROR', `${url.split('?')[0]}: ${err instanceof Error ? err.message : String(err)}`);
70
+ }
71
+ }
72
+ async getJson(url) {
73
+ const res = await this.fetch(url);
74
+ if (!res.ok)
75
+ throw new errors_1.TikTokError('HTTP_ERROR', `${url.split('?')[0]}: HTTP ${res.status}`, res.status);
76
+ return (await res.json());
77
+ }
78
+ /** Vỏ `Request` của IM: lệnh + body + đầu mục chung. */
79
+ buildRequest(cmd, bodyField, body) {
80
+ return new proto_1.Writer()
81
+ .int(1, cmd)
82
+ .int(2, this.seq++)
83
+ .bytes(3, constants_1.SDK_VERSION)
84
+ .bytes(4, '')
85
+ .int(5, 3)
86
+ .int(6, 0)
87
+ .bytes(7, constants_1.BUILD_NUMBER)
88
+ .msg(8, new proto_1.Writer().bytes(bodyField, body))
89
+ .bytes(9, this.deviceId)
90
+ .bytes(11, 'web')
91
+ .map(15, {
92
+ aid: constants_1.AID_WEB,
93
+ app_name: 'tiktok_web',
94
+ channel: 'web',
95
+ device_platform: 'web_pc',
96
+ device_id: this.deviceId,
97
+ user_is_login: 'true',
98
+ user_agent: this.userAgent,
99
+ })
100
+ .int(18, 1)
101
+ .finish();
102
+ }
103
+ /**
104
+ * Gọi một lệnh IM qua HTTP. Trả body đã bóc (`ResponseBody[cmd]`).
105
+ * KHÔNG cần chữ ký X-Gnarly/X-Bogus/msToken — server IM chỉ xét cookie (thử 09/11/2026).
106
+ */
107
+ async im(path, cmd, body, bodyField = cmd) {
108
+ const url = `${constants_1.IM_API}${path}?aid=${constants_1.AID_WEB}&version_code=1.0.0&app_name=tiktok_web&device_platform=web_pc`;
109
+ const res = await this.fetch(url, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/x-protobuf', Accept: 'application/x-protobuf' },
112
+ body: new Uint8Array(this.buildRequest(cmd, bodyField, body)),
113
+ });
114
+ if (!res.ok)
115
+ throw new errors_1.TikTokError('HTTP_ERROR', `${path}: HTTP ${res.status}`, res.status);
116
+ const resp = proto_1.Msg.tryParse(Buffer.from(await res.arrayBuffer()));
117
+ if (!resp)
118
+ throw new errors_1.TikTokError('BAD_RESPONSE', `${path}: phản hồi không phải protobuf`);
119
+ const status = resp.num(constants_1.Resp.status);
120
+ if (status !== 0) {
121
+ const desc = resp.str(constants_1.Resp.error);
122
+ // Chưa đăng nhập: 200005 (thử bằng request không cookie).
123
+ const code = status === 200005 ? 'SESSION_EXPIRED' : 'IM_ERROR';
124
+ throw new errors_1.TikTokError(code, `${path}: ${status} ${desc}`.trim(), status);
125
+ }
126
+ return resp.msg(constants_1.Resp.body)?.msg(cmd) ?? resp.msg(constants_1.Resp.body)?.msg(bodyField) ?? proto_1.Msg.parse(Buffer.alloc(0));
127
+ }
128
+ }
129
+ exports.Context = Context;
@@ -0,0 +1,18 @@
1
+ /** Lỗi từ TikTok hoặc từ chính thư viện. `code` để bên dùng phân nhánh (vd phiên hết hạn → quét lại). */
2
+ export declare class TikTokError extends Error {
3
+ readonly code: TikTokErrorCode;
4
+ /** Mã gốc TikTok trả (status_code / error_code) nếu có. */
5
+ readonly upstream?: number | string | undefined;
6
+ constructor(code: TikTokErrorCode, message: string,
7
+ /** Mã gốc TikTok trả (status_code / error_code) nếu có. */
8
+ upstream?: number | string | undefined);
9
+ }
10
+ export type TikTokErrorCode =
11
+ /** Cookie không còn đăng nhập — phải lấy phiên mới. */
12
+ 'SESSION_EXPIRED' | 'HTTP_ERROR'
13
+ /** IM trả `status_code` khác 0. */
14
+ | 'IM_ERROR' | 'UPLOAD_FAILED' | 'DOWNLOAD_FAILED' | 'BAD_RESPONSE'
15
+ /** Đăng nhập QR không thành (Chrome không mở được, QR hết hạn, không thấy cookie…). */
16
+ | 'LOGIN_FAILED'
17
+ /** Bên gọi huỷ đăng nhập QR (`signal`). */
18
+ | 'LOGIN_ABORTED';
package/dist/errors.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TikTokError = void 0;
4
+ /** Lỗi từ TikTok hoặc từ chính thư viện. `code` để bên dùng phân nhánh (vd phiên hết hạn → quét lại). */
5
+ class TikTokError extends Error {
6
+ code;
7
+ upstream;
8
+ constructor(code, message,
9
+ /** Mã gốc TikTok trả (status_code / error_code) nếu có. */
10
+ upstream) {
11
+ super(message);
12
+ this.code = code;
13
+ this.upstream = upstream;
14
+ this.name = 'TikTokError';
15
+ }
16
+ }
17
+ exports.TikTokError = TikTokError;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * tca — TikTok Chat API (giả lập TikTok Web), cùng ý tưởng với zca-js cho Zalo.
3
+ *
4
+ * ```ts
5
+ * const api = await new TikTok().login({ cookies, deviceId });
6
+ * api.listener.on('message', async (msg) => {
7
+ * if (msg.isSelf) return;
8
+ * await api.sendMessage(msg, 'Got it, thanks!');
9
+ * });
10
+ * api.listener.start();
11
+ * ```
12
+ *
13
+ * Lấy cookie lần đầu: `loginQR({ chromePath, onEvent })` — mở Chrome headless ở trang QR của TikTok
14
+ * (API đăng nhập đòi chữ ký JS nên không làm thuần Node được), quét xong trả cookie. Xem README.
15
+ */
16
+ import { API } from './api';
17
+ import type { Credentials, TikTokOptions } from './types';
18
+ export declare class TikTok {
19
+ private readonly options;
20
+ constructor(options?: TikTokOptions);
21
+ /** Đăng nhập bằng cookie đã có. Cookie hết hạn → `TikTokError('SESSION_EXPIRED')`. */
22
+ login(credentials: Credentials): Promise<API>;
23
+ }
24
+ export { API } from './api';
25
+ export type { SendResult } from './api';
26
+ export { MessageType } from './constants';
27
+ export { TikTokError } from './errors';
28
+ export type { TikTokErrorCode } from './errors';
29
+ export { Listener, MANUAL_CLOSE } from './listener';
30
+ export { loginQR } from './qr-login';
31
+ export type { QrLoginEvent, QrLoginOptions } from './qr-login';
32
+ export type { ListenerEvents } from './listener';
33
+ export type * from './types';
package/dist/index.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loginQR = exports.MANUAL_CLOSE = exports.Listener = exports.TikTokError = exports.MessageType = exports.API = exports.TikTok = void 0;
4
+ /**
5
+ * tca — TikTok Chat API (giả lập TikTok Web), cùng ý tưởng với zca-js cho Zalo.
6
+ *
7
+ * ```ts
8
+ * const api = await new TikTok().login({ cookies, deviceId });
9
+ * api.listener.on('message', async (msg) => {
10
+ * if (msg.isSelf) return;
11
+ * await api.sendMessage(msg, 'Got it, thanks!');
12
+ * });
13
+ * api.listener.start();
14
+ * ```
15
+ *
16
+ * Lấy cookie lần đầu: `loginQR({ chromePath, onEvent })` — mở Chrome headless ở trang QR của TikTok
17
+ * (API đăng nhập đòi chữ ký JS nên không làm thuần Node được), quét xong trả cookie. Xem README.
18
+ */
19
+ const api_1 = require("./api");
20
+ const context_1 = require("./context");
21
+ const errors_1 = require("./errors");
22
+ class TikTok {
23
+ options;
24
+ constructor(options = {}) {
25
+ this.options = options;
26
+ }
27
+ /** Đăng nhập bằng cookie đã có. Cookie hết hạn → `TikTokError('SESSION_EXPIRED')`. */
28
+ async login(credentials) {
29
+ const ctx = new context_1.Context(credentials, this.options);
30
+ if (!ctx.cookies.get('sessionid') && !ctx.cookies.get('sid_tt')) {
31
+ throw new errors_1.TikTokError('SESSION_EXPIRED', 'Cookie không có sessionid');
32
+ }
33
+ const account = await (0, api_1.fetchAccount)(ctx);
34
+ if (!account)
35
+ throw new errors_1.TikTokError('SESSION_EXPIRED', 'Cookie TikTok không còn đăng nhập');
36
+ ctx.ownId = account.uid;
37
+ ctx.deviceId = (await (0, api_1.fetchWebId)(ctx).catch(() => '')) || ctx.deviceId;
38
+ if (!ctx.deviceId)
39
+ throw new errors_1.TikTokError('BAD_RESPONSE', 'Không lấy được wid (device_id) — cookie thiếu ttwid?');
40
+ return new api_1.API(ctx, account);
41
+ }
42
+ }
43
+ exports.TikTok = TikTok;
44
+ var api_2 = require("./api");
45
+ Object.defineProperty(exports, "API", { enumerable: true, get: function () { return api_2.API; } });
46
+ var constants_1 = require("./constants");
47
+ Object.defineProperty(exports, "MessageType", { enumerable: true, get: function () { return constants_1.MessageType; } });
48
+ var errors_2 = require("./errors");
49
+ Object.defineProperty(exports, "TikTokError", { enumerable: true, get: function () { return errors_2.TikTokError; } });
50
+ var listener_1 = require("./listener");
51
+ Object.defineProperty(exports, "Listener", { enumerable: true, get: function () { return listener_1.Listener; } });
52
+ Object.defineProperty(exports, "MANUAL_CLOSE", { enumerable: true, get: function () { return listener_1.MANUAL_CLOSE; } });
53
+ var qr_login_1 = require("./qr-login");
54
+ Object.defineProperty(exports, "loginQR", { enumerable: true, get: function () { return qr_login_1.loginQR; } });
@@ -0,0 +1,43 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Context } from './context';
3
+ import type { TikTokMessage } from './types';
4
+ /** Mã đóng do chính mình gọi `stop()`. */
5
+ export declare const MANUAL_CLOSE = 4000;
6
+ export interface ListenerEvents {
7
+ connected: [];
8
+ /** Tin thật trong hội thoại (cả tin của chính mình — xem `isSelf`). */
9
+ message: [TikTokMessage];
10
+ /** Lệnh ẩn: đánh dấu đã đọc… (`content.command_type`). */
11
+ command: [TikTokMessage];
12
+ /** Mã + lý do đóng. Không phải `MANUAL_CLOSE` thì listener tự nối lại (nếu bật). */
13
+ closed: [number, string];
14
+ error: [Error];
15
+ }
16
+ /**
17
+ * Nghe tin mới qua websocket `im-ws-sg` (aid 1459) — chỉ cần cookie, như listener của zca-js.
18
+ * Sự kiện: `connected`, `message`, `closed`, `error`.
19
+ */
20
+ export declare class Listener extends EventEmitter<ListenerEvents> {
21
+ private readonly ctx;
22
+ private ws;
23
+ private heartbeat;
24
+ private idle;
25
+ private reconnectTimer;
26
+ private backoff;
27
+ private retry;
28
+ private readonly seen;
29
+ constructor(ctx: Context);
30
+ get connected(): boolean;
31
+ url(): string;
32
+ /** Mở kết nối. `retryOnClose` (mặc định bật): rớt mạng thì tự nối lại, giãn dần tới 1 phút. */
33
+ start(opts?: {
34
+ retryOnClose?: boolean;
35
+ }): void;
36
+ stop(): void;
37
+ private connect;
38
+ private touch;
39
+ private clearTimers;
40
+ private onFrame;
41
+ /** Báo đã nhận — `ack_id = <giờ base36>:<x_frontier_traceid>`, kèm lại seq/logid của khung. */
42
+ private ack;
43
+ }
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Listener = exports.MANUAL_CLOSE = void 0;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_events_1 = require("node:events");
9
+ const ws_1 = __importDefault(require("ws"));
10
+ const constants_1 = require("./constants");
11
+ const lz4_1 = require("./lz4");
12
+ const message_1 = require("./message");
13
+ const proto_1 = require("./proto");
14
+ /** Trình duyệt gửi "hi" mỗi 15–30 giây; lấy cận dưới. */
15
+ const HEARTBEAT_MS = 15_000;
16
+ /** Không nhận gì (kể cả "hi" đáp lại) quá chừng này thì coi như chết ngầm, nối lại. */
17
+ const IDLE_TIMEOUT_MS = 75_000;
18
+ const RECONNECT_MIN_MS = 2_000;
19
+ const RECONNECT_MAX_MS = 60_000;
20
+ /** Server đẩy cùng một tin vài lần (mỗi thiết bị một khung) — nhớ chừng này mã tin để lọc trùng. */
21
+ const DEDUPE_KEEP = 500;
22
+ /** Mã đóng do chính mình gọi `stop()`. */
23
+ exports.MANUAL_CLOSE = 4000;
24
+ /**
25
+ * Nghe tin mới qua websocket `im-ws-sg` (aid 1459) — chỉ cần cookie, như listener của zca-js.
26
+ * Sự kiện: `connected`, `message`, `closed`, `error`.
27
+ */
28
+ class Listener extends node_events_1.EventEmitter {
29
+ ctx;
30
+ ws = null;
31
+ heartbeat = null;
32
+ idle = null;
33
+ reconnectTimer = null;
34
+ backoff = RECONNECT_MIN_MS;
35
+ retry = true;
36
+ seen = new Set();
37
+ constructor(ctx) {
38
+ super();
39
+ this.ctx = ctx;
40
+ }
41
+ get connected() {
42
+ return this.ws?.readyState === ws_1.default.OPEN;
43
+ }
44
+ url() {
45
+ const accessKey = (0, node_crypto_1.createHash)('md5')
46
+ .update(constants_1.FRONTIER_FPID + constants_1.FRONTIER_APP_KEY + this.ctx.deviceId + constants_1.FRONTIER_SALT)
47
+ .digest('hex');
48
+ const q = new URLSearchParams({
49
+ aid: constants_1.AID_FRONTIER,
50
+ fpid: constants_1.FRONTIER_FPID,
51
+ device_platform: 'web',
52
+ version_code: 'fws_1.0.0',
53
+ access_key: accessKey,
54
+ device_id: this.ctx.deviceId,
55
+ // Giá trị cookie đã URL-encode sẵn (`1%7C…`) — giải ra để URLSearchParams không mã hoá hai lần.
56
+ ttwid: decodeURIComponent(this.ctx.cookies.get('ttwid') ?? ''),
57
+ xsack: '1',
58
+ xaack: '1',
59
+ xsqos: '0',
60
+ });
61
+ return `${constants_1.IM_WS}?${q}`;
62
+ }
63
+ /** Mở kết nối. `retryOnClose` (mặc định bật): rớt mạng thì tự nối lại, giãn dần tới 1 phút. */
64
+ start(opts = {}) {
65
+ this.retry = opts.retryOnClose ?? true;
66
+ this.connect();
67
+ }
68
+ stop() {
69
+ this.retry = false;
70
+ this.clearTimers();
71
+ if (this.reconnectTimer)
72
+ clearTimeout(this.reconnectTimer);
73
+ this.reconnectTimer = null;
74
+ const ws = this.ws;
75
+ this.ws = null;
76
+ if (ws && ws.readyState <= ws_1.default.OPEN)
77
+ ws.close(exports.MANUAL_CLOSE, 'manual');
78
+ }
79
+ connect() {
80
+ const ws = new ws_1.default(this.url(), {
81
+ headers: { Cookie: this.ctx.cookieHeader(), 'User-Agent': this.ctx.userAgent, Origin: constants_1.WEB },
82
+ });
83
+ this.ws = ws;
84
+ ws.binaryType = 'nodebuffer';
85
+ ws.on('open', () => {
86
+ this.backoff = RECONNECT_MIN_MS;
87
+ this.heartbeat = setInterval(() => ws.readyState === ws_1.default.OPEN && ws.send('hi'), HEARTBEAT_MS);
88
+ this.touch();
89
+ this.ctx.log('debug', 'tca: websocket đã nối');
90
+ this.emit('connected');
91
+ });
92
+ ws.on('message', (data, isBinary) => {
93
+ this.touch();
94
+ if (!isBinary)
95
+ return; // "hi" đáp lại
96
+ try {
97
+ this.onFrame(ws, data);
98
+ }
99
+ catch (err) {
100
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
101
+ }
102
+ });
103
+ ws.on('error', (err) => this.emit('error', err));
104
+ ws.on('close', (code, reason) => {
105
+ if (this.ws !== ws)
106
+ return; // kết nối cũ đã bị thay
107
+ this.ws = null;
108
+ this.clearTimers();
109
+ this.emit('closed', code, reason.toString());
110
+ if (!this.retry || code === exports.MANUAL_CLOSE)
111
+ return;
112
+ const wait = this.backoff;
113
+ this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
114
+ this.ctx.log('warn', `tca: websocket đóng (${code}) — nối lại sau ${wait / 1000}s`);
115
+ this.reconnectTimer = setTimeout(() => this.connect(), wait);
116
+ });
117
+ }
118
+ touch() {
119
+ if (this.idle)
120
+ clearTimeout(this.idle);
121
+ this.idle = setTimeout(() => {
122
+ this.ctx.log('warn', 'tca: websocket im lặng quá lâu — nối lại');
123
+ this.ws?.terminate();
124
+ }, IDLE_TIMEOUT_MS);
125
+ }
126
+ clearTimers() {
127
+ if (this.heartbeat)
128
+ clearInterval(this.heartbeat);
129
+ if (this.idle)
130
+ clearTimeout(this.idle);
131
+ this.heartbeat = null;
132
+ this.idle = null;
133
+ }
134
+ onFrame(ws, data) {
135
+ const m = proto_1.Msg.parse(data);
136
+ const frame = {
137
+ seq: m.int(1),
138
+ logId: m.int(2),
139
+ headers: m.map(5),
140
+ encoding: m.str(6),
141
+ payload: m.bytes(8),
142
+ };
143
+ if (frame.headers.need_ack === '1')
144
+ this.ack(ws, frame);
145
+ if (frame.payload.length === 0)
146
+ return;
147
+ const payload = frame.encoding === '__lz4' ? (0, lz4_1.lz4Decompress)(frame.payload) : frame.payload;
148
+ const resp = proto_1.Msg.tryParse(payload);
149
+ if (!resp || resp.num(constants_1.Resp.cmd) !== constants_1.Cmd.NewMessage)
150
+ return;
151
+ // NewMessageNotify: {2 conversationId, 3 loại, 4 ?, 5 MessageBody}
152
+ const body = resp.msg(constants_1.Resp.body)?.msg(constants_1.Cmd.NewMessage)?.msg(5);
153
+ if (!body)
154
+ return;
155
+ const msg = (0, message_1.parseMessage)(body, this.ctx.ownId);
156
+ if (!msg.serverMessageId || this.seen.has(msg.serverMessageId))
157
+ return;
158
+ this.seen.add(msg.serverMessageId);
159
+ if (this.seen.size > DEDUPE_KEEP)
160
+ this.seen.delete(this.seen.values().next().value);
161
+ if (msg.kind === 'command')
162
+ this.emit('command', msg);
163
+ else
164
+ this.emit('message', msg);
165
+ }
166
+ /** Báo đã nhận — `ack_id = <giờ base36>:<x_frontier_traceid>`, kèm lại seq/logid của khung. */
167
+ ack(ws, f) {
168
+ const ackId = `${Date.now().toString(36)}:${f.headers.x_frontier_traceid ?? ''}`;
169
+ const out = new proto_1.Writer()
170
+ .int(1, f.seq)
171
+ .int(2, f.logId)
172
+ .int(3, 5)
173
+ .int(4, 1)
174
+ .map(5, { is_ack: '1', ack_id: ackId, ack_code: '0' })
175
+ .bytes(9, ackId)
176
+ .finish();
177
+ if (ws.readyState === ws_1.default.OPEN)
178
+ ws.send(out);
179
+ }
180
+ }
181
+ exports.Listener = Listener;
package/dist/lz4.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Giải nén LZ4 dạng block (không có frame header) — khung websocket có
3
+ * `payload_encoding = "__lz4"` nén payload kiểu này. Không kéo thêm thư viện cho ~30 dòng.
4
+ */
5
+ export declare function lz4Decompress(src: Buffer): Buffer;
package/dist/lz4.js ADDED
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lz4Decompress = lz4Decompress;
4
+ /**
5
+ * Giải nén LZ4 dạng block (không có frame header) — khung websocket có
6
+ * `payload_encoding = "__lz4"` nén payload kiểu này. Không kéo thêm thư viện cho ~30 dòng.
7
+ */
8
+ function lz4Decompress(src) {
9
+ let out = Buffer.alloc(Math.max(src.length * 4, 1024));
10
+ let o = 0;
11
+ const ensure = (need) => {
12
+ if (o + need <= out.length)
13
+ return;
14
+ let size = out.length * 2;
15
+ while (o + need > size)
16
+ size *= 2;
17
+ const bigger = Buffer.alloc(size);
18
+ out.copy(bigger, 0, 0, o);
19
+ out = bigger;
20
+ };
21
+ let i = 0;
22
+ while (i < src.length) {
23
+ const token = src[i++];
24
+ let lit = token >> 4;
25
+ if (lit === 15) {
26
+ let b;
27
+ do {
28
+ b = src[i++];
29
+ lit += b;
30
+ } while (b === 255);
31
+ }
32
+ ensure(lit);
33
+ src.copy(out, o, i, i + lit);
34
+ i += lit;
35
+ o += lit;
36
+ if (i >= src.length)
37
+ break;
38
+ const offset = src[i] | (src[i + 1] << 8);
39
+ i += 2;
40
+ if (offset === 0 || offset > o)
41
+ throw new Error('lz4: offset hỏng');
42
+ let len = token & 15;
43
+ if (len === 15) {
44
+ let b;
45
+ do {
46
+ b = src[i++];
47
+ len += b;
48
+ } while (b === 255);
49
+ }
50
+ len += 4;
51
+ ensure(len);
52
+ // Sao từng byte: vùng nguồn và đích được phép chồng nhau.
53
+ for (let k = 0; k < len; k++, o++)
54
+ out[o] = out[o - offset];
55
+ }
56
+ return out.subarray(0, o);
57
+ }