@palmyr/cli 1.9.0 → 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 +467 -29
- 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 +10 -0
- package/dist/sdk.js +10 -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
|
@@ -778,12 +778,14 @@ const TWITTER_HELP = {
|
|
|
778
778
|
],
|
|
779
779
|
buy: [
|
|
780
780
|
{ flag: '(no args)', desc: 'Purchase the oldest ready X account from the supplier pool. Default for every filter below is random.' },
|
|
781
|
-
{ flag: '--country <CC>', desc: 'Filter
|
|
782
|
-
{ flag: '--
|
|
783
|
-
{ flag: '--
|
|
781
|
+
{ flag: '--country <CC>', desc: 'Filter by RESIDENCY (X about_profile.account_based_in). ISO alpha-2 — US, GB, DE, …. Run `pool-prices` to see what is priced.' },
|
|
782
|
+
{ flag: '--registered-country <CC>', desc: 'Filter by REGISTRATION country — where the X account was created from (parsed from X "Connected via" string). May differ from --country.' },
|
|
783
|
+
{ flag: '--platform android|ios|web', desc: 'Filter by registration platform (also parsed from X "Connected via" string).' },
|
|
784
|
+
{ flag: '--max-renames N', desc: 'Cap username-change count. --max-renames 0 = never renamed. Rows with unknown rename count do not match.' },
|
|
785
|
+
{ flag: '--source "raw string"', desc: 'Power-user: exact-match against the lowercased raw "Connected via" string. Used for fine-grained source_multiplier pricing.' },
|
|
784
786
|
{ flag: '--age 1y|2y|...', desc: 'Optional age category filter' },
|
|
785
787
|
{ flag: '(price)', desc: '$5 USDC default; country_price * source_multiplier when filters are passed.' },
|
|
786
|
-
{ flag: '(example)', desc: 'palmyr twitter buy --country
|
|
788
|
+
{ flag: '(example)', desc: 'palmyr twitter buy --country GB --registered-country GB --platform android --max-renames 0' },
|
|
787
789
|
],
|
|
788
790
|
login: [
|
|
789
791
|
{ flag: '<username>', desc: 'Force a fresh server-side session (browser runtime)' },
|
|
@@ -1012,10 +1014,23 @@ const TIKTOK_HELP = {
|
|
|
1012
1014
|
{ flag: '<username>', desc: 'TikTok handle to import' },
|
|
1013
1015
|
{ flag: '--sessionid <s> --csrf <c> --webid <w>', desc: 'Cookies from a logged-in TikTok browser' },
|
|
1014
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' },
|
|
1015
1018
|
{ flag: '(price)', desc: 'Free — local vault only' },
|
|
1016
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
|
+
],
|
|
1017
1031
|
list: [
|
|
1018
1032
|
{ flag: '(no args)', desc: 'List all local TikTok accounts' },
|
|
1033
|
+
{ flag: '--tag <name>', desc: 'Filter to accounts in this folder/tag' },
|
|
1019
1034
|
{ flag: '(price)', desc: 'Free' },
|
|
1020
1035
|
],
|
|
1021
1036
|
info: [{ flag: '<username>', desc: 'Show one account' }, { flag: '(price)', desc: 'Free' }],
|
|
@@ -1024,6 +1039,12 @@ const TIKTOK_HELP = {
|
|
|
1024
1039
|
{ flag: '--to <new>', desc: 'New handle' },
|
|
1025
1040
|
{ flag: '(price)', desc: 'Free — local-only metadata update' },
|
|
1026
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
|
+
],
|
|
1027
1048
|
remove: [
|
|
1028
1049
|
{ flag: '<username>', desc: 'Account to delete from local vault' },
|
|
1029
1050
|
{ flag: '--confirm', desc: 'Required' },
|
|
@@ -1032,14 +1053,22 @@ const TIKTOK_HELP = {
|
|
|
1032
1053
|
totp: [{ flag: '<username>', desc: 'Print current TOTP code' }, { flag: '(price)', desc: 'Free' }],
|
|
1033
1054
|
login: [
|
|
1034
1055
|
{ flag: '<username>', desc: 'Validate cookies and cache the session' },
|
|
1035
|
-
{ flag: '(price)', desc: '$0.
|
|
1056
|
+
{ flag: '(price)', desc: '$0.02 USDC' },
|
|
1036
1057
|
],
|
|
1037
1058
|
session: [{ flag: '<username>', desc: 'Check cached session' }, { flag: '(price)', desc: 'Free' }],
|
|
1038
1059
|
post: [
|
|
1039
1060
|
{ flag: '<username>', desc: 'Account to post from' },
|
|
1040
1061
|
{ flag: '--file video.mp4', desc: 'Video file' },
|
|
1041
1062
|
{ flag: '--caption "..."', desc: 'Caption' },
|
|
1042
|
-
{ 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" },
|
|
1043
1072
|
],
|
|
1044
1073
|
follow: [
|
|
1045
1074
|
{ flag: '<username>', desc: 'Account doing the follow' },
|
|
@@ -1071,6 +1100,47 @@ const TIKTOK_HELP = {
|
|
|
1071
1100
|
{ flag: '--file pic.png', desc: 'Image file' },
|
|
1072
1101
|
{ flag: '(price)', desc: '$0.005 USDC' },
|
|
1073
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
|
+
],
|
|
1074
1144
|
};
|
|
1075
1145
|
/**
|
|
1076
1146
|
* Render a per-command menu (no subcommand given). On a TTY → Ink MenuScreen
|
|
@@ -1228,10 +1298,10 @@ async function main() {
|
|
|
1228
1298
|
AGENT_MODE = !process.stdout.isTTY || !!flags.json || process.env.PALMYR_JSON === '1';
|
|
1229
1299
|
setUiAgentMode(AGENT_MODE);
|
|
1230
1300
|
if (flags.version) {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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')) {
|
|
1235
1305
|
print({
|
|
1236
1306
|
cliPackageVersion: VERSION,
|
|
1237
1307
|
version: VERSION, // back-compat alias for the legacy {version} shape
|
|
@@ -3125,8 +3195,6 @@ async function main() {
|
|
|
3125
3195
|
err('No session secret found. Was this wallet created on this machine?');
|
|
3126
3196
|
const data = await ao.walletConfig(walletId, sessionSecret);
|
|
3127
3197
|
return print(data);
|
|
3128
|
-
print(data.config || data);
|
|
3129
|
-
break;
|
|
3130
3198
|
}
|
|
3131
3199
|
case 'use': {
|
|
3132
3200
|
const walletId = positional[0] || flags.id;
|
|
@@ -6274,17 +6342,27 @@ async function main() {
|
|
|
6274
6342
|
return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
|
|
6275
6343
|
}
|
|
6276
6344
|
case 'buy': {
|
|
6277
|
-
// Agents say "buy" with optional
|
|
6278
|
-
//
|
|
6279
|
-
//
|
|
6280
|
-
//
|
|
6281
|
-
// -
|
|
6282
|
-
//
|
|
6283
|
-
//
|
|
6284
|
-
//
|
|
6345
|
+
// Agents say "buy" with optional filters. Each is independent
|
|
6346
|
+
// (default: random across that dimension).
|
|
6347
|
+
//
|
|
6348
|
+
// --country GB account_based_in = United Kingdom (residency)
|
|
6349
|
+
// --registered-country GB X's "Connected via …" country
|
|
6350
|
+
// --platform android|ios|web X's "Connected via" platform
|
|
6351
|
+
// --max-renames 0 never-renamed accounts only
|
|
6352
|
+
// --source "raw string" exact-match on the raw "Connected via" string (power user)
|
|
6353
|
+
//
|
|
6354
|
+
// Pricing = country_price * source_multiplier (multiplier
|
|
6355
|
+
// defaults to 1.0 when no source row exists). Without --country,
|
|
6356
|
+
// falls back to the legacy $5 flat rate.
|
|
6285
6357
|
const country = (flags.country || '').trim().toUpperCase() || undefined;
|
|
6286
6358
|
const ageCategory = flags.age || flags['age-category'] || undefined;
|
|
6287
6359
|
const source = (flags.source || '').trim().toLowerCase() || undefined;
|
|
6360
|
+
const registeredCountry = (flags['registered-country'] || '').trim().toUpperCase() || undefined;
|
|
6361
|
+
const platformFlag = (flags.platform || '').trim().toLowerCase() || undefined;
|
|
6362
|
+
if (platformFlag && !['android', 'ios', 'web'].includes(platformFlag)) {
|
|
6363
|
+
err('--platform must be one of: android, ios, web');
|
|
6364
|
+
}
|
|
6365
|
+
const registeredPlatform = platformFlag;
|
|
6288
6366
|
const maxRenamesRaw = flags['max-renames'];
|
|
6289
6367
|
const maxUsernameChanges = maxRenamesRaw === undefined || maxRenamesRaw === ''
|
|
6290
6368
|
? undefined
|
|
@@ -6294,7 +6372,12 @@ async function main() {
|
|
|
6294
6372
|
}
|
|
6295
6373
|
let data;
|
|
6296
6374
|
try {
|
|
6297
|
-
data = await ao.socialTwitterBuy(country, ageCategory, {
|
|
6375
|
+
data = await ao.socialTwitterBuy(country, ageCategory, {
|
|
6376
|
+
source,
|
|
6377
|
+
registeredCountry,
|
|
6378
|
+
registeredPlatform,
|
|
6379
|
+
maxUsernameChanges,
|
|
6380
|
+
});
|
|
6298
6381
|
}
|
|
6299
6382
|
catch (e) {
|
|
6300
6383
|
err(`Buy failed: ${e.message}`, EXIT.GENERAL);
|
|
@@ -6308,6 +6391,8 @@ async function main() {
|
|
|
6308
6391
|
// pre-seasoned at pool-add time.
|
|
6309
6392
|
const filterTags = [
|
|
6310
6393
|
country && `country=${country}`,
|
|
6394
|
+
registeredCountry && `registered_country=${registeredCountry}`,
|
|
6395
|
+
registeredPlatform && `platform=${registeredPlatform}`,
|
|
6311
6396
|
source && `source=${source}`,
|
|
6312
6397
|
maxUsernameChanges !== undefined && `max_renames=${maxUsernameChanges}`,
|
|
6313
6398
|
].filter(Boolean).join(', ');
|
|
@@ -6323,6 +6408,8 @@ async function main() {
|
|
|
6323
6408
|
platform,
|
|
6324
6409
|
username: summary.username,
|
|
6325
6410
|
country: account.country,
|
|
6411
|
+
registered_country: account.registered_country,
|
|
6412
|
+
registered_platform: account.registered_platform,
|
|
6326
6413
|
source: account.source,
|
|
6327
6414
|
username_change_count: account.username_change_count,
|
|
6328
6415
|
account_based_in: account.account_based_in,
|
|
@@ -6963,6 +7050,8 @@ async function main() {
|
|
|
6963
7050
|
}
|
|
6964
7051
|
case 'tiktok': {
|
|
6965
7052
|
const sv = await import('./social-vault.js');
|
|
7053
|
+
const sd = await import('./social-drafts.js');
|
|
7054
|
+
const sa = await import('./social-analytics.js');
|
|
6966
7055
|
const platform = 'tiktok';
|
|
6967
7056
|
// Same help guard as `twitter` — prevents `--help` from dispatching
|
|
6968
7057
|
// a paid subcommand. Same bug class lived here too in 1.8.3.
|
|
@@ -6971,23 +7060,34 @@ async function main() {
|
|
|
6971
7060
|
command: 'tiktok',
|
|
6972
7061
|
title: 'tiktok',
|
|
6973
7062
|
subtitle: 'Automated TikTok account management',
|
|
6974
|
-
footerLeft: '
|
|
7063
|
+
footerLeft: 'Start with `connect`: log in once in your own browser, then post / follow / like — all server-side.',
|
|
6975
7064
|
commands: [
|
|
6976
|
-
{ name: '
|
|
6977
|
-
{ 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>]' },
|
|
6978
7068
|
{ name: 'info', description: 'Show one account', hint: '<username>' },
|
|
6979
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' },
|
|
6980
7071
|
{ name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
|
|
6981
7072
|
{ name: 'totp', description: 'Print the current TOTP code', hint: '<username>' },
|
|
6982
7073
|
{ name: 'login', description: 'Validate cookies and cache the session', hint: '<username>' },
|
|
6983
7074
|
{ name: 'session', description: 'Check cached session status', hint: '<username>' },
|
|
6984
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 "..."' },
|
|
6985
7077
|
{ name: 'follow', description: 'Follow a TikTok user', hint: '<username> --user @handle' },
|
|
6986
7078
|
{ name: 'like', description: 'Like a video', hint: '<username> --video https://...' },
|
|
6987
7079
|
{ name: 'delete', description: 'Delete a video', hint: '<username> --video https://...' },
|
|
6988
7080
|
{ name: 'bio', description: 'Update bio (<=80 chars)', hint: '<username> --text "..."' },
|
|
6989
7081
|
{ name: 'name', description: 'Update display name (<=30 chars)', hint: '<username> --display "..."' },
|
|
6990
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' },
|
|
6991
7091
|
],
|
|
6992
7092
|
fromHome,
|
|
6993
7093
|
});
|
|
@@ -7087,14 +7187,16 @@ async function main() {
|
|
|
7087
7187
|
const summary = sv.importAccount(platform, username, creds, {
|
|
7088
7188
|
source: line ? 'marketplace-line' : 'import',
|
|
7089
7189
|
country,
|
|
7190
|
+
tag: flags.tag,
|
|
7090
7191
|
});
|
|
7091
7192
|
const loginPath = sessionid ? 'cookie-injection' : 'form-login (requires CAPSOLVER_API_KEY server-side)';
|
|
7092
7193
|
log(`tiktok import: ${summary.username} (${summary.id}) [login: ${loginPath}, country: ${country}]`);
|
|
7093
7194
|
return print({ ...summary, has_sessionid: !!sessionid, has_password: !!password, login_path: loginPath });
|
|
7094
7195
|
}
|
|
7095
7196
|
case 'list': {
|
|
7096
|
-
const
|
|
7097
|
-
|
|
7197
|
+
const tagFilter = flags.tag;
|
|
7198
|
+
const accounts = sv.listAccounts(platform, tagFilter);
|
|
7199
|
+
return print({ accounts, count: accounts.length, ...(tagFilter ? { tag: tagFilter } : {}) });
|
|
7098
7200
|
}
|
|
7099
7201
|
case 'info': {
|
|
7100
7202
|
const username = positional[0] || flags.username;
|
|
@@ -7116,6 +7218,19 @@ async function main() {
|
|
|
7116
7218
|
log(`tiktok rename: ${oldUsername} → ${newUsername}`);
|
|
7117
7219
|
return print(summary);
|
|
7118
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
|
+
}
|
|
7119
7234
|
case 'remove': {
|
|
7120
7235
|
const username = positional[0] || flags.username;
|
|
7121
7236
|
if (!username)
|
|
@@ -7144,6 +7259,113 @@ async function main() {
|
|
|
7144
7259
|
expires_in_seconds: secondsUntilNextCode(),
|
|
7145
7260
|
});
|
|
7146
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
|
+
}
|
|
7147
7369
|
case 'login': {
|
|
7148
7370
|
const username = positional[0] || flags.username;
|
|
7149
7371
|
if (!username)
|
|
@@ -7220,7 +7442,202 @@ async function main() {
|
|
|
7220
7442
|
stale: (ageHours || 0) > 12,
|
|
7221
7443
|
});
|
|
7222
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
|
+
}
|
|
7223
7639
|
case 'post':
|
|
7640
|
+
case 'schedule':
|
|
7224
7641
|
case 'follow':
|
|
7225
7642
|
case 'like':
|
|
7226
7643
|
case 'delete':
|
|
@@ -7241,7 +7658,7 @@ async function main() {
|
|
|
7241
7658
|
const country = sv.getCountry(platform, username);
|
|
7242
7659
|
let data;
|
|
7243
7660
|
try {
|
|
7244
|
-
if (subcommand === 'post') {
|
|
7661
|
+
if (subcommand === 'post' || subcommand === 'schedule') {
|
|
7245
7662
|
const caption = flags.caption || flags.body || flags.text;
|
|
7246
7663
|
if (!caption)
|
|
7247
7664
|
err('--caption "..." required');
|
|
@@ -7264,7 +7681,28 @@ async function main() {
|
|
|
7264
7681
|
media.video_url = videoUrl;
|
|
7265
7682
|
}
|
|
7266
7683
|
const privacy = flags.privacy !== undefined ? Number(flags.privacy) : undefined;
|
|
7267
|
-
|
|
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
|
+
}
|
|
7268
7706
|
}
|
|
7269
7707
|
else if (subcommand === 'follow') {
|
|
7270
7708
|
const target = flags.user || flags.target;
|
|
@@ -7328,7 +7766,7 @@ async function main() {
|
|
|
7328
7766
|
return print({ success: true, platform, username, op: subcommand, ...(data?.data || {}) });
|
|
7329
7767
|
}
|
|
7330
7768
|
default:
|
|
7331
|
-
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`);
|
|
7332
7770
|
}
|
|
7333
7771
|
break;
|
|
7334
7772
|
}
|