-
-
Notifications
You must be signed in to change notification settings - Fork 98
Expand file tree
/
Copy pathengageProfile.js
More file actions
1262 lines (1154 loc) · 63.6 KB
/
Copy pathengageProfile.js
File metadata and controls
1262 lines (1154 loc) · 63.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2024-2026 nich (@nichxbt). Licensed under the Apache License, Version 2.0.
// scripts/engageProfile.js
// Like, repost, and comment on every post in a feed: a profile, a search, a list, a hashtag, or home
// Paste in DevTools console on x.com/USERNAME, a search, a list, a hashtag, or x.com/home
// by nichxbt
//
// What it does: walks the feed you have open, top to bottom, and for every post it has
// not touched yet it likes, reposts, and/or replies. Comments come from your templates
// or from an LLM given a one-line brief ("be supportive, ask a follow-up question").
// Progress is saved per feed, so a reload picks up where it stopped.
//
// Works on: a profile (x.com/USERNAME), a profile's replies (/with_replies), search
// results, a list (x.com/i/lists/ID), a hashtag, and your home timeline. On feeds that
// mix authors, the author and keyword filters in the panel are what keep a sweep aimed.
//
// AI comments from the console:
// x.com's Content-Security-Policy only lets the page talk to a short list of hosts,
// and https://api.x.ai is on it. So `provider: 'xai'` (Grok) works straight from the
// console with your xAI key. OpenRouter, OpenAI, Anthropic and Ollama are blocked by
// that CSP; for those install the XActions browser extension (extension/) and set
// `provider: 'bridge'`, or run `npx xactions engage USERNAME --comment --prompt "..."`
// from a terminal, which has no such restriction.
//
// A floating panel appears when you paste. Everything below can also be changed there.
// Start in dry run. Read the log. Then switch it off.
/**
* ============================================================
* ⚡ Engage Profile
* ============================================================
*
* @name engageProfile.js
* @description Like, repost, and comment on every post of one profile, with comments from your templates or from an LLM.
* @author nichxbt (https://x.com/nichxbt)
* @version 1.0.0
* @date 2026-08-27
* @repository https://github.com/nirholas/XActions
* ============================================================
*/
(() => {
'use strict';
if (document.getElementById('xep-panel')) {
console.log('⚡ Profile sweep already loaded. Use the panel, or window.XEngage.');
return;
}
// ═══════════════════════════════════════════════════════════
// CONFIGURATION (the panel edits this live)
// ═══════════════════════════════════════════════════════════
const CONFIG = {
dryRun: true, // Log what would happen, touch nothing. SET FALSE TO RUN.
actions: {
like: true,
repost: true,
comment: true,
},
maxPosts: 0, // 0 = the whole feed. Set e.g. 50 for a first run.
includeReplies: false, // Also engage replies (a profile's /with_replies tab, or replies in a search)
includeReposts: false, // Also engage posts that are reposts of someone else
skipAlreadyEngaged: true, // Skip a post for an action you already did on it
// Filters. They matter most on feeds that mix authors: search, lists, hashtags, home.
onlyFrom: [], // Only these authors, e.g. ['nasa', 'nichxbt']. Empty = anyone.
skipUsers: [], // Never these authors
keywords: [], // Post must contain one of these words. Empty = any post.
skipKeywords: [], // Skip posts containing any of these words
minLikes: 0, // Only posts with at least this many likes
maxLikes: 0, // Only posts with at most this many likes (0 = no ceiling)
skipVerified: false, // Skip blue-check accounts (they notice you least)
// Pacing. 'safe' is what a fast human looks like. Faster presets get accounts limited.
speed: 'safe', // 'stealth' | 'safe' | 'moderate' | 'fast'
restEvery: 20, // After this many posts, take a longer break
restForSeconds: 90, // Length of that break
maxConsecutiveFailures: 3, // Back off after this many failed actions in a row
backoffSeconds: 300, // How long to back off (X soft-limits usually clear in 5-15 min)
comments: {
mode: 'templates', // 'templates' | 'ai'
// Template mode. {author} becomes @handle, {name} the display name.
templates: [
'Been following your work for a while, this one lands. What pushed you to write it up now?',
'The part about the details here is what most people skip. Appreciate you spelling it out.',
'Saving this. Curious how you would apply it at a smaller scale, {name}?',
'Strong take. The counterargument I keep hearing is timing. How do you think about that?',
'This matches what I have seen too. The second-order effects are the interesting part.',
],
// AI mode. The brief is the only thing most people need to change.
prompt: 'Reply as a thoughtful builder who genuinely follows this account. Be specific to the post, add one idea or one honest question, keep it under two sentences, no hype words.',
persona: '', // Optional: 'You are @yourhandle, a founder building X.'
provider: 'xai', // 'xai' (works from the console) | 'bridge' (XActions extension, any provider)
apiKey: '', // xAI key for provider 'xai'. Stored only if "remember" is ticked in the panel.
model: 'grok-3-mini', // xAI model. For 'bridge' this is the bridge provider's model.
bridgeProvider: 'openrouter', // For 'bridge': openrouter | openai | anthropic | ollama | xai | custom
bridgeBaseUrl: '', // For 'bridge' + custom: full chat-completions URL
temperature: 0.9,
allowHashtags: false,
allowEmoji: true,
fallbackToTemplates: true, // If the model fails for a post, use a template instead of skipping
},
};
const SPEEDS = {
stealth: { between: [45000, 90000], action: [2500, 5000] },
safe: { between: [15000, 35000], action: [1800, 3500] },
moderate: { between: [7000, 15000], action: [1200, 2500] },
fast: { between: [3000, 7000], action: [900, 1800] },
};
const SEL = {
article: 'article[data-testid="tweet"]',
tweetText: '[data-testid="tweetText"]',
userName: '[data-testid="User-Name"]',
socialContext: '[data-testid="socialContext"]',
like: '[data-testid="like"]',
unlike: '[data-testid="unlike"]',
retweet: '[data-testid="retweet"]',
unretweet: '[data-testid="unretweet"]',
retweetConfirm: '[data-testid="retweetConfirm"]',
unretweetConfirm: '[data-testid="unretweetConfirm"]',
reply: '[data-testid="reply"]',
tweetBox: '[data-testid="tweetTextarea_0"]',
tweetButton: '[data-testid="tweetButton"]',
closeModal: '[data-testid="app-bar-close"]',
toast: '[data-testid="toast"]',
retryButton: '[data-testid="primaryColumn"] [role="button"]',
placement: '[data-testid="placementTracking"]',
};
const GENERIC_OPENERS = [
'great post', 'great point', 'great thread', 'great take', 'love this', 'this is so true',
'so true', 'well said', "couldn't agree more", 'could not agree more', 'thanks for sharing',
'thank you for sharing', 'interesting take', 'interesting perspective', 'as an ai', 'as a language model',
];
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
/**
* Work out which feed is on screen.
*
* The sweep behaves the same everywhere, but three things depend on the
* feed: what to call it in the panel, which progress file to use, and
* whether a post by someone other than the page owner counts as a repost.
* Reserved first path segments (i, home, search, explore, ...) are what
* separate a profile from every other page X serves under one slug.
*/
const RESERVED = new Set([
'i', 'home', 'explore', 'search', 'notifications', 'messages', 'settings',
'compose', 'bookmarks', 'hashtag', 'topics', 'lists', 'communities',
'jobs', 'about', 'tos', 'privacy', 'login', 'signup', 'intent',
]);
const target = (() => {
const path = location.pathname;
const params = new URLSearchParams(location.search);
const profile = path.match(/^\/([A-Za-z0-9_]{1,15})(?:\/(with_replies|media|highlights|likes))?\/?$/);
if (profile && !RESERVED.has(profile[1].toLowerCase())) {
const handle = profile[1].toLowerCase();
const tab = profile[2] || 'posts';
return { kind: 'profile', handle, tab, label: `@${handle}${tab === 'with_replies' ? ' + replies' : ''}`, key: `profile_${handle}` };
}
const list = path.match(/^\/i\/lists\/(\d+)/);
if (list) return { kind: 'list', listId: list[1], label: `list ${list[1]}`, key: `list_${list[1]}` };
const hashtag = path.match(/^\/hashtag\/([^/?]+)/);
if (hashtag) {
const tag = decodeURIComponent(hashtag[1]).toLowerCase();
return { kind: 'hashtag', tag, label: `#${tag}`, key: `hashtag_${tag.replace(/[^a-z0-9]/g, '')}` };
}
if (path === '/search') {
const query = params.get('q') || '';
const slug = query.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'query';
return { kind: 'search', query, label: `search "${query}"`, key: `search_${slug}` };
}
if (path === '/home') return { kind: 'home', label: 'your home timeline', key: 'home' };
if (path === '/notifications' || path.startsWith('/notifications/')) {
return { kind: 'notifications', label: 'your notifications', key: 'notifications' };
}
if (path === '/i/bookmarks') return { kind: 'bookmarks', label: 'your bookmarks', key: 'bookmarks' };
return null;
})();
if (!target) {
console.error([
'❌ This page has no post feed to sweep. Open one of:',
' x.com/USERNAME a profile',
' x.com/USERNAME/with_replies a profile including its replies',
' x.com/search?q=... search results',
' x.com/i/lists/ID a list',
' x.com/hashtag/TAG a hashtag',
' x.com/home your timeline',
].join('\n'));
return;
}
if (target.tab === 'with_replies') CONFIG.includeReplies = true;
// On a mixed feed every post is by someone else, so treating "not the page
// owner" as a repost would filter the entire page out.
const MIXED_FEED = target.kind !== 'profile';
if (MIXED_FEED) CONFIG.includeReposts = true;
const STORAGE_KEY = `xactions_engage_${target.key}`;
const KEY_STORAGE = 'xactions_engage_ai_key';
const persisted = (() => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') || {}; } catch { return {}; }
})();
const STATE = {
running: false,
paused: false,
stopRequested: false,
done: new Map(Object.entries(persisted.done || {})), // tweetId -> { liked, reposted, commented, at }
seenThisRun: new Set(),
processed: 0,
liked: 0,
reposted: 0,
commented: 0,
skipped: 0,
failed: 0,
consecutiveFailures: 0,
startedAt: null,
undoStack: [],
results: [],
recentComments: [],
lastTemplate: -1,
};
// Your own handle, read from the sidebar account switcher. A home-timeline
// sweep that replies to your own posts looks unhinged, and X counts it.
const me = (() => {
const link = document.querySelector('[data-testid="AppTabBar_Profile_Link"], [data-testid="SideNav_AccountSwitcher_Button"] a[href^="/"]');
const href = link?.getAttribute('href') || '';
const m = href.match(/^\/([A-Za-z0-9_]{1,15})$/);
return m ? m[1].toLowerCase() : '';
})();
const savedKey = (() => { try { return localStorage.getItem(KEY_STORAGE) || ''; } catch { return ''; } })();
if (savedKey && !CONFIG.comments.apiKey) CONFIG.comments.apiKey = savedKey;
const persist = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
target: target.key,
updatedAt: Date.now(),
done: Object.fromEntries(STATE.done),
}));
} catch (e) {
log(`Could not save progress: ${e.message}`, 'warn');
}
};
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = ([min, max]) => Math.floor(min + ((Math.random() + Math.random()) / 2) * (max - min));
const speed = () => SPEEDS[CONFIG.speed] || SPEEDS.safe;
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const waitFor = async (sel, timeout = 6000, root = document) => {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = root.querySelector(sel);
if (el) return el;
await sleep(120);
}
return null;
};
const waitForGone = async (sel, timeout = 6000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (!document.querySelector(sel)) return true;
await sleep(120);
}
return false;
};
const pressEscape = () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
const close = $(SEL.closeModal);
if (close) close.click();
};
/** A toast that means X is pushing back, not just informing. */
const throttleToast = () => {
const toast = $(SEL.toast);
if (!toast) return null;
const text = toast.textContent || '';
return /rate limit|try again later|too many|temporarily|unable to|something went wrong|limit/i.test(text) ? text.trim() : null;
};
// ═══════════════════════════════════════════════════════════
// TWEET READING
// ═══════════════════════════════════════════════════════════
const readArticle = (article) => {
// The permalink is the anchor wrapping the timestamp. Picking that rather
// than the first /status/ link in the DOM is what makes this work on a
// mixed feed: a quoted post inside the article also carries a status link,
// and engaging the quote instead of the post is a silent wrong action.
const timeAnchor = $('time', article)?.closest('a[href*="/status/"]');
const statusLinks = $$('a[href*="/status/"]', article)
.map((a) => a.getAttribute('href') || '')
.filter((h) => /^\/[A-Za-z0-9_]+\/status\/\d+/.test(h));
const ownLink = timeAnchor?.getAttribute('href') || statusLinks[0];
if (!ownLink) return null;
const idMatch = ownLink.match(/\/status\/(\d+)/);
const authorMatch = ownLink.match(/^\/([A-Za-z0-9_]+)\/status\//);
if (!idMatch) return null;
const nameEl = $(SEL.userName, article);
const nameSpans = nameEl ? $$('span', nameEl).map((s) => s.textContent.trim()).filter(Boolean) : [];
const authorName = nameSpans.find((t) => !t.startsWith('@')) || '';
const author = (authorMatch ? authorMatch[1] : '').toLowerCase();
const social = ($(SEL.socialContext, article)?.textContent || '').toLowerCase();
const isRepost = /reposted|retweeted/.test(social)
|| (target.kind === 'profile' && author !== target.handle);
const isVerified = !!$('[data-testid="icon-verified"]', article)
|| !!$('svg[aria-label*="Verified"]', article);
const isPinned = /pinned/.test(social);
const isReply = /replying to/i.test(article.textContent || '') && !isRepost;
const isAd = !!$(SEL.placement, article) || $$('span', article).some((s) => s.textContent.trim() === 'Ad');
const text = $(SEL.tweetText, article)?.textContent?.trim() || '';
const hasMedia = !!$('[data-testid="tweetPhoto"], [data-testid="videoPlayer"], video', article);
const quotedText = $$('[data-testid="tweetText"]', article)[1]?.textContent?.trim() || '';
return {
id: idMatch[1],
url: `https://x.com${ownLink.split('?')[0]}`,
author,
authorName,
text,
quotedText,
hasMedia,
isRepost,
isPinned,
isReply,
isAd,
isVerified,
likes: (() => {
const label = $('[data-testid="like"], [data-testid="unlike"]', article)?.getAttribute('aria-label') || '';
const n = label.match(/([\d,.]+)\s*(K|M)?\s*(likes?|Like)/i);
if (!n) return 0;
const base = parseFloat(n[1].replace(/,/g, '')) || 0;
return n[2] === 'M' ? base * 1e6 : n[2] === 'K' ? base * 1e3 : base;
})(),
liked: !!$(SEL.unlike, article),
reposted: !!$(SEL.unretweet, article),
};
};
const listHas = (list, value) => list.some((entry) => String(entry).replace(/^@/, '').toLowerCase() === value);
const textHas = (list, text) => list.some((word) => text.includes(String(word).toLowerCase()));
const eligible = (info) => {
if (!info) return { ok: false, why: 'unreadable' };
if (info.isAd) return { ok: false, why: 'ad' };
if (info.isRepost && !CONFIG.includeReposts) return { ok: false, why: 'repost' };
if (info.isReply && !CONFIG.includeReplies) return { ok: false, why: 'reply' };
if (me && info.author === me) return { ok: false, why: 'your own post' };
if (CONFIG.skipUsers.length && listHas(CONFIG.skipUsers, info.author)) return { ok: false, why: `@${info.author} is on the skip list` };
if (CONFIG.onlyFrom.length && !listHas(CONFIG.onlyFrom, info.author)) return { ok: false, why: `@${info.author} is not on the only-from list` };
if (CONFIG.skipVerified && info.isVerified) return { ok: false, why: 'verified account' };
const lowerText = info.text.toLowerCase();
if (CONFIG.keywords.length && !textHas(CONFIG.keywords, lowerText)) return { ok: false, why: 'no keyword match' };
if (CONFIG.skipKeywords.length && textHas(CONFIG.skipKeywords, lowerText)) return { ok: false, why: 'matched a skip keyword' };
if (CONFIG.minLikes && info.likes < CONFIG.minLikes) return { ok: false, why: `only ${info.likes} likes` };
if (CONFIG.maxLikes && info.likes > CONFIG.maxLikes) return { ok: false, why: `${info.likes} likes, above the ceiling` };
if (!info.text && !info.hasMedia) return { ok: false, why: 'empty' };
return { ok: true };
};
// ═══════════════════════════════════════════════════════════
// COMMENT SOURCES
// ═══════════════════════════════════════════════════════════
const fillTemplate = (t, info) => t
.replace(/\{author\}/g, `@${info.author}`)
.replace(/\{name\}/g, info.authorName || `@${info.author}`);
const nextTemplate = (info) => {
const list = CONFIG.comments.templates.filter((t) => t && t.trim());
if (list.length === 0) return '';
let idx;
if (list.length === 1) idx = 0;
else {
do { idx = Math.floor(Math.random() * list.length); } while (idx === STATE.lastTemplate);
}
STATE.lastTemplate = idx;
return fillTemplate(list[idx], info);
};
const systemPrompt = () => {
const c = CONFIG.comments;
return [
c.persona ? c.persona.trim() : 'You are a real person replying to posts on X (Twitter).',
'',
'Your brief from the account owner, which you follow exactly:',
`"""${(c.prompt || 'Reply naturally and specifically to the post.').trim()}"""`,
'',
'Hard rules:',
'- Under 280 characters. One to two short sentences unless the brief asks for more.',
'- Respond to what THIS post actually says. Quote or reference a concrete detail from it.',
'- Never open with "Great post", "Love this", "So true", "Thanks for sharing", or any generic praise.',
'- No hashtags' + (c.allowHashtags ? ' unless the brief asks for them.' : '.'),
c.allowEmoji ? '- Emoji only where a person would naturally use one. Never more than one.' : '- No emoji.',
'- No links, no mentions of being an AI, no disclaimers, no quotation marks around the reply.',
'- Do not repeat the post back. Add something: a reaction, a question, a related fact, a joke.',
'- Match the register of the post: serious gets thoughtful, funny gets witty, technical gets precise.',
'',
'Output ONLY the reply text. No preamble, no labels, no markdown.',
].join('\n');
};
const userPrompt = (info) => {
const parts = [`Post by @${info.author}${info.authorName ? ` (${info.authorName})` : ''}:`, `"""${info.text}"""`];
if (info.quotedText) parts.push('', 'It quotes this post:', `"""${info.quotedText}"""`);
if (info.hasMedia) parts.push('', '(The post has an image or video attached that you cannot see. Do not pretend to describe it.)');
if (STATE.recentComments.length) {
parts.push('', 'Replies you already posted in this session. Do not reuse their openers or structure:');
for (const r of STATE.recentComments.slice(-5)) parts.push(`- ${r}`);
}
parts.push('', 'Write the reply now.');
return parts.join('\n');
};
const sanitize = (raw) => {
if (!raw) return '';
let t = String(raw).trim();
t = t.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/, '').trim();
const unquote = (s) => { const m = s.match(/^["“'‘](.*)["”'’]$/s); return m ? m[1].trim() : s; };
t = unquote(t);
t = t.replace(/^(reply|comment|response|answer)\s*[:\-]\s*/i, '').trim();
t = unquote(t);
if (!CONFIG.comments.allowHashtags) t = t.replace(/(^|\s)#[\w]+/g, '$1');
t = t.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if ([...t].length > 280) {
const clipped = [...t].slice(0, 280).join('');
const end = Math.max(clipped.lastIndexOf('. '), clipped.lastIndexOf('! '), clipped.lastIndexOf('? '));
t = end > 140 ? clipped.slice(0, end + 1) : clipped.slice(0, clipped.lastIndexOf(' ') || 280);
t = t.trim();
}
return t;
};
const isGeneric = (t) => {
const lower = (t || '').toLowerCase().replace(/^[^a-z]+/, '');
return lower.length < 2 || GENERIC_OPENERS.some((o) => lower.startsWith(o));
};
/** Direct call to xAI. The only LLM host x.com's CSP lets the page reach. */
const completeViaXai = async (messages) => {
const c = CONFIG.comments;
if (!c.apiKey) throw new Error('No xAI API key. Paste one in the panel (console.x.ai) or switch provider to bridge.');
const res = await fetch('https://api.x.ai/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${c.apiKey}` },
body: JSON.stringify({ model: c.model || 'grok-3-mini', messages, temperature: c.temperature, max_tokens: 160 }),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`xAI ${res.status}: ${detail.slice(0, 200)}`);
}
const data = await res.json();
return (data.choices?.[0]?.message?.content || '').trim();
};
/** Through the XActions extension, which can reach any provider. */
const completeViaBridge = (messages) => new Promise((resolve, reject) => {
if (!window.__xactions_bridge_loaded) {
reject(new Error('XActions extension not detected on this tab. Install extension/ and reload, or use provider xai.'));
return;
}
const c = CONFIG.comments;
const id = `xep-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const timer = setTimeout(() => { cleanup(); reject(new Error('Extension bridge timed out after 45s')); }, 45000);
const onMessage = (event) => {
if (event.source !== window || !event.data || event.data.source !== 'xactions-extension') return;
if (event.data.type !== 'LLM_RESPONSE' || event.data.id !== id) return;
cleanup();
if (event.data.error) reject(new Error(event.data.error));
else resolve(String(event.data.text || '').trim());
};
const cleanup = () => { clearTimeout(timer); window.removeEventListener('message', onMessage); };
window.addEventListener('message', onMessage);
window.postMessage({
source: 'xactions-page',
type: 'LLM_REQUEST',
id,
request: {
provider: c.bridgeProvider,
apiKey: c.apiKey,
baseUrl: c.bridgeBaseUrl,
model: c.model,
messages,
temperature: c.temperature,
maxTokens: 160,
},
}, '*');
});
const aiComment = async (info) => {
const complete = CONFIG.comments.provider === 'bridge' ? completeViaBridge : completeViaXai;
let last = '';
for (let attempt = 1; attempt <= 2; attempt++) {
const messages = [
{ role: 'system', content: systemPrompt() },
{ role: 'user', content: userPrompt(info) },
];
if (attempt === 2 && last) {
messages.push({ role: 'assistant', content: last });
messages.push({ role: 'user', content: 'That reads as generic. Rewrite it so it references a specific detail from the post and opens differently. Output only the reply.' });
}
let raw;
try {
raw = await complete(messages);
} catch (err) {
if (/Content Security Policy|Failed to fetch|NetworkError/i.test(err.message)) {
throw new Error(`Blocked by x.com's CSP: ${err.message}. Use provider 'xai', the extension bridge, or the CLI (npx xactions engage).`);
}
throw err;
}
const text = sanitize(raw);
last = text;
if (text && !isGeneric(text) && !STATE.recentComments.includes(text)) return text;
}
return last;
};
const buildComment = async (info) => {
if (CONFIG.comments.mode === 'ai') {
try {
const text = await aiComment(info);
if (text) return { text, source: 'ai' };
throw new Error('model returned nothing usable');
} catch (err) {
addLog(` 🤖 AI comment failed: ${err.message}`, 'warn');
if (!CONFIG.comments.fallbackToTemplates) return null;
}
}
const text = nextTemplate(info);
return text ? { text, source: 'template' } : null;
};
// ═══════════════════════════════════════════════════════════
// ACTIONS (each verifies the DOM changed, and watches for throttling)
// ═══════════════════════════════════════════════════════════
const afterClickCheck = async () => {
const toast = throttleToast();
if (toast) throw new Error(`X pushed back: "${toast}"`);
};
const doLike = async (article, info) => {
if (info.liked) return 'already';
const btn = $(SEL.like, article);
if (!btn) throw new Error('like button missing');
if (CONFIG.dryRun) return 'dry';
btn.click();
await sleep(jitter(speed().action));
await afterClickCheck();
if (!$(SEL.unlike, article)) throw new Error('like did not register');
return 'done';
};
const doRepost = async (article, info) => {
if (info.reposted) return 'already';
const btn = $(SEL.retweet, article);
if (!btn) throw new Error('repost button missing');
if (CONFIG.dryRun) return 'dry';
btn.click();
const confirm = await waitFor(SEL.retweetConfirm, 4000);
if (!confirm) { pressEscape(); throw new Error('repost menu did not open'); }
await sleep(jitter([400, 900]));
confirm.click();
await sleep(jitter(speed().action));
await afterClickCheck();
if (!$(SEL.unretweet, article)) throw new Error('repost did not register');
return 'done';
};
const doComment = async (article, info) => {
const comment = await buildComment(info);
if (!comment) return { status: 'skipped', text: '' };
if (CONFIG.dryRun) return { status: 'dry', text: comment.text, source: comment.source };
const btn = $(SEL.reply, article);
if (!btn) throw new Error('reply button missing');
btn.click();
const box = await waitFor(SEL.tweetBox, 6000);
if (!box) { pressEscape(); throw new Error('reply composer did not open'); }
await sleep(jitter([500, 1000]));
box.focus();
document.execCommand('insertText', false, comment.text);
await sleep(jitter([600, 1200]));
const typed = (box.textContent || '').trim();
if (!typed) { pressEscape(); throw new Error('could not type into the composer'); }
const send = await waitFor(SEL.tweetButton, 3000);
if (!send || send.getAttribute('aria-disabled') === 'true' || send.disabled) {
pressEscape();
throw new Error('post button not enabled');
}
send.click();
const closed = await waitForGone(SEL.tweetBox, 8000);
await afterClickCheck();
if (!closed) { pressEscape(); throw new Error('composer stayed open, reply probably rejected'); }
STATE.recentComments.push(comment.text);
if (STATE.recentComments.length > 30) STATE.recentComments.shift();
return { status: 'done', text: comment.text, source: comment.source };
};
const undoAll = async () => {
if (STATE.undoStack.length === 0) { addLog('Nothing to undo.', 'warn'); return; }
addLog(`↩ Undoing ${STATE.undoStack.length} likes/reposts (replies are left in place, delete those by hand)...`, 'warn');
let undone = 0;
for (const entry of [...STATE.undoStack].reverse()) {
const article = entry.article.isConnected ? entry.article : null;
if (!article) continue;
article.scrollIntoView({ behavior: 'smooth', block: 'center' });
await sleep(600);
try {
if (entry.type === 'like') { $(SEL.unlike, article)?.click(); undone++; }
if (entry.type === 'repost') {
$(SEL.unretweet, article)?.click();
const c = await waitFor(SEL.unretweetConfirm, 3000);
if (c) { c.click(); undone++; }
}
const rec = STATE.done.get(entry.id);
if (rec) { rec[entry.type === 'like' ? 'liked' : 'reposted'] = false; STATE.done.set(entry.id, rec); }
} catch (e) {
addLog(` undo failed on ${entry.id}: ${e.message}`, 'warn');
}
await sleep(jitter([1200, 2500]));
}
STATE.undoStack = [];
persist();
addLog(`↩ Undid ${undone} actions.`);
};
// ═══════════════════════════════════════════════════════════
// THE SWEEP
// ═══════════════════════════════════════════════════════════
const waitWhilePaused = async () => {
while (STATE.paused && !STATE.stopRequested) await sleep(300);
};
const countdown = async (seconds, label) => {
for (let s = seconds; s > 0 && !STATE.stopRequested; s--) {
setStatus(`${label} ${s}s`);
await sleep(1000);
await waitWhilePaused();
}
};
const processArticle = async (article, info) => {
const record = STATE.done.get(info.id) || { liked: false, reposted: false, commented: false, at: 0 };
const wants = {
like: CONFIG.actions.like && !(CONFIG.skipAlreadyEngaged && (record.liked || info.liked)),
repost: CONFIG.actions.repost && !(CONFIG.skipAlreadyEngaged && (record.reposted || info.reposted)),
comment: CONFIG.actions.comment && !(CONFIG.skipAlreadyEngaged && record.commented),
};
if (!wants.like && !wants.repost && !wants.comment) {
STATE.skipped++;
return { id: info.id, skipped: 'already engaged' };
}
highlight(article);
article.scrollIntoView({ behavior: 'smooth', block: 'center' });
await sleep(jitter([500, 1100]));
addLog(`📝 ${info.id} @${info.author}: "${info.text.slice(0, 70)}${info.text.length > 70 ? '…' : ''}"`);
const result = { id: info.id, url: info.url, text: info.text.slice(0, 140), actions: [], errors: [] };
let failed = false;
const step = async (name, fn) => {
try {
const out = await fn();
const status = typeof out === 'string' ? out : out.status;
if (status === 'done' || status === 'dry') {
result.actions.push(name);
if (typeof out !== 'string' && out.text) result.comment = out.text;
const label = status === 'dry' ? '[DRY] would ' : '';
if (name === 'like') addLog(` ❤️ ${label}like`);
if (name === 'repost') addLog(` 🔁 ${label}repost`);
if (name === 'comment') addLog(` 💬 ${label}reply (${out.source}): "${out.text}"`);
if (status === 'done') {
if (name === 'like') { STATE.liked++; record.liked = true; STATE.undoStack.push({ type: 'like', id: info.id, article }); }
if (name === 'repost') { STATE.reposted++; record.reposted = true; STATE.undoStack.push({ type: 'repost', id: info.id, article }); }
if (name === 'comment') { STATE.commented++; record.commented = true; }
}
} else if (status === 'already') {
addLog(` ${name}: already done, skipping`);
if (name === 'like') record.liked = true;
if (name === 'repost') record.reposted = true;
} else if (status === 'skipped') {
addLog(` ${name}: nothing to post (no template and no AI text)`, 'warn');
}
await sleep(jitter(speed().action));
} catch (err) {
failed = true;
result.errors.push(`${name}: ${err.message}`);
addLog(` ⚠️ ${name} failed: ${err.message}`, 'warn');
if (/pushed back/i.test(err.message)) throw err;
}
};
if (wants.like) await step('like', () => doLike(article, info));
if (wants.repost) await step('repost', () => doRepost(article, info));
if (wants.comment) await step('comment', () => doComment(article, info));
record.at = Date.now();
if (!CONFIG.dryRun) { STATE.done.set(info.id, record); persist(); }
if (failed) { STATE.failed++; STATE.consecutiveFailures++; }
else STATE.consecutiveFailures = 0;
unhighlight();
return result;
};
const clickRetryIfShown = () => {
const btn = $$(SEL.retryButton).find((b) => /retry|try again/i.test(b.textContent || ''));
if (btn) { btn.click(); return true; }
return false;
};
const sweep = async () => {
STATE.running = true;
STATE.stopRequested = false;
STATE.startedAt = Date.now();
STATE.results = [];
STATE.seenThisRun = new Set();
STATE.processed = 0; STATE.liked = 0; STATE.reposted = 0; STATE.commented = 0; STATE.skipped = 0; STATE.failed = 0;
STATE.consecutiveFailures = 0;
setButtons('running');
addLog(`🚀 Sweeping ${target.label}${CONFIG.dryRun ? ' (DRY RUN, nothing is touched)' : ''}`);
addLog(` like=${CONFIG.actions.like} repost=${CONFIG.actions.repost} comment=${CONFIG.actions.comment} (${CONFIG.comments.mode}) speed=${CONFIG.speed} max=${CONFIG.maxPosts || '∞'}`);
if (STATE.done.size) addLog(` ${STATE.done.size} posts already done from earlier runs will be skipped`);
window.scrollTo({ top: 0 });
await sleep(1200);
let idleRounds = 0;
let sinceRest = 0;
try {
while (!STATE.stopRequested) {
await waitWhilePaused();
if (STATE.stopRequested) break;
if (CONFIG.maxPosts && STATE.processed >= CONFIG.maxPosts) { addLog(`Reached maxPosts (${CONFIG.maxPosts}).`); break; }
if (STATE.consecutiveFailures >= CONFIG.maxConsecutiveFailures) {
addLog(`🛑 ${STATE.consecutiveFailures} failures in a row. X is probably throttling. Backing off ${CONFIG.backoffSeconds}s.`, 'warn');
await countdown(CONFIG.backoffSeconds, '⏳ Backing off');
STATE.consecutiveFailures = 0;
if (STATE.stopRequested) break;
}
const fresh = [];
for (const article of $$(SEL.article)) {
const info = readArticle(article);
if (!info || STATE.seenThisRun.has(info.id)) continue;
STATE.seenThisRun.add(info.id);
const check = eligible(info);
if (!check.ok) { addLog(` · skip ${info.id} (${check.why})`, 'dim'); continue; }
fresh.push({ article, info });
}
if (fresh.length === 0) {
idleRounds++;
if (clickRetryIfShown()) { addLog('Timeline errored, clicked retry', 'warn'); await sleep(3000); }
if (idleRounds >= 6) { addLog('No new posts after 6 scrolls. End of profile.'); break; }
window.scrollBy({ top: Math.round(window.innerHeight * 0.9), behavior: 'smooth' });
await sleep(jitter([1500, 2600]));
continue;
}
idleRounds = 0;
for (const { article, info } of fresh) {
await waitWhilePaused();
if (STATE.stopRequested) break;
if (CONFIG.maxPosts && STATE.processed >= CONFIG.maxPosts) break;
if (!article.isConnected) { STATE.seenThisRun.delete(info.id); continue; }
let result;
try {
result = await processArticle(article, info);
} catch (err) {
STATE.failed++;
STATE.consecutiveFailures = CONFIG.maxConsecutiveFailures;
result = { id: info.id, errors: [err.message] };
addLog(` ${err.message}`, 'warn');
pressEscape();
}
STATE.results.push(result);
if (!result.skipped) {
STATE.processed++;
sinceRest++;
updateStats();
if (CONFIG.restEvery && sinceRest >= CONFIG.restEvery) {
sinceRest = 0;
addLog(`☕ Resting ${CONFIG.restForSeconds}s after ${CONFIG.restEvery} posts`);
await countdown(CONFIG.restForSeconds, '☕ Resting');
} else {
const wait = jitter(speed().between);
await countdown(Math.round(wait / 1000), '⏱ Next post in');
}
} else {
updateStats();
}
if (STATE.consecutiveFailures >= CONFIG.maxConsecutiveFailures) break;
}
window.scrollBy({ top: Math.round(window.innerHeight * 0.8), behavior: 'smooth' });
await sleep(jitter([1200, 2200]));
}
} finally {
STATE.running = false;
STATE.paused = false;
unhighlight();
setButtons('idle');
persist();
summarize();
}
};
const summarize = () => {
const mins = ((Date.now() - STATE.startedAt) / 60000).toFixed(1);
addLog('════════════════════════════════');
addLog(`✅ ${STATE.stopRequested ? 'Stopped' : 'Finished'} in ${mins} min: ${STATE.processed} posts`);
addLog(` ❤️ ${STATE.liked} liked · 🔁 ${STATE.reposted} reposted · 💬 ${STATE.commented} replied · skipped ${STATE.skipped} · failed ${STATE.failed}`);
if (CONFIG.dryRun) addLog(' Dry run: nothing was actually posted. Untick "Dry run" and start again.', 'warn');
setStatus(STATE.stopRequested ? 'Stopped' : 'Done');
};
// ═══════════════════════════════════════════════════════════
// PANEL
// ═══════════════════════════════════════════════════════════
const css = `
#xep-panel{position:fixed;right:16px;bottom:16px;width:380px;max-height:86vh;display:flex;flex-direction:column;z-index:2147483000;background:#0f1419;color:#e7e9ea;border:1px solid #2f3336;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;transition:transform .2s ease,opacity .2s ease}
#xep-panel.xep-min .xep-body,#xep-panel.xep-min .xep-foot{display:none}
#xep-panel *{box-sizing:border-box}
.xep-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid #2f3336;cursor:move;user-select:none}
.xep-head b{font-size:14px;flex:1}
.xep-head button{background:transparent;border:0;color:#8b98a5;font-size:16px;cursor:pointer;padding:2px 6px;border-radius:6px}
.xep-head button:hover{background:#1d2226;color:#fff}
.xep-body{overflow:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px}
.xep-body::-webkit-scrollbar{width:5px}.xep-body::-webkit-scrollbar-thumb{background:#2f3336;border-radius:4px}
.xep-row{display:flex;align-items:center;justify-content:space-between;gap:8px}
.xep-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.xep-label{color:#8b98a5;font-size:12px}
.xep-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid #2f3336;border-radius:999px;cursor:pointer;user-select:none;transition:background .15s,border-color .15s}
.xep-chip:hover{border-color:#536471}
.xep-chip.on{background:#1d9bf0;border-color:#1d9bf0;color:#fff}
.xep-chip input{display:none}
#xep-panel input[type=text],#xep-panel input[type=password],#xep-panel input[type=number],#xep-panel select,#xep-panel textarea{width:100%;background:#16181c;color:#e7e9ea;border:1px solid #2f3336;border-radius:8px;padding:7px 9px;font:inherit;outline:none;transition:border-color .15s}
#xep-panel input:focus,#xep-panel select:focus,#xep-panel textarea:focus{border-color:#1d9bf0}
#xep-panel textarea{resize:vertical;min-height:58px}
.xep-section{border:1px solid #2f3336;border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:8px}
.xep-section>b{font-size:12px;color:#8b98a5;text-transform:uppercase;letter-spacing:.04em}
.xep-toggle{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer}
.xep-toggle input{accent-color:#1d9bf0;width:16px;height:16px}
.xep-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.xep-stat{background:#16181c;border-radius:10px;padding:8px 6px;text-align:center}
.xep-stat b{display:block;font-size:17px}
.xep-stat span{font-size:11px;color:#8b98a5}
.xep-bar{height:6px;background:#16181c;border-radius:3px;overflow:hidden}
.xep-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,#1d9bf0,#7856ff);transition:width .3s}
.xep-status{font-size:12px;color:#8b98a5;min-height:16px}
.xep-log{background:#000;border-radius:10px;padding:8px;height:150px;overflow:auto;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}
.xep-log::-webkit-scrollbar{width:4px}.xep-log::-webkit-scrollbar-thumb{background:#2f3336}
.xep-log .warn{color:#ffd166}.xep-log .dim{color:#536471}
.xep-foot{display:flex;gap:8px;padding:12px 14px;border-top:1px solid #2f3336}
.xep-btn{flex:1;padding:9px 10px;border-radius:999px;border:1px solid #2f3336;background:#16181c;color:#e7e9ea;font-weight:600;cursor:pointer;transition:background .15s,transform .05s}
.xep-btn:hover:not(:disabled){background:#1d2226}.xep-btn:active:not(:disabled){transform:scale(.98)}
.xep-btn:disabled{opacity:.4;cursor:not-allowed}
.xep-btn.primary{background:#1d9bf0;border-color:#1d9bf0;color:#fff}.xep-btn.primary:hover:not(:disabled){background:#1a8cd8}
.xep-btn.danger{border-color:#f4212e;color:#f4212e}.xep-btn.danger:hover:not(:disabled){background:rgba(244,33,46,.12)}
.xep-hint{font-size:11px;color:#8b98a5}
.xep-hint a{color:#1d9bf0;text-decoration:none}
.xep-hide{display:none!important}
`;
const panel = document.createElement('div');
panel.id = 'xep-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'XActions profile sweep');
panel.innerHTML = `
<style>${css}</style>
<div class="xep-head" id="xep-drag">
<span aria-hidden="true">⚡</span><b>Sweep ${esc(target.label)}</b>
<button id="xep-min" title="Minimize" aria-label="Minimize">–</button>
<button id="xep-close" title="Close panel" aria-label="Close">✕</button>
</div>
<div class="xep-body">
<div class="xep-stats">
<div class="xep-stat"><b id="xep-sPosts">0</b><span>posts</span></div>
<div class="xep-stat"><b id="xep-sLiked">0</b><span>liked</span></div>
<div class="xep-stat"><b id="xep-sReposted">0</b><span>reposted</span></div>
<div class="xep-stat"><b id="xep-sCommented">0</b><span>replied</span></div>
</div>
<div class="xep-bar"><i id="xep-progress"></i></div>
<div class="xep-status" id="xep-status" aria-live="polite">Ready. ${STATE.done.size ? `${STATE.done.size} posts done in earlier runs.` : 'Start in dry run.'}</div>
<div class="xep-section">
<b>Actions</b>
<div class="xep-row">
<label class="xep-chip ${CONFIG.actions.like ? 'on' : ''}"><input type="checkbox" id="xep-like" ${CONFIG.actions.like ? 'checked' : ''}>❤️ Like</label>
<label class="xep-chip ${CONFIG.actions.repost ? 'on' : ''}"><input type="checkbox" id="xep-repost" ${CONFIG.actions.repost ? 'checked' : ''}>🔁 Repost</label>
<label class="xep-chip ${CONFIG.actions.comment ? 'on' : ''}"><input type="checkbox" id="xep-comment" ${CONFIG.actions.comment ? 'checked' : ''}>💬 Reply</label>
</div>
<div class="xep-grid">
<label><span class="xep-label">Max posts (0 = all)</span><input type="number" id="xep-max" min="0" value="${CONFIG.maxPosts}"></label>
<label><span class="xep-label">Speed</span>
<select id="xep-speed">
<option value="stealth">Stealth (45-90s)</option>
<option value="safe">Safe (15-35s)</option>
<option value="moderate">Moderate (7-15s)</option>
<option value="fast">Fast (3-7s)</option>
</select></label>
</div>
<label class="xep-toggle"><span>Include replies</span><input type="checkbox" id="xep-replies" ${CONFIG.includeReplies ? 'checked' : ''}></label>
<label class="xep-toggle"><span>Include reposts${MIXED_FEED ? ' (on by default here: this feed mixes authors)' : ' of other accounts'}</span><input type="checkbox" id="xep-reposts" ${CONFIG.includeReposts ? 'checked' : ''}></label>
<label class="xep-toggle"><span>Skip posts already engaged</span><input type="checkbox" id="xep-skipDone" ${CONFIG.skipAlreadyEngaged ? 'checked' : ''}></label>
<label class="xep-toggle"><span><b>Dry run</b> (log only, touch nothing)</span><input type="checkbox" id="xep-dry" ${CONFIG.dryRun ? 'checked' : ''}></label>
</div>
<div class="xep-section">
<b>Filters</b>
<div class="xep-hint">Aimed at mixed feeds (search, lists, hashtags, home). Leave blank on a profile sweep.</div>
<div class="xep-grid">
<label><span class="xep-label">Only from (handles)</span><input type="text" id="xep-onlyFrom" value="${esc(CONFIG.onlyFrom.join(', '))}" placeholder="nasa, nichxbt"></label>
<label><span class="xep-label">Never these handles</span><input type="text" id="xep-skipUsers" value="${esc(CONFIG.skipUsers.join(', '))}" placeholder="spammer1"></label>
</div>
<div class="xep-grid">
<label><span class="xep-label">Must contain</span><input type="text" id="xep-keywords" value="${esc(CONFIG.keywords.join(', '))}" placeholder="solana, rust"></label>
<label><span class="xep-label">Must not contain</span><input type="text" id="xep-skipKeywords" value="${esc(CONFIG.skipKeywords.join(', '))}" placeholder="giveaway, airdrop"></label>
</div>
<div class="xep-grid">
<label><span class="xep-label">Min likes</span><input type="number" id="xep-minLikes" min="0" value="${CONFIG.minLikes}"></label>
<label><span class="xep-label">Max likes (0 = any)</span><input type="number" id="xep-maxLikes" min="0" value="${CONFIG.maxLikes}"></label>
</div>
<label class="xep-toggle"><span>Skip verified accounts</span><input type="checkbox" id="xep-skipVerified" ${CONFIG.skipVerified ? 'checked' : ''}></label>
</div>
<div class="xep-section" id="xep-commentSection">
<b>Replies</b>
<label><span class="xep-label">Source</span>
<select id="xep-mode">
<option value="templates">My templates (one per line)</option>
<option value="ai">AI, written per post from a brief</option>
</select></label>
<div id="xep-templatesWrap">
<textarea id="xep-templates" rows="4" spellcheck="false">${esc(CONFIG.comments.templates.join('\n'))}</textarea>
<div class="xep-hint">{author} becomes @handle, {name} the display name. Never the same one twice in a row.</div>
</div>
<div id="xep-aiWrap" class="xep-hide">
<label><span class="xep-label">Brief: how should the replies sound?</span><textarea id="xep-prompt" rows="3">${esc(CONFIG.comments.prompt)}</textarea></label>
<label><span class="xep-label">Persona (optional)</span><input type="text" id="xep-persona" value="${esc(CONFIG.comments.persona)}" placeholder="You are @you, a founder building ..."></label>
<div class="xep-grid">
<label><span class="xep-label">Provider</span>
<select id="xep-provider">
<option value="xai">xAI Grok (works from console)</option>
<option value="bridge">XActions extension (any provider)</option>
</select></label>
<label><span class="xep-label">Model</span><input type="text" id="xep-model" value="${esc(CONFIG.comments.model)}"></label>
</div>
<div class="xep-grid xep-hide" id="xep-bridgeWrap">
<label><span class="xep-label">Bridge provider</span>
<select id="xep-bridgeProvider">
<option value="openrouter">OpenRouter</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
<option value="ollama">Ollama (local)</option>
<option value="custom">Custom URL</option>
</select></label>
<label><span class="xep-label">Custom URL</span><input type="text" id="xep-bridgeUrl" value="${esc(CONFIG.comments.bridgeBaseUrl)}" placeholder="https://.../v1/chat/completions"></label>
</div>
<label><span class="xep-label">API key</span><input type="password" id="xep-key" value="${esc(CONFIG.comments.apiKey)}" autocomplete="off" placeholder="xai-..."></label>