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
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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;
|
|
@@ -0,0 +1,6 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|
package/dist/proto.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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;
|
|
@@ -0,0 +1,41 @@
|
|
|
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>;
|