tca-js 0.3.1 → 0.4.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/README.md +72 -53
- package/dist/index.d.mts +374 -0
- package/dist/index.d.ts +366 -25
- package/dist/index.js +1319 -52
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1277 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +33 -5
- package/dist/api.d.ts +0 -75
- package/dist/api.js +0 -277
- package/dist/constants.d.ts +0 -86
- package/dist/constants.js +0 -77
- package/dist/context.d.ts +0 -32
- package/dist/context.js +0 -129
- package/dist/errors.d.ts +0 -18
- package/dist/errors.js +0 -17
- package/dist/listener.d.ts +0 -43
- package/dist/listener.js +0 -181
- package/dist/lz4.d.ts +0 -5
- package/dist/lz4.js +0 -57
- package/dist/media.d.ts +0 -27
- package/dist/media.js +0 -152
- package/dist/message.d.ts +0 -6
- package/dist/message.js +0 -113
- package/dist/proto.d.ts +0 -43
- package/dist/proto.js +0 -162
- package/dist/qr-login.d.ts +0 -41
- package/dist/qr-login.js +0 -212
- package/dist/types.d.ts +0 -109
- package/dist/types.js +0 -2
package/dist/index.js
CHANGED
|
@@ -1,54 +1,1321 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key2 of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key2) && key2 !== except)
|
|
16
|
+
__defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
API: () => API,
|
|
34
|
+
Listener: () => Listener,
|
|
35
|
+
MANUAL_CLOSE: () => MANUAL_CLOSE,
|
|
36
|
+
MessageType: () => MessageType,
|
|
37
|
+
TikTok: () => TikTok,
|
|
38
|
+
TikTokError: () => TikTokError,
|
|
39
|
+
findChrome: () => findChrome,
|
|
40
|
+
loginQR: () => loginQR
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
|
|
44
|
+
// src/api/api.ts
|
|
45
|
+
var import_node_crypto3 = require("crypto");
|
|
46
|
+
|
|
47
|
+
// src/protocol/constants.ts
|
|
48
|
+
var IM_API = "https://im-api-sg.tiktok.com";
|
|
49
|
+
var IM_WS = "wss://im-ws-sg.tiktok.com/ws/v2";
|
|
50
|
+
var WEB = "https://www.tiktok.com";
|
|
51
|
+
var 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";
|
|
52
|
+
var AID_WEB = "1988";
|
|
53
|
+
var AID_FRONTIER = "1459";
|
|
54
|
+
var SDK_VERSION = "1.8.5";
|
|
55
|
+
var BUILD_NUMBER = "feffaa1:master";
|
|
56
|
+
var FRONTIER_FPID = "9";
|
|
57
|
+
var FRONTIER_APP_KEY = "e1bd35ec9db7b8d846de66ed140b1ad9";
|
|
58
|
+
var FRONTIER_SALT = "f8a69f1719916z";
|
|
59
|
+
var Cmd = {
|
|
60
|
+
SendMessage: 100,
|
|
61
|
+
GetMessagesByUserInit: 203,
|
|
62
|
+
GetMessagesByConversation: 301,
|
|
63
|
+
/** Websocket push of new messages. */
|
|
64
|
+
NewMessage: 500,
|
|
65
|
+
/** Add / remove a reaction (message `property`) — `/v1/message/set_property`. */
|
|
66
|
+
SetMessageProperty: 705,
|
|
67
|
+
GetConversationInfo: 610,
|
|
68
|
+
MarkRead: 2002,
|
|
69
|
+
UploadConfig: 2059
|
|
70
|
+
};
|
|
71
|
+
var MARK_READ_BODY_FIELD = 604;
|
|
72
|
+
var MessageType = {
|
|
73
|
+
System: 1,
|
|
74
|
+
Text: 7,
|
|
75
|
+
ShareVideo: 8,
|
|
76
|
+
PrivateImage: 1802,
|
|
77
|
+
/** Sticker — public image on the sticker CDN, not encrypted. */
|
|
78
|
+
Sticker: 1805,
|
|
79
|
+
/** Hidden command (read receipt…) — not shown in the conversation, but still pushed over websocket. */
|
|
80
|
+
Command: 50001
|
|
81
|
+
};
|
|
82
|
+
var ConversationType = { OneToOne: 1 };
|
|
83
|
+
var Resp = { cmd: 1, seq: 2, status: 3, error: 4, body: 6, logId: 7 };
|
|
84
|
+
var MsgF = {
|
|
85
|
+
conversationId: 1,
|
|
86
|
+
conversationType: 2,
|
|
87
|
+
serverMessageId: 3,
|
|
88
|
+
indexInConversation: 4,
|
|
89
|
+
conversationShortId: 5,
|
|
90
|
+
type: 6,
|
|
91
|
+
sender: 7,
|
|
92
|
+
content: 8,
|
|
93
|
+
ext: 9,
|
|
94
|
+
createTime: 10,
|
|
95
|
+
/** `property_list` — reaction: `{1 key "e:<emoji>", 2 {1 PropertyItem[]}}`. */
|
|
96
|
+
propertyList: 15,
|
|
97
|
+
attachments: 20,
|
|
98
|
+
tag: 21
|
|
99
|
+
};
|
|
100
|
+
var PropertyOp = { Add: 0, Remove: 1 };
|
|
101
|
+
var REACTION_PREFIX = "e:";
|
|
102
|
+
var REACTION_LOVE_KEY = "e:love";
|
|
103
|
+
var REACTION_HEART = "\u2764\uFE0F";
|
|
104
|
+
var IMAGEX_REGION = "cn-north-1";
|
|
105
|
+
var IMAGEX_SERVICE = "imagex";
|
|
106
|
+
var IMAGEX_VERSION = "2018-08-01";
|
|
107
|
+
|
|
108
|
+
// src/protocol/proto.ts
|
|
109
|
+
function readVarint(b, i) {
|
|
110
|
+
let x = 0n;
|
|
111
|
+
let shift = 0n;
|
|
112
|
+
let c;
|
|
113
|
+
do {
|
|
114
|
+
if (i >= b.length) throw new Error("varint runs past end of buffer");
|
|
115
|
+
c = b[i++];
|
|
116
|
+
x |= BigInt(c & 127) << shift;
|
|
117
|
+
shift += 7n;
|
|
118
|
+
} while (c & 128);
|
|
119
|
+
return [x, i];
|
|
120
|
+
}
|
|
121
|
+
function decodeFields(b) {
|
|
122
|
+
const out = [];
|
|
123
|
+
let i = 0;
|
|
124
|
+
try {
|
|
125
|
+
while (i < b.length) {
|
|
126
|
+
const [key2, j] = readVarint(b, i);
|
|
127
|
+
i = j;
|
|
128
|
+
const wire = Number(key2 & 7n);
|
|
129
|
+
const n = Number(key2 >> 3n);
|
|
130
|
+
if (n === 0) return null;
|
|
131
|
+
if (wire === 0) {
|
|
132
|
+
const [v, k] = readVarint(b, i);
|
|
133
|
+
out.push({ n, wire, int: v, bytes: Buffer.alloc(0) });
|
|
134
|
+
i = k;
|
|
135
|
+
} else if (wire === 2) {
|
|
136
|
+
const [len, k] = readVarint(b, i);
|
|
137
|
+
const end = k + Number(len);
|
|
138
|
+
if (end > b.length) return null;
|
|
139
|
+
out.push({ n, wire, int: 0n, bytes: b.subarray(k, end) });
|
|
140
|
+
i = end;
|
|
141
|
+
} else if (wire === 1 || wire === 5) {
|
|
142
|
+
const size = wire === 1 ? 8 : 4;
|
|
143
|
+
if (i + size > b.length) return null;
|
|
144
|
+
out.push({ n, wire, int: 0n, bytes: b.subarray(i, i + size) });
|
|
145
|
+
i += size;
|
|
146
|
+
} else {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
var Msg = class _Msg {
|
|
156
|
+
constructor(fields) {
|
|
157
|
+
this.fields = fields;
|
|
158
|
+
}
|
|
159
|
+
fields;
|
|
160
|
+
static parse(b) {
|
|
161
|
+
const f = decodeFields(b);
|
|
162
|
+
if (!f) throw new Error("Failed to decode protobuf");
|
|
163
|
+
return new _Msg(f);
|
|
164
|
+
}
|
|
165
|
+
static tryParse(b) {
|
|
166
|
+
if (!b) return null;
|
|
167
|
+
const f = decodeFields(b);
|
|
168
|
+
return f ? new _Msg(f) : null;
|
|
169
|
+
}
|
|
170
|
+
has(n) {
|
|
171
|
+
return this.fields.some((f) => f.n === n);
|
|
172
|
+
}
|
|
173
|
+
int(n) {
|
|
174
|
+
return this.fields.find((f) => f.n === n)?.int ?? 0n;
|
|
175
|
+
}
|
|
176
|
+
num(n) {
|
|
177
|
+
return Number(this.int(n));
|
|
178
|
+
}
|
|
179
|
+
/** 64-bit integer as a string — uids / message IDs exceed `Number.MAX_SAFE_INTEGER`. */
|
|
180
|
+
id(n) {
|
|
181
|
+
return this.int(n).toString();
|
|
182
|
+
}
|
|
183
|
+
bytes(n) {
|
|
184
|
+
return this.fields.find((f) => f.n === n)?.bytes ?? Buffer.alloc(0);
|
|
185
|
+
}
|
|
186
|
+
str(n) {
|
|
187
|
+
return this.bytes(n).toString("utf8");
|
|
188
|
+
}
|
|
189
|
+
strs(n) {
|
|
190
|
+
return this.fields.filter((f) => f.n === n).map((f) => f.bytes.toString("utf8"));
|
|
191
|
+
}
|
|
192
|
+
msg(n) {
|
|
193
|
+
return _Msg.tryParse(this.fields.find((f) => f.n === n)?.bytes);
|
|
194
|
+
}
|
|
195
|
+
msgs(n) {
|
|
196
|
+
return this.fields.filter((f) => f.n === n).map((f) => _Msg.tryParse(f.bytes)).filter((m) => m !== null);
|
|
197
|
+
}
|
|
198
|
+
/** `map<string,string>` — each entry is a message `{1: key, 2: value}`. */
|
|
199
|
+
map(n) {
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const m of this.msgs(n)) out[m.str(1)] = m.str(2);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
function varint(x) {
|
|
206
|
+
let v = BigInt(x);
|
|
207
|
+
if (v < 0n) v = BigInt.asUintN(64, v);
|
|
208
|
+
const out = [];
|
|
209
|
+
do {
|
|
210
|
+
const c = Number(v & 127n);
|
|
211
|
+
v >>= 7n;
|
|
212
|
+
out.push(v ? c | 128 : c);
|
|
213
|
+
} while (v);
|
|
214
|
+
return Buffer.from(out);
|
|
215
|
+
}
|
|
216
|
+
var key = (n, wire) => varint(BigInt(n) << 3n | BigInt(wire));
|
|
217
|
+
var Writer = class _Writer {
|
|
218
|
+
parts = [];
|
|
219
|
+
int(n, v) {
|
|
220
|
+
this.parts.push(key(n, 0), varint(typeof v === "string" ? BigInt(v) : v));
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
bytes(n, v) {
|
|
224
|
+
const b = typeof v === "string" ? Buffer.from(v, "utf8") : v;
|
|
225
|
+
this.parts.push(key(n, 2), varint(b.length), b);
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
msg(n, w) {
|
|
229
|
+
return this.bytes(n, w.finish());
|
|
230
|
+
}
|
|
231
|
+
/** `map<string,string>` in the object's key order. */
|
|
232
|
+
map(n, obj) {
|
|
233
|
+
for (const [k, v] of Object.entries(obj)) this.msg(n, new _Writer().bytes(1, k).bytes(2, v));
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
finish() {
|
|
237
|
+
return Buffer.concat(this.parts);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// src/errors.ts
|
|
242
|
+
var TikTokError = class extends Error {
|
|
243
|
+
constructor(code, message, upstream) {
|
|
244
|
+
super(message);
|
|
245
|
+
this.code = code;
|
|
246
|
+
this.upstream = upstream;
|
|
247
|
+
this.name = "TikTokError";
|
|
248
|
+
}
|
|
249
|
+
code;
|
|
250
|
+
upstream;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// src/listener/listener.ts
|
|
254
|
+
var import_node_events = require("events");
|
|
255
|
+
var import_ws = __toESM(require("ws"));
|
|
256
|
+
|
|
257
|
+
// src/models/message.ts
|
|
258
|
+
function parseJson(s) {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(s);
|
|
261
|
+
} catch {
|
|
262
|
+
return s;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function parseImage(m) {
|
|
266
|
+
const media = m.msg(MsgF.attachments)?.msg(4);
|
|
267
|
+
const origin = media?.msg(1);
|
|
268
|
+
if (!media || !origin) return null;
|
|
269
|
+
const size = origin.msg(5);
|
|
270
|
+
return {
|
|
271
|
+
uri: origin.str(1),
|
|
272
|
+
url: origin.strs(2)[0] ?? "",
|
|
273
|
+
thumbUrl: media.msg(2)?.strs(2)[0] ?? "",
|
|
274
|
+
width: size?.num(1) ?? 0,
|
|
275
|
+
height: size?.num(2) ?? 0,
|
|
276
|
+
decryptKey: origin.str(6)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function parseSticker(m) {
|
|
280
|
+
const st = m.msg(MsgF.attachments)?.msg(7);
|
|
281
|
+
const img = st?.msg(1);
|
|
282
|
+
if (!st || !img) return null;
|
|
283
|
+
const size = img.msg(5);
|
|
284
|
+
const ext = st.msg(200)?.msg(1)?.map(2) ?? {};
|
|
285
|
+
return {
|
|
286
|
+
stickerId: ext["a:sticker_id"] ?? "",
|
|
287
|
+
stickerType: Number(ext["a:sticker_type"] ?? 0),
|
|
288
|
+
url: img.str(2),
|
|
289
|
+
width: size?.num(1) ?? 0,
|
|
290
|
+
height: size?.num(2) ?? 0
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function parseReactions(m) {
|
|
294
|
+
const byEmoji = /* @__PURE__ */ new Map();
|
|
295
|
+
for (const p of m.msgs(MsgF.propertyList)) {
|
|
296
|
+
const key2 = p.str(1);
|
|
297
|
+
if (!key2.startsWith(REACTION_PREFIX)) continue;
|
|
298
|
+
const emoji = key2 === REACTION_LOVE_KEY ? REACTION_HEART : key2.slice(REACTION_PREFIX.length);
|
|
299
|
+
const uids = byEmoji.get(emoji) ?? /* @__PURE__ */ new Set();
|
|
300
|
+
for (const item of p.msg(2)?.msgs(1) ?? []) uids.add(item.id(1));
|
|
301
|
+
if (uids.size) byEmoji.set(emoji, uids);
|
|
302
|
+
}
|
|
303
|
+
return [...byEmoji].map(([emoji, uids]) => ({ emoji, userIds: [...uids] }));
|
|
304
|
+
}
|
|
305
|
+
var KIND = {
|
|
306
|
+
[MessageType.Text]: "text",
|
|
307
|
+
[MessageType.PrivateImage]: "image",
|
|
308
|
+
[MessageType.Sticker]: "sticker",
|
|
309
|
+
[MessageType.ShareVideo]: "video",
|
|
310
|
+
[MessageType.System]: "system",
|
|
311
|
+
[MessageType.Command]: "command"
|
|
312
|
+
};
|
|
313
|
+
function parseMessage(m, ownId) {
|
|
314
|
+
const type = m.num(MsgF.type);
|
|
315
|
+
const content = parseJson(m.str(MsgF.content));
|
|
316
|
+
const ext = m.map(MsgF.ext);
|
|
317
|
+
const senderId = m.id(MsgF.sender);
|
|
318
|
+
const text = type === MessageType.Text && content && typeof content === "object" && "text" in content ? String(content.text ?? "") : "";
|
|
319
|
+
return {
|
|
320
|
+
conversationId: m.str(MsgF.conversationId),
|
|
321
|
+
conversationShortId: m.id(MsgF.conversationShortId),
|
|
322
|
+
serverMessageId: m.id(MsgF.serverMessageId),
|
|
323
|
+
index: m.id(MsgF.indexInConversation),
|
|
324
|
+
type,
|
|
325
|
+
kind: KIND[type] ?? "other",
|
|
326
|
+
senderId,
|
|
327
|
+
isSelf: senderId === ownId,
|
|
328
|
+
text,
|
|
329
|
+
image: type === MessageType.PrivateImage ? parseImage(m) : null,
|
|
330
|
+
sticker: type === MessageType.Sticker ? parseSticker(m) : null,
|
|
331
|
+
reactions: parseReactions(m),
|
|
332
|
+
content,
|
|
333
|
+
createdAt: m.num(MsgF.createTime),
|
|
334
|
+
clientMessageId: ext["s:client_message_id"] ?? "",
|
|
335
|
+
ext
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function parseConversation(m, ownId) {
|
|
339
|
+
const participants = (m.msg(6)?.msgs(1) ?? []).map((p) => p.id(1));
|
|
340
|
+
return {
|
|
341
|
+
conversationId: m.str(1),
|
|
342
|
+
conversationShortId: m.id(2),
|
|
343
|
+
participants,
|
|
344
|
+
peerId: participants.find((p) => p !== ownId) ?? ""
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/listener/frame.ts
|
|
349
|
+
var import_node_crypto = require("crypto");
|
|
350
|
+
|
|
351
|
+
// src/protocol/lz4.ts
|
|
352
|
+
function lz4Decompress(src) {
|
|
353
|
+
let out = Buffer.alloc(Math.max(src.length * 4, 1024));
|
|
354
|
+
let o = 0;
|
|
355
|
+
const ensure = (need) => {
|
|
356
|
+
if (o + need <= out.length) return;
|
|
357
|
+
let size = out.length * 2;
|
|
358
|
+
while (o + need > size) size *= 2;
|
|
359
|
+
const bigger = Buffer.alloc(size);
|
|
360
|
+
out.copy(bigger, 0, 0, o);
|
|
361
|
+
out = bigger;
|
|
362
|
+
};
|
|
363
|
+
let i = 0;
|
|
364
|
+
while (i < src.length) {
|
|
365
|
+
const token = src[i++];
|
|
366
|
+
let lit = token >> 4;
|
|
367
|
+
if (lit === 15) {
|
|
368
|
+
let b;
|
|
369
|
+
do {
|
|
370
|
+
b = src[i++];
|
|
371
|
+
lit += b;
|
|
372
|
+
} while (b === 255);
|
|
373
|
+
}
|
|
374
|
+
ensure(lit);
|
|
375
|
+
src.copy(out, o, i, i + lit);
|
|
376
|
+
i += lit;
|
|
377
|
+
o += lit;
|
|
378
|
+
if (i >= src.length) break;
|
|
379
|
+
const offset = src[i] | src[i + 1] << 8;
|
|
380
|
+
i += 2;
|
|
381
|
+
if (offset === 0 || offset > o) throw new Error("lz4: invalid offset");
|
|
382
|
+
let len = token & 15;
|
|
383
|
+
if (len === 15) {
|
|
384
|
+
let b;
|
|
385
|
+
do {
|
|
386
|
+
b = src[i++];
|
|
387
|
+
len += b;
|
|
388
|
+
} while (b === 255);
|
|
389
|
+
}
|
|
390
|
+
len += 4;
|
|
391
|
+
ensure(len);
|
|
392
|
+
for (let k = 0; k < len; k++, o++) out[o] = out[o - offset];
|
|
393
|
+
}
|
|
394
|
+
return out.subarray(0, o);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/listener/frame.ts
|
|
398
|
+
function accessKey(deviceId) {
|
|
399
|
+
return (0, import_node_crypto.createHash)("md5").update(FRONTIER_FPID + FRONTIER_APP_KEY + deviceId + FRONTIER_SALT).digest("hex");
|
|
400
|
+
}
|
|
401
|
+
function parseFrame(data) {
|
|
402
|
+
const m = Msg.parse(data);
|
|
403
|
+
return {
|
|
404
|
+
seq: m.int(1),
|
|
405
|
+
logId: m.int(2),
|
|
406
|
+
headers: m.map(5),
|
|
407
|
+
encoding: m.str(6),
|
|
408
|
+
payload: m.bytes(8)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function newMessageBody(frame) {
|
|
412
|
+
if (frame.payload.length === 0) return null;
|
|
413
|
+
const payload = frame.encoding === "__lz4" ? lz4Decompress(frame.payload) : frame.payload;
|
|
414
|
+
const resp = Msg.tryParse(payload);
|
|
415
|
+
if (!resp || resp.num(Resp.cmd) !== Cmd.NewMessage) return null;
|
|
416
|
+
return resp.msg(Resp.body)?.msg(Cmd.NewMessage)?.msg(5) ?? null;
|
|
417
|
+
}
|
|
418
|
+
function buildAck(f, now = Date.now()) {
|
|
419
|
+
const ackId = `${now.toString(36)}:${f.headers.x_frontier_traceid ?? ""}`;
|
|
420
|
+
return new Writer().int(1, f.seq).int(2, f.logId).int(3, 5).int(4, 1).map(5, { is_ack: "1", ack_id: ackId, ack_code: "0" }).bytes(9, ackId).finish();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/listener/listener.ts
|
|
424
|
+
var HEARTBEAT_MS = 15e3;
|
|
425
|
+
var IDLE_TIMEOUT_MS = 75e3;
|
|
426
|
+
var RECONNECT_MIN_MS = 2e3;
|
|
427
|
+
var RECONNECT_MAX_MS = 6e4;
|
|
428
|
+
var DEDUPE_KEEP = 500;
|
|
429
|
+
var MANUAL_CLOSE = 4e3;
|
|
430
|
+
var Listener = class extends import_node_events.EventEmitter {
|
|
431
|
+
constructor(ctx) {
|
|
432
|
+
super();
|
|
433
|
+
this.ctx = ctx;
|
|
434
|
+
}
|
|
435
|
+
ctx;
|
|
436
|
+
ws = null;
|
|
437
|
+
heartbeat = null;
|
|
438
|
+
idle = null;
|
|
439
|
+
reconnectTimer = null;
|
|
440
|
+
backoff = RECONNECT_MIN_MS;
|
|
441
|
+
retry = true;
|
|
442
|
+
seen = /* @__PURE__ */ new Set();
|
|
443
|
+
get connected() {
|
|
444
|
+
return this.ws?.readyState === import_ws.default.OPEN;
|
|
445
|
+
}
|
|
446
|
+
url() {
|
|
447
|
+
const q = new URLSearchParams({
|
|
448
|
+
aid: AID_FRONTIER,
|
|
449
|
+
fpid: FRONTIER_FPID,
|
|
450
|
+
device_platform: "web",
|
|
451
|
+
version_code: "fws_1.0.0",
|
|
452
|
+
access_key: accessKey(this.ctx.deviceId),
|
|
453
|
+
device_id: this.ctx.deviceId,
|
|
454
|
+
// Cookie value is already URL-encoded (`1%7C…`) — decode it so URLSearchParams doesn't double-encode.
|
|
455
|
+
ttwid: decodeURIComponent(this.ctx.cookies.get("ttwid") ?? ""),
|
|
456
|
+
xsack: "1",
|
|
457
|
+
xaack: "1",
|
|
458
|
+
xsqos: "0"
|
|
459
|
+
});
|
|
460
|
+
return `${IM_WS}?${q}`;
|
|
461
|
+
}
|
|
462
|
+
/** Open the connection. `retryOnClose` (on by default): reconnect on drop, backing off up to 1 minute. */
|
|
463
|
+
start(opts = {}) {
|
|
464
|
+
this.retry = opts.retryOnClose ?? true;
|
|
465
|
+
this.connect();
|
|
466
|
+
}
|
|
467
|
+
stop() {
|
|
468
|
+
this.retry = false;
|
|
469
|
+
this.clearTimers();
|
|
470
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
471
|
+
this.reconnectTimer = null;
|
|
472
|
+
const ws = this.ws;
|
|
473
|
+
this.ws = null;
|
|
474
|
+
if (ws && ws.readyState <= import_ws.default.OPEN) ws.close(MANUAL_CLOSE, "manual");
|
|
475
|
+
}
|
|
476
|
+
connect() {
|
|
477
|
+
const ws = new import_ws.default(this.url(), {
|
|
478
|
+
headers: { Cookie: this.ctx.cookieHeader(), "User-Agent": this.ctx.userAgent, Origin: WEB }
|
|
479
|
+
});
|
|
480
|
+
this.ws = ws;
|
|
481
|
+
ws.binaryType = "nodebuffer";
|
|
482
|
+
ws.on("open", () => {
|
|
483
|
+
this.backoff = RECONNECT_MIN_MS;
|
|
484
|
+
this.heartbeat = setInterval(() => ws.readyState === import_ws.default.OPEN && ws.send("hi"), HEARTBEAT_MS);
|
|
485
|
+
this.touch();
|
|
486
|
+
this.ctx.log("debug", "tca: websocket connected");
|
|
487
|
+
this.emit("connected");
|
|
488
|
+
});
|
|
489
|
+
ws.on("message", (data, isBinary) => {
|
|
490
|
+
this.touch();
|
|
491
|
+
if (!isBinary) return;
|
|
492
|
+
try {
|
|
493
|
+
this.onFrame(ws, data);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
ws.on("error", (err) => this.emit("error", err));
|
|
499
|
+
ws.on("close", (code, reason) => {
|
|
500
|
+
if (this.ws !== ws) return;
|
|
501
|
+
this.ws = null;
|
|
502
|
+
this.clearTimers();
|
|
503
|
+
this.emit("closed", code, reason.toString());
|
|
504
|
+
if (!this.retry || code === MANUAL_CLOSE) return;
|
|
505
|
+
const wait = this.backoff;
|
|
506
|
+
this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
|
|
507
|
+
this.ctx.log("warn", `tca: websocket closed (${code}) \u2014 reconnecting in ${wait / 1e3}s`);
|
|
508
|
+
this.reconnectTimer = setTimeout(() => this.connect(), wait);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
touch() {
|
|
512
|
+
if (this.idle) clearTimeout(this.idle);
|
|
513
|
+
this.idle = setTimeout(() => {
|
|
514
|
+
this.ctx.log("warn", "tca: websocket silent too long \u2014 reconnecting");
|
|
515
|
+
this.ws?.terminate();
|
|
516
|
+
}, IDLE_TIMEOUT_MS);
|
|
517
|
+
}
|
|
518
|
+
clearTimers() {
|
|
519
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
520
|
+
if (this.idle) clearTimeout(this.idle);
|
|
521
|
+
this.heartbeat = null;
|
|
522
|
+
this.idle = null;
|
|
523
|
+
}
|
|
524
|
+
onFrame(ws, data) {
|
|
525
|
+
const frame = parseFrame(data);
|
|
526
|
+
if (frame.headers.need_ack === "1" && ws.readyState === import_ws.default.OPEN) ws.send(buildAck(frame));
|
|
527
|
+
const body = newMessageBody(frame);
|
|
528
|
+
if (!body) return;
|
|
529
|
+
const msg = parseMessage(body, this.ctx.ownId);
|
|
530
|
+
if (!msg.serverMessageId || this.seen.has(msg.serverMessageId)) return;
|
|
531
|
+
this.seen.add(msg.serverMessageId);
|
|
532
|
+
if (this.seen.size > DEDUPE_KEEP) this.seen.delete(this.seen.values().next().value);
|
|
533
|
+
if (msg.kind === "command") this.emit("command", msg);
|
|
534
|
+
else this.emit("message", msg);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// src/api/account.ts
|
|
539
|
+
async function fetchAccount(ctx) {
|
|
540
|
+
const res = await ctx.getJson(`${WEB}/passport/web/account/info/?aid=${AID_FRONTIER}`);
|
|
541
|
+
if (res.message !== "success" || !res.data?.user_id_str) return null;
|
|
542
|
+
return {
|
|
543
|
+
uid: res.data.user_id_str,
|
|
544
|
+
username: res.data.username ?? "",
|
|
545
|
+
screenName: res.data.screen_name ?? "",
|
|
546
|
+
avatar: res.data.avatar_url ?? ""
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
async function fetchWebId(ctx) {
|
|
550
|
+
const res = await ctx.fetch(`${WEB}/api/v1/web-cookie-privacy/config?locale=vi-VN&appId=1988&theme=default&tea=1`);
|
|
551
|
+
if (!res.ok) return "";
|
|
552
|
+
return /"wid":"(\d+)"/.exec(await res.text())?.[1] ?? "";
|
|
553
|
+
}
|
|
554
|
+
function avatarLinks(avatars) {
|
|
555
|
+
const urls = [];
|
|
556
|
+
const walk = (v) => {
|
|
557
|
+
if (typeof v === "string") {
|
|
558
|
+
if (/^https?:\/\//.test(v) && !urls.includes(v)) urls.push(v);
|
|
559
|
+
} else if (Array.isArray(v)) v.forEach(walk);
|
|
560
|
+
else if (v && typeof v === "object") Object.values(v).forEach(walk);
|
|
561
|
+
};
|
|
562
|
+
walk(avatars);
|
|
563
|
+
const heic = (u) => /\.heic(\?|$)/i.test(u.split("?")[0] + "?");
|
|
564
|
+
const ranked = [...urls.filter((u) => !heic(u)), ...urls.filter(heic)];
|
|
565
|
+
return { avatar: ranked[0] ?? "", avatars: ranked };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/api/media.ts
|
|
569
|
+
var import_node_crypto2 = require("crypto");
|
|
570
|
+
var import_node_zlib = require("zlib");
|
|
571
|
+
var sha256 = (s) => (0, import_node_crypto2.createHash)("sha256").update(s).digest("hex");
|
|
572
|
+
var hmac = (k, s) => (0, import_node_crypto2.createHmac)("sha256", k).update(s).digest();
|
|
573
|
+
var rfc3986 = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
574
|
+
var MediaClient = class {
|
|
575
|
+
constructor(ctx) {
|
|
576
|
+
this.ctx = ctx;
|
|
577
|
+
}
|
|
578
|
+
ctx;
|
|
579
|
+
cred = null;
|
|
580
|
+
/** `UploadConfig` returns two credential sets: type 1 = ImageX (images), type 2 = VOD (video). Use type 1. */
|
|
581
|
+
async credential() {
|
|
582
|
+
if (this.cred && this.cred.expiresAt - Date.now() > 6e4) return this.cred;
|
|
583
|
+
const body = await this.ctx.im("/v1/media/upload_config", Cmd.UploadConfig, Buffer.alloc(0));
|
|
584
|
+
const c = body.msgs(1).find((m) => m.num(1) === 1);
|
|
585
|
+
if (!c) throw new TikTokError("UPLOAD_FAILED", "upload_config has no ImageX credentials");
|
|
586
|
+
this.cred = {
|
|
587
|
+
serviceId: c.str(2),
|
|
588
|
+
host: c.str(3),
|
|
589
|
+
expiresAt: c.num(4),
|
|
590
|
+
accessKeyId: c.str(6),
|
|
591
|
+
sessionToken: c.str(7),
|
|
592
|
+
secretAccessKey: c.str(8)
|
|
593
|
+
};
|
|
594
|
+
return this.cred;
|
|
595
|
+
}
|
|
596
|
+
/** Call ImageX signed with AWS SigV4 (region cn-north-1, service imagex). */
|
|
597
|
+
async imagex(method, params, body) {
|
|
598
|
+
const c = await this.credential();
|
|
599
|
+
const amzDate = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
|
|
600
|
+
const date = amzDate.slice(0, 8);
|
|
601
|
+
const query = Object.keys(params).sort().map((k2) => `${rfc3986(k2)}=${rfc3986(params[k2])}`).join("&");
|
|
602
|
+
const headers = { "x-amz-date": amzDate, "x-amz-security-token": c.sessionToken };
|
|
603
|
+
if (body !== void 0) headers["x-amz-content-sha256"] = sha256(body);
|
|
604
|
+
const signed = Object.keys(headers).sort();
|
|
605
|
+
const canonical = [
|
|
606
|
+
method,
|
|
607
|
+
"/",
|
|
608
|
+
query,
|
|
609
|
+
signed.map((k2) => `${k2}:${headers[k2]}
|
|
610
|
+
`).join(""),
|
|
611
|
+
signed.join(";"),
|
|
612
|
+
body !== void 0 ? headers["x-amz-content-sha256"] : sha256("")
|
|
613
|
+
].join("\n");
|
|
614
|
+
const scope = `${date}/${IMAGEX_REGION}/${IMAGEX_SERVICE}/aws4_request`;
|
|
615
|
+
const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonical)].join("\n");
|
|
616
|
+
let k = hmac(`AWS4${c.secretAccessKey}`, date);
|
|
617
|
+
k = hmac(k, IMAGEX_REGION);
|
|
618
|
+
k = hmac(k, IMAGEX_SERVICE);
|
|
619
|
+
k = hmac(k, "aws4_request");
|
|
620
|
+
const signature = (0, import_node_crypto2.createHmac)("sha256", k).update(toSign).digest("hex");
|
|
621
|
+
const res = await fetch(`https://${c.host}/?${query}`, {
|
|
622
|
+
method,
|
|
623
|
+
headers: {
|
|
624
|
+
...headers,
|
|
625
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${c.accessKeyId}/${scope}, SignedHeaders=${signed.join(";")}, Signature=${signature}`,
|
|
626
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
627
|
+
},
|
|
628
|
+
body,
|
|
629
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 2e4)
|
|
630
|
+
});
|
|
631
|
+
const json = await res.json().catch(() => null);
|
|
632
|
+
const err = json?.ResponseMetadata?.Error;
|
|
633
|
+
if (!res.ok || !json || err) {
|
|
634
|
+
throw new TikTokError(
|
|
635
|
+
"UPLOAD_FAILED",
|
|
636
|
+
`imagex ${params.Action}: ${err?.Message ?? `HTTP ${res.status}`}`,
|
|
637
|
+
err?.CodeN ?? res.status
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
return json;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Upload an image to the private message store: Apply → POST file → Commit (the store encrypts with AES-GCM
|
|
644
|
+
* and returns the key). Image dimensions come from the Commit result — no need to decode the image.
|
|
645
|
+
*/
|
|
646
|
+
async upload(image) {
|
|
647
|
+
const c = await this.credential();
|
|
648
|
+
const ext = (/\.([a-z0-9]{2,5})$/i.exec(image.filename)?.[1] ?? "jpg").toLowerCase();
|
|
649
|
+
const apply = await this.imagex("GET", {
|
|
650
|
+
Action: "ApplyImageUpload",
|
|
651
|
+
Version: IMAGEX_VERSION,
|
|
652
|
+
ServiceId: c.serviceId,
|
|
653
|
+
FileSize: String(image.data.length),
|
|
654
|
+
FileExtension: `.${ext}`,
|
|
655
|
+
s: (0, import_node_crypto2.randomBytes)(6).toString("hex").slice(0, 10)
|
|
656
|
+
});
|
|
657
|
+
const addr = apply.Result.UploadAddress;
|
|
658
|
+
const store = addr.StoreInfos[0];
|
|
659
|
+
const host = addr.UploadHosts[0];
|
|
660
|
+
if (!store || !host) throw new TikTokError("UPLOAD_FAILED", "ApplyImageUpload returned no upload target");
|
|
661
|
+
const up = await fetch(`https://${host}/upload/v1/${store.StoreUri}`, {
|
|
662
|
+
method: "POST",
|
|
663
|
+
headers: {
|
|
664
|
+
Authorization: store.Auth,
|
|
665
|
+
"Content-CRC32": ((0, import_node_zlib.crc32)(image.data) >>> 0).toString(16).padStart(8, "0"),
|
|
666
|
+
// Without this header the store returns 4004 Not Found.
|
|
667
|
+
"X-Storage-U": this.ctx.ownId,
|
|
668
|
+
"Content-Type": "application/octet-stream",
|
|
669
|
+
"Content-Disposition": 'attachment; filename="undefined"'
|
|
670
|
+
},
|
|
671
|
+
body: new Uint8Array(image.data),
|
|
672
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 6e4)
|
|
673
|
+
});
|
|
674
|
+
const upJson = await up.json().catch(() => null);
|
|
675
|
+
if (!up.ok || upJson?.code !== 2e3) {
|
|
676
|
+
throw new TikTokError(
|
|
677
|
+
"UPLOAD_FAILED",
|
|
678
|
+
`upload: ${upJson?.message ?? `HTTP ${up.status}`}`,
|
|
679
|
+
upJson?.code ?? up.status
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const commit = await this.imagex(
|
|
683
|
+
"POST",
|
|
684
|
+
{ Action: "CommitImageUpload", Version: IMAGEX_VERSION, ServiceId: c.serviceId },
|
|
685
|
+
JSON.stringify({
|
|
686
|
+
SessionKey: addr.SessionKey,
|
|
687
|
+
Functions: [{ name: "Encryption", input: { Config: { copies: "cipher_v2" } } }]
|
|
688
|
+
})
|
|
689
|
+
);
|
|
690
|
+
const enc = commit.Result.Results?.[0]?.Encryption;
|
|
691
|
+
const plugin = commit.Result.PluginResult?.[0];
|
|
692
|
+
if (!enc?.Uri || !enc.SecretKey)
|
|
693
|
+
throw new TikTokError("UPLOAD_FAILED", "CommitImageUpload returned no encrypted result");
|
|
694
|
+
return {
|
|
695
|
+
uri: enc.Uri,
|
|
696
|
+
secretKey: enc.SecretKey,
|
|
697
|
+
width: plugin?.ImageWidth ?? Number(enc.Extra?.img_width ?? 0),
|
|
698
|
+
height: plugin?.ImageHeight ?? Number(enc.Extra?.img_height ?? 0),
|
|
699
|
+
size: plugin?.ImageSize ?? image.data.length,
|
|
700
|
+
filename: image.filename
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/** Download and decrypt a message image. Layout: 12-byte IV ‖ ciphertext ‖ 16-byte tag. */
|
|
704
|
+
async download(image, which = "origin") {
|
|
705
|
+
const url = which === "thumb" && image.thumbUrl ? image.thumbUrl : image.url;
|
|
706
|
+
if (!url) throw new TikTokError("DOWNLOAD_FAILED", "Image has no URL");
|
|
707
|
+
const res = await this.ctx.fetch(url);
|
|
708
|
+
if (!res.ok) throw new TikTokError("DOWNLOAD_FAILED", `Image download: HTTP ${res.status}`, res.status);
|
|
709
|
+
const enc = Buffer.from(await res.arrayBuffer());
|
|
710
|
+
if (!image.decryptKey) return enc;
|
|
711
|
+
try {
|
|
712
|
+
const d = (0, import_node_crypto2.createDecipheriv)("aes-256-gcm", Buffer.from(image.decryptKey, "hex"), enc.subarray(0, 12));
|
|
713
|
+
d.setAuthTag(enc.subarray(enc.length - 16));
|
|
714
|
+
return Buffer.concat([d.update(enc.subarray(12, enc.length - 16)), d.final()]);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
throw new TikTokError("DOWNLOAD_FAILED", `Image decryption: ${err instanceof Error ? err.message : String(err)}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// src/api/requests.ts
|
|
722
|
+
var NEWEST = 9999999999999999n;
|
|
723
|
+
function getMessagesBody(conv, limit, before) {
|
|
724
|
+
return new Writer().bytes(1, conv.conversationId).int(2, ConversationType.OneToOne).int(3, BigInt(conv.conversationShortId)).int(4, 1).int(5, before ? BigInt(before) : NEWEST).int(6, limit).finish();
|
|
725
|
+
}
|
|
726
|
+
function markReadBody(msg) {
|
|
727
|
+
return new Writer().bytes(1, msg.conversationId).int(2, BigInt(msg.conversationShortId)).int(3, ConversationType.OneToOne).int(4, BigInt(msg.index)).int(11, 4).finish();
|
|
728
|
+
}
|
|
729
|
+
function sendMessageBody(conv, cid, type, content, extra) {
|
|
730
|
+
const w = new Writer().bytes(1, conv.conversationId).int(2, ConversationType.OneToOne).int(3, BigInt(conv.conversationShortId)).bytes(4, content).map(5, { "s:mentioned_users": "", "s:client_message_id": cid }).int(6, type).bytes(7, "deprecated").bytes(8, cid);
|
|
731
|
+
return extra ? Buffer.concat([w.finish(), extra.finish()]) : w.finish();
|
|
732
|
+
}
|
|
733
|
+
function imageMessageExtra(up) {
|
|
734
|
+
const w = String(up.width);
|
|
735
|
+
const h = String(up.height);
|
|
736
|
+
const media = new Writer().msg(
|
|
737
|
+
1,
|
|
738
|
+
new Writer().bytes(1, up.uri).msg(5, new Writer().int(1, up.width).int(2, up.height)).bytes(6, up.secretKey).int(7, 0)
|
|
739
|
+
).msg(
|
|
740
|
+
3,
|
|
741
|
+
new Writer().msg(1, new Writer().bytes(1, "You sent a \u{1F4F7}")).msg(2, new Writer().bytes(1, "sent a \u{1F4F7}")).msg(3, new Writer().bytes(1, "[Photo]"))
|
|
742
|
+
).msg(
|
|
743
|
+
200,
|
|
744
|
+
new Writer().msg(
|
|
745
|
+
1,
|
|
746
|
+
new Writer().bytes(1, up.uri).map(2, {
|
|
747
|
+
image_width: w,
|
|
748
|
+
image_height: h,
|
|
749
|
+
decrypt_key: up.secretKey,
|
|
750
|
+
quote_preview: "dm_cam_preview_photo",
|
|
751
|
+
sender_preview: "sender_preview"
|
|
752
|
+
})
|
|
753
|
+
)
|
|
754
|
+
);
|
|
755
|
+
return new Writer().msg(13, new Writer().msg(4, media)).bytes(14, "private_image").msg(
|
|
756
|
+
17,
|
|
757
|
+
new Writer().int(1, 1).bytes(2, up.uri).bytes(3, up.secretKey).msg(10, new Writer().int(1, up.width).int(2, up.height).int(4, up.size).bytes(5, up.filename))
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
function reactionOps(msg, own, emoji) {
|
|
761
|
+
const mine = msg.reactions.filter((r) => r.userIds.includes(own)).map((r) => r.emoji);
|
|
762
|
+
const ops = [];
|
|
763
|
+
if (emoji && !mine.includes(emoji)) ops.push({ op: PropertyOp.Add, key: REACTION_PREFIX + emoji });
|
|
764
|
+
for (const e of mine) {
|
|
765
|
+
if (e === emoji) continue;
|
|
766
|
+
ops.push({ op: PropertyOp.Remove, key: REACTION_PREFIX + e });
|
|
767
|
+
if (e === REACTION_HEART) ops.push({ op: PropertyOp.Remove, key: REACTION_LOVE_KEY });
|
|
768
|
+
}
|
|
769
|
+
return ops;
|
|
770
|
+
}
|
|
771
|
+
function setPropertyBody(msg, ops, own) {
|
|
772
|
+
const target = new Writer().bytes(1, msg.conversationId).int(2, ConversationType.OneToOne).int(3, BigInt(msg.conversationShortId)).int(4, BigInt(msg.serverMessageId)).bytes(5, msg.clientMessageId);
|
|
773
|
+
for (const o of ops) target.msg(6, new Writer().int(1, o.op).bytes(2, o.key).bytes(4, own));
|
|
774
|
+
return new Writer().msg(1, target).bytes(2, "deprecated").finish();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/api/api.ts
|
|
778
|
+
var API = class {
|
|
779
|
+
constructor(ctx, account) {
|
|
780
|
+
this.ctx = ctx;
|
|
781
|
+
this.account = account;
|
|
782
|
+
this.listener = new Listener(ctx);
|
|
783
|
+
this.media = new MediaClient(ctx);
|
|
784
|
+
}
|
|
785
|
+
ctx;
|
|
786
|
+
account;
|
|
787
|
+
listener;
|
|
788
|
+
media;
|
|
789
|
+
getOwnId() {
|
|
790
|
+
return this.ctx.ownId;
|
|
791
|
+
}
|
|
792
|
+
/** Current cookies — save them so the next `login` needs no QR scan. */
|
|
793
|
+
getCookies() {
|
|
794
|
+
return [...this.ctx.cookies].map(([name, value]) => ({ name, value }));
|
|
795
|
+
}
|
|
796
|
+
getContext() {
|
|
797
|
+
return { deviceId: this.ctx.deviceId, userAgent: this.ctx.userAgent };
|
|
798
|
+
}
|
|
799
|
+
/** Whether the session is still alive — call periodically to detect expiry early. */
|
|
800
|
+
async isAlive() {
|
|
801
|
+
return await fetchAccount(this.ctx).catch(() => null) !== null;
|
|
802
|
+
}
|
|
803
|
+
// ─── Inbox ─────────────────────────────────────────────────────────────────
|
|
804
|
+
/** Recent conversations + their latest messages, paginated with `cursor`. */
|
|
805
|
+
async getConversations(cursor = "0") {
|
|
806
|
+
const body = await this.ctx.im(
|
|
807
|
+
"/v2/message/get_by_user_init",
|
|
808
|
+
Cmd.GetMessagesByUserInit,
|
|
809
|
+
new Writer().int(1, BigInt(cursor)).finish()
|
|
810
|
+
);
|
|
811
|
+
return {
|
|
812
|
+
messages: body.msgs(1).map((m) => parseMessage(m, this.ctx.ownId)),
|
|
813
|
+
conversations: body.msgs(2).map((m) => parseConversation(m, this.ctx.ownId)),
|
|
814
|
+
// Cursor = number of conversations fetched so far (0, 10, 20…) — field 4; field 3 is a timestamp, not used for paging.
|
|
815
|
+
cursor: body.id(4),
|
|
816
|
+
hasMore: body.num(5) === 1
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/** Messages of a conversation, newest first. For older messages: `before = <index of oldest message>`. */
|
|
820
|
+
async getMessages(conv, opts = {}) {
|
|
821
|
+
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 50);
|
|
822
|
+
const body = await this.ctx.im(
|
|
823
|
+
"/v1/message/get_by_conversation",
|
|
824
|
+
Cmd.GetMessagesByConversation,
|
|
825
|
+
getMessagesBody(conv, limit, opts.before)
|
|
826
|
+
);
|
|
827
|
+
const messages = body.msgs(1).map((m) => parseMessage(m, this.ctx.ownId));
|
|
828
|
+
return { messages, hasMore: messages.length >= limit };
|
|
829
|
+
}
|
|
830
|
+
/** User profiles by uid (display name, @username, avatar). */
|
|
831
|
+
async getUsers(uids) {
|
|
832
|
+
if (uids.length === 0) return [];
|
|
833
|
+
const q = new URLSearchParams({
|
|
834
|
+
aid: "1988",
|
|
835
|
+
app_name: "tiktok_web",
|
|
836
|
+
device_platform: "web_pc",
|
|
837
|
+
source: "im_chat",
|
|
838
|
+
from_page: "message",
|
|
839
|
+
user_ids: JSON.stringify(uids)
|
|
840
|
+
});
|
|
841
|
+
const res = await this.ctx.getJson(`${WEB}/tiktok/v1/im/user/profile/?${q}`);
|
|
842
|
+
if (res.status_code) throw new TikTokError("IM_ERROR", `user/profile: ${res.status_code}`, res.status_code);
|
|
843
|
+
return (res.users ?? []).map(({ im_user_profile: p = {} }) => ({
|
|
844
|
+
uid: p.user_id_str ?? "",
|
|
845
|
+
uniqueId: p.unique_id ?? "",
|
|
846
|
+
nickname: p.nick_name ?? "",
|
|
847
|
+
...avatarLinks(p.avatars)
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Download an avatar (tries each URL in turn, skips heic). No cookie / Referer sent: the image CDN is cross-origin and
|
|
852
|
+
* URLs are already signed. `null` if no URL could be downloaded.
|
|
853
|
+
*/
|
|
854
|
+
async downloadAvatar(user) {
|
|
855
|
+
for (const url of user.avatars) {
|
|
856
|
+
const res = await fetch(url, {
|
|
857
|
+
headers: { "User-Agent": this.ctx.userAgent },
|
|
858
|
+
referrerPolicy: "no-referrer",
|
|
859
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 2e4)
|
|
860
|
+
}).catch(() => null);
|
|
861
|
+
const type = res?.headers.get("content-type") ?? "";
|
|
862
|
+
if (!res?.ok || !/^image\/(jpeg|png|webp|gif)/.test(type)) continue;
|
|
863
|
+
return { data: Buffer.from(await res.arrayBuffer()), type };
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
/** Mark as read up to message `msg`. */
|
|
868
|
+
async markRead(msg) {
|
|
869
|
+
await this.ctx.im("/v3/conversation/mark_read", Cmd.MarkRead, markReadBody(msg), MARK_READ_BODY_FIELD);
|
|
870
|
+
}
|
|
871
|
+
// ─── Send ─────────────────────────────────────────────────────────────────
|
|
872
|
+
/** Send a text message to a conversation (usually the just-received `msg` — it carries the conversation ids). */
|
|
873
|
+
async sendMessage(conv, text) {
|
|
874
|
+
const cid = (0, import_node_crypto3.randomUUID)();
|
|
875
|
+
await this.send(conv, cid, MessageType.Text, JSON.stringify({ aweType: 0, text }), null);
|
|
876
|
+
return { clientMessageId: cid };
|
|
877
|
+
}
|
|
878
|
+
/** Send an image: upload to the private (encrypted) store, then send an image message. */
|
|
879
|
+
async sendImage(conv, image) {
|
|
880
|
+
const up = await this.media.upload(image);
|
|
881
|
+
const cid = (0, import_node_crypto3.randomUUID)();
|
|
882
|
+
await this.send(conv, cid, MessageType.PrivateImage, "", imageMessageExtra(up));
|
|
883
|
+
return { clientMessageId: cid };
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Set YOUR reaction on a message — like TikTok Web, one reaction per user: a new `emoji` replaces the old one,
|
|
887
|
+
* `null` = remove. Pass the whole `msg` just read (current `reactions` are needed to know which key to remove).
|
|
888
|
+
* Sends `ModifyMessagePropertyRequestBody` (command 705) — see README.
|
|
889
|
+
*/
|
|
890
|
+
async setReaction(msg, emoji) {
|
|
891
|
+
const own = this.ctx.ownId;
|
|
892
|
+
const ops = reactionOps(msg, own, emoji);
|
|
893
|
+
if (ops.length === 0) return;
|
|
894
|
+
const body = await this.ctx.im("/v1/message/set_property", Cmd.SetMessageProperty, setPropertyBody(msg, ops, own));
|
|
895
|
+
const status = body.num(1);
|
|
896
|
+
if (status > 1) throw new TikTokError("IM_ERROR", `set_property: status ${status}`, status);
|
|
897
|
+
}
|
|
898
|
+
// ─── Media ─────────────────────────────────────────────────────────────────
|
|
899
|
+
/** Download a sticker image (public CDN, unencrypted, no cookie needed). */
|
|
900
|
+
async downloadSticker(sticker) {
|
|
901
|
+
const res = await fetch(sticker.url, {
|
|
902
|
+
headers: { "User-Agent": this.ctx.userAgent },
|
|
903
|
+
referrerPolicy: "no-referrer",
|
|
904
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 2e4)
|
|
905
|
+
}).catch(() => null);
|
|
906
|
+
if (!res?.ok) throw new TikTokError("DOWNLOAD_FAILED", `Sticker download failed: ${res?.status ?? "network"}`);
|
|
907
|
+
return { data: Buffer.from(await res.arrayBuffer()), type: res.headers.get("content-type") ?? "image/webp" };
|
|
908
|
+
}
|
|
909
|
+
/** Download + decrypt a message's image (pass the whole `msg` — listener messages lack the URL, this looks it up). */
|
|
910
|
+
async downloadImage(source, which = "origin") {
|
|
911
|
+
let image = "serverMessageId" in source ? source.image : source;
|
|
912
|
+
if ("serverMessageId" in source && image && !image.url) {
|
|
913
|
+
const { messages } = await this.getMessages(source, { limit: 5, before: (BigInt(source.index) + 1n).toString() });
|
|
914
|
+
image = messages.find((m) => m.serverMessageId === source.serverMessageId)?.image ?? image;
|
|
915
|
+
}
|
|
916
|
+
if (!image) throw new TikTokError("DOWNLOAD_FAILED", "Message has no image");
|
|
917
|
+
return this.media.download(image, which);
|
|
918
|
+
}
|
|
919
|
+
async send(conv, cid, type, content, extra) {
|
|
920
|
+
if (!conv.conversationId || !conv.conversationShortId) {
|
|
921
|
+
throw new TikTokError("IM_ERROR", "Missing conversationId / conversationShortId");
|
|
922
|
+
}
|
|
923
|
+
await this.ctx.im("/v1/message/send", Cmd.SendMessage, sendMessageBody(conv, cid, type, content, extra));
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// src/protocol/context.ts
|
|
928
|
+
function normalizeCookies(input) {
|
|
929
|
+
const jar = /* @__PURE__ */ new Map();
|
|
930
|
+
if (typeof input === "string") {
|
|
931
|
+
for (const part of input.split(";")) {
|
|
932
|
+
const i = part.indexOf("=");
|
|
933
|
+
if (i > 0) jar.set(part.slice(0, i).trim(), part.slice(i + 1).trim());
|
|
934
|
+
}
|
|
935
|
+
} else if (Array.isArray(input)) {
|
|
936
|
+
for (const c of input) if (c?.name) jar.set(c.name, String(c.value ?? ""));
|
|
937
|
+
} else {
|
|
938
|
+
for (const [k, v] of Object.entries(input ?? {})) jar.set(k, String(v));
|
|
939
|
+
}
|
|
940
|
+
return jar;
|
|
941
|
+
}
|
|
942
|
+
var Context = class {
|
|
943
|
+
constructor(creds, options = {}) {
|
|
944
|
+
this.options = options;
|
|
945
|
+
this.cookies = normalizeCookies(creds.cookies);
|
|
946
|
+
this.deviceId = creds.deviceId ?? "";
|
|
947
|
+
this.userAgent = creds.userAgent || DEFAULT_USER_AGENT;
|
|
948
|
+
}
|
|
949
|
+
options;
|
|
950
|
+
cookies;
|
|
951
|
+
/**
|
|
952
|
+
* IM device id = the `wid` TikTok issues for the `ttwid` cookie (`login` fetches it). The websocket only
|
|
953
|
+
* accepts this id — a made-up id gets 400 (HTTP IM isn't picky).
|
|
954
|
+
*/
|
|
955
|
+
deviceId;
|
|
956
|
+
userAgent;
|
|
957
|
+
ownId = "";
|
|
958
|
+
seq = 1e4;
|
|
959
|
+
cookieHeader() {
|
|
960
|
+
return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
961
|
+
}
|
|
962
|
+
log(level, msg) {
|
|
963
|
+
this.options.logger?.(level, msg);
|
|
964
|
+
}
|
|
965
|
+
/** `fetch` with cookies + UA + timeout. */
|
|
966
|
+
async fetch(url, init = {}) {
|
|
967
|
+
try {
|
|
968
|
+
return await fetch(url, {
|
|
969
|
+
...init,
|
|
970
|
+
headers: {
|
|
971
|
+
"User-Agent": this.userAgent,
|
|
972
|
+
Referer: `${WEB}/`,
|
|
973
|
+
Origin: WEB,
|
|
974
|
+
Cookie: this.cookieHeader(),
|
|
975
|
+
...init.headers
|
|
976
|
+
},
|
|
977
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? 2e4)
|
|
978
|
+
});
|
|
979
|
+
} catch (err) {
|
|
980
|
+
throw new TikTokError("HTTP_ERROR", `${url.split("?")[0]}: ${err instanceof Error ? err.message : String(err)}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async getJson(url) {
|
|
984
|
+
const res = await this.fetch(url);
|
|
985
|
+
if (!res.ok) throw new TikTokError("HTTP_ERROR", `${url.split("?")[0]}: HTTP ${res.status}`, res.status);
|
|
986
|
+
return await res.json();
|
|
987
|
+
}
|
|
988
|
+
/** IM `Request` envelope: command + body + common headers. */
|
|
989
|
+
buildRequest(cmd, bodyField, body) {
|
|
990
|
+
return new Writer().int(1, cmd).int(2, this.seq++).bytes(3, SDK_VERSION).bytes(4, "").int(5, 3).int(6, 0).bytes(7, BUILD_NUMBER).msg(8, new Writer().bytes(bodyField, body)).bytes(9, this.deviceId).bytes(11, "web").map(15, {
|
|
991
|
+
aid: AID_WEB,
|
|
992
|
+
app_name: "tiktok_web",
|
|
993
|
+
channel: "web",
|
|
994
|
+
device_platform: "web_pc",
|
|
995
|
+
device_id: this.deviceId,
|
|
996
|
+
user_is_login: "true",
|
|
997
|
+
user_agent: this.userAgent
|
|
998
|
+
}).int(18, 1).finish();
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
|
|
1002
|
+
* NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 09/11/2026).
|
|
1003
|
+
*/
|
|
1004
|
+
async im(path, cmd, body, bodyField = cmd) {
|
|
1005
|
+
const url = `${IM_API}${path}?aid=${AID_WEB}&version_code=1.0.0&app_name=tiktok_web&device_platform=web_pc`;
|
|
1006
|
+
const res = await this.fetch(url, {
|
|
1007
|
+
method: "POST",
|
|
1008
|
+
headers: { "Content-Type": "application/x-protobuf", Accept: "application/x-protobuf" },
|
|
1009
|
+
body: new Uint8Array(this.buildRequest(cmd, bodyField, body))
|
|
1010
|
+
});
|
|
1011
|
+
if (!res.ok) throw new TikTokError("HTTP_ERROR", `${path}: HTTP ${res.status}`, res.status);
|
|
1012
|
+
const raw = Buffer.from(await res.arrayBuffer());
|
|
1013
|
+
if (/json/i.test(res.headers.get("content-type") ?? "")) throw gatewayError(path, raw);
|
|
1014
|
+
const resp = Msg.tryParse(raw);
|
|
1015
|
+
if (!resp) throw new TikTokError("BAD_RESPONSE", `${path}: response is not protobuf`);
|
|
1016
|
+
const status = resp.num(Resp.status);
|
|
1017
|
+
if (status !== 0) {
|
|
1018
|
+
const desc = resp.str(Resp.error);
|
|
1019
|
+
const code = status === 200005 ? "SESSION_EXPIRED" : "IM_ERROR";
|
|
1020
|
+
throw new TikTokError(code, `${path}: ${status} ${desc}`.trim(), status);
|
|
1021
|
+
}
|
|
1022
|
+
return resp.msg(Resp.body)?.msg(cmd) ?? resp.msg(Resp.body)?.msg(bodyField) ?? Msg.parse(Buffer.alloc(0));
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
function gatewayError(path, raw) {
|
|
1026
|
+
let status;
|
|
1027
|
+
try {
|
|
1028
|
+
status = JSON.parse(raw.toString("utf8")).status_code;
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
if (status === 7) return new TikTokError("SESSION_BLOCKED", `${path}: gateway status_code 7 \u2014 session blocked`, 7);
|
|
1032
|
+
return new TikTokError(
|
|
1033
|
+
"BAD_RESPONSE",
|
|
1034
|
+
`${path}: gateway JSON status_code ${String(status)}`,
|
|
1035
|
+
typeof status === "number" ? status : void 0
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/client.ts
|
|
1040
|
+
var TikTok = class {
|
|
1041
|
+
constructor(options = {}) {
|
|
1042
|
+
this.options = options;
|
|
1043
|
+
}
|
|
1044
|
+
options;
|
|
1045
|
+
/** Log in with existing cookies. Expired cookies → `TikTokError('SESSION_EXPIRED')`. */
|
|
1046
|
+
async login(credentials) {
|
|
1047
|
+
const ctx = new Context(credentials, this.options);
|
|
1048
|
+
if (!ctx.cookies.get("sessionid") && !ctx.cookies.get("sid_tt")) {
|
|
1049
|
+
throw new TikTokError("SESSION_EXPIRED", "Cookie has no sessionid");
|
|
1050
|
+
}
|
|
1051
|
+
const account = await fetchAccount(ctx);
|
|
1052
|
+
if (!account) throw new TikTokError("SESSION_EXPIRED", "TikTok cookie is no longer logged in");
|
|
1053
|
+
ctx.ownId = account.uid;
|
|
1054
|
+
ctx.deviceId = await fetchWebId(ctx).catch(() => "") || ctx.deviceId;
|
|
1055
|
+
if (!ctx.deviceId) throw new TikTokError("BAD_RESPONSE", "Could not get wid (device_id) \u2014 cookie missing ttwid?");
|
|
1056
|
+
return new API(ctx, account);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// src/auth/qr-login.ts
|
|
1061
|
+
var import_promises2 = require("fs/promises");
|
|
1062
|
+
|
|
1063
|
+
// src/auth/cdp.ts
|
|
1064
|
+
var import_ws2 = __toESM(require("ws"));
|
|
1065
|
+
async function openTab(browserWs) {
|
|
1066
|
+
const ws = new import_ws2.default(browserWs, { perMessageDeflate: false });
|
|
1067
|
+
await new Promise((resolve, reject) => {
|
|
1068
|
+
ws.once("open", () => resolve());
|
|
1069
|
+
ws.once("error", reject);
|
|
1070
|
+
});
|
|
1071
|
+
let id = 0;
|
|
1072
|
+
let sessionId;
|
|
1073
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1074
|
+
const handlers = [];
|
|
1075
|
+
ws.on("message", (raw2) => {
|
|
1076
|
+
const m = JSON.parse(raw2.toString());
|
|
1077
|
+
if (m.id !== void 0 && pending.has(m.id)) {
|
|
1078
|
+
const p = pending.get(m.id);
|
|
1079
|
+
pending.delete(m.id);
|
|
1080
|
+
if (m.error) p.reject(new Error(m.error.message));
|
|
1081
|
+
else p.resolve(m.result ?? {});
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (m.method && (!m.sessionId || m.sessionId === sessionId)) for (const h of handlers) h(m.method, m.params ?? {});
|
|
1085
|
+
});
|
|
1086
|
+
const raw = (method, params = {}, sid) => new Promise((resolve, reject) => {
|
|
1087
|
+
const i = ++id;
|
|
1088
|
+
pending.set(i, { resolve, reject });
|
|
1089
|
+
ws.send(JSON.stringify({ id: i, method, params, ...sid ? { sessionId: sid } : {} }));
|
|
1090
|
+
});
|
|
1091
|
+
const target = await raw("Target.createTarget", { url: "about:blank" });
|
|
1092
|
+
sessionId = (await raw("Target.attachToTarget", { targetId: target.targetId, flatten: true })).sessionId;
|
|
1093
|
+
return {
|
|
1094
|
+
send: (method, params) => raw(method, params, sessionId),
|
|
1095
|
+
on: (h) => handlers.push(h),
|
|
1096
|
+
close: () => ws.close()
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/auth/chrome.ts
|
|
1101
|
+
var import_node_child_process = require("child_process");
|
|
1102
|
+
var import_node_fs = require("fs");
|
|
1103
|
+
var import_promises = require("fs/promises");
|
|
1104
|
+
var import_node_os = require("os");
|
|
1105
|
+
var import_node_path = require("path");
|
|
1106
|
+
var PATH_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"];
|
|
1107
|
+
function knownPaths(platform, env) {
|
|
1108
|
+
if (platform === "darwin") {
|
|
1109
|
+
const apps = [
|
|
1110
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1111
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
1112
|
+
"Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
|
|
1113
|
+
];
|
|
1114
|
+
const roots = ["/Applications", ...env.HOME ? [(0, import_node_path.join)(env.HOME, "Applications")] : []];
|
|
1115
|
+
return roots.flatMap((r) => apps.map((a) => (0, import_node_path.join)(r, a)));
|
|
1116
|
+
}
|
|
1117
|
+
if (platform === "win32") {
|
|
1118
|
+
const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter((r) => !!r);
|
|
1119
|
+
return roots.flatMap((r) => [
|
|
1120
|
+
(0, import_node_path.join)(r, "Google", "Chrome", "Application", "chrome.exe"),
|
|
1121
|
+
(0, import_node_path.join)(r, "Chromium", "Application", "chrome.exe")
|
|
1122
|
+
]);
|
|
1123
|
+
}
|
|
1124
|
+
return [
|
|
1125
|
+
"/usr/bin/google-chrome",
|
|
1126
|
+
"/usr/bin/google-chrome-stable",
|
|
1127
|
+
"/usr/bin/chromium",
|
|
1128
|
+
"/usr/bin/chromium-browser",
|
|
1129
|
+
"/snap/bin/chromium",
|
|
1130
|
+
"/opt/google/chrome/chrome"
|
|
1131
|
+
];
|
|
1132
|
+
}
|
|
1133
|
+
function findChrome(env = process.env, platform = process.platform, exists = import_node_fs.existsSync) {
|
|
1134
|
+
if (env.CHROME_PATH && exists(env.CHROME_PATH)) return env.CHROME_PATH;
|
|
1135
|
+
for (const p of knownPaths(platform, env)) if (exists(p)) return p;
|
|
1136
|
+
if (platform !== "win32") {
|
|
1137
|
+
for (const dir of (env.PATH ?? "").split(import_node_path.delimiter).filter(Boolean)) {
|
|
1138
|
+
for (const name of PATH_NAMES) if (exists((0, import_node_path.join)(dir, name))) return (0, import_node_path.join)(dir, name);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
function resolveChromePath(chromePath) {
|
|
1144
|
+
if (chromePath) return chromePath;
|
|
1145
|
+
const found = findChrome();
|
|
1146
|
+
if (!found) {
|
|
1147
|
+
throw new TikTokError(
|
|
1148
|
+
"LOGIN_FAILED",
|
|
1149
|
+
"Chrome / Chromium not found \u2014 install it, set the CHROME_PATH environment variable, or pass `chromePath`"
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
return found;
|
|
1153
|
+
}
|
|
1154
|
+
async function launchChrome(chromePath, userAgent) {
|
|
1155
|
+
const dir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "tca-chrome-"));
|
|
1156
|
+
const proc = (0, import_node_child_process.spawn)(
|
|
1157
|
+
chromePath,
|
|
1158
|
+
[
|
|
1159
|
+
"--headless=new",
|
|
1160
|
+
"--remote-debugging-port=0",
|
|
1161
|
+
`--user-data-dir=${dir}`,
|
|
1162
|
+
"--no-first-run",
|
|
1163
|
+
"--no-default-browser-check",
|
|
1164
|
+
"--disable-gpu",
|
|
1165
|
+
"--disable-dev-shm-usage",
|
|
1166
|
+
// Containers run as root — Chromium requires disabling the sandbox.
|
|
1167
|
+
"--no-sandbox",
|
|
1168
|
+
"--window-size=1280,900",
|
|
1169
|
+
"--lang=vi-VN",
|
|
1170
|
+
// The default headless UA contains "HeadlessChrome" — replace it with a normal UA.
|
|
1171
|
+
`--user-agent=${userAgent}`,
|
|
1172
|
+
"about:blank"
|
|
1173
|
+
],
|
|
1174
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
1175
|
+
);
|
|
1176
|
+
const ws = await new Promise((resolve, reject) => {
|
|
1177
|
+
let buf = "";
|
|
1178
|
+
const timer = setTimeout(() => reject(new Error("Chrome did not start within 20s")), 2e4);
|
|
1179
|
+
proc.stderr.on("data", (d) => {
|
|
1180
|
+
buf += d.toString();
|
|
1181
|
+
const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
|
|
1182
|
+
if (m) {
|
|
1183
|
+
clearTimeout(timer);
|
|
1184
|
+
resolve(m[1]);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
proc.once("error", (e) => {
|
|
1188
|
+
clearTimeout(timer);
|
|
1189
|
+
reject(e);
|
|
1190
|
+
});
|
|
1191
|
+
proc.once("exit", (code) => {
|
|
1192
|
+
clearTimeout(timer);
|
|
1193
|
+
reject(new Error(`Chrome exited (${code}) before it was ready`));
|
|
1194
|
+
});
|
|
1195
|
+
}).catch(async (err) => {
|
|
1196
|
+
proc.kill("SIGKILL");
|
|
1197
|
+
await (0, import_promises.rm)(dir, { recursive: true, force: true });
|
|
1198
|
+
throw new TikTokError(
|
|
1199
|
+
"LOGIN_FAILED",
|
|
1200
|
+
`Failed to launch Chrome: ${err instanceof Error ? err.message : String(err)}`
|
|
1201
|
+
);
|
|
1202
|
+
});
|
|
1203
|
+
return { proc, ws, dir };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// src/auth/qr-login.ts
|
|
1207
|
+
async function loginQR(opts) {
|
|
1208
|
+
const userAgent = opts.userAgent || DEFAULT_USER_AGENT;
|
|
1209
|
+
const maxRefresh = opts.maxRefresh ?? 3;
|
|
1210
|
+
const { proc, ws, dir } = await launchChrome(resolveChromePath(opts.chromePath), userAgent);
|
|
1211
|
+
let cdp = null;
|
|
1212
|
+
try {
|
|
1213
|
+
cdp = await openTab(ws);
|
|
1214
|
+
const tab = cdp;
|
|
1215
|
+
await tab.send("Network.enable");
|
|
1216
|
+
await tab.send("Page.enable");
|
|
1217
|
+
const result = new Promise((resolve, reject) => {
|
|
1218
|
+
const urlById = /* @__PURE__ */ new Map();
|
|
1219
|
+
let refreshes = 0;
|
|
1220
|
+
let scanned = false;
|
|
1221
|
+
let done = false;
|
|
1222
|
+
const finish = (err, creds) => {
|
|
1223
|
+
if (done) return;
|
|
1224
|
+
done = true;
|
|
1225
|
+
clearTimeout(timer);
|
|
1226
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1227
|
+
if (err) reject(err);
|
|
1228
|
+
else resolve(creds);
|
|
1229
|
+
};
|
|
1230
|
+
const timer = setTimeout(
|
|
1231
|
+
() => finish(new TikTokError("LOGIN_FAILED", "Timed out waiting for QR scan")),
|
|
1232
|
+
opts.timeoutMs ?? 5 * 6e4
|
|
1233
|
+
);
|
|
1234
|
+
const onAbort = () => finish(new TikTokError("LOGIN_ABORTED", "QR login aborted"));
|
|
1235
|
+
opts.signal?.addEventListener("abort", onAbort);
|
|
1236
|
+
const body = async (requestId) => {
|
|
1237
|
+
const r = await tab.send("Network.getResponseBody", { requestId }).catch(() => null);
|
|
1238
|
+
if (!r) return null;
|
|
1239
|
+
try {
|
|
1240
|
+
return JSON.parse(r.base64Encoded ? Buffer.from(r.body, "base64").toString() : r.body);
|
|
1241
|
+
} catch {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
const collectCookies = async () => {
|
|
1246
|
+
for (let i = 0; i < 30 && !done; i++) {
|
|
1247
|
+
const { cookies } = await tab.send(
|
|
1248
|
+
"Network.getAllCookies"
|
|
1249
|
+
);
|
|
1250
|
+
const mine = cookies.filter((c) => c.domain.endsWith("tiktok.com"));
|
|
1251
|
+
if (mine.some((c) => c.name === "sessionid" && c.value) && mine.some((c) => c.name === "ttwid")) {
|
|
1252
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1253
|
+
for (const c of mine.sort(
|
|
1254
|
+
(a, b) => (a.domain === ".tiktok.com" ? 1 : 0) - (b.domain === ".tiktok.com" ? 1 : 0)
|
|
1255
|
+
)) {
|
|
1256
|
+
byName.set(c.name, { name: c.name, value: c.value });
|
|
1257
|
+
}
|
|
1258
|
+
return finish(null, { cookies: [...byName.values()], userAgent });
|
|
1259
|
+
}
|
|
1260
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
32
1261
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
1262
|
+
finish(new TikTokError("LOGIN_FAILED", "Confirmed but no sessionid cookie found"));
|
|
1263
|
+
};
|
|
1264
|
+
tab.on((method, p) => {
|
|
1265
|
+
if (done) return;
|
|
1266
|
+
if (method === "Network.responseReceived") {
|
|
1267
|
+
const res = p.response;
|
|
1268
|
+
urlById.set(p.requestId, res.url);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (method !== "Network.loadingFinished") return;
|
|
1272
|
+
const url = urlById.get(p.requestId) ?? "";
|
|
1273
|
+
if (url.includes("/passport/web/get_qrcode/")) {
|
|
1274
|
+
void body(p.requestId).then((j) => {
|
|
1275
|
+
const qr = j?.data?.qrcode;
|
|
1276
|
+
if (typeof qr === "string" && qr) {
|
|
1277
|
+
opts.onEvent?.({
|
|
1278
|
+
type: "qr",
|
|
1279
|
+
image: `data:image/png;base64,${qr}`,
|
|
1280
|
+
expiresAt: Number(j?.data?.expire_time ?? 0) * 1e3 || Date.now() + 6e4
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
} else if (url.includes("/passport/web/check_qrconnect/")) {
|
|
1285
|
+
void body(p.requestId).then((j) => {
|
|
1286
|
+
const status = String(j?.data?.status ?? "");
|
|
1287
|
+
if (status === "scanned" && !scanned) {
|
|
1288
|
+
scanned = true;
|
|
1289
|
+
opts.onEvent?.({ type: "scanned" });
|
|
1290
|
+
} else if (status === "confirmed") {
|
|
1291
|
+
opts.onEvent?.({ type: "confirmed" });
|
|
1292
|
+
void collectCookies();
|
|
1293
|
+
} else if (status === "expired" && !scanned) {
|
|
1294
|
+
if (refreshes++ >= maxRefresh)
|
|
1295
|
+
finish(new TikTokError("LOGIN_FAILED", "QR expired without being scanned"));
|
|
1296
|
+
else void tab.send("Page.reload");
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
await tab.send("Page.navigate", { url: `${WEB}/login/qrcode` });
|
|
1303
|
+
return await result;
|
|
1304
|
+
} finally {
|
|
1305
|
+
cdp?.close();
|
|
1306
|
+
proc.kill("SIGKILL");
|
|
1307
|
+
await (0, import_promises2.rm)(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1311
|
+
0 && (module.exports = {
|
|
1312
|
+
API,
|
|
1313
|
+
Listener,
|
|
1314
|
+
MANUAL_CLOSE,
|
|
1315
|
+
MessageType,
|
|
1316
|
+
TikTok,
|
|
1317
|
+
TikTokError,
|
|
1318
|
+
findChrome,
|
|
1319
|
+
loginQR
|
|
1320
|
+
});
|
|
1321
|
+
//# sourceMappingURL=index.js.map
|