tca-js 0.3.1 → 0.4.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/dist/proto.js DELETED
@@ -1,162 +0,0 @@
1
- "use strict";
2
- /**
3
- * Minimal protobuf, no `.proto` file needed.
4
- *
5
- * TikTok doesn't publish its IM `.proto` — field numbers in `constants.ts` were reconstructed from bytes captured
6
- * in the browser (09/11/2026). Only three wire types are needed: varint (0), length-delimited (2), and skipping 1/5.
7
- */
8
- Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.Writer = exports.Msg = void 0;
10
- exports.decodeFields = decodeFields;
11
- function readVarint(b, i) {
12
- let x = 0n;
13
- let shift = 0n;
14
- let c;
15
- do {
16
- if (i >= b.length)
17
- throw new Error('varint runs past end of buffer');
18
- c = b[i++];
19
- x |= BigInt(c & 127) << shift;
20
- shift += 7n;
21
- } while (c & 128);
22
- return [x, i];
23
- }
24
- /** Split a message into a list of fields. `null` if the bytes are not valid protobuf. */
25
- function decodeFields(b) {
26
- const out = [];
27
- let i = 0;
28
- try {
29
- while (i < b.length) {
30
- const [key, j] = readVarint(b, i);
31
- i = j;
32
- const wire = Number(key & 7n);
33
- const n = Number(key >> 3n);
34
- if (n === 0)
35
- return null;
36
- if (wire === 0) {
37
- const [v, k] = readVarint(b, i);
38
- out.push({ n, wire, int: v, bytes: Buffer.alloc(0) });
39
- i = k;
40
- }
41
- else if (wire === 2) {
42
- const [len, k] = readVarint(b, i);
43
- const end = k + Number(len);
44
- if (end > b.length)
45
- return null;
46
- out.push({ n, wire, int: 0n, bytes: b.subarray(k, end) });
47
- i = end;
48
- }
49
- else if (wire === 1 || wire === 5) {
50
- const size = wire === 1 ? 8 : 4;
51
- if (i + size > b.length)
52
- return null;
53
- out.push({ n, wire, int: 0n, bytes: b.subarray(i, i + size) });
54
- i += size;
55
- }
56
- else {
57
- return null;
58
- }
59
- }
60
- }
61
- catch {
62
- return null;
63
- }
64
- return out;
65
- }
66
- /** Decoded message — read by field number. */
67
- class Msg {
68
- fields;
69
- constructor(fields) {
70
- this.fields = fields;
71
- }
72
- static parse(b) {
73
- const f = decodeFields(b);
74
- if (!f)
75
- throw new Error('Failed to decode protobuf');
76
- return new Msg(f);
77
- }
78
- static tryParse(b) {
79
- if (!b)
80
- return null;
81
- const f = decodeFields(b);
82
- return f ? new Msg(f) : null;
83
- }
84
- has(n) {
85
- return this.fields.some((f) => f.n === n);
86
- }
87
- int(n) {
88
- return this.fields.find((f) => f.n === n)?.int ?? 0n;
89
- }
90
- num(n) {
91
- return Number(this.int(n));
92
- }
93
- /** 64-bit integer as a string — uids / message IDs exceed `Number.MAX_SAFE_INTEGER`. */
94
- id(n) {
95
- return this.int(n).toString();
96
- }
97
- bytes(n) {
98
- return this.fields.find((f) => f.n === n)?.bytes ?? Buffer.alloc(0);
99
- }
100
- str(n) {
101
- return this.bytes(n).toString('utf8');
102
- }
103
- strs(n) {
104
- return this.fields.filter((f) => f.n === n).map((f) => f.bytes.toString('utf8'));
105
- }
106
- msg(n) {
107
- return Msg.tryParse(this.fields.find((f) => f.n === n)?.bytes);
108
- }
109
- msgs(n) {
110
- return this.fields
111
- .filter((f) => f.n === n)
112
- .map((f) => Msg.tryParse(f.bytes))
113
- .filter((m) => m !== null);
114
- }
115
- /** `map<string,string>` — each entry is a message `{1: key, 2: value}`. */
116
- map(n) {
117
- const out = {};
118
- for (const m of this.msgs(n))
119
- out[m.str(1)] = m.str(2);
120
- return out;
121
- }
122
- }
123
- exports.Msg = Msg;
124
- function varint(x) {
125
- let v = BigInt(x);
126
- if (v < 0n)
127
- v = BigInt.asUintN(64, v);
128
- const out = [];
129
- do {
130
- const c = Number(v & 127n);
131
- v >>= 7n;
132
- out.push(v ? c | 128 : c);
133
- } while (v);
134
- return Buffer.from(out);
135
- }
136
- const key = (n, wire) => varint((BigInt(n) << 3n) | BigInt(wire));
137
- /** Build a message in call order. */
138
- class Writer {
139
- parts = [];
140
- int(n, v) {
141
- this.parts.push(key(n, 0), varint(typeof v === 'string' ? BigInt(v) : v));
142
- return this;
143
- }
144
- bytes(n, v) {
145
- const b = typeof v === 'string' ? Buffer.from(v, 'utf8') : v;
146
- this.parts.push(key(n, 2), varint(b.length), b);
147
- return this;
148
- }
149
- msg(n, w) {
150
- return this.bytes(n, w.finish());
151
- }
152
- /** `map<string,string>` in the object's key order. */
153
- map(n, obj) {
154
- for (const [k, v] of Object.entries(obj))
155
- this.msg(n, new Writer().bytes(1, k).bytes(2, v));
156
- return this;
157
- }
158
- finish() {
159
- return Buffer.concat(this.parts);
160
- }
161
- }
162
- exports.Writer = Writer;
@@ -1,41 +0,0 @@
1
- import type { Credentials } from './types';
2
- /**
3
- * QR login via headless Chrome.
4
- *
5
- * Why a browser: TikTok's QR API (`passport/web/check_qrconnect`) requires a JS signature
6
- * (X-Gnarly / msToken) — calling it bare from Node yields `error_code 7`. So we open the real
7
- * `tiktok.com/login/qrcode` page in headless Chrome and let it sign itself; we only LISTEN to
8
- * responses via CDP: the QR image from the `get_qrcode` body, the status from `check_qrconnect`.
9
- * After login we grab the cookies and close Chrome — everything after (`login`, chat) needs no browser.
10
- */
11
- export type QrLoginEvent =
12
- /** New QR image (`data:image/png;base64,…`) — show it to the user to scan. A new one comes on expiry. */
13
- {
14
- type: 'qr';
15
- image: string;
16
- expiresAt: number;
17
- }
18
- /** Scanned, waiting for confirmation on the phone. */
19
- | {
20
- type: 'scanned';
21
- }
22
- /** Confirmed — fetching cookies. */
23
- | {
24
- type: 'confirmed';
25
- };
26
- export interface QrLoginOptions {
27
- /** Path to Chrome / Chromium (e.g. `/usr/bin/chromium-browser`). */
28
- chromePath: string;
29
- onEvent?: (e: QrLoginEvent) => void;
30
- signal?: AbortSignal;
31
- /** Total time to wait for a scan (ms) — default 5 minutes. */
32
- timeoutMs?: number;
33
- /** When the QR expires (~1 minute), regenerate it at most this many times. */
34
- maxRefresh?: number;
35
- userAgent?: string;
36
- }
37
- /**
38
- * Log in to TikTok via QR. Returns `Credentials` (`.tiktok.com` cookies + UA) — pass them to
39
- * `new TikTok().login(creds)` and SAVE them so you don't need to scan next time.
40
- */
41
- export declare function loginQR(opts: QrLoginOptions): Promise<Credentials>;
package/dist/qr-login.js DELETED
@@ -1,212 +0,0 @@
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.loginQR = loginQR;
7
- const node_child_process_1 = require("node:child_process");
8
- const promises_1 = require("node:fs/promises");
9
- const node_os_1 = require("node:os");
10
- const node_path_1 = require("node:path");
11
- const ws_1 = __importDefault(require("ws"));
12
- const constants_1 = require("./constants");
13
- const errors_1 = require("./errors");
14
- /** Attach CDP to a new tab (a flat session on the browser websocket). */
15
- async function openTab(browserWs) {
16
- const ws = new ws_1.default(browserWs, { perMessageDeflate: false });
17
- await new Promise((resolve, reject) => {
18
- ws.once('open', () => resolve());
19
- ws.once('error', reject);
20
- });
21
- let id = 0;
22
- let sessionId;
23
- const pending = new Map();
24
- const handlers = [];
25
- ws.on('message', (raw) => {
26
- const m = JSON.parse(raw.toString());
27
- if (m.id !== undefined && pending.has(m.id)) {
28
- const p = pending.get(m.id);
29
- pending.delete(m.id);
30
- if (m.error)
31
- p.reject(new Error(m.error.message));
32
- else
33
- p.resolve(m.result ?? {});
34
- return;
35
- }
36
- if (m.method && (!m.sessionId || m.sessionId === sessionId))
37
- for (const h of handlers)
38
- h(m.method, m.params ?? {});
39
- });
40
- const raw = (method, params = {}, sid) => new Promise((resolve, reject) => {
41
- const i = ++id;
42
- pending.set(i, { resolve: resolve, reject });
43
- ws.send(JSON.stringify({ id: i, method, params, ...(sid ? { sessionId: sid } : {}) }));
44
- });
45
- const target = await raw('Target.createTarget', { url: 'about:blank' });
46
- sessionId = (await raw('Target.attachToTarget', { targetId: target.targetId, flatten: true })).sessionId;
47
- return {
48
- send: (method, params) => raw(method, params, sessionId),
49
- on: (h) => handlers.push(h),
50
- close: () => ws.close(),
51
- };
52
- }
53
- /** Launch headless Chrome and wait for the "DevTools listening on ws://…" line. */
54
- async function launchChrome(chromePath, userAgent) {
55
- const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'tca-chrome-'));
56
- const proc = (0, node_child_process_1.spawn)(chromePath, [
57
- '--headless=new',
58
- '--remote-debugging-port=0',
59
- `--user-data-dir=${dir}`,
60
- '--no-first-run',
61
- '--no-default-browser-check',
62
- '--disable-gpu',
63
- '--disable-dev-shm-usage',
64
- // Containers run as root — Chromium requires disabling the sandbox.
65
- '--no-sandbox',
66
- '--window-size=1280,900',
67
- '--lang=vi-VN',
68
- // The default headless UA contains "HeadlessChrome" — replace it with a normal UA.
69
- `--user-agent=${userAgent}`,
70
- 'about:blank',
71
- ], { stdio: ['ignore', 'ignore', 'pipe'] });
72
- const ws = await new Promise((resolve, reject) => {
73
- let buf = '';
74
- const timer = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20_000);
75
- proc.stderr.on('data', (d) => {
76
- buf += d.toString();
77
- const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
78
- if (m) {
79
- clearTimeout(timer);
80
- resolve(m[1]);
81
- }
82
- });
83
- proc.once('error', (e) => {
84
- clearTimeout(timer);
85
- reject(e);
86
- });
87
- proc.once('exit', (code) => {
88
- clearTimeout(timer);
89
- reject(new Error(`Chrome exited (${code}) before it was ready`));
90
- });
91
- }).catch(async (err) => {
92
- proc.kill('SIGKILL');
93
- await (0, promises_1.rm)(dir, { recursive: true, force: true });
94
- throw new errors_1.TikTokError('LOGIN_FAILED', `Failed to launch Chrome: ${err instanceof Error ? err.message : String(err)}`);
95
- });
96
- return { proc, ws, dir };
97
- }
98
- /**
99
- * Log in to TikTok via QR. Returns `Credentials` (`.tiktok.com` cookies + UA) — pass them to
100
- * `new TikTok().login(creds)` and SAVE them so you don't need to scan next time.
101
- */
102
- async function loginQR(opts) {
103
- const userAgent = opts.userAgent || constants_1.DEFAULT_USER_AGENT;
104
- const maxRefresh = opts.maxRefresh ?? 3;
105
- const { proc, ws, dir } = await launchChrome(opts.chromePath, userAgent);
106
- let cdp = null;
107
- try {
108
- cdp = await openTab(ws);
109
- const tab = cdp;
110
- await tab.send('Network.enable');
111
- await tab.send('Page.enable');
112
- const result = new Promise((resolve, reject) => {
113
- const urlById = new Map();
114
- let refreshes = 0;
115
- let scanned = false;
116
- let done = false;
117
- const finish = (err, creds) => {
118
- if (done)
119
- return;
120
- done = true;
121
- clearTimeout(timer);
122
- opts.signal?.removeEventListener('abort', onAbort);
123
- if (err)
124
- reject(err);
125
- else
126
- resolve(creds);
127
- };
128
- const timer = setTimeout(() => finish(new errors_1.TikTokError('LOGIN_FAILED', 'Timed out waiting for QR scan')), opts.timeoutMs ?? 5 * 60_000);
129
- const onAbort = () => finish(new errors_1.TikTokError('LOGIN_ABORTED', 'QR login aborted'));
130
- opts.signal?.addEventListener('abort', onAbort);
131
- const body = async (requestId) => {
132
- const r = await tab.send('Network.getResponseBody', { requestId }).catch(() => null);
133
- if (!r)
134
- return null;
135
- try {
136
- return JSON.parse(r.base64Encoded ? Buffer.from(r.body, 'base64').toString() : r.body);
137
- }
138
- catch {
139
- return null;
140
- }
141
- };
142
- /** After `confirmed`: the page redirects and sets session cookies — wait for `sessionid`. */
143
- const collectCookies = async () => {
144
- for (let i = 0; i < 30 && !done; i++) {
145
- const { cookies } = await tab.send('Network.getAllCookies');
146
- const mine = cookies.filter((c) => c.domain.endsWith('tiktok.com'));
147
- if (mine.some((c) => c.name === 'sessionid' && c.value) && mine.some((c) => c.name === 'ttwid')) {
148
- // Same name on `.tiktok.com` and `www.tiktok.com` → keep the `.tiktok.com` one.
149
- const byName = new Map();
150
- for (const c of mine.sort((a, b) => (a.domain === '.tiktok.com' ? 1 : 0) - (b.domain === '.tiktok.com' ? 1 : 0))) {
151
- byName.set(c.name, { name: c.name, value: c.value });
152
- }
153
- return finish(null, { cookies: [...byName.values()], userAgent });
154
- }
155
- await new Promise((r) => setTimeout(r, 1000));
156
- }
157
- finish(new errors_1.TikTokError('LOGIN_FAILED', 'Confirmed but no sessionid cookie found'));
158
- };
159
- tab.on((method, p) => {
160
- if (done)
161
- return;
162
- if (method === 'Network.responseReceived') {
163
- const res = p.response;
164
- urlById.set(p.requestId, res.url);
165
- return;
166
- }
167
- if (method !== 'Network.loadingFinished')
168
- return;
169
- const url = urlById.get(p.requestId) ?? '';
170
- if (url.includes('/passport/web/get_qrcode/')) {
171
- void body(p.requestId).then((j) => {
172
- const qr = j?.data?.qrcode;
173
- if (typeof qr === 'string' && qr) {
174
- opts.onEvent?.({
175
- type: 'qr',
176
- image: `data:image/png;base64,${qr}`,
177
- expiresAt: Number(j?.data?.expire_time ?? 0) * 1000 || Date.now() + 60_000,
178
- });
179
- }
180
- });
181
- }
182
- else if (url.includes('/passport/web/check_qrconnect/')) {
183
- // The page polls 3 regions in parallel (sg/us/eu) — only the account's region changes status, the rest stay "new".
184
- void body(p.requestId).then((j) => {
185
- const status = String(j?.data?.status ?? '');
186
- if (status === 'scanned' && !scanned) {
187
- scanned = true;
188
- opts.onEvent?.({ type: 'scanned' });
189
- }
190
- else if (status === 'confirmed') {
191
- opts.onEvent?.({ type: 'confirmed' });
192
- void collectCookies();
193
- }
194
- else if (status === 'expired' && !scanned) {
195
- if (refreshes++ >= maxRefresh)
196
- finish(new errors_1.TikTokError('LOGIN_FAILED', 'QR expired without being scanned'));
197
- else
198
- void tab.send('Page.reload');
199
- }
200
- });
201
- }
202
- });
203
- });
204
- await tab.send('Page.navigate', { url: `${constants_1.WEB}/login/qrcode` });
205
- return await result;
206
- }
207
- finally {
208
- cdp?.close();
209
- proc.kill('SIGKILL');
210
- await (0, promises_1.rm)(dir, { recursive: true, force: true }).catch(() => undefined);
211
- }
212
- }
package/dist/types.d.ts DELETED
@@ -1,109 +0,0 @@
1
- /** A cookie — same shape as `chrome.cookies.getAll` / CDP `Storage.getCookies` returns (extra fields are fine). */
2
- export interface CookieInput {
3
- name: string;
4
- value: string;
5
- }
6
- /** What you need to log in again — save it after each login. */
7
- export interface Credentials {
8
- /** `.tiktok.com` cookies: array of `{name, value}`, object `{name: value}`, or string `a=b; c=d`. */
9
- cookies: CookieInput[] | Record<string, string> | string;
10
- /** IM device id (`wid`) — leave empty and `login` asks TikTok using `ttwid`. Only used when that fails. */
11
- deviceId?: string;
12
- userAgent?: string;
13
- }
14
- export interface TikTokOptions {
15
- /** Debug logging — silent by default. */
16
- logger?: (level: 'debug' | 'warn', msg: string) => void;
17
- /** Timeout per HTTP request (ms). */
18
- timeoutMs?: number;
19
- }
20
- /** Enough to target a conversation: send, read, mark as read. Every `TikTokMessage` satisfies it. */
21
- export interface ConversationRef {
22
- conversationId: string;
23
- conversationShortId: string;
24
- }
25
- export interface TikTokImage {
26
- /** Image id in TikTok storage (encrypted). */
27
- uri: string;
28
- /** Original download link — needs cookies, content is AES-256-GCM. */
29
- url: string;
30
- thumbUrl: string;
31
- width: number;
32
- height: number;
33
- /** AES-256-GCM key (hex) to decrypt `url` — `api.downloadImage` handles this. */
34
- decryptKey: string;
35
- }
36
- /** Sticker — including user-made stickers (`stickerType` 15). Public CDN link, signed + expires in a few days. */
37
- export interface TikTokSticker {
38
- stickerId: string;
39
- /** 14 = built-in TikTok sticker, 15 = user-made sticker (memes, AI…). */
40
- stickerType: number;
41
- url: string;
42
- width: number;
43
- height: number;
44
- }
45
- /** A reaction on a message: emoji + users who reacted (uid). The mobile app's `e:love` is merged into ❤️. */
46
- export interface TikTokReaction {
47
- emoji: string;
48
- userIds: string[];
49
- }
50
- export interface TikTokMessage extends ConversationRef {
51
- serverMessageId: string;
52
- /** Position in the conversation (µs) — cursor for paging older messages. */
53
- index: string;
54
- /** `MessageType` — 7 text, 1802 image, 1805 sticker, 8 video share, 1 system. */
55
- type: number;
56
- /** `command` = hidden command (read receipt…) — listener emits it via the `command` event, not `message`. */
57
- kind: 'text' | 'image' | 'sticker' | 'video' | 'system' | 'command' | 'other';
58
- senderId: string;
59
- isSelf: boolean;
60
- /** Text content (`kind = text`), empty for other kinds. */
61
- text: string;
62
- image: TikTokImage | null;
63
- sticker: TikTokSticker | null;
64
- reactions: TikTokReaction[];
65
- /** Raw JSON content (`{"aweType":0,"text":"…"}`…). */
66
- content: unknown;
67
- /** Send time, ms epoch. */
68
- createdAt: number;
69
- clientMessageId: string;
70
- ext: Record<string, string>;
71
- }
72
- export interface TikTokConversation extends ConversationRef {
73
- participants: string[];
74
- /** The other participant in a 1-1 conversation. */
75
- peerId: string;
76
- }
77
- export interface TikTokUser {
78
- uid: string;
79
- /** `@username` (without @). */
80
- uniqueId: string;
81
- nickname: string;
82
- /** Best avatar (prefers jpeg/webp/png, avoids heic which browsers can't read). */
83
- avatar: string;
84
- /** All avatar links TikTok returns, by priority — signed, expiring links; `downloadAvatar` tries them in order. */
85
- avatars: string[];
86
- }
87
- export interface TikTokAccount {
88
- uid: string;
89
- username: string;
90
- screenName: string;
91
- avatar: string;
92
- }
93
- /** Image attachment. */
94
- export interface ImageInput {
95
- data: Buffer;
96
- /** Only the extension matters so TikTok knows the format: `photo.jpg`, `bill.png`. */
97
- filename: string;
98
- }
99
- export interface MessagePage {
100
- messages: TikTokMessage[];
101
- hasMore: boolean;
102
- }
103
- export interface ConversationPage {
104
- conversations: TikTokConversation[];
105
- /** Latest messages included — handy for building an inbox list. */
106
- messages: TikTokMessage[];
107
- cursor: string;
108
- hasMore: boolean;
109
- }
package/dist/types.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });