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.
- package/LICENSE +21 -0
- package/README.md +216 -2
- package/dist/api.d.ts +75 -0
- package/dist/api.js +277 -0
- package/dist/constants.d.ts +86 -0
- package/dist/constants.js +77 -0
- package/dist/context.d.ts +32 -0
- package/dist/context.js +129 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +54 -0
- package/dist/listener.d.ts +43 -0
- package/dist/listener.js +181 -0
- package/dist/lz4.d.ts +5 -0
- package/dist/lz4.js +57 -0
- package/dist/media.d.ts +27 -0
- package/dist/media.js +152 -0
- package/dist/message.d.ts +6 -0
- package/dist/message.js +113 -0
- package/dist/proto.d.ts +43 -0
- package/dist/proto.js +162 -0
- package/dist/qr-login.d.ts +41 -0
- package/dist/qr-login.js +212 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +2 -0
- package/package.json +44 -4
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TikTok Web IM protocol constants — reconstructed from `tiktok.com/business-suite/messages` traffic
|
|
4
|
+
* (09/11/2026). If TikTok changes things on their side, fix them here; how they were found is in the 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
|
+
/** TikTok Web app id (HTTP IM). */
|
|
13
|
+
exports.AID_WEB = '1988';
|
|
14
|
+
/** "frontier" channel app id — websocket pushing new messages. */
|
|
15
|
+
exports.AID_FRONTIER = '1459';
|
|
16
|
+
exports.SDK_VERSION = '1.8.5';
|
|
17
|
+
exports.BUILD_NUMBER = 'feffaa1:master';
|
|
18
|
+
/** Websocket `access_key` = md5(FPID + APP_KEY + device_id + SALT). */
|
|
19
|
+
exports.FRONTIER_FPID = '9';
|
|
20
|
+
exports.FRONTIER_APP_KEY = 'e1bd35ec9db7b8d846de66ed140b1ad9';
|
|
21
|
+
exports.FRONTIER_SALT = 'f8a69f1719916z';
|
|
22
|
+
/** IM command (`Request.cmd`) — also the body's field number in `RequestBody`. */
|
|
23
|
+
exports.Cmd = {
|
|
24
|
+
SendMessage: 100,
|
|
25
|
+
GetMessagesByUserInit: 203,
|
|
26
|
+
GetMessagesByConversation: 301,
|
|
27
|
+
/** Websocket push of new messages. */
|
|
28
|
+
NewMessage: 500,
|
|
29
|
+
/** Add / remove a reaction (message `property`) — `/v1/message/set_property`. */
|
|
30
|
+
SetMessageProperty: 705,
|
|
31
|
+
GetConversationInfo: 610,
|
|
32
|
+
MarkRead: 2002,
|
|
33
|
+
UploadConfig: 2059,
|
|
34
|
+
};
|
|
35
|
+
/** MarkRead's body lives in field 604, not the command number. */
|
|
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 — public image on the sticker CDN, not encrypted. */
|
|
44
|
+
Sticker: 1805,
|
|
45
|
+
/** Hidden command (read receipt…) — not shown in the conversation, but still pushed over websocket. */
|
|
46
|
+
Command: 50001,
|
|
47
|
+
};
|
|
48
|
+
exports.ConversationType = { OneToOne: 1 };
|
|
49
|
+
/** `Response` fields (both HTTP and websocket payload). */
|
|
50
|
+
exports.Resp = { cmd: 1, seq: 2, status: 3, error: 4, body: 6, logId: 7 };
|
|
51
|
+
/** `MessageBody` fields. */
|
|
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 key "e:<emoji>", 2 {1 PropertyItem[]}}`. */
|
|
64
|
+
propertyList: 15,
|
|
65
|
+
attachments: 20,
|
|
66
|
+
tag: 21,
|
|
67
|
+
};
|
|
68
|
+
/** Operation on a message `property` (`ModifyPropertyContent.operation`). */
|
|
69
|
+
exports.PropertyOp = { Add: 0, Remove: 1 };
|
|
70
|
+
/** Reaction key = `e:<emoji>`. The mobile app reacting ❤️ also writes the legacy key `e:love` — treated as ❤️. */
|
|
71
|
+
exports.REACTION_PREFIX = 'e:';
|
|
72
|
+
exports.REACTION_LOVE_KEY = 'e:love';
|
|
73
|
+
exports.REACTION_HEART = '\u2764\uFE0F';
|
|
74
|
+
/** ImageX — image upload endpoint (AWS SigV4-style signing). */
|
|
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
|
+
/** `name → value` cookies from any input shape of `Credentials.cookies`. */
|
|
4
|
+
export declare function normalizeCookies(input: Credentials['cookies']): Map<string, string>;
|
|
5
|
+
/** Shared state of a session: cookies, device id, uid, HTTP calls. */
|
|
6
|
+
export declare class Context {
|
|
7
|
+
readonly options: TikTokOptions;
|
|
8
|
+
readonly cookies: Map<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* IM device id = the `wid` TikTok issues for the `ttwid` cookie (`login` fetches it). The websocket only
|
|
11
|
+
* accepts this id — a made-up id gets 400 (HTTP IM isn't picky).
|
|
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` with cookies + UA + timeout. */
|
|
21
|
+
fetch(url: string, init?: RequestInit & {
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
}): Promise<Response>;
|
|
24
|
+
getJson<T>(url: string): Promise<T>;
|
|
25
|
+
/** IM `Request` envelope: command + body + common headers. */
|
|
26
|
+
buildRequest(cmd: number, bodyField: number, body: Buffer): Buffer;
|
|
27
|
+
/**
|
|
28
|
+
* Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
|
|
29
|
+
* NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 09/11/2026).
|
|
30
|
+
*/
|
|
31
|
+
im(path: string, cmd: number, body: Buffer, bodyField?: number): Promise<Msg>;
|
|
32
|
+
}
|
package/dist/context.js
ADDED
|
@@ -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
|
+
/** `name → value` cookies from any input shape of `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
|
+
/** Shared state of a session: cookies, device id, uid, HTTP calls. */
|
|
30
|
+
class Context {
|
|
31
|
+
options;
|
|
32
|
+
cookies;
|
|
33
|
+
/**
|
|
34
|
+
* IM device id = the `wid` TikTok issues for the `ttwid` cookie (`login` fetches it). The websocket only
|
|
35
|
+
* accepts this id — a made-up id gets 400 (HTTP IM isn't picky).
|
|
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` with cookies + UA + timeout. */
|
|
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
|
+
/** IM `Request` envelope: command + body + common headers. */
|
|
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
|
+
* Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
|
|
105
|
+
* NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 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}: response is not protobuf`);
|
|
119
|
+
const status = resp.num(constants_1.Resp.status);
|
|
120
|
+
if (status !== 0) {
|
|
121
|
+
const desc = resp.str(constants_1.Resp.error);
|
|
122
|
+
// Not logged in: 200005 (tested with a cookieless request).
|
|
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;
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Error from TikTok or from the library itself. `code` lets callers branch (e.g. session expired → rescan). */
|
|
2
|
+
export declare class TikTokError extends Error {
|
|
3
|
+
readonly code: TikTokErrorCode;
|
|
4
|
+
/** Original TikTok code (status_code / error_code), if any. */
|
|
5
|
+
readonly upstream?: number | string | undefined;
|
|
6
|
+
constructor(code: TikTokErrorCode, message: string,
|
|
7
|
+
/** Original TikTok code (status_code / error_code), if any. */
|
|
8
|
+
upstream?: number | string | undefined);
|
|
9
|
+
}
|
|
10
|
+
export type TikTokErrorCode =
|
|
11
|
+
/** Cookie is no longer logged in — a new session is required. */
|
|
12
|
+
'SESSION_EXPIRED' | 'HTTP_ERROR'
|
|
13
|
+
/** IM returned a non-zero `status_code`. */
|
|
14
|
+
| 'IM_ERROR' | 'UPLOAD_FAILED' | 'DOWNLOAD_FAILED' | 'BAD_RESPONSE'
|
|
15
|
+
/** QR login failed (Chrome failed to launch, QR expired, no cookie found…). */
|
|
16
|
+
| 'LOGIN_FAILED'
|
|
17
|
+
/** Caller aborted QR login (`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
|
+
/** Error from TikTok or from the library itself. `code` lets callers branch (e.g. session expired → rescan). */
|
|
5
|
+
class TikTokError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
upstream;
|
|
8
|
+
constructor(code, message,
|
|
9
|
+
/** Original TikTok code (status_code / error_code), if any. */
|
|
10
|
+
upstream) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.upstream = upstream;
|
|
14
|
+
this.name = 'TikTokError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.TikTokError = TikTokError;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tca-js — unofficial TikTok Chat API (speaks the TikTok Web protocol).
|
|
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
|
+
* Getting cookies the first time: `loginQR({ chromePath, onEvent })` — opens headless Chrome on TikTok's QR page
|
|
14
|
+
* (the login API requires a JS signature so it can't be done in pure Node), returns cookies after scanning. See 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
|
+
/** Log in with existing cookies. Expired cookies → `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-js — unofficial TikTok Chat API (speaks the TikTok Web protocol).
|
|
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
|
+
* Getting cookies the first time: `loginQR({ chromePath, onEvent })` — opens headless Chrome on TikTok's QR page
|
|
17
|
+
* (the login API requires a JS signature so it can't be done in pure Node), returns cookies after scanning. See 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
|
+
/** Log in with existing cookies. Expired cookies → `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 has no sessionid');
|
|
32
|
+
}
|
|
33
|
+
const account = await (0, api_1.fetchAccount)(ctx);
|
|
34
|
+
if (!account)
|
|
35
|
+
throw new errors_1.TikTokError('SESSION_EXPIRED', 'TikTok cookie is no longer logged in');
|
|
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', 'Could not get wid (device_id) — cookie missing 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
|
+
/** Close code used when we call `stop()` ourselves. */
|
|
5
|
+
export declare const MANUAL_CLOSE = 4000;
|
|
6
|
+
export interface ListenerEvents {
|
|
7
|
+
connected: [];
|
|
8
|
+
/** A real message in a conversation (including our own — see `isSelf`). */
|
|
9
|
+
message: [TikTokMessage];
|
|
10
|
+
/** Hidden command: mark as read… (`content.command_type`). */
|
|
11
|
+
command: [TikTokMessage];
|
|
12
|
+
/** Close code + reason. Unless `MANUAL_CLOSE`, the listener reconnects automatically (if enabled). */
|
|
13
|
+
closed: [number, string];
|
|
14
|
+
error: [Error];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Listen for new messages via the `im-ws-sg` websocket (aid 1459) — cookies only.
|
|
18
|
+
* Events: `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
|
+
/** Open the connection. `retryOnClose` (on by default): reconnect on drop, backing off up to 1 minute. */
|
|
33
|
+
start(opts?: {
|
|
34
|
+
retryOnClose?: boolean;
|
|
35
|
+
}): void;
|
|
36
|
+
stop(): void;
|
|
37
|
+
private connect;
|
|
38
|
+
private touch;
|
|
39
|
+
private clearTimers;
|
|
40
|
+
private onFrame;
|
|
41
|
+
/** Acknowledge receipt — `ack_id = <base36 time>:<x_frontier_traceid>`, echoing the frame's seq/logid. */
|
|
42
|
+
private ack;
|
|
43
|
+
}
|
package/dist/listener.js
ADDED
|
@@ -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
|
+
/** The browser sends "hi" every 15–30 seconds; use the lower bound. */
|
|
15
|
+
const HEARTBEAT_MS = 15_000;
|
|
16
|
+
/** If nothing (not even a "hi" reply) arrives for this long, treat it as silently dead and reconnect. */
|
|
17
|
+
const IDLE_TIMEOUT_MS = 75_000;
|
|
18
|
+
const RECONNECT_MIN_MS = 2_000;
|
|
19
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
20
|
+
/** The server pushes the same message several times (one frame per device) — remember this many message IDs to dedupe. */
|
|
21
|
+
const DEDUPE_KEEP = 500;
|
|
22
|
+
/** Close code used when we call `stop()` ourselves. */
|
|
23
|
+
exports.MANUAL_CLOSE = 4000;
|
|
24
|
+
/**
|
|
25
|
+
* Listen for new messages via the `im-ws-sg` websocket (aid 1459) — cookies only.
|
|
26
|
+
* Events: `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
|
+
// Cookie value is already URL-encoded (`1%7C…`) — decode it so URLSearchParams doesn't double-encode.
|
|
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
|
+
/** Open the connection. `retryOnClose` (on by default): reconnect on drop, backing off up to 1 minute. */
|
|
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 connected');
|
|
90
|
+
this.emit('connected');
|
|
91
|
+
});
|
|
92
|
+
ws.on('message', (data, isBinary) => {
|
|
93
|
+
this.touch();
|
|
94
|
+
if (!isBinary)
|
|
95
|
+
return; // "hi" reply
|
|
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; // old connection was replaced
|
|
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 closed (${code}) — reconnecting in ${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 silent too long — reconnecting');
|
|
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 type, 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
|
+
/** Acknowledge receipt — `ack_id = <base36 time>:<x_frontier_traceid>`, echoing the frame's seq/logid. */
|
|
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
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
|
+
* LZ4 block decompression (no frame header) — websocket frames with
|
|
6
|
+
* `payload_encoding = "__lz4"` compress their payload this way. No extra dependency for ~30 lines.
|
|
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: invalid offset');
|
|
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
|
+
// Copy byte by byte: source and destination ranges may overlap.
|
|
53
|
+
for (let k = 0; k < len; k++, o++)
|
|
54
|
+
out[o] = out[o - offset];
|
|
55
|
+
}
|
|
56
|
+
return out.subarray(0, o);
|
|
57
|
+
}
|