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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rice2411
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,217 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# tca-js
|
|
4
|
+
|
|
5
|
+
**Unofficial TikTok Chat API for Node.js**
|
|
6
|
+
|
|
7
|
+
Read and send TikTok direct messages, react, and receive new messages in real time.
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/tca-js)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](package.json)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> [!WARNING]
|
|
16
|
+
> This project is not affiliated with or endorsed by TikTok / ByteDance. It relies on undocumented APIs that can change
|
|
17
|
+
> at any time. Automating an account may violate TikTok's Terms of Service and can get it restricted β use at your own risk.
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Features](#features)
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Quick Start](#quick-start)
|
|
24
|
+
- [Authentication](#authentication)
|
|
25
|
+
- [API Reference](#api-reference)
|
|
26
|
+
- [Listener](#listener)
|
|
27
|
+
- [Error Handling](#error-handling)
|
|
28
|
+
- [Contributing](#contributing)
|
|
29
|
+
- [Support Us](#support-us)
|
|
30
|
+
- [License](#license)
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- π¬ Send text and image messages
|
|
35
|
+
- π₯ List conversations, page through message history, look up users
|
|
36
|
+
- β‘ Real-time listener over websocket with auto-reconnect
|
|
37
|
+
- πΌοΈ Download and decrypt private images, download stickers and avatars
|
|
38
|
+
- β€οΈ Read and set reactions, mark conversations as read
|
|
39
|
+
- π Cookie-based session β no browser at runtime
|
|
40
|
+
- π± Optional QR-code login
|
|
41
|
+
- π¦ Written in TypeScript, ships type definitions, one runtime dependency (`ws`)
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install tca-js
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires **Node.js 22+**. QR login also needs a local Chrome or Chromium; cookie login does not.
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { TikTok } from 'tca-js';
|
|
55
|
+
|
|
56
|
+
const api = await new TikTok().login({ cookies });
|
|
57
|
+
|
|
58
|
+
api.listener.on('message', async (msg) => {
|
|
59
|
+
if (msg.isSelf) return;
|
|
60
|
+
await api.sendMessage(msg, `You said: ${msg.text}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
api.listener.start();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Authentication
|
|
67
|
+
|
|
68
|
+
### Cookies
|
|
69
|
+
|
|
70
|
+
Log in with the cookies of a `tiktok.com` session. Accepted formats:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await new TikTok().login({ cookies: [{ name: 'sessionid', value: 'β¦' }, /* β¦ */] }); // array
|
|
74
|
+
await new TikTok().login({ cookies: { sessionid: 'β¦', ttwid: 'β¦' } }); // object
|
|
75
|
+
await new TikTok().login({ cookies: 'sessionid=β¦; ttwid=β¦' }); // header string
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Required cookies: `sessionid`, `sid_tt`, `sid_guard`, `uid_tt`, `ttwid`, `odin_tt`, `store-idc`, `tt-target-idc`.
|
|
79
|
+
A session lasts about 6 months, and logging in does not sign out other devices.
|
|
80
|
+
|
|
81
|
+
### QR code
|
|
82
|
+
|
|
83
|
+
`loginQR` opens a headless Chrome on TikTok's QR page, emits the QR image, and resolves with credentials once the code is
|
|
84
|
+
scanned and confirmed on the phone.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { loginQR, TikTok } from 'tca-js';
|
|
88
|
+
|
|
89
|
+
const credentials = await loginQR({
|
|
90
|
+
chromePath: '/usr/bin/chromium-browser',
|
|
91
|
+
onEvent: (e) => {
|
|
92
|
+
if (e.type === 'qr') showQr(e.image); // data:image/png;base64,β¦
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const api = await new TikTok().login(credentials);
|
|
97
|
+
// Save `credentials` to skip scanning next time.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Option | Type | Default | Description |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `chromePath` | `string` | β | Path to Chrome / Chromium |
|
|
103
|
+
| `onEvent` | `(e) => void` | β | `qr` Β· `scanned` Β· `confirmed` |
|
|
104
|
+
| `signal` | `AbortSignal` | β | Cancel the login |
|
|
105
|
+
| `timeoutMs` | `number` | `300000` | Total time to wait for a scan |
|
|
106
|
+
| `maxRefresh` | `number` | `3` | Max QR regenerations after expiry |
|
|
107
|
+
| `userAgent` | `string` | β | Browser user agent |
|
|
108
|
+
|
|
109
|
+
## API Reference
|
|
110
|
+
|
|
111
|
+
`new TikTok(options?)` β `logger?: (level, msg) => void`, `timeoutMs?: number`.
|
|
112
|
+
`.login(credentials)` resolves to an `API` instance.
|
|
113
|
+
|
|
114
|
+
### Conversations & messages
|
|
115
|
+
|
|
116
|
+
| Method | Returns | Description |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `getConversations(cursor?)` | `ConversationPage` | 10 conversations per page with their latest messages |
|
|
119
|
+
| `getMessages(conv, { limit?, before? })` | `MessagePage` | Messages of a conversation, newest first |
|
|
120
|
+
| `sendMessage(conv, text)` | `SendResult` | Send a text message |
|
|
121
|
+
| `sendImage(conv, { data, filename })` | `SendResult` | Send an image (`Buffer`) |
|
|
122
|
+
| `setReaction(msg, emoji \| null)` | `void` | Set your reaction; `null` removes it |
|
|
123
|
+
| `markRead(msg)` | `void` | Mark the conversation as read up to `msg` |
|
|
124
|
+
|
|
125
|
+
`conv` is any object with `conversationId` and `conversationShortId` β every message satisfies it, so you can reply
|
|
126
|
+
with `sendMessage(msg, β¦)`.
|
|
127
|
+
|
|
128
|
+
### Users & media
|
|
129
|
+
|
|
130
|
+
| Method | Returns | Description |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `getUsers(uids)` | `TikTokUser[]` | Nickname, `@username`, avatar |
|
|
133
|
+
| `downloadImage(msg, 'origin' \| 'thumb')` | `Buffer` | Download and decrypt a private image |
|
|
134
|
+
| `downloadSticker(sticker)` | `{ data, type }` | Download a sticker image |
|
|
135
|
+
| `downloadAvatar(user)` | `{ data, type } \| null` | Download a user's avatar |
|
|
136
|
+
|
|
137
|
+
### Session
|
|
138
|
+
|
|
139
|
+
| Method / property | Description |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `account` | Logged-in account: `uid`, `username`, `screenName`, `avatar` |
|
|
142
|
+
| `getOwnId()` | Own user id |
|
|
143
|
+
| `getCookies()` | Current cookies β persist them to resume the session |
|
|
144
|
+
| `isAlive()` | `true` while the session is still valid |
|
|
145
|
+
|
|
146
|
+
### Message object
|
|
147
|
+
|
|
148
|
+
| Field | Type | Description |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `kind` | `'text' \| 'image' \| 'sticker' \| 'video' \| 'system' \| 'other'` | Message kind |
|
|
151
|
+
| `text` | `string` | Text content |
|
|
152
|
+
| `image` / `sticker` | `object \| null` | Attachment details |
|
|
153
|
+
| `reactions` | `{ emoji, userIds }[]` | Reactions on the message |
|
|
154
|
+
| `senderId` / `isSelf` | `string` / `boolean` | Sender |
|
|
155
|
+
| `createdAt` | `number` | Epoch milliseconds |
|
|
156
|
+
|
|
157
|
+
Full typings are shipped in `dist/index.d.ts`.
|
|
158
|
+
|
|
159
|
+
## Listener
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
api.listener.on('connected', () => console.log('connected'));
|
|
163
|
+
api.listener.on('message', (msg) => { /* new message */ });
|
|
164
|
+
api.listener.on('command', (msg) => { /* read receipts and other hidden commands */ });
|
|
165
|
+
api.listener.on('closed', (code, reason) => {});
|
|
166
|
+
api.listener.on('error', (err) => {});
|
|
167
|
+
|
|
168
|
+
api.listener.start(); // reconnects automatically; start({ retryOnClose: false }) to disable
|
|
169
|
+
api.listener.stop();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
All library errors are instances of `TikTokError` with a `code`:
|
|
175
|
+
|
|
176
|
+
| Code | Meaning |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `SESSION_EXPIRED` | Cookies are no longer valid β log in again |
|
|
179
|
+
| `IM_ERROR` | TikTok rejected the request |
|
|
180
|
+
| `HTTP_ERROR` Β· `BAD_RESPONSE` | Network or unexpected response |
|
|
181
|
+
| `UPLOAD_FAILED` Β· `DOWNLOAD_FAILED` | Media transfer failed |
|
|
182
|
+
| `LOGIN_FAILED` Β· `LOGIN_ABORTED` | QR login failed or was cancelled |
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { TikTokError } from 'tca-js';
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await api.sendMessage(conv, 'hi');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof TikTokError && err.code === 'SESSION_EXPIRED') {
|
|
191
|
+
// refresh credentials
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Contributing
|
|
197
|
+
|
|
198
|
+
Issues and pull requests are welcome. Protocol notes for maintainers are in [docs/PROTOCOL.md](docs/PROTOCOL.md).
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npm install
|
|
202
|
+
npm run build
|
|
203
|
+
node scripts/smoke.mjs --cookies cookies.json # read-only smoke test against a real account
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Support Us
|
|
207
|
+
|
|
208
|
+
- β **Star the repository** if you find it useful
|
|
209
|
+
- π **Share** it with others
|
|
210
|
+
- π‘ **Contribute** ideas and code
|
|
211
|
+
- β **Buy me a coffee** β VietQR (MB Bank Β· `0776750418`)
|
|
212
|
+
|
|
213
|
+
<img src=".github/assets/vietqr-mb.png" alt="VietQR MB Bank 0776750418" width="220">
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
[MIT](LICENSE) Β© rice2411
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Context } from './context';
|
|
2
|
+
import { Listener } from './listener';
|
|
3
|
+
import type { ConversationPage, ConversationRef, ImageInput, MessagePage, TikTokAccount, TikTokImage, TikTokMessage, TikTokSticker, TikTokUser } from './types';
|
|
4
|
+
/** Send result: client-side message id (TikTok echoes it back). The server id arrives via the listener (`isSelf`). */
|
|
5
|
+
export interface SendResult {
|
|
6
|
+
clientMessageId: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A logged-in TikTok session β create with `new TikTok().login(credentials)`.
|
|
10
|
+
* All calls go directly over HTTP/WS to TikTok using cookies, no browser needed.
|
|
11
|
+
*/
|
|
12
|
+
export declare class API {
|
|
13
|
+
private readonly ctx;
|
|
14
|
+
readonly account: TikTokAccount;
|
|
15
|
+
readonly listener: Listener;
|
|
16
|
+
private readonly media;
|
|
17
|
+
constructor(ctx: Context, account: TikTokAccount);
|
|
18
|
+
getOwnId(): string;
|
|
19
|
+
/** Current cookies β save them so the next `login` needs no QR scan. */
|
|
20
|
+
getCookies(): {
|
|
21
|
+
name: string;
|
|
22
|
+
value: string;
|
|
23
|
+
}[];
|
|
24
|
+
getContext(): {
|
|
25
|
+
deviceId: string;
|
|
26
|
+
userAgent: string;
|
|
27
|
+
};
|
|
28
|
+
/** Whether the session is still alive β call periodically to detect expiry early. */
|
|
29
|
+
isAlive(): Promise<boolean>;
|
|
30
|
+
/** Recent conversations + their latest messages, paginated with `cursor`. */
|
|
31
|
+
getConversations(cursor?: string): Promise<ConversationPage>;
|
|
32
|
+
/** Messages of a conversation, newest first. For older messages: `before = <index of oldest message>`. */
|
|
33
|
+
getMessages(conv: ConversationRef, opts?: {
|
|
34
|
+
limit?: number;
|
|
35
|
+
before?: string;
|
|
36
|
+
}): Promise<MessagePage>;
|
|
37
|
+
/** User profiles by uid (display name, @username, avatar). */
|
|
38
|
+
getUsers(uids: string[]): Promise<TikTokUser[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Download an avatar (tries each URL in turn, skips heic). No cookie / Referer sent: the image CDN is cross-origin and
|
|
41
|
+
* URLs are already signed. `null` if no URL could be downloaded.
|
|
42
|
+
*/
|
|
43
|
+
downloadAvatar(user: Pick<TikTokUser, 'avatars'>): Promise<{
|
|
44
|
+
data: Buffer;
|
|
45
|
+
type: string;
|
|
46
|
+
} | null>;
|
|
47
|
+
/** Mark as read up to message `msg`. */
|
|
48
|
+
markRead(msg: ConversationRef & {
|
|
49
|
+
index: string;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
/** Send a text message to a conversation (usually the just-received `msg` β it carries the conversation ids). */
|
|
52
|
+
sendMessage(conv: ConversationRef, text: string): Promise<SendResult>;
|
|
53
|
+
/** Send an image: upload to the private (encrypted) store, then send an image message. */
|
|
54
|
+
sendImage(conv: ConversationRef, image: ImageInput): Promise<SendResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Set YOUR reaction on a message β like TikTok Web, one reaction per user: a new `emoji` replaces the old one,
|
|
57
|
+
* `null` = remove. Pass the whole `msg` just read (current `reactions` are needed to know which key to remove).
|
|
58
|
+
* Sends `ModifyMessagePropertyRequestBody` (command 705) β see README.
|
|
59
|
+
*/
|
|
60
|
+
setReaction(msg: TikTokMessage, emoji: string | null): Promise<void>;
|
|
61
|
+
/** Download a sticker image (public CDN, unencrypted, no cookie needed). */
|
|
62
|
+
downloadSticker(sticker: Pick<TikTokSticker, 'url'>): Promise<{
|
|
63
|
+
data: Buffer;
|
|
64
|
+
type: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** Download + decrypt a message's image (pass the whole `msg` β listener messages lack the URL, this looks it up). */
|
|
67
|
+
downloadImage(source: TikTokMessage | TikTokImage, which?: 'origin' | 'thumb'): Promise<Buffer>;
|
|
68
|
+
/** `SendMessageRequestBody`: {1 convId, 2 conversation type, 3 shortId, 4 content, 5 ext, 6 message type, 7 ticket, 8 client id, β¦}. */
|
|
69
|
+
private send;
|
|
70
|
+
}
|
|
71
|
+
/** Account for the cookie β `null` if the cookie is no longer logged in. */
|
|
72
|
+
export declare function fetchAccount(ctx: Context): Promise<TikTokAccount | null>;
|
|
73
|
+
/** `wid` β device id TikTok issues per `ttwid` cookie; IM + websocket use it as `device_id`. */
|
|
74
|
+
export declare function fetchWebId(ctx: Context): Promise<string>;
|
|
75
|
+
export type { TikTokMessage };
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.API = void 0;
|
|
4
|
+
exports.fetchAccount = fetchAccount;
|
|
5
|
+
exports.fetchWebId = fetchWebId;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const constants_1 = require("./constants");
|
|
8
|
+
const errors_1 = require("./errors");
|
|
9
|
+
const listener_1 = require("./listener");
|
|
10
|
+
const media_1 = require("./media");
|
|
11
|
+
const message_1 = require("./message");
|
|
12
|
+
const proto_1 = require("./proto");
|
|
13
|
+
/** "Latest" cursor for reading history β greater than any `index` (Β΅s). */
|
|
14
|
+
const NEWEST = 9999999999999999n;
|
|
15
|
+
/**
|
|
16
|
+
* A logged-in TikTok session β create with `new TikTok().login(credentials)`.
|
|
17
|
+
* All calls go directly over HTTP/WS to TikTok using cookies, no browser needed.
|
|
18
|
+
*/
|
|
19
|
+
class API {
|
|
20
|
+
ctx;
|
|
21
|
+
account;
|
|
22
|
+
listener;
|
|
23
|
+
media;
|
|
24
|
+
constructor(ctx, account) {
|
|
25
|
+
this.ctx = ctx;
|
|
26
|
+
this.account = account;
|
|
27
|
+
this.listener = new listener_1.Listener(ctx);
|
|
28
|
+
this.media = new media_1.MediaClient(ctx);
|
|
29
|
+
}
|
|
30
|
+
getOwnId() {
|
|
31
|
+
return this.ctx.ownId;
|
|
32
|
+
}
|
|
33
|
+
/** Current cookies β save them so the next `login` needs no QR scan. */
|
|
34
|
+
getCookies() {
|
|
35
|
+
return [...this.ctx.cookies].map(([name, value]) => ({ name, value }));
|
|
36
|
+
}
|
|
37
|
+
getContext() {
|
|
38
|
+
return { deviceId: this.ctx.deviceId, userAgent: this.ctx.userAgent };
|
|
39
|
+
}
|
|
40
|
+
/** Whether the session is still alive β call periodically to detect expiry early. */
|
|
41
|
+
async isAlive() {
|
|
42
|
+
return (await fetchAccount(this.ctx).catch(() => null)) !== null;
|
|
43
|
+
}
|
|
44
|
+
// βββ Inbox βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
45
|
+
/** Recent conversations + their latest messages, paginated with `cursor`. */
|
|
46
|
+
async getConversations(cursor = '0') {
|
|
47
|
+
const body = await this.ctx.im('/v2/message/get_by_user_init', constants_1.Cmd.GetMessagesByUserInit, new proto_1.Writer().int(1, BigInt(cursor)).finish());
|
|
48
|
+
return {
|
|
49
|
+
messages: body.msgs(1).map((m) => (0, message_1.parseMessage)(m, this.ctx.ownId)),
|
|
50
|
+
conversations: body.msgs(2).map((m) => (0, message_1.parseConversation)(m, this.ctx.ownId)),
|
|
51
|
+
// Cursor = number of conversations fetched so far (0, 10, 20β¦) β field 4; field 3 is a timestamp, not used for paging.
|
|
52
|
+
cursor: body.id(4),
|
|
53
|
+
hasMore: body.num(5) === 1,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Messages of a conversation, newest first. For older messages: `before = <index of oldest message>`. */
|
|
57
|
+
async getMessages(conv, opts = {}) {
|
|
58
|
+
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 50);
|
|
59
|
+
const body = await this.ctx.im('/v1/message/get_by_conversation', constants_1.Cmd.GetMessagesByConversation, new proto_1.Writer()
|
|
60
|
+
.bytes(1, conv.conversationId)
|
|
61
|
+
.int(2, constants_1.ConversationType.OneToOne)
|
|
62
|
+
.int(3, BigInt(conv.conversationShortId))
|
|
63
|
+
.int(4, 1) // direction: older than cursor
|
|
64
|
+
.int(5, opts.before ? BigInt(opts.before) : NEWEST)
|
|
65
|
+
.int(6, limit)
|
|
66
|
+
.finish());
|
|
67
|
+
const messages = body.msgs(1).map((m) => (0, message_1.parseMessage)(m, this.ctx.ownId));
|
|
68
|
+
return { messages, hasMore: messages.length >= limit };
|
|
69
|
+
}
|
|
70
|
+
/** User profiles by uid (display name, @username, avatar). */
|
|
71
|
+
async getUsers(uids) {
|
|
72
|
+
if (uids.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const q = new URLSearchParams({
|
|
75
|
+
aid: '1988',
|
|
76
|
+
app_name: 'tiktok_web',
|
|
77
|
+
device_platform: 'web_pc',
|
|
78
|
+
source: 'im_chat',
|
|
79
|
+
from_page: 'message',
|
|
80
|
+
user_ids: JSON.stringify(uids),
|
|
81
|
+
});
|
|
82
|
+
const res = await this.ctx.getJson(`${constants_1.WEB}/tiktok/v1/im/user/profile/?${q}`);
|
|
83
|
+
if (res.status_code)
|
|
84
|
+
throw new errors_1.TikTokError('IM_ERROR', `user/profile: ${res.status_code}`, res.status_code);
|
|
85
|
+
return (res.users ?? []).map(({ im_user_profile: p = {} }) => ({
|
|
86
|
+
uid: p.user_id_str ?? '',
|
|
87
|
+
uniqueId: p.unique_id ?? '',
|
|
88
|
+
nickname: p.nick_name ?? '',
|
|
89
|
+
...avatarLinks(p.avatars),
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Download an avatar (tries each URL in turn, skips heic). No cookie / Referer sent: the image CDN is cross-origin and
|
|
94
|
+
* URLs are already signed. `null` if no URL could be downloaded.
|
|
95
|
+
*/
|
|
96
|
+
async downloadAvatar(user) {
|
|
97
|
+
for (const url of user.avatars) {
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
headers: { 'User-Agent': this.ctx.userAgent },
|
|
100
|
+
referrerPolicy: 'no-referrer',
|
|
101
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 20_000),
|
|
102
|
+
}).catch(() => null);
|
|
103
|
+
const type = res?.headers.get('content-type') ?? '';
|
|
104
|
+
if (!res?.ok || !/^image\/(jpeg|png|webp|gif)/.test(type))
|
|
105
|
+
continue;
|
|
106
|
+
return { data: Buffer.from(await res.arrayBuffer()), type };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** Mark as read up to message `msg`. */
|
|
111
|
+
async markRead(msg) {
|
|
112
|
+
await this.ctx.im('/v3/conversation/mark_read', constants_1.Cmd.MarkRead, new proto_1.Writer()
|
|
113
|
+
.bytes(1, msg.conversationId)
|
|
114
|
+
.int(2, BigInt(msg.conversationShortId))
|
|
115
|
+
.int(3, constants_1.ConversationType.OneToOne)
|
|
116
|
+
.int(4, BigInt(msg.index))
|
|
117
|
+
.int(11, 4)
|
|
118
|
+
.finish(), constants_1.MARK_READ_BODY_FIELD);
|
|
119
|
+
}
|
|
120
|
+
// βββ Send βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
121
|
+
/** Send a text message to a conversation (usually the just-received `msg` β it carries the conversation ids). */
|
|
122
|
+
async sendMessage(conv, text) {
|
|
123
|
+
const cid = (0, node_crypto_1.randomUUID)();
|
|
124
|
+
await this.send(conv, cid, constants_1.MessageType.Text, JSON.stringify({ aweType: 0, text }), null);
|
|
125
|
+
return { clientMessageId: cid };
|
|
126
|
+
}
|
|
127
|
+
/** Send an image: upload to the private (encrypted) store, then send an image message. */
|
|
128
|
+
async sendImage(conv, image) {
|
|
129
|
+
const up = await this.media.upload(image);
|
|
130
|
+
const cid = (0, node_crypto_1.randomUUID)();
|
|
131
|
+
const w = String(up.width);
|
|
132
|
+
const h = String(up.height);
|
|
133
|
+
const media = new proto_1.Writer()
|
|
134
|
+
.msg(1, new proto_1.Writer()
|
|
135
|
+
.bytes(1, up.uri)
|
|
136
|
+
.msg(5, new proto_1.Writer().int(1, up.width).int(2, up.height))
|
|
137
|
+
.bytes(6, up.secretKey)
|
|
138
|
+
.int(7, 0))
|
|
139
|
+
.msg(3, new proto_1.Writer()
|
|
140
|
+
.msg(1, new proto_1.Writer().bytes(1, 'You sent a π·'))
|
|
141
|
+
.msg(2, new proto_1.Writer().bytes(1, 'sent a π·'))
|
|
142
|
+
.msg(3, new proto_1.Writer().bytes(1, '[Photo]')))
|
|
143
|
+
.msg(200, new proto_1.Writer().msg(1, new proto_1.Writer().bytes(1, up.uri).map(2, {
|
|
144
|
+
image_width: w,
|
|
145
|
+
image_height: h,
|
|
146
|
+
decrypt_key: up.secretKey,
|
|
147
|
+
quote_preview: 'dm_cam_preview_photo',
|
|
148
|
+
sender_preview: 'sender_preview',
|
|
149
|
+
})));
|
|
150
|
+
const extra = new proto_1.Writer()
|
|
151
|
+
.msg(13, new proto_1.Writer().msg(4, media))
|
|
152
|
+
.bytes(14, 'private_image')
|
|
153
|
+
.msg(17, new proto_1.Writer()
|
|
154
|
+
.int(1, 1)
|
|
155
|
+
.bytes(2, up.uri)
|
|
156
|
+
.bytes(3, up.secretKey)
|
|
157
|
+
.msg(10, new proto_1.Writer().int(1, up.width).int(2, up.height).int(4, up.size).bytes(5, up.filename)));
|
|
158
|
+
await this.send(conv, cid, constants_1.MessageType.PrivateImage, '', extra);
|
|
159
|
+
return { clientMessageId: cid };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Set YOUR reaction on a message β like TikTok Web, one reaction per user: a new `emoji` replaces the old one,
|
|
163
|
+
* `null` = remove. Pass the whole `msg` just read (current `reactions` are needed to know which key to remove).
|
|
164
|
+
* Sends `ModifyMessagePropertyRequestBody` (command 705) β see README.
|
|
165
|
+
*/
|
|
166
|
+
async setReaction(msg, emoji) {
|
|
167
|
+
const own = this.ctx.ownId;
|
|
168
|
+
const mine = msg.reactions.filter((r) => r.userIds.includes(own)).map((r) => r.emoji);
|
|
169
|
+
const ops = [];
|
|
170
|
+
if (emoji && !mine.includes(emoji))
|
|
171
|
+
ops.push({ op: constants_1.PropertyOp.Add, key: constants_1.REACTION_PREFIX + emoji });
|
|
172
|
+
for (const e of mine) {
|
|
173
|
+
if (e === emoji)
|
|
174
|
+
continue;
|
|
175
|
+
ops.push({ op: constants_1.PropertyOp.Remove, key: constants_1.REACTION_PREFIX + e });
|
|
176
|
+
// β€οΈ sent from the mobile app still uses the legacy key `e:love` β remove it too or the app keeps showing the heart.
|
|
177
|
+
if (e === constants_1.REACTION_HEART)
|
|
178
|
+
ops.push({ op: constants_1.PropertyOp.Remove, key: constants_1.REACTION_LOVE_KEY });
|
|
179
|
+
}
|
|
180
|
+
if (ops.length === 0)
|
|
181
|
+
return;
|
|
182
|
+
const target = new proto_1.Writer()
|
|
183
|
+
.bytes(1, msg.conversationId)
|
|
184
|
+
.int(2, constants_1.ConversationType.OneToOne)
|
|
185
|
+
.int(3, BigInt(msg.conversationShortId))
|
|
186
|
+
.int(4, BigInt(msg.serverMessageId))
|
|
187
|
+
.bytes(5, msg.clientMessageId);
|
|
188
|
+
for (const o of ops)
|
|
189
|
+
target.msg(6, new proto_1.Writer().int(1, o.op).bytes(2, o.key).bytes(4, own));
|
|
190
|
+
const body = await this.ctx.im('/v1/message/set_property', constants_1.Cmd.SetMessageProperty, new proto_1.Writer().msg(1, target).bytes(2, 'deprecated').finish());
|
|
191
|
+
// status: 0 success, 1 duplicate (also done); anything else means TikTok rejected it.
|
|
192
|
+
const status = body.num(1);
|
|
193
|
+
if (status > 1)
|
|
194
|
+
throw new errors_1.TikTokError('IM_ERROR', `set_property: status ${status}`, status);
|
|
195
|
+
}
|
|
196
|
+
/** Download a sticker image (public CDN, unencrypted, no cookie needed). */
|
|
197
|
+
async downloadSticker(sticker) {
|
|
198
|
+
const res = await fetch(sticker.url, {
|
|
199
|
+
headers: { 'User-Agent': this.ctx.userAgent },
|
|
200
|
+
referrerPolicy: 'no-referrer',
|
|
201
|
+
signal: AbortSignal.timeout(this.ctx.options.timeoutMs ?? 20_000),
|
|
202
|
+
}).catch(() => null);
|
|
203
|
+
if (!res?.ok)
|
|
204
|
+
throw new errors_1.TikTokError('DOWNLOAD_FAILED', `Sticker download failed: ${res?.status ?? 'network'}`);
|
|
205
|
+
return { data: Buffer.from(await res.arrayBuffer()), type: res.headers.get('content-type') ?? 'image/webp' };
|
|
206
|
+
}
|
|
207
|
+
/** Download + decrypt a message's image (pass the whole `msg` β listener messages lack the URL, this looks it up). */
|
|
208
|
+
async downloadImage(source, which = 'origin') {
|
|
209
|
+
let image = 'serverMessageId' in source ? source.image : source;
|
|
210
|
+
// Websocket-pushed messages only have uri + key, no download URL β look up history by message id.
|
|
211
|
+
if ('serverMessageId' in source && image && !image.url) {
|
|
212
|
+
const { messages } = await this.getMessages(source, { limit: 5, before: (BigInt(source.index) + 1n).toString() });
|
|
213
|
+
image = messages.find((m) => m.serverMessageId === source.serverMessageId)?.image ?? image;
|
|
214
|
+
}
|
|
215
|
+
if (!image)
|
|
216
|
+
throw new errors_1.TikTokError('DOWNLOAD_FAILED', 'Message has no image');
|
|
217
|
+
return this.media.download(image, which);
|
|
218
|
+
}
|
|
219
|
+
/** `SendMessageRequestBody`: {1 convId, 2 conversation type, 3 shortId, 4 content, 5 ext, 6 message type, 7 ticket, 8 client id, β¦}. */
|
|
220
|
+
async send(conv, cid, type, content, extra) {
|
|
221
|
+
if (!conv.conversationId || !conv.conversationShortId) {
|
|
222
|
+
throw new errors_1.TikTokError('IM_ERROR', 'Missing conversationId / conversationShortId');
|
|
223
|
+
}
|
|
224
|
+
const w = new proto_1.Writer()
|
|
225
|
+
.bytes(1, conv.conversationId)
|
|
226
|
+
.int(2, constants_1.ConversationType.OneToOne)
|
|
227
|
+
.int(3, BigInt(conv.conversationShortId))
|
|
228
|
+
.bytes(4, content)
|
|
229
|
+
.map(5, { 's:mentioned_users': '', 's:client_message_id': cid })
|
|
230
|
+
.int(6, type)
|
|
231
|
+
.bytes(7, 'deprecated')
|
|
232
|
+
.bytes(8, cid);
|
|
233
|
+
const body = extra ? Buffer.concat([w.finish(), extra.finish()]) : w.finish();
|
|
234
|
+
await this.ctx.im('/v1/message/send', constants_1.Cmd.SendMessage, body);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.API = API;
|
|
238
|
+
/** Account for the cookie β `null` if the cookie is no longer logged in. */
|
|
239
|
+
async function fetchAccount(ctx) {
|
|
240
|
+
const res = await ctx.getJson(`${constants_1.WEB}/passport/web/account/info/?aid=${constants_1.AID_FRONTIER}`);
|
|
241
|
+
if (res.message !== 'success' || !res.data?.user_id_str)
|
|
242
|
+
return null;
|
|
243
|
+
return {
|
|
244
|
+
uid: res.data.user_id_str,
|
|
245
|
+
username: res.data.username ?? '',
|
|
246
|
+
screenName: res.data.screen_name ?? '',
|
|
247
|
+
avatar: res.data.avatar_url ?? '',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Avatar URLs from the IM profile `avatars` β collects every `https://β¦` string (thumb β medium β larger, whatever
|
|
252
|
+
* TikTok returns), then puts browser-readable ones (jpeg/webp/png) first and heic last.
|
|
253
|
+
*/
|
|
254
|
+
function avatarLinks(avatars) {
|
|
255
|
+
const urls = [];
|
|
256
|
+
const walk = (v) => {
|
|
257
|
+
if (typeof v === 'string') {
|
|
258
|
+
if (/^https?:\/\//.test(v) && !urls.includes(v))
|
|
259
|
+
urls.push(v);
|
|
260
|
+
}
|
|
261
|
+
else if (Array.isArray(v))
|
|
262
|
+
v.forEach(walk);
|
|
263
|
+
else if (v && typeof v === 'object')
|
|
264
|
+
Object.values(v).forEach(walk);
|
|
265
|
+
};
|
|
266
|
+
walk(avatars);
|
|
267
|
+
const heic = (u) => /\.heic(\?|$)/i.test(u.split('?')[0] + '?');
|
|
268
|
+
const ranked = [...urls.filter((u) => !heic(u)), ...urls.filter(heic)];
|
|
269
|
+
return { avatar: ranked[0] ?? '', avatars: ranked };
|
|
270
|
+
}
|
|
271
|
+
/** `wid` β device id TikTok issues per `ttwid` cookie; IM + websocket use it as `device_id`. */
|
|
272
|
+
async function fetchWebId(ctx) {
|
|
273
|
+
const res = await ctx.fetch(`${constants_1.WEB}/api/v1/web-cookie-privacy/config?locale=vi-VN&appId=1988&theme=default&tea=1`);
|
|
274
|
+
if (!res.ok)
|
|
275
|
+
return '';
|
|
276
|
+
return /"wid":"(\d+)"/.exec(await res.text())?.[1] ?? '';
|
|
277
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TikTok Web IM protocol constants β reconstructed from `tiktok.com/business-suite/messages` traffic
|
|
3
|
+
* (09/11/2026). If TikTok changes things on their side, fix them here; how they were found is in the README.
|
|
4
|
+
*/
|
|
5
|
+
export declare const IM_API = "https://im-api-sg.tiktok.com";
|
|
6
|
+
export declare const IM_WS = "wss://im-ws-sg.tiktok.com/ws/v2";
|
|
7
|
+
export declare const WEB = "https://www.tiktok.com";
|
|
8
|
+
export declare const 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";
|
|
9
|
+
/** TikTok Web app id (HTTP IM). */
|
|
10
|
+
export declare const AID_WEB = "1988";
|
|
11
|
+
/** "frontier" channel app id β websocket pushing new messages. */
|
|
12
|
+
export declare const AID_FRONTIER = "1459";
|
|
13
|
+
export declare const SDK_VERSION = "1.8.5";
|
|
14
|
+
export declare const BUILD_NUMBER = "feffaa1:master";
|
|
15
|
+
/** Websocket `access_key` = md5(FPID + APP_KEY + device_id + SALT). */
|
|
16
|
+
export declare const FRONTIER_FPID = "9";
|
|
17
|
+
export declare const FRONTIER_APP_KEY = "e1bd35ec9db7b8d846de66ed140b1ad9";
|
|
18
|
+
export declare const FRONTIER_SALT = "f8a69f1719916z";
|
|
19
|
+
/** IM command (`Request.cmd`) β also the body's field number in `RequestBody`. */
|
|
20
|
+
export declare const Cmd: {
|
|
21
|
+
readonly SendMessage: 100;
|
|
22
|
+
readonly GetMessagesByUserInit: 203;
|
|
23
|
+
readonly GetMessagesByConversation: 301;
|
|
24
|
+
/** Websocket push of new messages. */
|
|
25
|
+
readonly NewMessage: 500;
|
|
26
|
+
/** Add / remove a reaction (message `property`) β `/v1/message/set_property`. */
|
|
27
|
+
readonly SetMessageProperty: 705;
|
|
28
|
+
readonly GetConversationInfo: 610;
|
|
29
|
+
readonly MarkRead: 2002;
|
|
30
|
+
readonly UploadConfig: 2059;
|
|
31
|
+
};
|
|
32
|
+
/** MarkRead's body lives in field 604, not the command number. */
|
|
33
|
+
export declare const MARK_READ_BODY_FIELD = 604;
|
|
34
|
+
/** `MessageBody.message_type`. */
|
|
35
|
+
export declare const MessageType: {
|
|
36
|
+
readonly System: 1;
|
|
37
|
+
readonly Text: 7;
|
|
38
|
+
readonly ShareVideo: 8;
|
|
39
|
+
readonly PrivateImage: 1802;
|
|
40
|
+
/** Sticker β public image on the sticker CDN, not encrypted. */
|
|
41
|
+
readonly Sticker: 1805;
|
|
42
|
+
/** Hidden command (read receiptβ¦) β not shown in the conversation, but still pushed over websocket. */
|
|
43
|
+
readonly Command: 50001;
|
|
44
|
+
};
|
|
45
|
+
export declare const ConversationType: {
|
|
46
|
+
readonly OneToOne: 1;
|
|
47
|
+
};
|
|
48
|
+
/** `Response` fields (both HTTP and websocket payload). */
|
|
49
|
+
export declare const Resp: {
|
|
50
|
+
readonly cmd: 1;
|
|
51
|
+
readonly seq: 2;
|
|
52
|
+
readonly status: 3;
|
|
53
|
+
readonly error: 4;
|
|
54
|
+
readonly body: 6;
|
|
55
|
+
readonly logId: 7;
|
|
56
|
+
};
|
|
57
|
+
/** `MessageBody` fields. */
|
|
58
|
+
export declare const MsgF: {
|
|
59
|
+
readonly conversationId: 1;
|
|
60
|
+
readonly conversationType: 2;
|
|
61
|
+
readonly serverMessageId: 3;
|
|
62
|
+
readonly indexInConversation: 4;
|
|
63
|
+
readonly conversationShortId: 5;
|
|
64
|
+
readonly type: 6;
|
|
65
|
+
readonly sender: 7;
|
|
66
|
+
readonly content: 8;
|
|
67
|
+
readonly ext: 9;
|
|
68
|
+
readonly createTime: 10;
|
|
69
|
+
/** `property_list` β reaction: `{1 key "e:<emoji>", 2 {1 PropertyItem[]}}`. */
|
|
70
|
+
readonly propertyList: 15;
|
|
71
|
+
readonly attachments: 20;
|
|
72
|
+
readonly tag: 21;
|
|
73
|
+
};
|
|
74
|
+
/** Operation on a message `property` (`ModifyPropertyContent.operation`). */
|
|
75
|
+
export declare const PropertyOp: {
|
|
76
|
+
readonly Add: 0;
|
|
77
|
+
readonly Remove: 1;
|
|
78
|
+
};
|
|
79
|
+
/** Reaction key = `e:<emoji>`. The mobile app reacting β€οΈ also writes the legacy key `e:love` β treated as β€οΈ. */
|
|
80
|
+
export declare const REACTION_PREFIX = "e:";
|
|
81
|
+
export declare const REACTION_LOVE_KEY = "e:love";
|
|
82
|
+
export declare const REACTION_HEART = "\u2764\uFE0F";
|
|
83
|
+
/** ImageX β image upload endpoint (AWS SigV4-style signing). */
|
|
84
|
+
export declare const IMAGEX_REGION = "cn-north-1";
|
|
85
|
+
export declare const IMAGEX_SERVICE = "imagex";
|
|
86
|
+
export declare const IMAGEX_VERSION = "2018-08-01";
|