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.
@@ -1,43 +0,0 @@
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 DELETED
@@ -1,181 +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.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 DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * LZ4 block decompression (no frame header) — websocket frames with
3
- * `payload_encoding = "__lz4"` compress their payload this way. No extra dependency for ~30 lines.
4
- */
5
- export declare function lz4Decompress(src: Buffer): Buffer;
package/dist/lz4.js DELETED
@@ -1,57 +0,0 @@
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
- }
package/dist/media.d.ts DELETED
@@ -1,27 +0,0 @@
1
- import type { Context } from './context';
2
- import type { ImageInput, TikTokImage } from './types';
3
- /** Uploaded image, encrypted by the store — enough to send an image message. */
4
- export interface UploadedImage {
5
- uri: string;
6
- secretKey: string;
7
- width: number;
8
- height: number;
9
- size: number;
10
- filename: string;
11
- }
12
- export declare class MediaClient {
13
- private readonly ctx;
14
- private cred;
15
- constructor(ctx: Context);
16
- /** `UploadConfig` returns two credential sets: type 1 = ImageX (images), type 2 = VOD (video). Use type 1. */
17
- private credential;
18
- /** Call ImageX signed with AWS SigV4 (region cn-north-1, service imagex). */
19
- private imagex;
20
- /**
21
- * Upload an image to the private message store: Apply → POST file → Commit (the store encrypts with AES-GCM
22
- * and returns the key). Image dimensions come from the Commit result — no need to decode the image.
23
- */
24
- upload(image: ImageInput): Promise<UploadedImage>;
25
- /** Download and decrypt a message image. Layout: 12-byte IV ‖ ciphertext ‖ 16-byte tag. */
26
- download(image: TikTokImage, which?: 'origin' | 'thumb'): Promise<Buffer>;
27
- }
package/dist/media.js DELETED
@@ -1,152 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MediaClient = void 0;
4
- const node_crypto_1 = require("node:crypto");
5
- const node_zlib_1 = require("node:zlib");
6
- const constants_1 = require("./constants");
7
- const errors_1 = require("./errors");
8
- const sha256 = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
9
- const hmac = (k, s) => (0, node_crypto_1.createHmac)('sha256', k).update(s).digest();
10
- const rfc3986 = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
11
- class MediaClient {
12
- ctx;
13
- cred = null;
14
- constructor(ctx) {
15
- this.ctx = ctx;
16
- }
17
- /** `UploadConfig` returns two credential sets: type 1 = ImageX (images), type 2 = VOD (video). Use type 1. */
18
- async credential() {
19
- if (this.cred && this.cred.expiresAt - Date.now() > 60_000)
20
- return this.cred;
21
- const body = await this.ctx.im('/v1/media/upload_config', constants_1.Cmd.UploadConfig, Buffer.alloc(0));
22
- const c = body.msgs(1).find((m) => m.num(1) === 1);
23
- if (!c)
24
- throw new errors_1.TikTokError('UPLOAD_FAILED', 'upload_config has no ImageX credentials');
25
- this.cred = {
26
- serviceId: c.str(2),
27
- host: c.str(3),
28
- expiresAt: c.num(4),
29
- accessKeyId: c.str(6),
30
- sessionToken: c.str(7),
31
- secretAccessKey: c.str(8),
32
- };
33
- return this.cred;
34
- }
35
- /** Call ImageX signed with AWS SigV4 (region cn-north-1, service imagex). */
36
- async imagex(method, params, body) {
37
- const c = await this.credential();
38
- const amzDate = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
39
- const date = amzDate.slice(0, 8);
40
- const query = Object.keys(params)
41
- .sort()
42
- .map((k) => `${rfc3986(k)}=${rfc3986(params[k])}`)
43
- .join('&');
44
- const headers = { 'x-amz-date': amzDate, 'x-amz-security-token': c.sessionToken };
45
- if (body !== undefined)
46
- headers['x-amz-content-sha256'] = sha256(body);
47
- const signed = Object.keys(headers).sort();
48
- const canonical = [
49
- method,
50
- '/',
51
- query,
52
- signed.map((k) => `${k}:${headers[k]}\n`).join(''),
53
- signed.join(';'),
54
- body !== undefined ? headers['x-amz-content-sha256'] : sha256(''),
55
- ].join('\n');
56
- const scope = `${date}/${constants_1.IMAGEX_REGION}/${constants_1.IMAGEX_SERVICE}/aws4_request`;
57
- const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
58
- let k = hmac(`AWS4${c.secretAccessKey}`, date);
59
- k = hmac(k, constants_1.IMAGEX_REGION);
60
- k = hmac(k, constants_1.IMAGEX_SERVICE);
61
- k = hmac(k, 'aws4_request');
62
- const signature = (0, node_crypto_1.createHmac)('sha256', k).update(toSign).digest('hex');
63
- const res = await fetch(`https://${c.host}/?${query}`, {
64
- method,
65
- headers: {
66
- ...headers,
67
- Authorization: `AWS4-HMAC-SHA256 Credential=${c.accessKeyId}/${scope}, SignedHeaders=${signed.join(';')}, Signature=${signature}`,
68
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
69
- },
70
- body,
71
- signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 20_000),
72
- });
73
- const json = (await res.json().catch(() => null));
74
- const err = json?.ResponseMetadata?.Error;
75
- if (!res.ok || !json || err) {
76
- throw new errors_1.TikTokError('UPLOAD_FAILED', `imagex ${params.Action}: ${err?.Message ?? `HTTP ${res.status}`}`, err?.CodeN ?? res.status);
77
- }
78
- return json;
79
- }
80
- /**
81
- * Upload an image to the private message store: Apply → POST file → Commit (the store encrypts with AES-GCM
82
- * and returns the key). Image dimensions come from the Commit result — no need to decode the image.
83
- */
84
- async upload(image) {
85
- const c = await this.credential();
86
- const ext = (/\.([a-z0-9]{2,5})$/i.exec(image.filename)?.[1] ?? 'jpg').toLowerCase();
87
- const apply = await this.imagex('GET', {
88
- Action: 'ApplyImageUpload',
89
- Version: constants_1.IMAGEX_VERSION,
90
- ServiceId: c.serviceId,
91
- FileSize: String(image.data.length),
92
- FileExtension: `.${ext}`,
93
- s: (0, node_crypto_1.randomBytes)(6).toString('hex').slice(0, 10),
94
- });
95
- const addr = apply.Result.UploadAddress;
96
- const store = addr.StoreInfos[0];
97
- const host = addr.UploadHosts[0];
98
- if (!store || !host)
99
- throw new errors_1.TikTokError('UPLOAD_FAILED', 'ApplyImageUpload returned no upload target');
100
- const up = await fetch(`https://${host}/upload/v1/${store.StoreUri}`, {
101
- method: 'POST',
102
- headers: {
103
- Authorization: store.Auth,
104
- 'Content-CRC32': ((0, node_zlib_1.crc32)(image.data) >>> 0).toString(16).padStart(8, '0'),
105
- // Without this header the store returns 4004 Not Found.
106
- 'X-Storage-U': this.ctx.ownId,
107
- 'Content-Type': 'application/octet-stream',
108
- 'Content-Disposition': 'attachment; filename="undefined"',
109
- },
110
- body: new Uint8Array(image.data),
111
- signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 60_000),
112
- });
113
- const upJson = (await up.json().catch(() => null));
114
- if (!up.ok || upJson?.code !== 2000) {
115
- throw new errors_1.TikTokError('UPLOAD_FAILED', `upload: ${upJson?.message ?? `HTTP ${up.status}`}`, upJson?.code ?? up.status);
116
- }
117
- const commit = await this.imagex('POST', { Action: 'CommitImageUpload', Version: constants_1.IMAGEX_VERSION, ServiceId: c.serviceId }, JSON.stringify({ SessionKey: addr.SessionKey, Functions: [{ name: 'Encryption', input: { Config: { copies: 'cipher_v2' } } }] }));
118
- const enc = commit.Result.Results?.[0]?.Encryption;
119
- const plugin = commit.Result.PluginResult?.[0];
120
- if (!enc?.Uri || !enc.SecretKey)
121
- throw new errors_1.TikTokError('UPLOAD_FAILED', 'CommitImageUpload returned no encrypted result');
122
- return {
123
- uri: enc.Uri,
124
- secretKey: enc.SecretKey,
125
- width: plugin?.ImageWidth ?? Number(enc.Extra?.img_width ?? 0),
126
- height: plugin?.ImageHeight ?? Number(enc.Extra?.img_height ?? 0),
127
- size: plugin?.ImageSize ?? image.data.length,
128
- filename: image.filename,
129
- };
130
- }
131
- /** Download and decrypt a message image. Layout: 12-byte IV ‖ ciphertext ‖ 16-byte tag. */
132
- async download(image, which = 'origin') {
133
- const url = which === 'thumb' && image.thumbUrl ? image.thumbUrl : image.url;
134
- if (!url)
135
- throw new errors_1.TikTokError('DOWNLOAD_FAILED', 'Image has no URL');
136
- const res = await this.ctx.fetch(url);
137
- if (!res.ok)
138
- throw new errors_1.TikTokError('DOWNLOAD_FAILED', `Image download: HTTP ${res.status}`, res.status);
139
- const enc = Buffer.from(await res.arrayBuffer());
140
- if (!image.decryptKey)
141
- return enc;
142
- try {
143
- const d = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', Buffer.from(image.decryptKey, 'hex'), enc.subarray(0, 12));
144
- d.setAuthTag(enc.subarray(enc.length - 16));
145
- return Buffer.concat([d.update(enc.subarray(12, enc.length - 16)), d.final()]);
146
- }
147
- catch (err) {
148
- throw new errors_1.TikTokError('DOWNLOAD_FAILED', `Image decryption: ${err instanceof Error ? err.message : String(err)}`);
149
- }
150
- }
151
- }
152
- exports.MediaClient = MediaClient;
package/dist/message.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import { Msg } from './proto';
2
- import type { TikTokConversation, TikTokMessage } from './types';
3
- /** `MessageBody` → `TikTokMessage`. */
4
- export declare function parseMessage(m: Msg, ownId: string): TikTokMessage;
5
- /** `ConversationInfo` (field 2 of get_by_user_init): {1 id, 2 shortId, 3 type, 6 {1 {1 uid}}…}. */
6
- export declare function parseConversation(m: Msg, ownId: string): TikTokConversation;
package/dist/message.js DELETED
@@ -1,113 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseMessage = parseMessage;
4
- exports.parseConversation = parseConversation;
5
- const constants_1 = require("./constants");
6
- function parseJson(s) {
7
- try {
8
- return JSON.parse(s);
9
- }
10
- catch {
11
- return s;
12
- }
13
- }
14
- /** Image in `attachments` (field 20 → 4): 1 = original, 2 = thumbnail; each {1 uri, 2 url…, 5 {w,h}, 6 key}. */
15
- function parseImage(m) {
16
- const media = m.msg(constants_1.MsgF.attachments)?.msg(4);
17
- const origin = media?.msg(1);
18
- if (!media || !origin)
19
- return null;
20
- const size = origin.msg(5);
21
- return {
22
- uri: origin.str(1),
23
- url: origin.strs(2)[0] ?? '',
24
- thumbUrl: media.msg(2)?.strs(2)[0] ?? '',
25
- width: size?.num(1) ?? 0,
26
- height: size?.num(2) ?? 0,
27
- decryptKey: origin.str(6),
28
- };
29
- }
30
- /**
31
- * Sticker (field 20 → 7): `{1 {1 uri, 2 url, 5 {w,h}}, 2 hint, 200 {1 {2 map a:sticker_id, a:sticker_type…}}}`.
32
- * Links point to the public sticker CDN (`…-dm-sticker-sign-…`) — not encrypted, no cookies needed.
33
- */
34
- function parseSticker(m) {
35
- const st = m.msg(constants_1.MsgF.attachments)?.msg(7);
36
- const img = st?.msg(1);
37
- if (!st || !img)
38
- return null;
39
- const size = img.msg(5);
40
- const ext = st.msg(200)?.msg(1)?.map(2) ?? {};
41
- return {
42
- stickerId: ext['a:sticker_id'] ?? '',
43
- stickerType: Number(ext['a:sticker_type'] ?? 0),
44
- url: img.str(2),
45
- width: size?.num(1) ?? 0,
46
- height: size?.num(2) ?? 0,
47
- };
48
- }
49
- /**
50
- * Reaction (field 15, `property_list`): each entry `{1 key, 2 {1 PropertyItem[]}}`, key `e:<emoji>`,
51
- * `PropertyItem {1 uid, 3 sec, 4 idempotent_id, 6 µs}`. The mobile app writes ❤️ as both `e:❤️` and `e:love` → merged.
52
- */
53
- function parseReactions(m) {
54
- const byEmoji = new Map();
55
- for (const p of m.msgs(constants_1.MsgF.propertyList)) {
56
- const key = p.str(1);
57
- if (!key.startsWith(constants_1.REACTION_PREFIX))
58
- continue;
59
- const emoji = key === constants_1.REACTION_LOVE_KEY ? constants_1.REACTION_HEART : key.slice(constants_1.REACTION_PREFIX.length);
60
- const uids = byEmoji.get(emoji) ?? new Set();
61
- for (const item of p.msg(2)?.msgs(1) ?? [])
62
- uids.add(item.id(1));
63
- if (uids.size)
64
- byEmoji.set(emoji, uids);
65
- }
66
- return [...byEmoji].map(([emoji, uids]) => ({ emoji, userIds: [...uids] }));
67
- }
68
- const KIND = {
69
- [constants_1.MessageType.Text]: 'text',
70
- [constants_1.MessageType.PrivateImage]: 'image',
71
- [constants_1.MessageType.Sticker]: 'sticker',
72
- [constants_1.MessageType.ShareVideo]: 'video',
73
- [constants_1.MessageType.System]: 'system',
74
- [constants_1.MessageType.Command]: 'command',
75
- };
76
- /** `MessageBody` → `TikTokMessage`. */
77
- function parseMessage(m, ownId) {
78
- const type = m.num(constants_1.MsgF.type);
79
- const content = parseJson(m.str(constants_1.MsgF.content));
80
- const ext = m.map(constants_1.MsgF.ext);
81
- const senderId = m.id(constants_1.MsgF.sender);
82
- const text = type === constants_1.MessageType.Text && content && typeof content === 'object' && 'text' in content
83
- ? String(content.text ?? '')
84
- : '';
85
- return {
86
- conversationId: m.str(constants_1.MsgF.conversationId),
87
- conversationShortId: m.id(constants_1.MsgF.conversationShortId),
88
- serverMessageId: m.id(constants_1.MsgF.serverMessageId),
89
- index: m.id(constants_1.MsgF.indexInConversation),
90
- type,
91
- kind: KIND[type] ?? 'other',
92
- senderId,
93
- isSelf: senderId === ownId,
94
- text,
95
- image: type === constants_1.MessageType.PrivateImage ? parseImage(m) : null,
96
- sticker: type === constants_1.MessageType.Sticker ? parseSticker(m) : null,
97
- reactions: parseReactions(m),
98
- content,
99
- createdAt: m.num(constants_1.MsgF.createTime),
100
- clientMessageId: ext['s:client_message_id'] ?? '',
101
- ext,
102
- };
103
- }
104
- /** `ConversationInfo` (field 2 of get_by_user_init): {1 id, 2 shortId, 3 type, 6 {1 {1 uid}}…}. */
105
- function parseConversation(m, ownId) {
106
- const participants = (m.msg(6)?.msgs(1) ?? []).map((p) => p.id(1));
107
- return {
108
- conversationId: m.str(1),
109
- conversationShortId: m.id(2),
110
- participants,
111
- peerId: participants.find((p) => p !== ownId) ?? '',
112
- };
113
- }
package/dist/proto.d.ts DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * Minimal protobuf, no `.proto` file needed.
3
- *
4
- * TikTok doesn't publish its IM `.proto` — field numbers in `constants.ts` were reconstructed from bytes captured
5
- * in the browser (09/11/2026). Only three wire types are needed: varint (0), length-delimited (2), and skipping 1/5.
6
- */
7
- export interface Field {
8
- n: number;
9
- wire: number;
10
- int: bigint;
11
- bytes: Buffer;
12
- }
13
- /** Split a message into a list of fields. `null` if the bytes are not valid protobuf. */
14
- export declare function decodeFields(b: Buffer): Field[] | null;
15
- /** Decoded message — read by field number. */
16
- export declare class Msg {
17
- readonly fields: Field[];
18
- private constructor();
19
- static parse(b: Buffer): Msg;
20
- static tryParse(b: Buffer | undefined): Msg | null;
21
- has(n: number): boolean;
22
- int(n: number): bigint;
23
- num(n: number): number;
24
- /** 64-bit integer as a string — uids / message IDs exceed `Number.MAX_SAFE_INTEGER`. */
25
- id(n: number): string;
26
- bytes(n: number): Buffer;
27
- str(n: number): string;
28
- strs(n: number): string[];
29
- msg(n: number): Msg | null;
30
- msgs(n: number): Msg[];
31
- /** `map<string,string>` — each entry is a message `{1: key, 2: value}`. */
32
- map(n: number): Record<string, string>;
33
- }
34
- /** Build a message in call order. */
35
- export declare class Writer {
36
- private readonly parts;
37
- int(n: number, v: bigint | number | string): this;
38
- bytes(n: number, v: Buffer | string): this;
39
- msg(n: number, w: Writer): this;
40
- /** `map<string,string>` in the object's key order. */
41
- map(n: number, obj: Record<string, string>): this;
42
- finish(): Buffer;
43
- }