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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rice2411
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,133 @@
1
- # Temporary Holding Version
1
+ # tca-js — Unofficial TikTok Chat API
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read and send TikTok **direct messages** (text, images, reactions) and receive new messages in real time over
4
+ websocket — **using session cookies only, no browser needed**. Same idea as [`zca-js`](https://github.com/RFS-ADRENO/zca-js)
5
+ for Zalo: it speaks the same protocol as TikTok Web (`tiktok.com/messages`, `tiktok.com/business-suite/messages`).
6
+
7
+ > [!WARNING]
8
+ > This is an **unofficial** library, not affiliated with or endorsed by TikTok / ByteDance. It relies on undocumented
9
+ > internal APIs that can change or break at any time. Automating a TikTok account may violate TikTok's Terms of Service
10
+ > and **can get the account restricted or banned** — use it at your own risk, don't spam.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install tca-js
16
+ ```
17
+
18
+ Node.js ≥ 22. QR login additionally needs a local Chrome / Chromium (see below); cookie login does not.
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { TikTok, loginQR } from 'tca-js';
24
+
25
+ const api = await new TikTok().login(await loginQR({ chromePath, onEvent })); // or saved cookies
26
+ api.listener.on('message', async (msg) => {
27
+ if (msg.isSelf) return;
28
+ if (msg.image) await api.downloadImage(msg); // decrypted image Buffer
29
+ await api.sendMessage(msg, 'Got it, thanks!'); // a message carries its conversation
30
+ });
31
+ api.listener.start(); // reconnects on drop
32
+ await api.sendImage(conv, { data: buf, filename: 'photo.jpg' });
33
+ ```
34
+
35
+ ## API
36
+
37
+ | Method | Does |
38
+ |---|---|
39
+ | `loginQR({chromePath, onEvent, signal})` | QR login through headless Chrome → `Credentials` |
40
+ | `new TikTok().login({cookies, deviceId?, userAgent?})` | validate cookies (`passport/web/account/info`), resolve `wid` as `device_id` → `API` |
41
+ | `api.getConversations(cursor?)` | 10 conversations per page + latest message; page with `cursor` |
42
+ | `api.getMessages(conv, {limit, before})` | messages of a conversation, newest → oldest |
43
+ | `api.getUsers(uids)` | display name, `@username`, avatar |
44
+ | `api.sendMessage(conv, text)` / `api.sendImage(conv, {data, filename})` | send |
45
+ | `api.setReaction(msg, emoji \| null)` | your reaction on a message (one per user, `null` = remove); read via `msg.reactions` |
46
+ | `api.downloadSticker(msg.sticker)` | sticker image (public CDN) |
47
+ | `api.downloadImage(msg)` | download + decrypt AES-256-GCM (listener messages carry no link — looked up from history) |
48
+ | `api.markRead(msg)` · `api.isAlive()` · `api.getCookies()` | |
49
+ | `api.listener` | events `connected` · `message` · `command` (read receipts…) · `closed` · `error` |
50
+
51
+ Errors: `TikTokError.code` — `SESSION_EXPIRED` (get fresh cookies), `IM_ERROR`, `UPLOAD_FAILED`, `DOWNLOAD_FAILED`, `HTTP_ERROR`.
52
+
53
+ ## Session — QR login and cookies
54
+
55
+ ```ts
56
+ import { loginQR, TikTok } from 'tca-js';
57
+ const creds = await loginQR({ chromePath: '/usr/bin/chromium-browser', onEvent: (e) => { /* qr | scanned | confirmed */ } });
58
+ const api = await new TikTok().login(creds); // SAVE creds (cookies) to skip scanning next time
59
+ ```
60
+
61
+ - **Reading / sending / websocket only need cookies.** The `X-Gnarly`, `X-Dynosaur`, `X-Bogus`, `msToken` signatures
62
+ and `tt-ticket-guard-*` headers the page attaches to IM requests are **not checked** by the server.
63
+ - **The QR login API (`passport/web/check_qrconnect`) DOES check signatures** — calling it bare from Node returns
64
+ `error_code 7`. So `loginQR` opens **headless Chrome/Chromium** at `tiktok.com/login/qrcode`, lets the page sign its
65
+ own requests, and only LISTENS via CDP: the QR image comes from the `get_qrcode` response, status
66
+ (`new → scanned → confirmed`, or `expired` → reload for a new QR) from `check_qrconnect`. Once confirmed it reads the
67
+ cookies (`Network.getAllCookies`) and closes Chrome. Works with `--no-sandbox` Chromium on Alpine.
68
+ - Core cookies: `sessionid`, `sid_tt`, `sid_guard`, `uid_tt`, `ttwid`, `odin_tt`, `store-idc`, `tt-target-idc`.
69
+ `sessionid` lives ~6 months. TikTok allows concurrent sessions — logging in here doesn't kick other devices.
70
+ - `device_id` = the `wid` TikTok issues for a `ttwid` (`/api/v1/web-cookie-privacy/config`). HTTP IM accepts any id
71
+ but **the websocket only accepts the real `wid`** (made-up id → 400).
72
+
73
+ ## Protocol (for fixing it when TikTok changes)
74
+
75
+ **HTTP IM** — `POST https://im-api-sg.tiktok.com/<path>?aid=1988&…`, protobuf request and response.
76
+ `Request {1 cmd, 2 seq, 3 sdk "1.8.5", 5 3, 6 0, 7 build, 8 {<cmd>: body}, 9 device_id, 11 "web", 15 map, 18 1}`,
77
+ `Response {1 cmd, 3 status_code, 4 error, 6 {<cmd>: body}}` (`200005` = not logged in).
78
+
79
+ | path | cmd | body |
80
+ |---|---|---|
81
+ | `/v1/message/send` | 100 | `{1 convId, 2 1, 3 shortId, 4 content JSON, 5 ext, 6 type, 7 "deprecated", 8 clientMsgId, [13 image, 14 "private_image", 17 image]}` |
82
+ | `/v2/message/get_by_user_init` | 203 | `{1 offset}` → `{1 messages[], 2 conversations[], 4 next offset, 5 has more}` |
83
+ | `/v1/message/get_by_conversation` | 301 | `{1 convId, 2 1, 3 shortId, 4 1 (older), 5 index anchor, 6 limit}` |
84
+ | `/v3/conversation/mark_read` | 2002 (body at 604) | `{1 convId, 2 shortId, 3 1, 4 index, 11 4}` |
85
+ | `/v1/media/upload_config` | 2059 | empty → ImageX STS keys |
86
+ | `/v1/message/set_property` | 705 | `{1 {1 convId, 2 1, 3 shortId, 4 serverMsgId, 5 clientMsgId, 6 [{1 op 0 add/1 remove, 2 "e:<emoji>", 4 own uid}]}, 2 ticket}` → `{1 status (0 ok, 1 duplicate), 2 version}` |
87
+
88
+ `MessageBody {1 convId, 3 serverMsgId, 4 index, 5 shortId, 6 type, 7 sender, 8 content JSON, 9 ext, 10 time ms, 15 reactions, 18 quote, 20 image/sticker}`.
89
+ Types: `7` text (`{"aweType":0,"text":…}`), `1802` private image, `1805` sticker, `8` shared video, `1` system, `50001` hidden command.
90
+
91
+ **Reactions** (field 15): `{1 "e:<emoji>", 2 {1 [{1 uid, 3 seconds, 4 idempotent_id, 6 µs}]}}`.
92
+ The mobile app writes ❤️ as both `e:❤️` and the legacy `e:love` key (web ignores `e:love`) → `tca-js` merges them into ❤️.
93
+ Web offers 12 emoji, one reaction per user (add the new key + remove your old one in the same 705 command).
94
+ **Stickers** (`1805`, field 20 → 7): `{1 {1 uri, 2 public CDN url, 5 {w,h}}, 200 {1 {2 map a:sticker_id, a:sticker_type (14 stock / 15 custom)…}}}`.
95
+ TikTok Web only DISPLAYS stickers, it has no send button — sending stickers is not implemented.
96
+ 1-1 `convId` = `0:1:<smaller uid>:<larger uid>`.
97
+
98
+ **Websocket** — `wss://im-ws-sg.tiktok.com/ws/v2?aid=1459&fpid=9&access_key=…&device_id=<wid>&ttwid=…`
99
+ + cookies. `access_key = md5("9" + "e1bd35ec9db7b8d846de66ed140b1ad9" + wid + "f8a69f1719916z")`.
100
+ Frame `{1 seq, 2 logid, 3 5, 4 1, 5 headers, 6 encoding ("__lz4" = block compression), 7 "pb", 8 Response}`;
101
+ new message = `Response.cmd 500`, `body[500][5]` is a `MessageBody`. Keep-alive: send text `"hi"` every 15 s.
102
+ Frames with header `need_ack=1` → reply `{1 seq, 2 logid, 3 5, 4 1, 5 {is_ack:1, ack_id, ack_code:0}, 9 ack_id}`,
103
+ `ack_id = <Date.now() base36>:<x_frontier_traceid>`. The server may push a message several times → dedupe by message id.
104
+
105
+ **Sending images** — `upload_config` → ImageX signed with AWS SigV4 (`cn-north-1`/`imagex`): `ApplyImageUpload` →
106
+ `POST https://<UploadHost>/upload/v1/<StoreUri>` (headers `Authorization: <Auth>`, `Content-CRC32`,
107
+ **`X-Storage-U: <uid>`** — missing it gives 4004) → `CommitImageUpload` with `Functions: Encryption cipher_v2`
108
+ → returns `Uri` + `SecretKey` + dimensions → send message type `1802`.
109
+ **Receiving images** — `p0-tiktok-dm-private-sg…` links (need cookies), content is AES-256-GCM:
110
+ `12-byte IV ‖ ciphertext ‖ 16-byte tag`, key = `decrypt_key` (hex) in the message.
111
+
112
+ ## Re-discovering the protocol when it breaks
113
+
114
+ Run Chrome with a debug port and capture traffic over CDP (`Network.*` for every target, including the chat iframe
115
+ `tiktok.com/messages?scene=business`), then print the protobuf tree without a `.proto`. The web app's message bundle
116
+ (`messages-vmok` manifest) ships full protobufjs schemas — the fastest way to find new commands. Requests the browser
117
+ sends are the reference: rebuild them with `Writer`, drop the signatures, check the server still accepts them.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ npm install && npm run build
123
+ node scripts/qr-login.mjs <chromePath> <output dir> # try QR login (writes qr.png)
124
+ node scripts/smoke.mjs --cookies <cookies.json> [--peer <uid>] [--text "…"] [--image <file>] [--listen 10]
125
+ ```
126
+
127
+ `cookies.json` holds a real session — keep it out of the repo (already in `.gitignore`).
128
+
129
+ Source comments are in Vietnamese. Issues and PRs welcome in English or Vietnamese.
130
+
131
+ ## License
132
+
133
+ [MIT](LICENSE)
package/dist/api.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Context } from './context';
2
+ import { Listener } from './listener';
3
+ import type { ConversationPage, ConversationRef, ImageInput, MessagePage, TikTokAccount, TikTokImage, TikTokMessage, TikTokSticker, TikTokUser } from './types';
4
+ /** Kết quả gửi: mã tin phía client (TikTok trả lại đúng mã này). Mã server tới qua listener (`isSelf`). */
5
+ export interface SendResult {
6
+ clientMessageId: string;
7
+ }
8
+ /**
9
+ * Một phiên TikTok đã đăng nhập — tạo bằng `new TikTok().login(credentials)`.
10
+ * Mọi lệnh đi thẳng HTTP/WS tới TikTok bằng cookie, không cần trình duyệt.
11
+ */
12
+ export declare class API {
13
+ private readonly ctx;
14
+ readonly account: TikTokAccount;
15
+ readonly listener: Listener;
16
+ private readonly media;
17
+ constructor(ctx: Context, account: TikTokAccount);
18
+ getOwnId(): string;
19
+ /** Cookie hiện tại — lưu lại để lần sau `login` không cần quét QR. */
20
+ getCookies(): {
21
+ name: string;
22
+ value: string;
23
+ }[];
24
+ getContext(): {
25
+ deviceId: string;
26
+ userAgent: string;
27
+ };
28
+ /** Phiên còn sống không — gọi định kỳ để phát hiện hết hạn sớm. */
29
+ isAlive(): Promise<boolean>;
30
+ /** Hội thoại gần đây + tin mới nhất của chúng, lật trang bằng `cursor`. */
31
+ getConversations(cursor?: string): Promise<ConversationPage>;
32
+ /** Tin của một hội thoại, mới nhất trước. Đọc tiếp tin cũ hơn: `before = <index của tin cũ nhất>`. */
33
+ getMessages(conv: ConversationRef, opts?: {
34
+ limit?: number;
35
+ before?: string;
36
+ }): Promise<MessagePage>;
37
+ /** Hồ sơ người dùng theo uid (tên hiển thị, @username, ảnh đại diện). */
38
+ getUsers(uids: string[]): Promise<TikTokUser[]>;
39
+ /**
40
+ * Tải ảnh đại diện (thử lần lượt các link, bỏ heic). Không gửi cookie / Referer: CDN ảnh khác miền và
41
+ * link đã có chữ ký. `null` nếu không link nào tải được.
42
+ */
43
+ downloadAvatar(user: Pick<TikTokUser, 'avatars'>): Promise<{
44
+ data: Buffer;
45
+ type: string;
46
+ } | null>;
47
+ /** Đánh dấu đã đọc tới tin `msg`. */
48
+ markRead(msg: ConversationRef & {
49
+ index: string;
50
+ }): Promise<void>;
51
+ /** Gửi tin chữ vào hội thoại (thường là `msg` vừa nhận — nó mang sẵn mã hội thoại). */
52
+ sendMessage(conv: ConversationRef, text: string): Promise<SendResult>;
53
+ /** Gửi một ảnh: tải lên kho riêng tư (được mã hoá) rồi gửi tin ảnh. */
54
+ sendImage(conv: ConversationRef, image: ImageInput): Promise<SendResult>;
55
+ /**
56
+ * Đặt reaction CỦA MÌNH trên một tin — như TikTok Web, mỗi người một reaction: `emoji` mới thay cái cũ,
57
+ * `null` = gỡ. Truyền nguyên `msg` vừa đọc (cần `reactions` hiện tại để biết gỡ khoá nào).
58
+ * Gửi `ModifyMessagePropertyRequestBody` (lệnh 705) — xem README.
59
+ */
60
+ setReaction(msg: TikTokMessage, emoji: string | null): Promise<void>;
61
+ /** Tải ảnh sticker (CDN công khai, không mã hoá, không cần cookie). */
62
+ downloadSticker(sticker: Pick<TikTokSticker, 'url'>): Promise<{
63
+ data: Buffer;
64
+ type: string;
65
+ }>;
66
+ /** Tải + giải mã ảnh của một tin (truyền cả `msg` — tin từ listener thiếu link, hàm tự tra lại). */
67
+ downloadImage(source: TikTokMessage | TikTokImage, which?: 'origin' | 'thumb'): Promise<Buffer>;
68
+ /** `SendMessageRequestBody`: {1 convId, 2 loại hội thoại, 3 shortId, 4 nội dung, 5 ext, 6 loại tin, 7 ticket, 8 client id, …}. */
69
+ private send;
70
+ }
71
+ /** Tài khoản của cookie — `null` nếu cookie không còn đăng nhập. */
72
+ export declare function fetchAccount(ctx: Context): Promise<TikTokAccount | null>;
73
+ /** `wid` — mã máy TikTok cấp theo cookie `ttwid`; IM + websocket dùng làm `device_id`. */
74
+ export declare function fetchWebId(ctx: Context): Promise<string>;
75
+ export type { TikTokMessage };
package/dist/api.js ADDED
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.API = void 0;
4
+ exports.fetchAccount = fetchAccount;
5
+ exports.fetchWebId = fetchWebId;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const constants_1 = require("./constants");
8
+ const errors_1 = require("./errors");
9
+ const listener_1 = require("./listener");
10
+ const media_1 = require("./media");
11
+ const message_1 = require("./message");
12
+ const proto_1 = require("./proto");
13
+ /** Mốc "mới nhất" khi đọc lịch sử — lớn hơn mọi `index` (µs). */
14
+ const NEWEST = 9999999999999999n;
15
+ /**
16
+ * Một phiên TikTok đã đăng nhập — tạo bằng `new TikTok().login(credentials)`.
17
+ * Mọi lệnh đi thẳng HTTP/WS tới TikTok bằng cookie, không cần trình duyệt.
18
+ */
19
+ class API {
20
+ ctx;
21
+ account;
22
+ listener;
23
+ media;
24
+ constructor(ctx, account) {
25
+ this.ctx = ctx;
26
+ this.account = account;
27
+ this.listener = new listener_1.Listener(ctx);
28
+ this.media = new media_1.MediaClient(ctx);
29
+ }
30
+ getOwnId() {
31
+ return this.ctx.ownId;
32
+ }
33
+ /** Cookie hiện tại — lưu lại để lần sau `login` không cần quét QR. */
34
+ getCookies() {
35
+ return [...this.ctx.cookies].map(([name, value]) => ({ name, value }));
36
+ }
37
+ getContext() {
38
+ return { deviceId: this.ctx.deviceId, userAgent: this.ctx.userAgent };
39
+ }
40
+ /** Phiên còn sống không — gọi định kỳ để phát hiện hết hạn sớm. */
41
+ async isAlive() {
42
+ return (await fetchAccount(this.ctx).catch(() => null)) !== null;
43
+ }
44
+ // ─── Hộp thư ─────────────────────────────────────────────────────────────
45
+ /** Hội thoại gần đây + tin mới nhất của chúng, lật trang bằng `cursor`. */
46
+ async getConversations(cursor = '0') {
47
+ const body = await this.ctx.im('/v2/message/get_by_user_init', constants_1.Cmd.GetMessagesByUserInit, new proto_1.Writer().int(1, BigInt(cursor)).finish());
48
+ return {
49
+ messages: body.msgs(1).map((m) => (0, message_1.parseMessage)(m, this.ctx.ownId)),
50
+ conversations: body.msgs(2).map((m) => (0, message_1.parseConversation)(m, this.ctx.ownId)),
51
+ // Con trỏ = số hội thoại đã lấy (0, 10, 20…) — trường 4; trường 3 là mốc thời gian, không dùng để lật.
52
+ cursor: body.id(4),
53
+ hasMore: body.num(5) === 1,
54
+ };
55
+ }
56
+ /** Tin của một hội thoại, mới nhất trước. Đọc tiếp tin cũ hơn: `before = <index của tin cũ nhất>`. */
57
+ async getMessages(conv, opts = {}) {
58
+ const limit = Math.min(Math.max(opts.limit ?? 20, 1), 50);
59
+ const body = await this.ctx.im('/v1/message/get_by_conversation', constants_1.Cmd.GetMessagesByConversation, new proto_1.Writer()
60
+ .bytes(1, conv.conversationId)
61
+ .int(2, constants_1.ConversationType.OneToOne)
62
+ .int(3, BigInt(conv.conversationShortId))
63
+ .int(4, 1) // hướng: cũ hơn mốc
64
+ .int(5, opts.before ? BigInt(opts.before) : NEWEST)
65
+ .int(6, limit)
66
+ .finish());
67
+ const messages = body.msgs(1).map((m) => (0, message_1.parseMessage)(m, this.ctx.ownId));
68
+ return { messages, hasMore: messages.length >= limit };
69
+ }
70
+ /** Hồ sơ người dùng theo uid (tên hiển thị, @username, ảnh đại diện). */
71
+ async getUsers(uids) {
72
+ if (uids.length === 0)
73
+ return [];
74
+ const q = new URLSearchParams({
75
+ aid: '1988',
76
+ app_name: 'tiktok_web',
77
+ device_platform: 'web_pc',
78
+ source: 'im_chat',
79
+ from_page: 'message',
80
+ user_ids: JSON.stringify(uids),
81
+ });
82
+ const res = await this.ctx.getJson(`${constants_1.WEB}/tiktok/v1/im/user/profile/?${q}`);
83
+ if (res.status_code)
84
+ throw new errors_1.TikTokError('IM_ERROR', `user/profile: ${res.status_code}`, res.status_code);
85
+ return (res.users ?? []).map(({ im_user_profile: p = {} }) => ({
86
+ uid: p.user_id_str ?? '',
87
+ uniqueId: p.unique_id ?? '',
88
+ nickname: p.nick_name ?? '',
89
+ ...avatarLinks(p.avatars),
90
+ }));
91
+ }
92
+ /**
93
+ * Tải ảnh đại diện (thử lần lượt các link, bỏ heic). Không gửi cookie / Referer: CDN ảnh khác miền và
94
+ * link đã có chữ ký. `null` nếu không link nào tải được.
95
+ */
96
+ async downloadAvatar(user) {
97
+ for (const url of user.avatars) {
98
+ const res = await fetch(url, {
99
+ headers: { 'User-Agent': this.ctx.userAgent },
100
+ referrerPolicy: 'no-referrer',
101
+ signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 20_000),
102
+ }).catch(() => null);
103
+ const type = res?.headers.get('content-type') ?? '';
104
+ if (!res?.ok || !/^image\/(jpeg|png|webp|gif)/.test(type))
105
+ continue;
106
+ return { data: Buffer.from(await res.arrayBuffer()), type };
107
+ }
108
+ return null;
109
+ }
110
+ /** Đánh dấu đã đọc tới tin `msg`. */
111
+ async markRead(msg) {
112
+ await this.ctx.im('/v3/conversation/mark_read', constants_1.Cmd.MarkRead, new proto_1.Writer()
113
+ .bytes(1, msg.conversationId)
114
+ .int(2, BigInt(msg.conversationShortId))
115
+ .int(3, constants_1.ConversationType.OneToOne)
116
+ .int(4, BigInt(msg.index))
117
+ .int(11, 4)
118
+ .finish(), constants_1.MARK_READ_BODY_FIELD);
119
+ }
120
+ // ─── Gửi ──────────────────────────────────────────────────────────────────
121
+ /** Gửi tin chữ vào hội thoại (thường là `msg` vừa nhận — nó mang sẵn mã hội thoại). */
122
+ async sendMessage(conv, text) {
123
+ const cid = (0, node_crypto_1.randomUUID)();
124
+ await this.send(conv, cid, constants_1.MessageType.Text, JSON.stringify({ aweType: 0, text }), null);
125
+ return { clientMessageId: cid };
126
+ }
127
+ /** Gửi một ảnh: tải lên kho riêng tư (được mã hoá) rồi gửi tin ảnh. */
128
+ async sendImage(conv, image) {
129
+ const up = await this.media.upload(image);
130
+ const cid = (0, node_crypto_1.randomUUID)();
131
+ const w = String(up.width);
132
+ const h = String(up.height);
133
+ const media = new proto_1.Writer()
134
+ .msg(1, new proto_1.Writer()
135
+ .bytes(1, up.uri)
136
+ .msg(5, new proto_1.Writer().int(1, up.width).int(2, up.height))
137
+ .bytes(6, up.secretKey)
138
+ .int(7, 0))
139
+ .msg(3, new proto_1.Writer()
140
+ .msg(1, new proto_1.Writer().bytes(1, 'You sent a 📷'))
141
+ .msg(2, new proto_1.Writer().bytes(1, 'sent a 📷'))
142
+ .msg(3, new proto_1.Writer().bytes(1, '[Photo]')))
143
+ .msg(200, new proto_1.Writer().msg(1, new proto_1.Writer().bytes(1, up.uri).map(2, {
144
+ image_width: w,
145
+ image_height: h,
146
+ decrypt_key: up.secretKey,
147
+ quote_preview: 'dm_cam_preview_photo',
148
+ sender_preview: 'sender_preview',
149
+ })));
150
+ const extra = new proto_1.Writer()
151
+ .msg(13, new proto_1.Writer().msg(4, media))
152
+ .bytes(14, 'private_image')
153
+ .msg(17, new proto_1.Writer()
154
+ .int(1, 1)
155
+ .bytes(2, up.uri)
156
+ .bytes(3, up.secretKey)
157
+ .msg(10, new proto_1.Writer().int(1, up.width).int(2, up.height).int(4, up.size).bytes(5, up.filename)));
158
+ await this.send(conv, cid, constants_1.MessageType.PrivateImage, '', extra);
159
+ return { clientMessageId: cid };
160
+ }
161
+ /**
162
+ * Đặt reaction CỦA MÌNH trên một tin — như TikTok Web, mỗi người một reaction: `emoji` mới thay cái cũ,
163
+ * `null` = gỡ. Truyền nguyên `msg` vừa đọc (cần `reactions` hiện tại để biết gỡ khoá nào).
164
+ * Gửi `ModifyMessagePropertyRequestBody` (lệnh 705) — xem README.
165
+ */
166
+ async setReaction(msg, emoji) {
167
+ const own = this.ctx.ownId;
168
+ const mine = msg.reactions.filter((r) => r.userIds.includes(own)).map((r) => r.emoji);
169
+ const ops = [];
170
+ if (emoji && !mine.includes(emoji))
171
+ ops.push({ op: constants_1.PropertyOp.Add, key: constants_1.REACTION_PREFIX + emoji });
172
+ for (const e of mine) {
173
+ if (e === emoji)
174
+ continue;
175
+ ops.push({ op: constants_1.PropertyOp.Remove, key: constants_1.REACTION_PREFIX + e });
176
+ // ❤️ thả từ app điện thoại còn nằm ở khoá cũ `e:love` — gỡ luôn kẻo app vẫn hiện tim.
177
+ if (e === constants_1.REACTION_HEART)
178
+ ops.push({ op: constants_1.PropertyOp.Remove, key: constants_1.REACTION_LOVE_KEY });
179
+ }
180
+ if (ops.length === 0)
181
+ return;
182
+ const target = new proto_1.Writer()
183
+ .bytes(1, msg.conversationId)
184
+ .int(2, constants_1.ConversationType.OneToOne)
185
+ .int(3, BigInt(msg.conversationShortId))
186
+ .int(4, BigInt(msg.serverMessageId))
187
+ .bytes(5, msg.clientMessageId);
188
+ for (const o of ops)
189
+ target.msg(6, new proto_1.Writer().int(1, o.op).bytes(2, o.key).bytes(4, own));
190
+ const body = await this.ctx.im('/v1/message/set_property', constants_1.Cmd.SetMessageProperty, new proto_1.Writer().msg(1, target).bytes(2, 'deprecated').finish());
191
+ // status: 0 thành công, 1 gửi trùng (cũng là xong); còn lại TikTok từ chối.
192
+ const status = body.num(1);
193
+ if (status > 1)
194
+ throw new errors_1.TikTokError('IM_ERROR', `set_property: status ${status}`, status);
195
+ }
196
+ /** Tải ảnh sticker (CDN công khai, không mã hoá, không cần cookie). */
197
+ async downloadSticker(sticker) {
198
+ const res = await fetch(sticker.url, {
199
+ headers: { 'User-Agent': this.ctx.userAgent },
200
+ referrerPolicy: 'no-referrer',
201
+ signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 20_000),
202
+ }).catch(() => null);
203
+ if (!res?.ok)
204
+ throw new errors_1.TikTokError('DOWNLOAD_FAILED', `Tải sticker lỗi: ${res?.status ?? 'mạng'}`);
205
+ return { data: Buffer.from(await res.arrayBuffer()), type: res.headers.get('content-type') ?? 'image/webp' };
206
+ }
207
+ /** Tải + giải mã ảnh của một tin (truyền cả `msg` — tin từ listener thiếu link, hàm tự tra lại). */
208
+ async downloadImage(source, which = 'origin') {
209
+ let image = 'serverMessageId' in source ? source.image : source;
210
+ // Tin đẩy qua websocket chỉ có uri + khoá, không có link tải → tra lại lịch sử theo mã tin.
211
+ if ('serverMessageId' in source && image && !image.url) {
212
+ const { messages } = await this.getMessages(source, { limit: 5, before: (BigInt(source.index) + 1n).toString() });
213
+ image = messages.find((m) => m.serverMessageId === source.serverMessageId)?.image ?? image;
214
+ }
215
+ if (!image)
216
+ throw new errors_1.TikTokError('DOWNLOAD_FAILED', 'Tin không có ảnh');
217
+ return this.media.download(image, which);
218
+ }
219
+ /** `SendMessageRequestBody`: {1 convId, 2 loại hội thoại, 3 shortId, 4 nội dung, 5 ext, 6 loại tin, 7 ticket, 8 client id, …}. */
220
+ async send(conv, cid, type, content, extra) {
221
+ if (!conv.conversationId || !conv.conversationShortId) {
222
+ throw new errors_1.TikTokError('IM_ERROR', 'Thiếu conversationId / conversationShortId');
223
+ }
224
+ const w = new proto_1.Writer()
225
+ .bytes(1, conv.conversationId)
226
+ .int(2, constants_1.ConversationType.OneToOne)
227
+ .int(3, BigInt(conv.conversationShortId))
228
+ .bytes(4, content)
229
+ .map(5, { 's:mentioned_users': '', 's:client_message_id': cid })
230
+ .int(6, type)
231
+ .bytes(7, 'deprecated')
232
+ .bytes(8, cid);
233
+ const body = extra ? Buffer.concat([w.finish(), extra.finish()]) : w.finish();
234
+ await this.ctx.im('/v1/message/send', constants_1.Cmd.SendMessage, body);
235
+ }
236
+ }
237
+ exports.API = API;
238
+ /** Tài khoản của cookie — `null` nếu cookie không còn đăng nhập. */
239
+ async function fetchAccount(ctx) {
240
+ const res = await ctx.getJson(`${constants_1.WEB}/passport/web/account/info/?aid=${constants_1.AID_FRONTIER}`);
241
+ if (res.message !== 'success' || !res.data?.user_id_str)
242
+ return null;
243
+ return {
244
+ uid: res.data.user_id_str,
245
+ username: res.data.username ?? '',
246
+ screenName: res.data.screen_name ?? '',
247
+ avatar: res.data.avatar_url ?? '',
248
+ };
249
+ }
250
+ /**
251
+ * Link ảnh đại diện trong `avatars` của hồ sơ IM — gom mọi chuỗi `https://…` (cỡ thumb → medium → larger, tuỳ
252
+ * TikTok trả gì), rồi xếp bản trình duyệt đọc được (jpeg/webp/png) lên trước, heic xuống cuối.
253
+ */
254
+ function avatarLinks(avatars) {
255
+ const urls = [];
256
+ const walk = (v) => {
257
+ if (typeof v === 'string') {
258
+ if (/^https?:\/\//.test(v) && !urls.includes(v))
259
+ urls.push(v);
260
+ }
261
+ else if (Array.isArray(v))
262
+ v.forEach(walk);
263
+ else if (v && typeof v === 'object')
264
+ Object.values(v).forEach(walk);
265
+ };
266
+ walk(avatars);
267
+ const heic = (u) => /\.heic(\?|$)/i.test(u.split('?')[0] + '?');
268
+ const ranked = [...urls.filter((u) => !heic(u)), ...urls.filter(heic)];
269
+ return { avatar: ranked[0] ?? '', avatars: ranked };
270
+ }
271
+ /** `wid` — mã máy TikTok cấp theo cookie `ttwid`; IM + websocket dùng làm `device_id`. */
272
+ async function fetchWebId(ctx) {
273
+ const res = await ctx.fetch(`${constants_1.WEB}/api/v1/web-cookie-privacy/config?locale=vi-VN&appId=1988&theme=default&tea=1`);
274
+ if (!res.ok)
275
+ return '';
276
+ return /"wid":"(\d+)"/.exec(await res.text())?.[1] ?? '';
277
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Hằng giao thức IM của TikTok Web — dựng lại từ lưu lượng `tiktok.com/business-suite/messages`
3
+ * (09/11/2026). TikTok đổi phía họ thì sửa ở đây; ghi lại cách dò ra ở README.
4
+ */
5
+ export declare const IM_API = "https://im-api-sg.tiktok.com";
6
+ export declare const IM_WS = "wss://im-ws-sg.tiktok.com/ws/v2";
7
+ export declare const WEB = "https://www.tiktok.com";
8
+ export declare const 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";
9
+ /** App id của TikTok Web (HTTP IM). */
10
+ export declare const AID_WEB = "1988";
11
+ /** App id kênh "frontier" — websocket đẩy tin mới. */
12
+ export declare const AID_FRONTIER = "1459";
13
+ export declare const SDK_VERSION = "1.8.5";
14
+ export declare const BUILD_NUMBER = "feffaa1:master";
15
+ /** `access_key` của websocket = md5(FPID + APP_KEY + device_id + SALT). */
16
+ export declare const FRONTIER_FPID = "9";
17
+ export declare const FRONTIER_APP_KEY = "e1bd35ec9db7b8d846de66ed140b1ad9";
18
+ export declare const FRONTIER_SALT = "f8a69f1719916z";
19
+ /** Lệnh IM (`Request.cmd`) — đồng thời là số trường của body trong `RequestBody`. */
20
+ export declare const Cmd: {
21
+ readonly SendMessage: 100;
22
+ readonly GetMessagesByUserInit: 203;
23
+ readonly GetMessagesByConversation: 301;
24
+ /** Websocket đẩy tin mới. */
25
+ readonly NewMessage: 500;
26
+ /** Thả / gỡ reaction (`property` của tin) — `/v1/message/set_property`. */
27
+ readonly SetMessageProperty: 705;
28
+ readonly GetConversationInfo: 610;
29
+ readonly MarkRead: 2002;
30
+ readonly UploadConfig: 2059;
31
+ };
32
+ /** Body của MarkRead nằm ở trường 604, không trùng số lệnh. */
33
+ export declare const MARK_READ_BODY_FIELD = 604;
34
+ /** `MessageBody.message_type`. */
35
+ export declare const MessageType: {
36
+ readonly System: 1;
37
+ readonly Text: 7;
38
+ readonly ShareVideo: 8;
39
+ readonly PrivateImage: 1802;
40
+ /** Sticker (nhãn dán) — ảnh công khai trên CDN sticker, không mã hoá. */
41
+ readonly Sticker: 1805;
42
+ /** Lệnh ẩn (đã đọc…) — không hiện trong hội thoại, websocket vẫn đẩy. */
43
+ readonly Command: 50001;
44
+ };
45
+ export declare const ConversationType: {
46
+ readonly OneToOne: 1;
47
+ };
48
+ /** Trường của `Response` (cả HTTP lẫn payload websocket). */
49
+ export declare const Resp: {
50
+ readonly cmd: 1;
51
+ readonly seq: 2;
52
+ readonly status: 3;
53
+ readonly error: 4;
54
+ readonly body: 6;
55
+ readonly logId: 7;
56
+ };
57
+ /** Trường của `MessageBody`. */
58
+ export declare const MsgF: {
59
+ readonly conversationId: 1;
60
+ readonly conversationType: 2;
61
+ readonly serverMessageId: 3;
62
+ readonly indexInConversation: 4;
63
+ readonly conversationShortId: 5;
64
+ readonly type: 6;
65
+ readonly sender: 7;
66
+ readonly content: 8;
67
+ readonly ext: 9;
68
+ readonly createTime: 10;
69
+ /** `property_list` — reaction: `{1 khoá "e:<emoji>", 2 {1 PropertyItem[]}}`. */
70
+ readonly propertyList: 15;
71
+ readonly attachments: 20;
72
+ readonly tag: 21;
73
+ };
74
+ /** Thao tác trên `property` của tin (`ModifyPropertyContent.operation`). */
75
+ export declare const PropertyOp: {
76
+ readonly Add: 0;
77
+ readonly Remove: 1;
78
+ };
79
+ /** Khoá reaction = `e:<emoji>`. App điện thoại thả ❤️ còn ghi thêm khoá cũ `e:love` — coi như ❤️. */
80
+ export declare const REACTION_PREFIX = "e:";
81
+ export declare const REACTION_LOVE_KEY = "e:love";
82
+ export declare const REACTION_HEART = "\u2764\uFE0F";
83
+ /** ImageX — nơi tải ảnh lên (ký kiểu AWS SigV4). */
84
+ export declare const IMAGEX_REGION = "cn-north-1";
85
+ export declare const IMAGEX_SERVICE = "imagex";
86
+ export declare const IMAGEX_VERSION = "2018-08-01";