tca-js 0.0.0-stage → 0.3.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.
@@ -0,0 +1,212 @@
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
+ }
@@ -0,0 +1,109 @@
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 ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "tca-js",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "Unofficial TikTok Chat API for JavaScript — read/send TikTok direct messages (text, images, reactions) and listen over websocket using session cookies, no browser needed.",
5
+ "license": "MIT",
6
+ "author": "rice2411",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rice2411/tca-js.git"
10
+ },
11
+ "homepage": "https://github.com/rice2411/tca-js#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/rice2411/tca-js/issues"
14
+ },
15
+ "keywords": [
16
+ "tiktok",
17
+ "tiktok-api",
18
+ "tiktok-dm",
19
+ "tiktok-chat",
20
+ "direct-message",
21
+ "unofficial",
22
+ "chat",
23
+ "websocket"
24
+ ],
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "scripts": {
34
+ "build": "rm -rf dist && tsc -p tsconfig.json",
35
+ "prepublishOnly": "npm run build",
36
+ "smoke": "node scripts/smoke.mjs"
37
+ },
38
+ "dependencies": {
39
+ "ws": "^8.18.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "@types/ws": "^8.5.11",
44
+ "typescript": "5.9.3"
45
+ }
46
+ }