@palmyr/cli 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/cli.js +433 -16
- package/dist/cli.js.map +1 -1
- package/dist/credential-store.js +3 -3
- package/dist/credential-store.js.map +1 -1
- package/dist/sdk.d.ts +8 -0
- package/dist/sdk.js +8 -0
- package/dist/sdk.js.map +1 -1
- package/dist/social-analytics.d.ts +47 -0
- package/dist/social-analytics.js +102 -0
- package/dist/social-analytics.js.map +1 -0
- package/dist/social-drafts.d.ts +43 -0
- package/dist/social-drafts.js +92 -0
- package/dist/social-drafts.js.map +1 -0
- package/dist/social-monitor.d.ts +37 -0
- package/dist/social-monitor.js +147 -0
- package/dist/social-monitor.js.map +1 -0
- package/dist/social-vault.d.ts +13 -1
- package/dist/social-vault.js +20 -2
- package/dist/social-vault.js.map +1 -1
- package/dist/tiktok-connect.d.ts +58 -0
- package/dist/tiktok-connect.js +518 -0
- package/dist/tiktok-connect.js.map +1 -0
- package/dist/wallet-daemon.js +20 -4
- package/dist/wallet-daemon.js.map +1 -1
- package/dist/wallet-doctor.js +8 -2
- package/dist/wallet-doctor.js.map +1 -1
- package/dist/wallet-trading.d.ts +2 -0
- package/dist/wallet-trading.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1014,10 +1014,23 @@ const TIKTOK_HELP = {
|
|
|
1014
1014
|
{ flag: '<username>', desc: 'TikTok handle to import' },
|
|
1015
1015
|
{ flag: '--sessionid <s> --csrf <c> --webid <w>', desc: 'Cookies from a logged-in TikTok browser' },
|
|
1016
1016
|
{ flag: '--credentials-line "..."', desc: 'Marketplace login:pw:email:email_pw format' },
|
|
1017
|
+
{ flag: '--tag <name>', desc: 'Assign a folder-like grouping tag at import' },
|
|
1017
1018
|
{ flag: '(price)', desc: 'Free — local vault only' },
|
|
1018
1019
|
],
|
|
1020
|
+
connect: [
|
|
1021
|
+
{ flag: '<username>', desc: 'Log in once via your real browser; auto-captures the session' },
|
|
1022
|
+
{ flag: '--qr', desc: 'QR login: opens a window with a login QR for a human to scan with the TikTok app (no password/captcha)' },
|
|
1023
|
+
{ flag: '--country <iso-2>', desc: 'Optional — auto-detected from your browser; override e.g. --country de' },
|
|
1024
|
+
{ flag: '--timeout <sec>', desc: 'How long to wait for login (default 300)' },
|
|
1025
|
+
{ flag: '--browser-path <path>', desc: 'Override Chrome/Edge/Brave auto-detection' },
|
|
1026
|
+
{ flag: '--no-sandbox', desc: 'Launch without the browser sandbox (auto on root Linux; for headless/CI)' },
|
|
1027
|
+
{ flag: '--force', desc: 'Re-capture even if a fresh session is already cached' },
|
|
1028
|
+
{ flag: '--tag <name>', desc: 'Save under a folder-like grouping tag (organize many accounts)' },
|
|
1029
|
+
{ flag: '(price)', desc: 'Free — local, no server call' },
|
|
1030
|
+
],
|
|
1019
1031
|
list: [
|
|
1020
1032
|
{ flag: '(no args)', desc: 'List all local TikTok accounts' },
|
|
1033
|
+
{ flag: '--tag <name>', desc: 'Filter to accounts in this folder/tag' },
|
|
1021
1034
|
{ flag: '(price)', desc: 'Free' },
|
|
1022
1035
|
],
|
|
1023
1036
|
info: [{ flag: '<username>', desc: 'Show one account' }, { flag: '(price)', desc: 'Free' }],
|
|
@@ -1026,6 +1039,12 @@ const TIKTOK_HELP = {
|
|
|
1026
1039
|
{ flag: '--to <new>', desc: 'New handle' },
|
|
1027
1040
|
{ flag: '(price)', desc: 'Free — local-only metadata update' },
|
|
1028
1041
|
],
|
|
1042
|
+
tag: [
|
|
1043
|
+
{ flag: '<username>', desc: 'Account to tag' },
|
|
1044
|
+
{ flag: '<tag-name>', desc: 'Folder name (positional) to assign' },
|
|
1045
|
+
{ flag: '--clear', desc: "Remove the account's tag instead" },
|
|
1046
|
+
{ flag: '(price)', desc: 'Free — local-only metadata' },
|
|
1047
|
+
],
|
|
1029
1048
|
remove: [
|
|
1030
1049
|
{ flag: '<username>', desc: 'Account to delete from local vault' },
|
|
1031
1050
|
{ flag: '--confirm', desc: 'Required' },
|
|
@@ -1034,14 +1053,22 @@ const TIKTOK_HELP = {
|
|
|
1034
1053
|
totp: [{ flag: '<username>', desc: 'Print current TOTP code' }, { flag: '(price)', desc: 'Free' }],
|
|
1035
1054
|
login: [
|
|
1036
1055
|
{ flag: '<username>', desc: 'Validate cookies and cache the session' },
|
|
1037
|
-
{ flag: '(price)', desc: '$0.
|
|
1056
|
+
{ flag: '(price)', desc: '$0.02 USDC' },
|
|
1038
1057
|
],
|
|
1039
1058
|
session: [{ flag: '<username>', desc: 'Check cached session' }, { flag: '(price)', desc: 'Free' }],
|
|
1040
1059
|
post: [
|
|
1041
1060
|
{ flag: '<username>', desc: 'Account to post from' },
|
|
1042
1061
|
{ flag: '--file video.mp4', desc: 'Video file' },
|
|
1043
1062
|
{ flag: '--caption "..."', desc: 'Caption' },
|
|
1044
|
-
{ flag: '
|
|
1063
|
+
{ flag: '--privacy 0|1|2', desc: 'Audience: 0 public (default) · 1 friends · 2 private' },
|
|
1064
|
+
{ flag: '(price)', desc: '$0.01 USDC' },
|
|
1065
|
+
],
|
|
1066
|
+
schedule: [
|
|
1067
|
+
{ flag: '<username>', desc: 'Account to post from' },
|
|
1068
|
+
{ flag: '--at <iso8601>', desc: 'When TikTok publishes it — ~15 min to ~10 days out' },
|
|
1069
|
+
{ flag: '--file video.mp4 | --url <https>', desc: 'Video' },
|
|
1070
|
+
{ flag: '--caption "..."', desc: 'Caption' },
|
|
1071
|
+
{ flag: '(price)', desc: "Same as post — uses TikTok's own scheduler" },
|
|
1045
1072
|
],
|
|
1046
1073
|
follow: [
|
|
1047
1074
|
{ flag: '<username>', desc: 'Account doing the follow' },
|
|
@@ -1073,6 +1100,47 @@ const TIKTOK_HELP = {
|
|
|
1073
1100
|
{ flag: '--file pic.png', desc: 'Image file' },
|
|
1074
1101
|
{ flag: '(price)', desc: '$0.005 USDC' },
|
|
1075
1102
|
],
|
|
1103
|
+
draft: [
|
|
1104
|
+
{ flag: '<username>', desc: 'Account to post from' },
|
|
1105
|
+
{ flag: '--file video.mp4 | --url <https>', desc: 'Video' },
|
|
1106
|
+
{ flag: '--caption "..."', desc: 'Caption' },
|
|
1107
|
+
{ flag: '--privacy 0|1|2', desc: 'Audience: 0 public (default) · 1 friends · 2 private' },
|
|
1108
|
+
{ flag: '--at <iso8601>', desc: 'Optional — schedule on approval instead of posting now' },
|
|
1109
|
+
{ flag: '(price)', desc: 'Free — queues for approval; you pay on approve' },
|
|
1110
|
+
],
|
|
1111
|
+
drafts: [
|
|
1112
|
+
{ flag: '[<username>]', desc: 'List drafts awaiting approval (optionally for one account)' },
|
|
1113
|
+
{ flag: '--tag <name>', desc: 'Filter by folder/tag' },
|
|
1114
|
+
{ flag: '(price)', desc: 'Free' },
|
|
1115
|
+
],
|
|
1116
|
+
approve: [
|
|
1117
|
+
{ flag: '<draft-id>', desc: 'Publish the queued draft + write it to the post log' },
|
|
1118
|
+
{ flag: '(price)', desc: "Same as post — $0.01 (a scheduled draft uses TikTok's scheduler)" },
|
|
1119
|
+
],
|
|
1120
|
+
reject: [
|
|
1121
|
+
{ flag: '<draft-id>', desc: 'Discard a queued draft' },
|
|
1122
|
+
{ flag: '(price)', desc: 'Free' },
|
|
1123
|
+
],
|
|
1124
|
+
logs: [
|
|
1125
|
+
{ flag: '[<username>]', desc: 'Recent posts (audit log) — approved drafts + direct posts' },
|
|
1126
|
+
{ flag: '--tag <name>', desc: 'Filter by folder/tag' },
|
|
1127
|
+
{ flag: '--limit <N>', desc: 'How many to show (default 20)' },
|
|
1128
|
+
{ flag: '(price)', desc: 'Free' },
|
|
1129
|
+
],
|
|
1130
|
+
analytics: [
|
|
1131
|
+
{ flag: '<username>', desc: 'Scrape per-post views/likes/comments, categorize into tiers, snapshot the time-series' },
|
|
1132
|
+
{ flag: '(price)', desc: '$0.005 USDC (free in self-hosted mode)' },
|
|
1133
|
+
],
|
|
1134
|
+
review: [
|
|
1135
|
+
{ flag: '<username>', desc: 'Performance review — best/worst, tier mix, engagement, trend vs last snapshot' },
|
|
1136
|
+
{ flag: '(price)', desc: 'Free — reads the local snapshot store' },
|
|
1137
|
+
],
|
|
1138
|
+
monitor: [
|
|
1139
|
+
{ flag: 'tick | start | stop | status', desc: 'Unattended analytics loop (self-learning)' },
|
|
1140
|
+
{ flag: '--every <6h|30m>', desc: 'Interval for `start` (default 6h)' },
|
|
1141
|
+
{ flag: '--account a,b', desc: 'Limit to specific accounts (default: all connected)' },
|
|
1142
|
+
{ flag: '(price)', desc: 'Free locally; each tick runs `analytics`' },
|
|
1143
|
+
],
|
|
1076
1144
|
};
|
|
1077
1145
|
/**
|
|
1078
1146
|
* Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
|
|
@@ -1230,10 +1298,10 @@ async function main() {
|
|
|
1230
1298
|
AGENT_MODE = !process.stdout.isTTY || !!flags.json || process.env.PALMYR_JSON === '1';
|
|
1231
1299
|
setUiAgentMode(AGENT_MODE);
|
|
1232
1300
|
if (flags.version) {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1301
|
+
// `--version` follows the universal CLI convention: a bare version string on
|
|
1302
|
+
// stdout so wrappers/CI can grep it. Agents that want the richer cli/node/
|
|
1303
|
+
// platform report opt in explicitly with --json (or PALMYR_JSON=1).
|
|
1304
|
+
if (AGENT_MODE && (!!flags.json || process.env.PALMYR_JSON === '1')) {
|
|
1237
1305
|
print({
|
|
1238
1306
|
cliPackageVersion: VERSION,
|
|
1239
1307
|
version: VERSION, // back-compat alias for the legacy {version} shape
|
|
@@ -3127,8 +3195,6 @@ async function main() {
|
|
|
3127
3195
|
err('No session secret found. Was this wallet created on this machine?');
|
|
3128
3196
|
const data = await ao.walletConfig(walletId, sessionSecret);
|
|
3129
3197
|
return print(data);
|
|
3130
|
-
print(data.config || data);
|
|
3131
|
-
break;
|
|
3132
3198
|
}
|
|
3133
3199
|
case 'use': {
|
|
3134
3200
|
const walletId = positional[0] || flags.id;
|
|
@@ -6984,6 +7050,8 @@ async function main() {
|
|
|
6984
7050
|
}
|
|
6985
7051
|
case 'tiktok': {
|
|
6986
7052
|
const sv = await import('./social-vault.js');
|
|
7053
|
+
const sd = await import('./social-drafts.js');
|
|
7054
|
+
const sa = await import('./social-analytics.js');
|
|
6987
7055
|
const platform = 'tiktok';
|
|
6988
7056
|
// Same help guard as `twitter` — prevents `--help` from dispatching
|
|
6989
7057
|
// a paid subcommand. Same bug class lived here too in 1.8.3.
|
|
@@ -6992,23 +7060,34 @@ async function main() {
|
|
|
6992
7060
|
command: 'tiktok',
|
|
6993
7061
|
title: 'tiktok',
|
|
6994
7062
|
subtitle: 'Automated TikTok account management',
|
|
6995
|
-
footerLeft: '
|
|
7063
|
+
footerLeft: 'Start with `connect`: log in once in your own browser, then post / follow / like — all server-side.',
|
|
6996
7064
|
commands: [
|
|
6997
|
-
{ name: '
|
|
6998
|
-
{ name: '
|
|
7065
|
+
{ name: 'connect', description: 'Log in once via your own browser — opens TikTok, you sign in (incl. captcha/2FA), and Palmyr auto-captures the session into the local vault. Auto-creates the account and infers the country from your browser. This is the easy path.', hint: '<username> [--tag <folder>]' },
|
|
7066
|
+
{ name: 'import', description: 'Manual fallback to `connect`: save a BYO account from a marketplace --credentials-line "login:pw:email:email_pw", or paste cookies from DevTools → Application → Cookies → .tiktok.com via --sessionid.', hint: '--credentials-line "..." OR <username> --sessionid ... --csrf ... --webid ...' },
|
|
7067
|
+
{ name: 'list', description: 'List local TikTok accounts; --tag filters to one folder', hint: '[--tag <folder>]' },
|
|
6999
7068
|
{ name: 'info', description: 'Show one account', hint: '<username>' },
|
|
7000
7069
|
{ name: 'rename', description: 'Update the local handle', hint: '<old> --to <new>' },
|
|
7070
|
+
{ name: 'tag', description: 'Group an account under a folder-like tag so one agent can organize 30+ accounts; --clear removes it', hint: '<username> <folder> | <username> --clear' },
|
|
7001
7071
|
{ name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
|
|
7002
7072
|
{ name: 'totp', description: 'Print the current TOTP code', hint: '<username>' },
|
|
7003
7073
|
{ name: 'login', description: 'Validate cookies and cache the session', hint: '<username>' },
|
|
7004
7074
|
{ name: 'session', description: 'Check cached session status', hint: '<username>' },
|
|
7005
7075
|
{ name: 'post', description: 'Post a video', hint: '<username> --file video.mp4 --caption "..."' },
|
|
7076
|
+
{ name: 'schedule', description: "Schedule a video via TikTok's own scheduler (~15 min to ~10 days out) — fires even if your machine and our server are off.", hint: '<username> --at 2026-06-03T18:00Z --file v.mp4 --caption "..."' },
|
|
7006
7077
|
{ name: 'follow', description: 'Follow a TikTok user', hint: '<username> --user @handle' },
|
|
7007
7078
|
{ name: 'like', description: 'Like a video', hint: '<username> --video https://...' },
|
|
7008
7079
|
{ name: 'delete', description: 'Delete a video', hint: '<username> --video https://...' },
|
|
7009
7080
|
{ name: 'bio', description: 'Update bio (<=80 chars)', hint: '<username> --text "..."' },
|
|
7010
7081
|
{ name: 'name', description: 'Update display name (<=30 chars)', hint: '<username> --display "..."' },
|
|
7011
7082
|
{ name: 'pfp', description: 'Update avatar', hint: '<username> --file pic.png' },
|
|
7083
|
+
{ name: 'draft', description: 'Stage a post for human approval instead of publishing — queues it locally (free).', hint: '<username> --file v.mp4 --caption "..." [--at <iso>]' },
|
|
7084
|
+
{ name: 'drafts', description: 'List drafts awaiting approval', hint: '[<username>] [--tag <folder>]' },
|
|
7085
|
+
{ name: 'approve', description: 'Publish a queued draft + record it in the post log', hint: '<draft-id>' },
|
|
7086
|
+
{ name: 'reject', description: 'Discard a queued draft', hint: '<draft-id>' },
|
|
7087
|
+
{ name: 'logs', description: 'Audit log of posts that went out (approved drafts + direct posts)', hint: '[<username>] [--tag <folder>] [--limit N]' },
|
|
7088
|
+
{ name: 'analytics', description: 'Scrape per-post views/likes/comments, categorize into tiers vs the account’s own posts, and snapshot the time-series', hint: '<username>' },
|
|
7089
|
+
{ name: 'review', description: 'Performance review: best/worst posts, tier mix, engagement, and trend vs the last snapshot — the self-learning surface', hint: '<username>' },
|
|
7090
|
+
{ name: 'monitor', description: 'Unattended loop that periodically runs analytics so review stays fresh (mirrors the wallet daemon)', hint: 'tick | start --every 6h | stop | status' },
|
|
7012
7091
|
],
|
|
7013
7092
|
fromHome,
|
|
7014
7093
|
});
|
|
@@ -7108,14 +7187,16 @@ async function main() {
|
|
|
7108
7187
|
const summary = sv.importAccount(platform, username, creds, {
|
|
7109
7188
|
source: line ? 'marketplace-line' : 'import',
|
|
7110
7189
|
country,
|
|
7190
|
+
tag: flags.tag,
|
|
7111
7191
|
});
|
|
7112
7192
|
const loginPath = sessionid ? 'cookie-injection' : 'form-login (requires CAPSOLVER_API_KEY server-side)';
|
|
7113
7193
|
log(`tiktok import: ${summary.username} (${summary.id}) [login: ${loginPath}, country: ${country}]`);
|
|
7114
7194
|
return print({ ...summary, has_sessionid: !!sessionid, has_password: !!password, login_path: loginPath });
|
|
7115
7195
|
}
|
|
7116
7196
|
case 'list': {
|
|
7117
|
-
const
|
|
7118
|
-
|
|
7197
|
+
const tagFilter = flags.tag;
|
|
7198
|
+
const accounts = sv.listAccounts(platform, tagFilter);
|
|
7199
|
+
return print({ accounts, count: accounts.length, ...(tagFilter ? { tag: tagFilter } : {}) });
|
|
7119
7200
|
}
|
|
7120
7201
|
case 'info': {
|
|
7121
7202
|
const username = positional[0] || flags.username;
|
|
@@ -7137,6 +7218,19 @@ async function main() {
|
|
|
7137
7218
|
log(`tiktok rename: ${oldUsername} → ${newUsername}`);
|
|
7138
7219
|
return print(summary);
|
|
7139
7220
|
}
|
|
7221
|
+
case 'tag': {
|
|
7222
|
+
// Folder-like grouping so one agent can organize 30+ accounts.
|
|
7223
|
+
const username = positional[0] || flags.username;
|
|
7224
|
+
if (!username)
|
|
7225
|
+
err('<username> required');
|
|
7226
|
+
const clear = !!flags.clear;
|
|
7227
|
+
const newTag = clear ? null : (positional[1] || flags.tag);
|
|
7228
|
+
if (!clear && !newTag)
|
|
7229
|
+
err('provide a tag (e.g. `palmyr tiktok tag <username> brand-x`) or --clear');
|
|
7230
|
+
const summary = sv.tagAccount(platform, username, newTag);
|
|
7231
|
+
log(clear ? `tiktok tag: cleared on ${username}` : `tiktok tag: ${username} → ${newTag}`);
|
|
7232
|
+
return print(summary);
|
|
7233
|
+
}
|
|
7140
7234
|
case 'remove': {
|
|
7141
7235
|
const username = positional[0] || flags.username;
|
|
7142
7236
|
if (!username)
|
|
@@ -7165,6 +7259,113 @@ async function main() {
|
|
|
7165
7259
|
expires_in_seconds: secondsUntilNextCode(),
|
|
7166
7260
|
});
|
|
7167
7261
|
}
|
|
7262
|
+
case 'connect': {
|
|
7263
|
+
// Real-browser login. Launches the operator's own Chrome/Edge,
|
|
7264
|
+
// they sign in (solving any captcha/2FA themselves), and we harvest
|
|
7265
|
+
// the session via CDP — no headless form-driving, no captcha solver.
|
|
7266
|
+
// Agent-smooth: auto-provisions the account, auto-detects login
|
|
7267
|
+
// (no keystroke), and always terminates with structured JSON.
|
|
7268
|
+
const username = positional[0] || flags.username;
|
|
7269
|
+
if (!username)
|
|
7270
|
+
err('<username> required');
|
|
7271
|
+
// --country is optional: if omitted we infer it from the real
|
|
7272
|
+
// browser during login (locale/timezone). Explicit flag overrides.
|
|
7273
|
+
const explicitCountry = flags.country?.toLowerCase();
|
|
7274
|
+
let acc = sv.getAccount(platform, username);
|
|
7275
|
+
// Idempotent: an existing account with a fresh cached session returns
|
|
7276
|
+
// fast, no browser. (New accounts have no session yet.) Lets an agent
|
|
7277
|
+
// call `connect` defensively before a run.
|
|
7278
|
+
if (acc && !flags.force) {
|
|
7279
|
+
const existing = sv.loadSession(acc.id);
|
|
7280
|
+
if (existing && existing.cookies?.length) {
|
|
7281
|
+
const ageH = sv.sessionAgeHours(acc.id) ?? 999;
|
|
7282
|
+
if (ageH < 12) {
|
|
7283
|
+
return print({
|
|
7284
|
+
success: true, platform, username, connected: true, already: true,
|
|
7285
|
+
cookies: existing.cookies.length,
|
|
7286
|
+
age_hours: Number(ageH.toFixed(2)),
|
|
7287
|
+
captured_at: existing.captured_at,
|
|
7288
|
+
hint: 'Session is fresh. Pass --force to re-capture.',
|
|
7289
|
+
});
|
|
7290
|
+
}
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
const { connectTikTok } = await import('./tiktok-connect.js');
|
|
7294
|
+
const timeoutSec = flags.timeout !== undefined ? Math.max(30, Number(flags.timeout)) : 300;
|
|
7295
|
+
let hostedLink;
|
|
7296
|
+
const result = await connectTikTok({
|
|
7297
|
+
country: explicitCountry || acc?.country,
|
|
7298
|
+
timeoutMs: timeoutSec * 1000,
|
|
7299
|
+
browserPath: flags['browser-path'],
|
|
7300
|
+
noSandbox: !!flags['no-sandbox'],
|
|
7301
|
+
qr: !!flags.qr,
|
|
7302
|
+
onQr: async (dataUrl) => {
|
|
7303
|
+
// Host the QR so the agent gets a clean link to forward. Best-effort:
|
|
7304
|
+
// if the server lacks the endpoint, the PNG/data-URL still work.
|
|
7305
|
+
try {
|
|
7306
|
+
const hosted = await ao.socialTiktokHostQr(dataUrl);
|
|
7307
|
+
hostedLink = `${ao.api.replace(/\/+$/, '')}/connect/${hosted.token}`;
|
|
7308
|
+
process.stderr.write(`[connect] QR link (send to a human): ${hostedLink}\n`);
|
|
7309
|
+
}
|
|
7310
|
+
catch { /* hosting optional */ }
|
|
7311
|
+
},
|
|
7312
|
+
onProgress: (m) => process.stderr.write(`[connect] ${m}\n`),
|
|
7313
|
+
});
|
|
7314
|
+
if (!result.success) {
|
|
7315
|
+
const details = { platform, username, reason: result.reason };
|
|
7316
|
+
if (result.qrPngPath)
|
|
7317
|
+
details.qr_png_path = result.qrPngPath;
|
|
7318
|
+
if (hostedLink)
|
|
7319
|
+
details.qr_link = hostedLink;
|
|
7320
|
+
if (result.reason === 'no_local_browser') {
|
|
7321
|
+
details.remedy =
|
|
7322
|
+
`No Chrome/Edge/Brave found. Install one, pass --browser-path <path>, or import cookies manually: ` +
|
|
7323
|
+
`open tiktok.com (logged in) → DevTools → Application → Cookies → .tiktok.com, then ` +
|
|
7324
|
+
`palmyr tiktok import ${username} --country us --sessionid <sessionid> --csrf <tt_csrf_token> --webid <tt_webid_v2>`;
|
|
7325
|
+
}
|
|
7326
|
+
err(`connect failed: ${result.error || result.reason}`, EXIT.GENERAL, details);
|
|
7327
|
+
}
|
|
7328
|
+
// Country precedence: existing account keeps its stored value;
|
|
7329
|
+
// otherwise explicit flag > detected-from-browser > env default > us.
|
|
7330
|
+
// Agents never have to know or pass it.
|
|
7331
|
+
const resolvedCountry = acc?.country ||
|
|
7332
|
+
explicitCountry ||
|
|
7333
|
+
result.detectedCountry ||
|
|
7334
|
+
process.env.PALMYR_DEFAULT_COUNTRY?.toLowerCase() ||
|
|
7335
|
+
'us';
|
|
7336
|
+
const countrySource = acc?.country
|
|
7337
|
+
? 'account'
|
|
7338
|
+
: explicitCountry
|
|
7339
|
+
? 'flag'
|
|
7340
|
+
: result.detectedCountry
|
|
7341
|
+
? 'detected'
|
|
7342
|
+
: 'default';
|
|
7343
|
+
// Auto-create the account now that the country is known (one command,
|
|
7344
|
+
// not import-then-connect).
|
|
7345
|
+
if (!acc) {
|
|
7346
|
+
acc = sv.importAccount(platform, username, { login: username, password: 'unknown' }, { source: 'connect', country: resolvedCountry, tag: flags.tag });
|
|
7347
|
+
process.stderr.write(`[connect] created local account ${username} (${acc.id}) [country: ${resolvedCountry}, ${countrySource}]\n`);
|
|
7348
|
+
}
|
|
7349
|
+
else if (flags.tag) {
|
|
7350
|
+
// Re-connecting an existing account with --tag (re)assigns the folder.
|
|
7351
|
+
sv.tagAccount(platform, username, flags.tag);
|
|
7352
|
+
}
|
|
7353
|
+
// Persist into the same encrypted session cache that post/follow/like read.
|
|
7354
|
+
sv.saveSession(acc.id, platform, result.cookies || []);
|
|
7355
|
+
sv.updateMeta(platform, username, { last_action_at: new Date().toISOString() });
|
|
7356
|
+
return print({
|
|
7357
|
+
success: true, platform, username, connected: true,
|
|
7358
|
+
browser: result.browser,
|
|
7359
|
+
country: resolvedCountry,
|
|
7360
|
+
country_source: countrySource,
|
|
7361
|
+
cookies_captured: result.cookiesCaptured,
|
|
7362
|
+
sessionid_present: true,
|
|
7363
|
+
...(flags.tag ? { tag: flags.tag } : {}),
|
|
7364
|
+
...(result.qrPngPath ? { qr_png_path: result.qrPngPath } : {}),
|
|
7365
|
+
...(hostedLink ? { qr_link: hostedLink } : {}),
|
|
7366
|
+
next: `palmyr tiktok post ${username} --file video.mp4 --caption "..."`,
|
|
7367
|
+
});
|
|
7368
|
+
}
|
|
7168
7369
|
case 'login': {
|
|
7169
7370
|
const username = positional[0] || flags.username;
|
|
7170
7371
|
if (!username)
|
|
@@ -7241,7 +7442,202 @@ async function main() {
|
|
|
7241
7442
|
stale: (ageHours || 0) > 12,
|
|
7242
7443
|
});
|
|
7243
7444
|
}
|
|
7445
|
+
case 'draft': {
|
|
7446
|
+
// Stage a post for human approval — does NOT publish. Free, local.
|
|
7447
|
+
const username = positional[0] || flags.username;
|
|
7448
|
+
if (!username)
|
|
7449
|
+
err('<username> required');
|
|
7450
|
+
const acc = sv.getAccount(platform, username);
|
|
7451
|
+
if (!acc)
|
|
7452
|
+
err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
|
|
7453
|
+
const caption = flags.caption || flags.body || flags.text;
|
|
7454
|
+
if (!caption)
|
|
7455
|
+
err('--caption "..." required');
|
|
7456
|
+
const filePath = flags.file || flags.path;
|
|
7457
|
+
const videoUrl = flags.url;
|
|
7458
|
+
if (!filePath && !videoUrl)
|
|
7459
|
+
err('--file <local-path> or --url <https-url> required');
|
|
7460
|
+
if (filePath) {
|
|
7461
|
+
const { existsSync, statSync } = await import('fs');
|
|
7462
|
+
if (!existsSync(filePath))
|
|
7463
|
+
err(`File not found: ${filePath}`, EXIT.NOT_FOUND);
|
|
7464
|
+
if (statSync(filePath).size > 100 * 1024 * 1024)
|
|
7465
|
+
err('Video too large (max 100 MB)', EXIT.BAD_INPUT);
|
|
7466
|
+
}
|
|
7467
|
+
const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
|
|
7468
|
+
let schedule_at;
|
|
7469
|
+
const at = flags.at || flags.when;
|
|
7470
|
+
if (at) {
|
|
7471
|
+
const when = new Date(at);
|
|
7472
|
+
if (isNaN(when.getTime()))
|
|
7473
|
+
err('--at must be a valid ISO-8601 datetime', EXIT.BAD_INPUT);
|
|
7474
|
+
const ms = when.getTime() - Date.now();
|
|
7475
|
+
if (ms < 15 * 60 * 1000)
|
|
7476
|
+
err('--at must be at least ~15 minutes in the future', EXIT.BAD_INPUT);
|
|
7477
|
+
if (ms > 10 * 24 * 60 * 60 * 1000)
|
|
7478
|
+
err('--at must be within ~10 days', EXIT.BAD_INPUT);
|
|
7479
|
+
schedule_at = when.toISOString();
|
|
7480
|
+
}
|
|
7481
|
+
const absFile = filePath ? (await import('path')).resolve(filePath) : undefined;
|
|
7482
|
+
const draft = sd.createDraft({
|
|
7483
|
+
platform, account: username, caption, file: absFile, url: videoUrl,
|
|
7484
|
+
privacy, tag: flags.tag || acc.tag, schedule_at,
|
|
7485
|
+
});
|
|
7486
|
+
log(`tiktok draft ${draft.id} staged for ${username} (awaiting approval)`);
|
|
7487
|
+
return print({
|
|
7488
|
+
draft_id: draft.id, status: draft.status, account: username, caption,
|
|
7489
|
+
...(draft.tag ? { tag: draft.tag } : {}), ...(schedule_at ? { schedule_at } : {}),
|
|
7490
|
+
next: `palmyr tiktok approve ${draft.id}`,
|
|
7491
|
+
});
|
|
7492
|
+
}
|
|
7493
|
+
case 'drafts': {
|
|
7494
|
+
const drafts = sd.listDrafts({ platform, account: positional[0] || flags.account, tag: flags.tag });
|
|
7495
|
+
return print({
|
|
7496
|
+
drafts: drafts.map(d => ({ id: d.id, account: d.account, caption: d.caption, privacy: d.privacy, tag: d.tag, schedule_at: d.schedule_at, created_at: d.created_at })),
|
|
7497
|
+
count: drafts.length,
|
|
7498
|
+
});
|
|
7499
|
+
}
|
|
7500
|
+
case 'reject': {
|
|
7501
|
+
const id = positional[0] || flags.id;
|
|
7502
|
+
if (!id)
|
|
7503
|
+
err('<draft-id> required');
|
|
7504
|
+
if (!sd.deleteDraft(id))
|
|
7505
|
+
err(`draft "${id}" not found`, EXIT.NOT_FOUND);
|
|
7506
|
+
log(`tiktok reject ${id}`);
|
|
7507
|
+
return print({ rejected: true, draft_id: id });
|
|
7508
|
+
}
|
|
7509
|
+
case 'logs': {
|
|
7510
|
+
const entries = sd.readPostLog({
|
|
7511
|
+
platform, account: positional[0] || flags.account,
|
|
7512
|
+
tag: flags.tag, limit: flags.limit ? Number(flags.limit) : 20,
|
|
7513
|
+
});
|
|
7514
|
+
return print({ posts: entries, count: entries.length });
|
|
7515
|
+
}
|
|
7516
|
+
case 'approve': {
|
|
7517
|
+
// Publish a queued draft from the human-in-the-loop flow + log it.
|
|
7518
|
+
const id = positional[0] || flags.id;
|
|
7519
|
+
if (!id)
|
|
7520
|
+
err('<draft-id> required');
|
|
7521
|
+
const draft = sd.getDraft(id);
|
|
7522
|
+
if (!draft)
|
|
7523
|
+
err(`draft "${id}" not found`, EXIT.NOT_FOUND);
|
|
7524
|
+
const acc = sv.getAccount(platform, draft.account);
|
|
7525
|
+
if (!acc)
|
|
7526
|
+
err(`account "${draft.account}" for draft ${id} not found locally`, EXIT.NOT_FOUND);
|
|
7527
|
+
const sess = sv.loadSession(acc.id);
|
|
7528
|
+
if (!sess || !sess.cookies || sess.cookies.length === 0) {
|
|
7529
|
+
err(`No cached session for ${draft.account}. Run 'tiktok connect ${draft.account}' first.`, EXIT.NOT_FOUND);
|
|
7530
|
+
}
|
|
7531
|
+
const psid = sv.getProxySessionId(platform, draft.account);
|
|
7532
|
+
const country = sv.getCountry(platform, draft.account);
|
|
7533
|
+
const media = {};
|
|
7534
|
+
if (draft.file) {
|
|
7535
|
+
const { readFileSync, existsSync, statSync } = await import('fs');
|
|
7536
|
+
if (!existsSync(draft.file))
|
|
7537
|
+
err(`Draft video no longer exists at ${draft.file}`, EXIT.NOT_FOUND);
|
|
7538
|
+
if (statSync(draft.file).size > 100 * 1024 * 1024)
|
|
7539
|
+
err('Video too large (max 100 MB)', EXIT.BAD_INPUT);
|
|
7540
|
+
media.video_base64 = `data:video/mp4;base64,${readFileSync(draft.file).toString('base64')}`;
|
|
7541
|
+
}
|
|
7542
|
+
else {
|
|
7543
|
+
media.video_url = draft.url;
|
|
7544
|
+
}
|
|
7545
|
+
let data;
|
|
7546
|
+
try {
|
|
7547
|
+
data = await ao.socialTiktokPost(acc.id, sess.cookies, draft.caption, media, { privacy: draft.privacy, schedule_at: draft.schedule_at }, psid, country);
|
|
7548
|
+
}
|
|
7549
|
+
catch (e) {
|
|
7550
|
+
err(`approve failed: ${e.message}`, EXIT.GENERAL);
|
|
7551
|
+
}
|
|
7552
|
+
if (!data?.success) {
|
|
7553
|
+
// Keep the draft so it can be retried (e.g. after re-connecting a stale session).
|
|
7554
|
+
err(`approve failed: ${data?.error || 'unknown'}${data?.error_code ? ` [${data.error_code}]` : ''}`, EXIT.GENERAL);
|
|
7555
|
+
}
|
|
7556
|
+
sv.updateMeta(platform, draft.account, { last_action_at: new Date().toISOString() });
|
|
7557
|
+
const entry = sd.appendPostLog({ platform, account: draft.account, caption: draft.caption, source: 'draft', status: draft.schedule_at ? 'scheduled' : 'posted', url: data?.data?.video_url, tag: draft.tag, draft_id: id, result: data?.data });
|
|
7558
|
+
sd.deleteDraft(id);
|
|
7559
|
+
log(`tiktok approve ${id} → ${entry.status} for ${draft.account}`);
|
|
7560
|
+
return print({ approved: true, draft_id: id, account: draft.account, status: entry.status, ...(data?.data || {}) });
|
|
7561
|
+
}
|
|
7562
|
+
case 'analytics': {
|
|
7563
|
+
const username = positional[0] || flags.username;
|
|
7564
|
+
if (!username)
|
|
7565
|
+
err('<username> required');
|
|
7566
|
+
const acc = sv.getAccount(platform, username);
|
|
7567
|
+
if (!acc)
|
|
7568
|
+
err(`tiktok account "${username}" not found locally`, EXIT.NOT_FOUND);
|
|
7569
|
+
const sess = sv.loadSession(acc.id);
|
|
7570
|
+
if (!sess || !sess.cookies || sess.cookies.length === 0) {
|
|
7571
|
+
err(`No cached session for ${username}. Run 'tiktok connect ${username}' first.`, EXIT.NOT_FOUND);
|
|
7572
|
+
}
|
|
7573
|
+
const psid = sv.getProxySessionId(platform, username);
|
|
7574
|
+
const country = sv.getCountry(platform, username);
|
|
7575
|
+
let data;
|
|
7576
|
+
try {
|
|
7577
|
+
data = await ao.socialTiktokAnalytics(acc.id, sess.cookies, psid, country);
|
|
7578
|
+
}
|
|
7579
|
+
catch (e) {
|
|
7580
|
+
err(`analytics failed: ${e.message}`, EXIT.GENERAL);
|
|
7581
|
+
}
|
|
7582
|
+
if (!data?.success) {
|
|
7583
|
+
err(`analytics failed: ${data?.error || 'unknown'}${data?.error_code ? ` [${data.error_code}]` : ''}`, EXIT.GENERAL);
|
|
7584
|
+
}
|
|
7585
|
+
// Categorize (relative tiers) + snapshot to the local time-series so
|
|
7586
|
+
// `review` can show trends. `tiktok monitor` calls this same op.
|
|
7587
|
+
const snap = sa.appendSnapshot(username, data?.data?.posts || []);
|
|
7588
|
+
return print({ platform, username, scraped_at: data?.data?.scraped_at, summary: snap.summary, posts: snap.posts });
|
|
7589
|
+
}
|
|
7590
|
+
case 'review': {
|
|
7591
|
+
const username = positional[0] || flags.username;
|
|
7592
|
+
if (!username)
|
|
7593
|
+
err('<username> required');
|
|
7594
|
+
return print(sa.review(username));
|
|
7595
|
+
}
|
|
7596
|
+
case 'monitor': {
|
|
7597
|
+
// Unattended self-learning loop: periodically run `analytics` (scrape
|
|
7598
|
+
// + categorize + snapshot) for the monitored accounts. Mirrors the
|
|
7599
|
+
// wallet daemon — tick / start / stop / status.
|
|
7600
|
+
const sm = await import('./social-monitor.js');
|
|
7601
|
+
const sub = positional[0] || 'status';
|
|
7602
|
+
const cliPath = process.argv[1];
|
|
7603
|
+
const resolveAccounts = () => {
|
|
7604
|
+
const flagAcc = flags.account;
|
|
7605
|
+
if (flagAcc)
|
|
7606
|
+
return flagAcc.split(',').map((s) => s.trim()).filter(Boolean);
|
|
7607
|
+
return sv.listAccounts(platform).map((a) => a.username);
|
|
7608
|
+
};
|
|
7609
|
+
if (sub === 'tick') {
|
|
7610
|
+
const accounts = resolveAccounts();
|
|
7611
|
+
if (!accounts.length)
|
|
7612
|
+
err('No TikTok accounts to monitor — connect one first.', EXIT.NOT_FOUND);
|
|
7613
|
+
return print({ ticked: accounts.length, results: sm.monitorTick(cliPath, accounts) });
|
|
7614
|
+
}
|
|
7615
|
+
if (sub === 'start') {
|
|
7616
|
+
const accounts = resolveAccounts();
|
|
7617
|
+
if (!accounts.length)
|
|
7618
|
+
err('No TikTok accounts to monitor — connect one first.', EXIT.NOT_FOUND);
|
|
7619
|
+
const intervalSeconds = sm.parseInterval(flags.every || flags.interval, 21600);
|
|
7620
|
+
const { pid } = sm.startMonitor(cliPath, { intervalSeconds, accounts });
|
|
7621
|
+
return print({ started: true, pid, every_seconds: intervalSeconds, accounts });
|
|
7622
|
+
}
|
|
7623
|
+
if (sub === 'stop') {
|
|
7624
|
+
const r = await sm.stopMonitor();
|
|
7625
|
+
return print({ stopped: r.wasRunning, pid: r.pid });
|
|
7626
|
+
}
|
|
7627
|
+
if (sub === 'status') {
|
|
7628
|
+
return print(sm.monitorStatus());
|
|
7629
|
+
}
|
|
7630
|
+
if (sub === '_run') {
|
|
7631
|
+
const intervalSeconds = sm.parseInterval(flags.interval, 21600);
|
|
7632
|
+
const accounts = resolveAccounts();
|
|
7633
|
+
await sm.runMonitorLoop(cliPath, { intervalSeconds, accounts });
|
|
7634
|
+
return;
|
|
7635
|
+
}
|
|
7636
|
+
err(`Unknown monitor action: ${sub}. Try: tick, start, stop, status`, EXIT.BAD_INPUT);
|
|
7637
|
+
return;
|
|
7638
|
+
}
|
|
7244
7639
|
case 'post':
|
|
7640
|
+
case 'schedule':
|
|
7245
7641
|
case 'follow':
|
|
7246
7642
|
case 'like':
|
|
7247
7643
|
case 'delete':
|
|
@@ -7262,7 +7658,7 @@ async function main() {
|
|
|
7262
7658
|
const country = sv.getCountry(platform, username);
|
|
7263
7659
|
let data;
|
|
7264
7660
|
try {
|
|
7265
|
-
if (subcommand === 'post') {
|
|
7661
|
+
if (subcommand === 'post' || subcommand === 'schedule') {
|
|
7266
7662
|
const caption = flags.caption || flags.body || flags.text;
|
|
7267
7663
|
if (!caption)
|
|
7268
7664
|
err('--caption "..." required');
|
|
@@ -7285,7 +7681,28 @@ async function main() {
|
|
|
7285
7681
|
media.video_url = videoUrl;
|
|
7286
7682
|
}
|
|
7287
7683
|
const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
|
|
7288
|
-
|
|
7684
|
+
// `schedule` is `post` with a future time — drives TikTok's own
|
|
7685
|
+
// scheduler. Validate the window client-side for fast feedback;
|
|
7686
|
+
// the server re-checks and renders it into the account timezone.
|
|
7687
|
+
let schedule_at;
|
|
7688
|
+
if (subcommand === 'schedule') {
|
|
7689
|
+
const at = flags.at || flags.when;
|
|
7690
|
+
if (!at)
|
|
7691
|
+
err('--at <iso8601> required (e.g. --at 2026-06-03T18:00:00Z)');
|
|
7692
|
+
const when = new Date(at);
|
|
7693
|
+
if (isNaN(when.getTime()))
|
|
7694
|
+
err('--at must be a valid ISO-8601 datetime (e.g. 2026-06-03T18:00:00Z)', EXIT.BAD_INPUT);
|
|
7695
|
+
const ms = when.getTime() - Date.now();
|
|
7696
|
+
if (ms < 15 * 60 * 1000)
|
|
7697
|
+
err('--at must be at least ~15 minutes in the future (TikTok minimum)', EXIT.BAD_INPUT);
|
|
7698
|
+
if (ms > 10 * 24 * 60 * 60 * 1000)
|
|
7699
|
+
err('--at must be within ~10 days (TikTok maximum)', EXIT.BAD_INPUT);
|
|
7700
|
+
schedule_at = when.toISOString();
|
|
7701
|
+
}
|
|
7702
|
+
data = await ao.socialTiktokPost(acc.id, sess.cookies, caption, media, { privacy, schedule_at }, psid, country);
|
|
7703
|
+
if (data?.success) {
|
|
7704
|
+
sd.appendPostLog({ platform, account: username, caption, source: 'direct', status: schedule_at ? 'scheduled' : 'posted', url: data?.data?.video_url, tag: acc.tag, result: data?.data });
|
|
7705
|
+
}
|
|
7289
7706
|
}
|
|
7290
7707
|
else if (subcommand === 'follow') {
|
|
7291
7708
|
const target = flags.user || flags.target;
|
|
@@ -7349,7 +7766,7 @@ async function main() {
|
|
|
7349
7766
|
return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
|
|
7350
7767
|
}
|
|
7351
7768
|
default:
|
|
7352
|
-
err(`Unknown tiktok command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, session, post, follow, like, delete, bio, name, pfp`);
|
|
7769
|
+
err(`Unknown tiktok command: ${subcommand}. Try: connect, import, list, info, rename, tag, remove, totp, login, session, post, schedule, draft, drafts, approve, reject, logs, analytics, review, monitor, follow, like, delete, bio, name, pfp`);
|
|
7353
7770
|
}
|
|
7354
7771
|
break;
|
|
7355
7772
|
}
|