Repository navigation
Expand file tree
/
Copy pathgiveaway.user.js
More file actions
7880 lines (6740 loc) · 322 KB
/
Copy pathgiveaway.user.js
File metadata and controls
7880 lines (6740 loc) · 322 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
// ==UserScript==
// @name UNIT3D BON Giveaway
// @namespace https://github.com/flowerey/unit3d-scripts
// @version 6.5.3
// @description Enables the functionality to become poor
// @author blueberry, Nums
// @match https://*/chatbox*
// @match https://*/chat/*
// @downloadURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/giveaway.user.js
// @updateURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/giveaway.user.js.meta.js
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
// If the website is not listed as a match already, please verify with tracker admins before using this script on their site.
// Additional credits
// @TheEther - Integration with Aither + some additional features
// @Nums - added new commands, command spam detection, admin controls, multi-winners, refactored BON API polling + trying to keep the public version updated
// @ahoimate - got BON gifting API polling working + added new commands
// @ruckus612 - fixed BON gift bug
// @ZukoXZuko - added formatting to the giveaway menu
(function() {
'use strict';
// ───────────────────────────────────────────────────────────
// SECTION 1: Global Constants and Configuration
// ───────────────────────────────────────────────────────────
const COMMAND_WINDOW_MS = 10000; // look back 10 seconds
const MAX_COMMANDS_PER_WINDOW = 3; // allow 3 commands in that window
const BASE_PENALTY_SECONDS = 30; // base lockout for exceeding (in seconds)
// Spam filter tightening (keeps responses snappy but reduces chat spam):
// - MIN_ACTION_GAP_MS blocks ultra-fast repeat triggers (usually bots/double-sends)
// - REPEAT_COMMAND_COOLDOWNS_MS prevents the same command from being spammed for identical output
// - strikes increase lockout length for repeat offenders (decays over time)
const MIN_ACTION_GAP_MS = 900; // ignore triggers faster than this per user
const ENTRY_FEEDBACK_COOLDOWN_MS = 8000; // throttle duplicate/out-of-range feedback per user
const STRIKE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
const MAX_STRIKE_MULTIPLIER = 8; // caps exponential backoff
const REPEAT_COMMAND_COOLDOWNS_MS = Object.freeze({
entry: 2000,
time: 3000,
entries: 5000,
free: 7000,
lucky: 7000,
luckye: 7000,
random: 7000,
range: 5000,
sponsors: 8000,
stats: 8000,
top: 8000,
most: 8000,
largest: 8000,
scale: 5000
});const RIG_DENY_COOLDOWN_MS = 10000; // 10s per-user cooldown for funny !rig/!unrig denial messages
const MAX_WINNERS = 50; // central location to update max allowable number of winners
const MAX_REMINDERS = 6; //maximum number of reminders allowed
// Persistent stats (saved in localStorage on this site)
const STATS_KEY = `BON_GIVEAWAY_STATS::${location.hostname}`;
const STATS_VERSION = 1;
const STATS_DEFAULT_TOP_N = 3;
const STATS_MAX_TOP_N = 10;
// Default text to populate the custom giveaway message field
const DEFAULT_CUSTOM_MESSAGE = "";
const GIFT_HINT_COLOR = "#F3D34A";
const SCALING_ACCENT_COLOR = "#7C4DFF";
const ENTRY_IGNORE_WINDOW_MS = 2000;
// Sponsor announcement controls (host chat spam reduction)
// - mode: "immediate" (old behavior), "digest" (recommended), or "off" (silent; still counts sponsors)
// - digest_ms: max frequency for sponsor announcements in chat
// - immediate_single_min: big single gifts are announced right away (even in digest mode)
// - flush_min_total: announce early if combined pending sponsorship reaches this BON
// - show_top_n / show_min_per_user: keep the line short; omit tiny sponsors from the name list (still counted in totals)
const SPONSOR_ANNOUNCE = {
mode: "digest",
digest_ms: 60_000,
immediate_single_min: 500,
flush_min_total: 250,
max_pending_events: 50,
show_top_n: Infinity,
show_min_per_user: 0
};
const GENERAL_SETTINGS = {
disable_random: false,
disable_lucky: false,
disable_free: false,
suppress_entry_replies: false,
silent_mode: false
};
const DEBUG_SETTINGS = {
log_chat_messages: false,
disable_chat_output: false,
verify_extractor: false,
verify_sendmessage: false,
verify_cacheChatContext: false,
suppressApiMessages: false, // new flag to suppress API message sending
enable_self_checks: false
};
const PERF = false; // Debug-only perf counters (must stay false in normal use)
const PERF_LOG_EVERY = 50;
const perfCounters = PERF ? Object.create(null) : null;
function perfMeasure(section, startMs) {
if (!PERF) return;
const elapsed = performance.now() - startMs;
const rec = perfCounters[section] || (perfCounters[section] = { count: 0, total: 0, max: 0 });
rec.count += 1;
rec.total += elapsed;
if (elapsed > rec.max) rec.max = elapsed;
if ((rec.count % PERF_LOG_EVERY) === 0) {
console.debug(
`[BON Giveaway PERF] ${section}: count=${rec.count}, avg=${(rec.total / rec.count).toFixed(2)}ms, max=${rec.max.toFixed(2)}ms`
);
}
}
const SELF_CHECK_FLAG = "BON_GIVEAWAY_SELF_CHECKS";
const SELF_CHECK_QUERY_RE = /(?:^|[?&])bg_self_checks=1(?:&|$)/i;
const SELF_CHECKS_ENABLED = !!(
DEBUG_SETTINGS.enable_self_checks ||
localStorage.getItem(SELF_CHECK_FLAG) === "true" ||
SELF_CHECK_QUERY_RE.test(String(window.location.search || ""))
);
function selfCheck(condition, message, details) {
if (!SELF_CHECKS_ENABLED || condition) return;
const err = new Error(`[BON Giveaway self-check] ${message}`);
if (details && typeof details === "object") {
try {
console.error(err.message, details);
} catch {
console.error(err.message);
}
} else {
console.error(err.message);
}
throw err;
}
const SCRIPT_ID = 'bon-giveaway-update';
const CHECK_EVERY_HOURS = 48;
const CHATROOM_IDS = {
'upload.cx': '11',
'oldtoons.world': '4',
'aither.cc': '4',
'reelflix.cc': '1',
'homiehelpdesk.net': '3',
'darkpeers.org': '2',
'yu-scene.net': '5',
'polishtorrent.top': '16',
'luminarr.me': '4',
'midnightscene.cc': '2',
'znth.cx' : '2',
};
// Central host/site adapter: isolate per-site quirks in one place
function createSiteAdapter(hostname, chatroomMap) {
const host = String(hostname || '').trim().toLowerCase();
const isUploadCx = host === 'upload.cx';
const chatroomId = (chatroomMap && chatroomMap[host]) ? String(chatroomMap[host]) : '2';
function getMessageContentElement(messageNode) {
if (!messageNode || messageNode.nodeType !== 1) return null;
return messageNode.querySelector('.chatbox-message__content');
}
function getGiftEndpointPath(slug) {
const safeSlug = String(slug || '').trim();
if (!safeSlug) return null;
return `/users/${safeSlug}/gifts`;
}
return Object.freeze({
host,
chatroomId,
isUploadCx,
getMessageContentElement,
getGiftEndpointPath
});
}
const LS_SUPPRESS = "giveaway-suppressEntryReplies";
const LS_SILENT = "giveaway-silentMode";
const LS_SHOW_GIVEAWAY_LOG = "giveaway-showLog";
const LS_HOST_PANEL_OPEN = "giveaway-hostPanelOpen";
const LS_HOST_PANEL_POS = "bonGiveaway_hostPanelPos";
const LS_MINIMIZED = "giveaway-minimized";
const LS_PRESETS = "giveaway-presets";
const LS_ACTIVE_GIVEAWAY = `giveaway-activeState::${location.hostname}`;
const LS_TAB_LOCK = `giveaway-tabLock::${location.hostname}`;
// Per-giveaway ledger of completed gift attempts. Survives reload + visible to other tabs,
// so even if endGiveaway runs in two tabs the second one won't re-pay.
const LS_PAID_GIFTS = `giveaway-paidGifts::${location.hostname}`;
// Cap retained giveaway-id entries in the ledger so it can't grow unbounded over time.
const PAID_GIFTS_MAX_GIVEAWAYS = 50;
const TAB_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const TAB_LOCK_HEARTBEAT_MS = 5000; // update lock every 5s
const TAB_LOCK_STALE_MS = 15000; // lock is stale if no heartbeat for 15s
let tabLockHeartbeatTimer = null;
function readStoredBooleanSetting(key, fallback = false, persistFallback = false) {
const raw = localStorage.getItem(key);
if (raw === "true") return true;
if (raw === "false") return false;
if (persistFallback) {
localStorage.setItem(key, String(!!fallback));
}
return !!fallback;
}
// Initialize silent mode as early as possible (no page refresh needed).
GENERAL_SETTINGS.silent_mode = readStoredBooleanSetting(LS_SILENT, false, true);
GENERAL_SETTINGS.show_giveaway_log = readStoredBooleanSetting(LS_SHOW_GIVEAWAY_LOG, false, true);
const currentHost = window.location.hostname;
const Site = createSiteAdapter(currentHost, CHATROOM_IDS);
const chatroomId = Site.chatroomId;
const chatboxId = "chatbox__messages-create";
const COMMAND_PANEL_SECTIONS = Object.freeze({
giveaway: "General Commands",
stats: "Stats Commands",
entry: "Entry Commands",
help: "Help",
rigging: "Rigging Commands",
pot: "BON Commands",
fun: "Fun / Extras"
});
const HOST_PANEL_NAUGHTY_SECTION_TITLE = "Naughty List";
const HOST_PANEL_END_COMMAND_DENYLIST = Object.freeze(["end", "endgiveaway", "giveawayend", "stop", "stopgiveaway"]);
const HOST_PANEL_INTERNAL_COMMAND_DENYLIST = Object.freeze(["commands"]);
const HOST_PANEL_SECTION_ORDER = Object.freeze(["giveaway", "stats", "pot", "entry", "rigging", "help", "fun"]);
const HOST_PANEL_COMMAND_SECTION_BY_KEY = Object.freeze({
reminder: "giveaway",
entries: "giveaway",
time: "giveaway",
addtime: "giveaway",
removetime: "giveaway",
winners: "giveaway",
maxwinners: "giveaway",
scale: "giveaway",
stats: "stats",
top: "stats",
most: "stats",
largest: "stats",
unlucky: "stats",
sponsors: "stats",
help: "help",
gift: "help",
bon: "pot",
addbon: "pot",
rig: "rigging",
unrig: "rigging",
naughty: "naughty"
});
const HOST_PANEL_COMMAND_ORDER_BY_SECTION = Object.freeze({
giveaway: Object.freeze(["reminder", "entries", "time", "addtime", "removetime", "winners", "maxwinners", "scale"]),
stats: Object.freeze(["stats", "top", "most", "largest", "unlucky", "sponsors"]),
help: Object.freeze(["help", "gift"]),
pot: Object.freeze(["bon", "addbon"]),
rigging: Object.freeze(["rig", "unrig"])
});
// only run the cooldown/spam‑detection logic on available commands
const baseCommands = ["time", "entries", "help", "commands", "bon", "range", "gift","random", "number", "free", "lucky", "luckye", "rig", "unrig", "stats", "top", "most", "sponsors", "unlucky", "largest", "scale", "host"];
const hostCommands = ["addtime", "removetime", "reminder", "addbon", "end", "winners", "maxwinners", "naughty", "history"];
const uploadCxExtras = ["ruckus", "ick", "corigins", "lejosh", "suckur", "bloom", "dawg", "greglechin"];
const validCommands = new Set([
...baseCommands,
...hostCommands,
...(Site.isUploadCx ? uploadCxExtras : [])
]);
const HOST_PANEL_COMMAND_METADATA = Object.freeze({
time: { label: "Time", section: "giveaway", description: "Show remaining giveaway time.", usage: "!time", requiresGiveaway: true },
entries: { label: "Entries", section: "info", description: "List current entries.", usage: "!entries", requiresGiveaway: true },
help: { label: "Help", section: "info", description: "Show available commands in chat.", usage: "!help", requiresGiveaway: false },
commands: { label: "Commands", section: "info", description: "Alias for !help.", usage: "!commands", requiresGiveaway: false },
stats: { label: "Stats", section: "info", description: "Show saved stats for a user.", usage: "!stats [username]", requiresGiveaway: false,
args: [{ name: "username", label: "User", type: "username", required: false, placeholder: "optional username" }] },
top: { label: "Top", section: "info", description: "Top winners leaderboard.", usage: "!top [N]", requiresGiveaway: false,
args: [{ name: "count", label: "N", type: "int", required: false, min: 1, max: STATS_MAX_TOP_N, placeholder: String(STATS_DEFAULT_TOP_N) }] },
most: { label: "Most", section: "info", description: "Most BON won leaderboard.", usage: "!most [N]", requiresGiveaway: false,
args: [{ name: "count", label: "N", type: "int", required: false, min: 1, max: STATS_MAX_TOP_N, placeholder: String(STATS_DEFAULT_TOP_N) }] },
sponsors: { label: "Sponsors", section: "pot", description: "Show top sponsors.", usage: "!sponsors [N]", requiresGiveaway: false,
args: [{ name: "count", label: "N", type: "int", required: false, min: 1, max: STATS_MAX_TOP_N, placeholder: String(STATS_DEFAULT_TOP_N) }] },
unlucky: { label: "Unlucky", section: "info", description: "Show most losses leaderboard.", usage: "!unlucky [N]", requiresGiveaway: false,
args: [{ name: "count", label: "N", type: "int", required: false, min: 1, max: STATS_MAX_TOP_N, placeholder: String(STATS_DEFAULT_TOP_N) }] },
largest: { label: "Largest", section: "info", description: "Show largest giveaways.", usage: "!largest [N]", requiresGiveaway: false,
args: [{ name: "count", label: "N", type: "int", required: false, min: 1, max: STATS_MAX_TOP_N, placeholder: String(STATS_DEFAULT_TOP_N) }] },
gift: { label: "Gift", section: "pot", description: "Show giveaway gift status.", usage: "!gift", requiresGiveaway: true },
bon: { label: "BON", section: "pot", description: "Show current pot amount.", usage: "!bon", requiresGiveaway: true },
range: { label: "Range", section: "entry", description: "Show valid entry range.", usage: "!range", requiresGiveaway: true },
lucky: { label: "Lucky", section: "entry", description: "Show lucky number.", usage: "!lucky", requiresGiveaway: true },
luckye: { label: "Lucky Enter", section: "entry", description: "Enter using lucky number.", usage: "!luckye", requiresGiveaway: true },
rig: { label: "Rig", section: "entry", description: "Fun rig toggle command.", usage: "!rig", requiresGiveaway: true },
unrig: { label: "Unrig", section: "entry", description: "Fun rig toggle command.", usage: "!unrig", requiresGiveaway: true },
random: { label: "Random", section: "entry", description: "Enter with a random number.", usage: "!random", requiresGiveaway: true },
number: { label: "Number", section: "entry", description: "Show your current entry.", usage: "!number", requiresGiveaway: true },
free: { label: "Free", section: "entry", description: "Show available entry numbers.", usage: "!free", requiresGiveaway: true },
addbon: { label: "Add BON", section: "pot", description: "Add BON to the pot.", usage: "!addbon <amount>", requiresGiveaway: true, hostOnly: true,
args: [{ name: "amount", label: "BON", type: "int", required: true, min: 1, placeholder: "amount" }] },
reminder: { label: "Reminder", section: "giveaway", description: "Send reminder now.", usage: "!reminder", requiresGiveaway: true, hostOnly: true },
winners: { label: "Winners", section: "giveaway", description: "Set winner count.", usage: `!winners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true,
args: [{ name: "count", label: "Count", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "winners" }] },
maxwinners: { label: "Max Winners", section: "giveaway", description: "Set max scaled winners.", usage: `!maxwinners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true,
args: [{ name: "count", label: "Max", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "max" }] },
scale: { label: "Scale", section: "info", description: "Show scaling progress.", usage: "!scale", requiresGiveaway: true },
addtime: { label: "Add Time", section: "giveaway", description: "Add giveaway minutes.", usage: "!addtime <minutes>", requiresGiveaway: true, hostOnly: true,
args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] },
removetime: { label: "Remove Time", section: "giveaway", description: "Remove giveaway minutes.", usage: "!removetime <minutes>", requiresGiveaway: true, hostOnly: true,
args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] },
naughty: { label: "Naughty", section: "giveaway", description: "Manage naughty list.", usage: "!naughty (add|remove|list) [username]", requiresGiveaway: true, hostOnly: true,
args: [
{ name: "action", label: "Action", type: "select", required: true, placeholder: "action", options: [{ label: "Add", value: "add" }, { label: "Remove", value: "remove" }, { label: "List", value: "list" }], validate: (v) => /^(add|remove|list)$/i.test(String(v || "").trim()), hint: "Use add, remove, or list." },
{ name: "username", label: "User", type: "username", required: false, placeholder: "username", requiredWhen: (all) => /^(add|remove)$/i.test(String(all.action || "").trim()), hint: "Username is required for add/remove." }
] },
end: { label: "End", section: "giveaway", description: "End the active giveaway.", usage: "!end [host]", requiresGiveaway: true, hostOnly: true,
args: [{ name: "host", label: "Host", type: "username", required: false, placeholder: "optional host" }] },
host: { label: "Quick Host", section: "giveaway", description: "Start giveaway: !host <amount> <start>-<end> <time>m [w]winners", usage: "!host 1000 1-100 5m 3w", requiresGiveaway: false },
history: { label: "History", section: "info", description: "Show past giveaway history.", usage: "!history [N]", requiresGiveaway: false },
suckur: { label: "Suckur", section: "fun", description: "Upload.cx fun command.", usage: "!suckur", requiresGiveaway: false },
ruckus: { label: "Ruckus", section: "fun", description: "Upload.cx fun command.", usage: "!ruckus", requiresGiveaway: false },
ick: { label: "Ick", section: "fun", description: "Upload.cx fun command.", usage: "!ick", requiresGiveaway: false },
corigins: { label: "Corigins", section: "fun", description: "Upload.cx fun command.", usage: "!corigins", requiresGiveaway: false },
lejosh: { label: "Lejosh", section: "fun", description: "Upload.cx fun command.", usage: "!lejosh", requiresGiveaway: false },
bloom: { label: "Bloom", section: "fun", description: "Upload.cx fun command.", usage: "!bloom", requiresGiveaway: false },
dawg: { label: "Dawg", section: "fun", description: "Upload.cx fun command.", usage: "!dawg", requiresGiveaway: false },
greglechin: { label: "Greglechin", section: "fun", description: "Upload.cx fun command.", usage: "!greglechin", requiresGiveaway: false }
});
// Declared early so UI init paths can safely reference this object before command handlers are populated.
const COMMAND_HANDLERS = Object.create(null);
// ───────────────────────────────────────────────────────────
// SECTION 2: Runtime State Variables
// ───────────────────────────────────────────────────────────
let giveawayStartTime;
let sponsorsInterval;
let observer;
let giveawayData;
let chatbox = null;
let reminderRetryTimeout = null;
let frameHeader;
let OT_USER_ID = null;
let OT_CHATROOM_ID = null;
let OT_CSRF_TOKEN = null;
let riggedMode = false; // fun cosmetic mode, does NOT affect fairness
// Grouped mutable collections (keeps behavior while improving maintainability/discoverability).
const state = {
moderation: {
userCooldown: new Map(), // authorKey(lower) → timestamp(ms) when lockout ends
userCommandLog: new Map(), // authorKey(lower) → [timestamps of recent triggers]
userLastActionAt: new Map(), // authorKey(lower) → last trigger timestamp(ms)
userLastCommandAt: new Map(), // `${authorKey}::${command}` → last timestamp(ms)
userSpamStrikes: new Map(), // authorKey(lower) → { count:number, lastAt:number }
userFeedbackCooldown: new Map(), // `${authorKey}::${bucket}` → last feedback timestamp(ms)
rigDenyCooldown: new Map() // author → timestamp(ms) when next rig/unrig deny message is allowed
},
entries: {
numberEntries: new Map(),
numberTakenBy: new Map(), // entryNumber -> author (fast duplicate checks)
fancyNames: new Map(),
naughtyWarned: new Set() // Users that have already been warned this giveaway
},
liveStats: {
enteredThisGiveaway: new Set(), // userKey
sponsorSeenThisGiveaway: new Set(), // sponsorKey (for sponsorCount once/giveaway)
sponsorTotalThisGiveaway: new Map() // sponsorKey -> running total
},
winners: {
winnerPayouts: new Map(), // lowercase author -> BON amount
winnerGiftStatus: new Map() // lowercase author -> "pending" | "confirmed" | "failed"
},
audit: {
giveawayLog: []
}
};
const { userCooldown, userCommandLog, userLastActionAt, userLastCommandAt, userSpamStrikes, userFeedbackCooldown, rigDenyCooldown } = state.moderation;
const { numberEntries, numberTakenBy, fancyNames, naughtyWarned } = state.entries;
const { enteredThisGiveaway: liveEnteredThisGiveaway, sponsorSeenThisGiveaway: liveSponsorSeenThisGiveaway, sponsorTotalThisGiveaway: liveSponsorTotalThisGiveaway } = state.liveStats;
const { winnerPayouts, winnerGiftStatus } = state.winners;
const { giveawayLog } = state.audit;
const normalizeLower = (value) => String(value || "").trim().toLowerCase();
const CHAT_MESSAGE_SELECTOR = '.chatbox-message';
const CHATROOM_MESSAGES_SELECTOR = '.chatroom__messages';
const GIFT_AMOUNT_RE = /has gifted\s*([\d.]+)\s*BON/i;
const giftDOMParser = new DOMParser();
let entriesTableEl = null;
let entriesTbodyEl = null;
let chatMessagesListEl = null;
const entryRowByKey = new Map();
const regNum = /^-?\d+$/; // matches integers (including negative) for entry detection
// Cross-page message deduplication: tracks API message IDs already processed
// to prevent double-processing when both the DOM observer and API poller are active.
const processedApiMessageIds = new Set();
/* --- Naughty (exclusion) list ------------------------------------- */
const NAUGHTY_KEY = "giveaway-naughty-list";
const naughtySet = new Set(
JSON.parse(localStorage.getItem(NAUGHTY_KEY) || "[]")
.map(normalizeLower) // store lowercase for case-insensitive match
);
function saveNaughty() {
localStorage.setItem(NAUGHTY_KEY, JSON.stringify([...naughtySet]));
}
const coinsIcon = document.createElement("i");
coinsIcon.setAttribute("class", "fas fa-coins");
const goldCoins = document.createElement("i");
goldCoins.setAttribute("class", "fas fa-coins");
goldCoins.style.color = "#ffc00a";
goldCoins.style.padding = "5px";
const giveawayBTN = document.createElement("a");
giveawayBTN.setAttribute("class", "form__button form__button--text");
giveawayBTN.textContent = "Giveaway";
giveawayBTN.prepend(coinsIcon.cloneNode(false));
giveawayBTN.onclick = toggleMenu;
// ───────────────────────────────────────────────────────────
// SECTION 3: Script Metadata Parsing
// ───────────────────────────────────────────────────────────
const META = (() => {
/* 1. Tampermonkey / Violentmonkey / classic Greasemonkey */
if (typeof GM_info !== "undefined" && GM_info.script) {
return GM_info.script;
}
/* 2. Greasemonkey 4 (GM.info) */
if (typeof GM !== "undefined" && GM.info && GM.info.script) {
return GM.info.script;
}
/* 3. Fallback: read our own source and regex the @version etc. */
try {
/* GM-3 keeps the original userscript text in the <script> tag it
injects. document.currentScript points to that tag. */
const src = document.currentScript?.textContent || "";
const fetch = key => {
const m = src.match(new RegExp(`@${key}\\s+([^\\n]+)`));
return m ? m[1].trim() : "";
};
return {
name: fetch("name") || "BON Giveaway",
updateURL: fetch("updateURL") || "https://openuserjs.org/meta/Nums/Blutopia_BON_Giveaway.meta.js",
version: fetch("version") || "0.0.0"
};
} catch (e) {
/* Last-ditch – never crash the script */
return { name:"BON Giveaway", version:"0.0.0" };
}
})();
const {
name: SCRIPT_NAME,
updateURL: SCRIPT_UPDATE_URL,
version: SCRIPT_VERSION
} = META;
/* — persistent “out-of-date” flag — */
const UPDATE_KEY = `${SCRIPT_ID}-latestRemote`;
const latestRemote = localStorage.getItem(UPDATE_KEY) || "";
/* If we already know a newer version exists, draw the badge immediately */
if (latestRemote && isNewer(latestRemote, SCRIPT_VERSION)) {
/* frame isn’t on the page yet → retry until it is */
waitForBadge(latestRemote);
}
// ───────────────────────────────────────────────────────────
// SECTION 4: UI Template Definitions
// ───────────────────────────────────────────────────────────
const frameHTML = `
<section
id="giveawayFrame"
class="panelV2"
style="width:450px;height:90%;position:fixed;z-index:9999;inset:50px 150px auto auto;overflow:auto;border:1px solid black;"
hidden
>
<!-- HEADER -->
<header class="panel__heading">
<div class="button-holder no-space giveaway-header-top-row">
<div class="button-left">
<h4 class="panel__heading">
<i class="fa-solid fa-gifts" style="padding:5px;"></i>
${SCRIPT_NAME}
<small style="color:#aaa;margin-left:8px;font-size:0.8em;">v${SCRIPT_VERSION}</small>
</h4>
</div>
<div class="button-right giveaway-header-actions">
<button id="minimizeButton" class="form__button form__button--text giveaway-btn" style="background-color:#4e595f;" title="Minimize panel">
<i class="fa-solid fa-window-minimize"></i>
</button>
<button id="closeButton" class="form__button form__button--text giveaway-btn" style="background-color:#4e595f;">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div class="giveaway-header-actions__menu-row no-drag" data-no-drag="1">
<button id="resetButton" class="form__button form__button--text giveaway-btn no-drag" data-no-drag="1" style="background-color:#b32525;">
<i class="fa-solid fa-rotate-right"></i> Reset
</button>
<button id="giveawaySettingsBtn" class="form__button form__button--text giveaway-btn no-drag" data-no-drag="1" style="background-color:#ff6400;">
<i class="fa-solid fa-gear"></i> Settings
</button>
<button id="commandsButton" class="form__button form__button--text giveaway-btn no-drag" data-no-drag="1" style="background-color:#ff9600;">
<i class="fa-solid fa-list"></i> Commands
</button>
</div>
</header>
<!-- MAIN BODY -->
<div class="panel__body" id="giveaway_body" style="display:flex; flex-direction:column; gap:10px;">
<!-- Presets -->
<div class="giveaway-presets-row" style="display:flex; align-items:center; justify-content:center; gap:6px; flex-wrap:wrap; margin:0;">
<select id="presetSelect" class="form__text" style="width:auto; min-width:120px; max-width:180px; padding:3px 6px; font-size:12px;">
<option value="">— Presets —</option>
</select>
<button type="button" id="presetLoadBtn" class="form__button form__button--text giveaway-btn no-drag" style="background-color:#2a7acc; font-size:11px; padding:3px 8px;" title="Load selected preset">
<i class="fa-solid fa-folder-open"></i> Load
</button>
<button type="button" id="presetSaveBtn" class="form__button form__button--text giveaway-btn no-drag" style="background-color:#02B008; font-size:11px; padding:3px 8px;" title="Save current form as a preset">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<button type="button" id="presetDeleteBtn" class="form__button form__button--text giveaway-btn no-drag" style="background-color:#b32525; font-size:11px; padding:3px 8px;" title="Delete selected preset">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<h1 id="coinHeader" class="panel__heading--centered"></h1>
<form class="form" id="giveawayForm" style="display:flex;flex-flow:column;align-items:center;">
<p class="form__group" style="max-width:35%;">
<input
class="form__text"
required
id="giveawayAmount"
inputmode="numeric"
type="text"
>
<label class="form__label form__label--floating" for="giveawayAmount">
Giveaway Amount
</label>
</p>
<div class="panel__body flex-row" style="justify-content:center; gap:20px;">
${
[
['startNum', '1'],
['endNum', '50']
]
.map(
([id, val]) => `
<p class="form__group" style="width:20%;">
<input
class="form__text"
required
id="${id}"
pattern="-?\\d+"
value="${val}"
inputmode="numeric"
type="text"
maxlength="9"
>
<label class="form__label form__label--floating" for="${id}">
${id === 'startNum' ? 'Start #' : 'End #'}
</label>
</p>`
)
.join('')
}
</div>
<!-- Giveaway length / reminders / winners row -->
<div class="panel__body flex-row" style="justify-content:center; flex-wrap:wrap; gap:20px;">
<!-- giveaway length -->
<p class="form__group" style="width:28%;">
<input
class="form__text"
required
id="timerNum"
type="number"
inputmode="numeric"
min="1"
step="1"
value="5"
autocomplete="off"
>
<label class="form__label form__label--floating" for="timerNum">Time (min)</label>
</p>
<!-- reminders -->
<p class="form__group" style="width:28%;">
<input class="form__text" id="reminderNum" type="number" min="0" step="1" value="0" autocomplete="off">
<label class="form__label form__label--floating"># Reminders</label>
</p>
<!-- cadence label -->
<p class="form__group" style="width:28%;">
<input class="form__text" id="reminderEvery" readonly tabindex="-1" style="cursor:default;">
<label class="form__label form__label--floating">Every (min)</label>
</p>
</div>
<!-- winners + max winners row -->
<div class="panel__body flex-row giveaway-number-row giveaway-winners-row">
<p class="form__group giveaway-number-col">
<input
class="form__text"
type="number"
id="winnersNum"
min="1"
max="${MAX_WINNERS}"
step="1"
value="1"
>
<label class="form__label form__label--floating" for="winnersNum"># Winners</label>
</p>
<p class="form__group giveaway-number-col" id="maxScaledWinnersGroup" style="display:none;">
<input
class="form__text"
type="number"
id="maxScaledWinnersNum"
title="Hard cap: ${MAX_WINNERS}. Scaling can’t exceed this."
min="1"
max="${MAX_WINNERS}"
step="1"
value="1"
disabled
>
<label class="form__label form__label--floating" for="maxScaledWinnersNum" title="Hard cap: ${MAX_WINNERS}. Scaling can’t exceed this.">Max Winners</label>
<small id="maxScaledWinnersError" class="giveaway-inline-error" aria-live="polite"></small>
</p>
<p class="form__group giveaway-number-col" id="scaleBonPerWinnerGroup" style="display:none;">
<input
class="form__text"
type="number"
id="scaleBonPerWinnerNum"
title="BON sponsored per extra winner. Leave empty to auto-calculate from pot size."
min="1"
step="1"
placeholder="auto"
disabled
>
<label class="form__label form__label--floating" for="scaleBonPerWinnerNum" title="BON per additional winner via sponsorship.">BON/Winner</label>
</p>
</div>
<div class="panel__body giveaway-custom-message-row" style="display:flex;justify-content:center;gap:20px;width:100%;">
<p class="form__group" style="width:100%;">
<input
class="form__text"
id="customMessage"
type="text"
maxlength="100"
placeholder="Max 100 chars"
value="${DEFAULT_CUSTOM_MESSAGE}"
>
<label class="form__label form__label--floating" for="customMessage">
Custom Message
</label>
</p>
</div>
<p class="form__group" style="text-align:center;">
<div style="display:flex; align-items:center; justify-content:center; gap:10px; margin-bottom:8px;">
<label style="font-size:11px; color:#aaa; white-space:nowrap;">Schedule start:</label>
<input class="form__text" id="scheduleTime" type="time" style="width:auto; padding:3px 6px; font-size:12px;" title="Optional: set a time to auto-start the giveaway">
<input class="form__text" id="scheduleDate" type="date" style="width:auto; padding:3px 6px; font-size:12px;" title="Optional: set a date to auto-start the giveaway">
</div>
<button
type="button"
id="startButton"
class="form__button form__button--filled"
style="background-color:#02B008;"
>
Start
</button>
<div id="scheduleStatus" style="font-size:11px; color:#ffc00a; margin-top:4px; display:none;"></div>
</p>
</form>
<!-- Countdown timer below the form, full width -->
<h2 id="countdownHeader" class="panel__heading--centered" hidden
style="display:block; width:100%; margin-top:10px; margin-bottom:10px; text-align:center;">
</h2>
<!-- Entries table below the countdown -->
<div id="entriesWrapper" class="data-table-wrapper" hidden
style="width:100%; overflow-x:auto; margin-top:10px;">
<table id="entriesTable" class="data-table" style="width:100%; border-collapse:collapse; table-layout:fixed;">
<thead><tr><th>User</th><th>Entry #</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Winners / payout status -->
<div id="winnersWrapper" class="data-table-wrapper" hidden
style="width:100%; overflow-x:auto; margin-top:6px;">
<table id="winnersTable" class="data-table" style="width:100%; border-collapse:collapse; table-layout:fixed;">
<thead>
<tr>
<th>Winner</th>
<th>Prize BON</th>
<th>Gift</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="giveawayLogPanel" class="data-table-wrapper" style="width:100%; margin-top:10px; display:none;">
<h3 style="margin:0 0 6px 0; color:#ddd; font-size:14px;">Giveaway Log</h3>
<pre id="giveawayLogContent" style="margin:0; max-height:160px; overflow:auto; background:#1f1f1f; color:#cfcfcf; border:1px solid #444; border-radius:4px; padding:8px; white-space:pre-wrap; word-break:break-word;">No events yet.</pre>
<div style="display:flex; gap:8px; margin-top:8px;">
<button type="button" id="copyGiveawayLogButton" class="form__button form__button--filled">Copy log</button>
<button type="button" id="clearGiveawayLogButton" class="form__button form__button--filled" style="background:#7d3333;">Clear log</button>
</div>
</div>
</div>
<!-- SETTINGS MENU -->
<div id="giveaway_settings_menu" class="giveaway_settings_menu" style="display:none">
<div class="settings-menu-content">
<div class="settings-group" aria-label="Entry Modes" data-settings-group="entry-modes" data-toggle-ids="randomToggle,luckyToggle,freeToggle">
<div class="settings-group__header">
<p class="settings-group__title">Entry Modes</p>
<button type="button" class="form__button form__button--filled settings-section-toggle" data-settings-toggle="entry-modes" title="Toggle all options in Entry Modes only.">Toggle all</button>
</div>
${[
{ label: 'Random', id: 'randomToggle', tip: 'Enable !random (enter with a random free number).' },
{ label: 'Lucky', id: 'luckyToggle', tip: 'Enable !lucky (show lucky #) and !luckye (enter with lucky #).' },
{ label: 'Free', id: 'freeToggle', tip: 'Enable !free (show some available numbers).' }
].map(({ label, id, tip }) => `
<label class="settings-row" title="${tip}" for="${id}">
<span class="settings-row__label">${label}</span>
<input
type="checkbox"
id="${id}"
title="${tip}"
class="settings-row__toggle"
checked
>
</label>`).join('')}
</div>
<div class="settings-group" aria-label="Chat and Replies" data-settings-group="chat-replies" data-toggle-ids="entryrepliesToggle,silentmodeToggle">
<div class="settings-group__header">
<p class="settings-group__title">Chat & Replies</p>
<button type="button" class="form__button form__button--filled settings-section-toggle" data-settings-toggle="chat-replies" title="Toggle all options in Chat & Replies only.">Toggle all</button>
</div>
${[
{ label: 'Entry Replies', id: 'entryrepliesToggle', tip: 'When enabled, the bot replies when an entry is logged. Disable to reduce chat spam.' },
{ label: 'Silent Mode', id: 'silentmodeToggle', tip: 'When enabled, command replies are sent privately via /msg instead of public chat.' }
].map(({ label, id, tip }) => `
<label class="settings-row" title="${tip}" for="${id}">
<span class="settings-row__label">${label}</span>
<input
type="checkbox"
id="${id}"
title="${tip}"
class="settings-row__toggle"
checked
>
</label>`).join('')}
</div>
<div class="settings-group" aria-label="Scaling and Rules" data-settings-group="scaling-rules" data-toggle-ids="scaleWinnersToggle,rigModeToggle,showgiveawaylogToggle">
<div class="settings-group__header">
<p class="settings-group__title">Scaling & Rules</p>
<button type="button" class="form__button form__button--filled settings-section-toggle" data-settings-toggle="scaling-rules" title="Toggle all options in Scaling & Rules only.">Toggle all</button>
</div>
${[
{ label: 'Scale Winners', id: 'scaleWinnersToggle', tip: 'When enabled, winners may increase based on sponsorship BON (up to the max set in the giveaway form).' },
{ label: 'Rigged mode (visual only)', id: 'rigModeToggle', tip: 'Rigged mode is purely cosmetic… allegedly.' },
{ label: 'Show Giveaway Log', id: 'showgiveawaylogToggle', tip: 'Only controls Giveaway Log panel visibility. Logging still continues in the background.' }
].map(({ label, id, tip }) => `
<label class="settings-row" title="${tip}" for="${id}">
<span class="settings-row__label">${label}</span>
<input
type="checkbox"
id="${id}"
title="${tip}"
class="settings-row__toggle"
checked
>
</label>`).join('')}
</div>
</div>
</div>
<!-- COMMANDS MENU -->
<div id="giveaway_commands_menu" class="commands-menu" style="display:none">
<ul class="commands-list">
<li class="section-label">General Commands</li>
<li><code>!time </code> <span class="desc">Show remaining time</span></li>
<li><code>!entries </code> <span class="desc">List all entries</span></li>
<li><code>!free </code> <span class="desc">Show free numbers</span></li>
<li><code>!number </code> <span class="desc">Show your entry</span></li>
<li><code>!random </code> <span class="desc">Enter with a random #</span></li>
<li><code>!lucky </code> <span class="desc">Show lucky number</span></li>
<li><code>!luckye </code> <span class="desc">Enter with lucky #</span></li>
<li><code>!bon </code> <span class="desc">Show pot amount</span></li>
<li><code>!range </code> <span class="desc">Show valid range</span></li>
<li><code>!scale </code> <span class="desc">Show scaling progress</span></li>
<li><code>!rig/!unrig </code> <span class="desc">Toggle rigging (fun)</span></li>
<li><code>!help </code> <span class="desc">Show this list in chat</span></li>
<li><code>!stats [user]</code> <span class="desc">Show saved stats</span></li>
<li><code>!top [N]</code> <span class="desc">Top winners (by wins)</span></li>
<li><code>!most [N]</code> <span class="desc">Most BON won (total)</span></li>
<li><code>!sponsors [N]</code> <span class="desc">Top sponsors</span></li>
<li><code>!unlucky [N]</code> <span class="desc">Most losses</span></li>
<li class="section-label">Host-Only Commands</li>
<li class="full-span">
<code>!time add N / remove N </code>
<span class="desc">Adjust remaining minutes</span>
</li>
<li><code>!reminder </code> <span class="desc">Send reminder msg</span></li>
<li><code>!addbon </code> <span class="desc">Add BON to pot</span></li>
<li><code>!winners N</code> <span class="desc">Set number of winners</span></li>
<li><code>!maxwinners N</code> <span class="desc">Set max scaled winners</span></li>
<li><code>!end </code> <span class="desc">End the giveaway</span></li>
<li><code>!naughty </code> <span class="desc">list/add/remove a user</span></li>
<li class="naughty-alert">
⚠⚠ !naughty excludes users from the giveaway entirely ⚠⚠ ************************USE RESPONSIBLY************************
</li>
</ul>
</div>
<!-- RIGGED WATERMARK (only visible in rigged mode) -->
<div class="rigged-watermark">RIGGED</div>
</section>
`;
const hostPanelHTML = `
<aside id="hostCommandPanel" class="host-command-panel" aria-hidden="true">
<div id="hostCommandPanelHandle" class="host-command-panel__handle" title="Drag to move Host Panel">
<span class="host-command-panel__handle-title">Host Panel</span>
<button id="hostPanelCloseBtn" type="button" class="form__button form__button--text host-command-panel__close" title="Close Host Panel" aria-label="Close Host Panel">×</button>
</div>
<div id="hostCommandPanelBody" class="host-command-panel__body"></div>
</aside>
`;
const baseMenuStyle = `
background-color: #2C2C2C;
color: #CCC;
border-radius: 5px;
position: absolute;
top: 100px;
right: 10px;
z-index: 10020;
padding: 15px;
overflow: auto;
flex-direction: column;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
pointer-events: auto;
`;
// Settings menu CSS styles
const settingsMenuStyle = `
.giveaway_settings_menu {
${baseMenuStyle}
width: 280px;
height: auto;
max-height: 72vh;
}
.giveaway_settings_menu > div {
margin: 0;
}
.giveaway_settings_menu .settings-menu-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.giveaway_settings_menu .settings-group {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 7px;
}
.giveaway_settings_menu .settings-group:last-of-type {
border-bottom: 0;
padding-bottom: 0;
}
.giveaway_settings_menu .settings-group__title {
margin: 0 0 4px;
font-size: 12px;
color: #ffa200;
font-weight: 700;
}
.giveaway_settings_menu .settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0;
padding: 3px 0;
}
.giveaway_settings_menu .settings-row__label {
color: #d7d7d7;
font-size: 13px;
line-height: 1.3;
}
.giveaway_settings_menu .settings-row__toggle {
width: 15px;
height: 15px;
cursor: pointer;
flex-shrink: 0;
}
.giveaway_settings_menu .settings-group__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.giveaway_settings_menu .settings-section-toggle {
padding: 2px 8px;
min-height: 24px;
font-size: 11px;
line-height: 1;
}`;
// Commands menu CSS styles – shrink-wrap width, 2-column grid, section labels
const commandsMenuStyle = `
.commands-menu{
${baseMenuStyle}
width:max-content;
max-width:425px;
max-height:70vh;
}
/* ── compact two-column grid ───────────────────────────── */
.commands-menu .commands-list{
list-style:none;
padding:0;
margin:0;
display:grid;
grid-template-columns:max-content 1fr; /* code | description */
column-gap:5px;
row-gap:4px;
}
.commands-menu .full-span{
grid-column: 1 / -1; /* occupy the whole row */
}
/* left column (command keyword) */
.commands-menu code{
font-family:inherit;
font-weight:600;
color:#ffb84d;
font-size:14px;
white-space:nowrap;
}
/* right column (description) */
.commands-menu .desc{
color:#d0d0d0; /* dimmer grey */
font-size:13px;
}
/* orange section headers that span both columns */
.commands-menu .section-label{
grid-column:1 / -1;
margin:6px 0 2px;
font-size:14px;
font-weight:700;
color:#ffa200;
border-bottom:1px solid #555;
}