Compare commits

..

14 commits

Author SHA1 Message Date
Nuno Duque Nunes
be84fa2fb6 fix ayana GL to DM in update poll example 2026-06-12 04:32:01 +01:00
Nuno Duque Nunes
3dbf8c7cab feat: Updates/changelog system, BaseLayout shared functions, Leaves system, WRank delta fix 2026-06-12 04:29:00 +01:00
Nuno Duque Nunes
d2377ff404 feat: BaseLayout shared functions, WRank delta placeholder fix, Leaves system 2026-06-11 23:53:06 +01:00
Nuno Duque Nunes
a0876f0af8 remove wrank numbers, use simple numbers for cockroach system for leaves 2026-06-11 05:23:37 +01:00
Nuno Duque Nunes
fd1b8ed50c add leave system, add cockroach on players that leave 2026-06-11 05:17:29 +01:00
Nuno Duque Nunes
347d1423fc fix format.char() 2026-06-11 04:44:20 +01:00
Nuno Duque Nunes
ed9e7209d0 feat: WRankEntry hydration, Nation enum keys, Config section access, Cron system rework fix (remove old), WRank delta fix with lastRankChangeAt, midnight snapshot cron 2026-06-11 04:29:17 +01:00
Nuno Duque Nunes
17ff1d932f feat: nested Config system with section/key access pattern, Discord API abstraction start 2026-06-11 02:42:30 +01:00
Nuno Duque Nunes
1911cbe225 feat: Runtime lifecycle system, poll layout persistence 2026-06-10 04:44:57 +01:00
Nuno Duque Nunes
2cde01e633 feat: UI layout system, Config namespace, Bootstrap phases design, Logger/Benchmark 2026-06-10 04:20:03 +01:00
Nuno Duque Nunes
3c4aed93df big architectural changes, add Attendance/Score/TG/Registry/Scheduler systems, logger & benchmarker, tg-admin command 2026-06-09 23:13:21 +01:00
Nuno Duque Nunes
61bb590c87 fix history overwritten for same userKey with different chars 2026-06-05 03:51:36 +01:00
Nuno Duque Nunes
e40594e107 fix wrank deltas sync, fix wrank no rank display unalignment 2026-06-05 03:07:51 +01:00
Nuno Duque Nunes
ce92315b48 remove cached files 2026-06-05 01:43:55 +01:00
171 changed files with 6573 additions and 1609 deletions

13
.gitignore vendored
View file

@ -1,6 +1,9 @@
# Dependencies # Dependencies
node_modules/ node_modules/
docker-compose.yml
package-lock.json
# Environment variables — never commit these # Environment variables — never commit these
.env .env
@ -8,16 +11,24 @@ node_modules/
data/config.json data/config.json
data/characters.json data/characters.json
data/accounts.json data/accounts.json
data/poll-state.json
data/usermap.json data/usermap.json
data/wrank.json data/wrank.json
data/bringer.json data/bringer.json
data/attendance.json
data/leaves.json
data/sessionPreferences.json data/sessionPreferences.json
data/tg-history/ data/tg-history/
data/poll-state.json
# Emoji data # Emoji data
emoji-uploads/ emoji-uploads/
# Scripts
scripts/
# Tests
tests/
# Keep the data directory structure but not the contents # Keep the data directory structure but not the contents
!data/.gitkeep !data/.gitkeep
!data/tg-history/.gitkeep !data/tg-history/.gitkeep

View file

View file

@ -1 +0,0 @@
{}

View file

@ -1,5 +0,0 @@
{
"currentWeek": "",
"capella": null,
"procyon": null
}

View file

@ -1,5 +0,0 @@
{
"showLevelInMessages": true,
"showClassInMessages": true,
"confirmYesMessage": "⚔️ TG is confirmed for tonight!"
}

11
data/emojis/classes.json Normal file
View file

@ -0,0 +1,11 @@
{
"bl": "<:bl:1511906439516651561>",
"dm": "<:dm:1511906450866180126>",
"fa": "<:fa:1511906454506967242>",
"fb": "<:fb:1511906458231377950>",
"fg": "<:fg:1511906461977022605>",
"fs": "<:fs:1511906465798029423>",
"gl": "<:gl:1511906470684524594>",
"wa": "<:wa:1511906499889467492>",
"wi": "<:wi:1511906503647563807>"
}

14
data/emojis/misc.json Normal file
View file

@ -0,0 +1,14 @@
{
"borrowed": "<:borrowed:1511906443245391944>",
"capella": "<:capella:1511906447167062137>",
"kd": "<:kd:1511906474497146983>",
"luminous_bringer": "<:luminous_bringer:1511906480184492263>",
"procyon": "<:procyon:1511906483993055295>",
"rank": "<:rank:1511906488380293180>",
"score": "<:score:1511906491903250525>",
"storm_bringer": "<:storm_bringer:1511906496097554594>",
"wrank_down": "<:wrank_down:1511906547104616643>",
"wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>",
"wrank_up": "<:wrank_up:1512114414474756132>",
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>"
}

102
data/emojis/wrank-down.json Normal file
View file

@ -0,0 +1,102 @@
{
"wrank_down_1": "<:wrank_down_1:1512124970698801244>",
"wrank_down_2": "<:wrank_down_2:1512125016114729133>",
"wrank_down_3": "<:wrank_down_3:1512125023199166536>",
"wrank_down_4": "<:wrank_down_4:1512125027372237040>",
"wrank_down_5": "<:wrank_down_5:1512125030765691072>",
"wrank_down_10": "<:wrank_down_10:1512124974582989000>",
"wrank_down_11": "<:wrank_down_11:1512124978504536114>",
"wrank_down_12": "<:wrank_down_12:1512124982728069192>",
"wrank_down_13": "<:wrank_down_13:1512124987501314150>",
"wrank_down_14": "<:wrank_down_14:1512124991292837918>",
"wrank_down_15": "<:wrank_down_15:1512124995340468335>",
"wrank_down_16": "<:wrank_down_16:1512124999576850462>",
"wrank_down_17": "<:wrank_down_17:1512125004353896642>",
"wrank_down_18": "<:wrank_down_18:1512125008132964486>",
"wrank_down_19": "<:wrank_down_19:1512125011857510410>",
"wrank_down_20": "<:wrank_down_20:1512125019814101173>",
"wrank_down_6": "<:wrank_down_6:1512125035123576922>",
"wrank_down_7": "<:wrank_down_7:1512125039091126434>",
"wrank_down_8": "<:wrank_down_8:1512125042757210123>",
"wrank_down_9": "<:wrank_down_9:1512125047798759706>",
"wrank_down_100": "<:wrank_down_100:1513360157923475496>",
"wrank_down_21": "<:wrank_down_21:1513360161446559745>",
"wrank_down_22": "<:wrank_down_22:1513360165443993720>",
"wrank_down_23": "<:wrank_down_23:1513360169453752380>",
"wrank_down_24": "<:wrank_down_24:1513360173241077762>",
"wrank_down_25": "<:wrank_down_25:1513360177087254620>",
"wrank_down_26": "<:wrank_down_26:1513360181054935134>",
"wrank_down_27": "<:wrank_down_27:1513360184758505673>",
"wrank_down_28": "<:wrank_down_28:1513360188353282100>",
"wrank_down_29": "<:wrank_down_29:1513360191826038966>",
"wrank_down_30": "<:wrank_down_30:1513360195387129896>",
"wrank_down_31": "<:wrank_down_31:1513360198981648434>",
"wrank_down_32": "<:wrank_down_32:1513360202492149861>",
"wrank_down_33": "<:wrank_down_33:1513360206166360146>",
"wrank_down_34": "<:wrank_down_34:1513360209790242887>",
"wrank_down_35": "<:wrank_down_35:1513360214429007954>",
"wrank_down_36": "<:wrank_down_36:1513360218250022984>",
"wrank_down_37": "<:wrank_down_37:1513360223484510218>",
"wrank_down_38": "<:wrank_down_38:1513360226873512078>",
"wrank_down_39": "<:wrank_down_39:1513360230606442557>",
"wrank_down_40": "<:wrank_down_40:1513360234469523547>",
"wrank_down_41": "<:wrank_down_41:1513360238399586314>",
"wrank_down_42": "<:wrank_down_42:1513360241834594356>",
"wrank_down_43": "<:wrank_down_43:1513360245609730109>",
"wrank_down_44": "<:wrank_down_44:1513360248667246805>",
"wrank_down_45": "<:wrank_down_45:1513360253025124453>",
"wrank_down_46": "<:wrank_down_46:1513360256946929715>",
"wrank_down_47": "<:wrank_down_47:1513360261128654971>",
"wrank_down_48": "<:wrank_down_48:1513360265935192104>",
"wrank_down_49": "<:wrank_down_49:1513360269768654888>",
"wrank_down_50": "<:wrank_down_50:1513360273468297438>",
"wrank_down_51": "<:wrank_down_51:1513360277117337770>",
"wrank_down_52": "<:wrank_down_52:1513360280808067172>",
"wrank_down_53": "<:wrank_down_53:1513360284033618092>",
"wrank_down_54": "<:wrank_down_54:1513360288081117284>",
"wrank_down_55": "<:wrank_down_55:1513360291398946868>",
"wrank_down_56": "<:wrank_down_56:1513360295630868550>",
"wrank_down_57": "<:wrank_down_57:1513360299078713447>",
"wrank_down_58": "<:wrank_down_58:1513360303360970896>",
"wrank_down_59": "<:wrank_down_59:1513360306993369229>",
"wrank_down_60": "<:wrank_down_60:1513360310583427092>",
"wrank_down_61": "<:wrank_down_61:1513360314203246773>",
"wrank_down_62": "<:wrank_down_62:1513360318410002522>",
"wrank_down_63": "<:wrank_down_63:1513360322268893296>",
"wrank_down_64": "<:wrank_down_64:1513360325771137095>",
"wrank_down_65": "<:wrank_down_65:1513360329676034068>",
"wrank_down_66": "<:wrank_down_66:1513360333518143668>",
"wrank_down_67": "<:wrank_down_67:1513360337112662026>",
"wrank_down_68": "<:wrank_down_68:1513360340635877467>",
"wrank_down_69": "<:wrank_down_69:1513360344318349352>",
"wrank_down_70": "<:wrank_down_70:1513360347925319862>",
"wrank_down_71": "<:wrank_down_71:1513360352023285770>",
"wrank_down_72": "<:wrank_down_72:1513360356070785066>",
"wrank_down_73": "<:wrank_down_73:1513360359803846716>",
"wrank_down_74": "<:wrank_down_74:1513360363595239596>",
"wrank_down_75": "<:wrank_down_75:1513360367538016410>",
"wrank_down_76": "<:wrank_down_76:1513360372302745721>",
"wrank_down_77": "<:wrank_down_77:1513360376035545328>",
"wrank_down_78": "<:wrank_down_78:1513360379810545755>",
"wrank_down_79": "<:wrank_down_79:1513360383451201707>",
"wrank_down_80": "<:wrank_down_80:1513360387012296895>",
"wrank_down_81": "<:wrank_down_81:1513360390573002872>",
"wrank_down_82": "<:wrank_down_82:1513360393983234242>",
"wrank_down_83": "<:wrank_down_83:1513360398076743792>",
"wrank_down_84": "<:wrank_down_84:1513360401763405947>",
"wrank_down_85": "<:wrank_down_85:1513360405681016843>",
"wrank_down_86": "<:wrank_down_86:1513360409929973812>",
"wrank_down_87": "<:wrank_down_87:1513360413985865828>",
"wrank_down_88": "<:wrank_down_88:1513360417802420395>",
"wrank_down_89": "<:wrank_down_89:1513360421430497441>",
"wrank_down_90": "<:wrank_down_90:1513360424882667554>",
"wrank_down_91": "<:wrank_down_91:1513360428900548628>",
"wrank_down_92": "<:wrank_down_92:1513360432856043550>",
"wrank_down_93": "<:wrank_down_93:1513360436706283530>",
"wrank_down_94": "<:wrank_down_94:1513360440879747112>",
"wrank_down_95": "<:wrank_down_95:1513360445728096456>",
"wrank_down_96": "<:wrank_down_96:1513360449276477509>",
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
"wrank_down_99": "<:wrank_down_99:1513360463558082590>"
}

View file

@ -0,0 +1,22 @@
{
"wrank_1_gold": "<:wrank_1_gold:1512125051728560278>",
"wrank_2_gold": "<:wrank_2_gold:1512125095974535271>",
"wrank_3_gold": "<:wrank_3_gold:1512125103964684390>",
"wrank_4_gold": "<:wrank_4_gold:1512125108154663133>",
"wrank_5_gold": "<:wrank_5_gold:1512125112084594818>",
"wrank_10_gold": "<:wrank_10_gold:1512125055432396910>",
"wrank_11_gold": "<:wrank_11_gold:1512125059123249242>",
"wrank_12_gold": "<:wrank_12_gold:1512125063304974438>",
"wrank_13_gold": "<:wrank_13_gold:1512125067201609908>",
"wrank_14_gold": "<:wrank_14_gold:1512125071043596520>",
"wrank_15_gold": "<:wrank_15_gold:1512125074893832443>",
"wrank_16_gold": "<:wrank_16_gold:1512125078966374420>",
"wrank_17_gold": "<:wrank_17_gold:1512125083605532715>",
"wrank_18_gold": "<:wrank_18_gold:1512125088378392718>",
"wrank_19_gold": "<:wrank_19_gold:1512125091960459508>",
"wrank_20_gold": "<:wrank_20_gold:1512125100265181204>",
"wrank_6_gold": "<:wrank_6_gold:1512125115956203601>",
"wrank_7_gold": "<:wrank_7_gold:1512125120204771338>",
"wrank_8_gold": "<:wrank_8_gold:1512125123874918661>",
"wrank_9_gold": "<:wrank_9_gold:1512125128299905104>"
}

View file

@ -0,0 +1,5 @@
{
"wrank_neutral": "<:wrank_neutral:1511950713713070160>",
"wrank_no_dash": "<:wrank_no_dash:1511956379403943979>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>"
}

102
data/emojis/wrank-up.json Normal file
View file

@ -0,0 +1,102 @@
{
"wrank_up_1": "<:wrank_up_1:1512125132242554890>",
"wrank_up_10": "<:wrank_up_10:1512125136445243503>",
"wrank_up_2": "<:wrank_up_2:1512127569259135139>",
"wrank_up_3": "<:wrank_up_3:1512127577051893843>",
"wrank_up_4": "<:wrank_up_4:1512127582068281455>",
"wrank_up_6": "<:wrank_up_6:1512127590062620723>",
"wrank_up_7": "<:wrank_up_7:1512127593883766978>",
"wrank_up_8": "<:wrank_up_8:1512127598044643428>",
"wrank_up_9": "<:wrank_up_9:1512127601811128501>",
"wrank_up_5": "<:wrank_up_5:1512127585826377928>",
"wrank_up_11": "<:wrank_up_11:1512125140454998018>",
"wrank_up_12": "<:wrank_up_12:1512125144984719630>",
"wrank_up_13": "<:wrank_up_13:1512125149057388667>",
"wrank_up_14": "<:wrank_up_14:1512125153671123124>",
"wrank_up_15": "<:wrank_up_15:1512127541995901100>",
"wrank_up_16": "<:wrank_up_16:1512127545753993439>",
"wrank_up_17": "<:wrank_up_17:1512127549956821002>",
"wrank_up_18": "<:wrank_up_18:1512127553995931959>",
"wrank_up_19": "<:wrank_up_19:1512127558143971534>",
"wrank_up_20": "<:wrank_up_20:1512127573092597840>",
"wrank_up_100": "<:wrank_up_100:1513360467228364852>",
"wrank_up_21": "<:wrank_up_21:1513360471611408494>",
"wrank_up_22": "<:wrank_up_22:1513360476015296603>",
"wrank_up_23": "<:wrank_up_23:1513360479433785436>",
"wrank_up_24": "<:wrank_up_24:1513360483351007355>",
"wrank_up_25": "<:wrank_up_25:1513360487096651876>",
"wrank_up_26": "<:wrank_up_26:1513360490334654555>",
"wrank_up_27": "<:wrank_up_27:1513360494264713409>",
"wrank_up_28": "<:wrank_up_28:1513360497972609175>",
"wrank_up_29": "<:wrank_up_29:1513360501730709746>",
"wrank_up_30": "<:wrank_up_30:1513360505530745004>",
"wrank_up_31": "<:wrank_up_31:1513360508819079350>",
"wrank_up_32": "<:wrank_up_32:1513360512749142086>",
"wrank_up_33": "<:wrank_up_33:1513360516695986206>",
"wrank_up_34": "<:wrank_up_34:1513360521850785993>",
"wrank_up_35": "<:wrank_up_35:1513360525508087900>",
"wrank_up_36": "<:wrank_up_36:1513360529278632009>",
"wrank_up_37": "<:wrank_up_37:1513360533745569882>",
"wrank_up_38": "<:wrank_up_38:1513360537235226804>",
"wrank_up_39": "<:wrank_up_39:1513360541228470474>",
"wrank_up_40": "<:wrank_up_40:1513360545078579290>",
"wrank_up_41": "<:wrank_up_41:1513360549411422288>",
"wrank_up_42": "<:wrank_up_42:1513360552896888904>",
"wrank_up_43": "<:wrank_up_43:1513360557485326397>",
"wrank_up_44": "<:wrank_up_44:1513360561348280450>",
"wrank_up_45": "<:wrank_up_45:1513360564905312286>",
"wrank_up_46": "<:wrank_up_46:1513360568445046914>",
"wrank_up_47": "<:wrank_up_47:1513360571964067940>",
"wrank_up_48": "<:wrank_up_48:1513360575776817353>",
"wrank_up_49": "<:wrank_up_49:1513360579501232189>",
"wrank_up_50": "<:wrank_up_50:1513360583519371364>",
"wrank_up_51": "<:wrank_up_51:1513360586732343471>",
"wrank_up_52": "<:wrank_up_52:1513360590641299568>",
"wrank_up_53": "<:wrank_up_53:1513360594051399731>",
"wrank_up_54": "<:wrank_up_54:1513360597981335572>",
"wrank_up_55": "<:wrank_up_55:1513360601567723641>",
"wrank_up_56": "<:wrank_up_56:1513360605560438825>",
"wrank_up_57": "<:wrank_up_57:1513360609725382798>",
"wrank_up_58": "<:wrank_up_58:1513360613517037669>",
"wrank_up_59": "<:wrank_up_59:1513360617397031084>",
"wrank_up_60": "<:wrank_up_60:1513360620865454122>",
"wrank_up_61": "<:wrank_up_61:1513360624720281630>",
"wrank_up_62": "<:wrank_up_62:1513360628528447592>",
"wrank_up_63": "<:wrank_up_63:1513360632517365870>",
"wrank_up_64": "<:wrank_up_64:1513360635713552466>",
"wrank_up_65": "<:wrank_up_65:1513360639404544094>",
"wrank_up_66": "<:wrank_up_66:1513360643867021464>",
"wrank_up_67": "<:wrank_up_67:1513360647809667092>",
"wrank_up_68": "<:wrank_up_68:1513360652750684200>",
"wrank_up_69": "<:wrank_up_69:1513360656626094140>",
"wrank_up_70": "<:wrank_up_70:1513360660531118330>",
"wrank_up_71": "<:wrank_up_71:1513360663987355710>",
"wrank_up_72": "<:wrank_up_72:1513360668462551100>",
"wrank_up_73": "<:wrank_up_73:1513360672740737104>",
"wrank_up_74": "<:wrank_up_74:1513360676666478723>",
"wrank_up_75": "<:wrank_up_75:1513360680399409293>",
"wrank_up_76": "<:wrank_up_76:1513360683914367116>",
"wrank_up_77": "<:wrank_up_77:1513360688045887672>",
"wrank_up_78": "<:wrank_up_78:1513360691443138684>",
"wrank_up_79": "<:wrank_up_79:1513360695972987061>",
"wrank_up_80": "<:wrank_up_80:1513360699793997875>",
"wrank_up_81": "<:wrank_up_81:1513360703560618136>",
"wrank_up_82": "<:wrank_up_82:1513360708899967116>",
"wrank_up_83": "<:wrank_up_83:1513360712393560214>",
"wrank_up_84": "<:wrank_up_84:1513360716101582910>",
"wrank_up_85": "<:wrank_up_85:1513360720094302329>",
"wrank_up_86": "<:wrank_up_86:1513360724091473950>",
"wrank_up_87": "<:wrank_up_87:1513360728390897896>",
"wrank_up_88": "<:wrank_up_88:1513360732249391104>",
"wrank_up_89": "<:wrank_up_89:1513360735923736606>",
"wrank_up_90": "<:wrank_up_90:1513360739530833950>",
"wrank_up_91": "<:wrank_up_91:1513360743402176604>",
"wrank_up_92": "<:wrank_up_92:1513360747600805918>",
"wrank_up_93": "<:wrank_up_93:1513360752197767329>",
"wrank_up_94": "<:wrank_up_94:1513360755494359161>",
"wrank_up_95": "<:wrank_up_95:1513360759197794385>",
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>"
}

22
data/emojis/wrank.json Normal file
View file

@ -0,0 +1,22 @@
{
"wrank_1": "<:wrank_1:1512124887592996995>",
"wrank_2": "<:wrank_2:1512124931075342376>",
"wrank_3": "<:wrank_3:1512124938453254334>",
"wrank_4": "<:wrank_4:1512124943465316433>",
"wrank_5": "<:wrank_5:1512124947852431513>",
"wrank_10": "<:wrank_10:1512124891250561096>",
"wrank_11": "<:wrank_11:1512124894694080576>",
"wrank_12": "<:wrank_12:1512124898611429387>",
"wrank_13": "<:wrank_13:1512124902831030282>",
"wrank_14": "<:wrank_14:1512124907511611537>",
"wrank_15": "<:wrank_15:1512124911550730452>",
"wrank_16": "<:wrank_16:1512124915367673886>",
"wrank_17": "<:wrank_17:1512124919029305434>",
"wrank_18": "<:wrank_18:1512124923018219721>",
"wrank_19": "<:wrank_19:1512124927262855239>",
"wrank_20": "<:wrank_20:1512124934762135684>",
"wrank_6": "<:wrank_6:1512124952738795581>",
"wrank_7": "<:wrank_7:1512124956622979143>",
"wrank_8": "<:wrank_8:1512124961450496020>",
"wrank_9": "<:wrank_9:1512124965363650631>"
}

View file

@ -0,0 +1,25 @@
{
"version": "v0.1",
"date": "2026-05-01",
"title": "Core Poll System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Poll creation with `/tg poll start` — opens voting for the upcoming TG", "emojiKey":null },
{ "text": "Poll scheduling with cronjobs — opens voting for the upcoming TGs at specific times", "emojiKey":null },
{ "text": "Yes/No voting with character display — class emoji, level and name", "emojiKey": "wi" },
{ "text": "Nation-separated fields — Capella and Procyon listed independently", "emojiKey": "capella" },
{ "text": "Public messages per player — leave a note when voting", "emojiKey": null },
{ "text": "Poll lock and confirm system — lock at TG start, confirm yes/no after", "emojiKey": null },
{ "text": "W.Rank display — rank number with gold variant for 7 TGs done", "emojiKey": "wrank_1_gold" },
{ "text": "Bringer display — Storm Bringer and Luminous Bringer indicators", "emojiKey": "storm_bringer" }
]
}
],
"examples": []
}

View file

@ -0,0 +1,34 @@
{
"version": "v0.2",
"date": "2026-05-10",
"title": "Character System & Impersonation",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Character management — `/tg char add/remove/set-active/set-nation`", "emojiKey": "active_char" },
{ "text": "Character sharing — lend your character to another player with `/tg char share`", "emojiKey": "borrowed" },
{ "text": "Character borrowing — request to play someone else's character", "emojiKey": "borrowed" },
{ "text": "Session borrows and persistent preferences across restarts", "emojiKey": "active_char" },
{ "text": "`/tg switch` — change your active character at any time", "emojiKey": "active_char" },
{ "text": "Impersonation system — officers can vote on behalf of players", "emojiKey": null },
{ "text": "Autocomplete for character names with nation emoji and shared indicator 🔗", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "Character data stored per user in `characters.json`", "emojiKey": null },
{ "text": "Borrow requests tracked with expiry timestamps", "emojiKey": null },
{ "text": "ID-first usermap lookup — survives Discord username changes", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,39 @@
{
"version": "v0.3",
"date": "2026-05-18",
"title": "Conflict Resolution",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Character conflict detection — warns when two players want the same character", "emojiKey": null },
{ "text": "Conflict embed with Reclaim and Switch buttons", "emojiKey": null },
{ "text": "Reclaim notifies the borrower via DM with a character selection prompt", "emojiKey": null },
{ "text": "Auto-vote on conflict switch — voting Yes automatically after switching", "emojiKey": "active_char" }
]
},
{
"type": "fix",
"label": "Bug Fixes",
"emoji": "🔧",
"items": [
{ "text": "Score overwrite fixed for shared characters", "emojiKey": null },
{ "text": "Bringer display corrected after weekly reset", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "Conflict resolution state machine with proper cleanup", "emojiKey": null },
{ "text": "Reclaim flow uses interaction tokens for ephemeral editing", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,38 @@
{
"version": "v0.4",
"date": "2026-05-25",
"title": "Companion System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Companion ephemeral — after voting Yes, shows your active character with switch buttons", "emojiKey": "active_char" },
{ "text": "Character switch buttons update the poll embed in real time", "emojiKey": "active_char" },
{ "text": "Switching to a taken character triggers the conflict resolution flow", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Interaction lock — prevents double-click issues on all buttons", "emojiKey": null },
{ "text": "Companion ephemeral updates in place instead of spawning a new message", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`EphemeralRegistry` — edit ephemeral messages via interaction token within 15 minute window", "emojiKey": null },
{ "text": "`InteractionLock` — prevents duplicate interaction processing", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,82 @@
{
"slot": 20,
"locked": true,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45"
},
{
"userKey": "keira",
"displayName": "Keira",
"characterName": "«Keira»",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:46"
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45"
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "DM",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:46"
}
],
"no": [],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "«Keira»",
"userKey": "keira",
"nation": "Capella",
"currentRank": 2,
"previousRank": 1,
"weeklyPoints": 5600,
"tgCount": 4
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,47 @@
{
"version": "v0.5",
"date": "2026-06-01",
"title": "W.Rank Improvements",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "W.Rank delta system — tracks rank movement with <:wrank_up:1512114414474756132><:wrank_down:1511906547104616643> and grey placeholder for unchanged", "emojiKey": "wrank_up" },
{ "text": "Midnight snapshot — after 24 hours of no rank change, delta resets to ( - 0 )", "emojiKey": "wrank_1" },
{ "text": "Weekly reset carries Bringer forward — W.Rank 1 with goal TGs becomes next week's Bringer", "emojiKey": "storm_bringer" },
{ "text": "No-rank placeholder alignment — players without W.Rank align with those who have it", "emojiKey": "wrank_no_dash" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "W.Rank now tracked per character, not per player — borrowing a character preserves its rank", "emojiKey": "wrank_2_gold" },
{ "text": "Bringer validation — must be W.Rank 1 AND have 7 TGs, otherwise no Bringer this week", "emojiKey": "luminous_bringer" }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`lastRankChangeAt` timestamp on W.Rank entries — drives the 24h snapshot window", "emojiKey": null },
{ "text": "W.Rank keys migrated from lowercase `capella/procyon` to `Nation` enum values", "emojiKey": null },
{ "text": "`WRankEntry` hydration — runtime entries carry full `Character` object, not just flat fields", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "W.Rank delta display — rank movement since last TG",
"type": "poll",
"layout": "default",
"file": "examples/poll-wrank.json"
}
]
}

View file

@ -0,0 +1,40 @@
{
"version": "v0.6",
"date": "2026-06-05",
"title": "Administration & Score Submission",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "`/tg-admin user map/unmap/list` — register Discord accounts to player profiles", "emojiKey": null },
{ "text": "`/tg-admin poll fix-voter` — correct stale poll entries after a restart", "emojiKey": null },
{ "text": "Score submission via Submit Score button after TG ends", "emojiKey": null },
{ "text": "`/tg score get` — retrieve your score for a specific TG slot", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "ID-first usermap lookup — account links survive Discord username changes", "emojiKey": null },
{ "text": "`UserRegistry` — centralized user identity with caching", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`CharacterRegistry` — cached character lookups across all users", "emojiKey": null },
{ "text": "`Attendance` system — snapshots who attended each TG at lock time", "emojiKey": null },
{ "text": "`Score` namespace — centralized score submission and retrieval", "emojiKey": null }
]
}
],
"examples": []
}

View file

@ -0,0 +1,116 @@
{
"slot": 20,
"locked": false,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45",
"publicMessage": "Dey is in, bow down."
},
{
"userKey": "keira",
"displayName": "Keira",
"characterName": "«Keira»",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:46",
"publicMessage": "Keira is in."
},
{
"userKey": "zephyr",
"displayName": "Zephyr",
"characterName": "XefronYokuda",
"characterClass": "FA",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:47",
"publicMessage": "Legend is in."
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45",
"publicMessage": "Flash? Flash? Flash!!"
},
{
"userKey": "invicjusz",
"displayName": "Vic",
"characterName": "«Flash»",
"characterClass": "FB",
"characterLevel": 79,
"characterNation": "Procyon",
"borrowedFrom": "flash",
"votedAt": "19:46",
"publicMessage": "Vic is in."
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "DM",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:47",
"publicMessage": "Ayana is in, get your silence pots ready!"
}
],
"no": [],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "«Keira»",
"userKey": "keira",
"nation": "Capella",
"currentRank": 2,
"previousRank": 1,
"weeklyPoints": 5600,
"tgCount": 4
},
{
"characterName": "XefronYokuda",
"userKey": "zephyr",
"nation": "Capella",
"currentRank": 3,
"previousRank": 3,
"weeklyPoints": 2867,
"tgCount": 5
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,50 @@
{
"version": "v0.7",
"date": "2026-06-08",
"title": "UI Layout System",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "Poll layout system — multiple display styles, switchable via `/tg-config poll set-layout`", "emojiKey": null },
{ "text": "`default` layout — standard vertical nation fields", "emojiKey": null },
{ "text": "`side-by-side` layout — nations displayed inline, auto-stacks when >5 players per nation", "emojiKey": null },
{ "text": "Layout persists across bot restarts", "emojiKey": null }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Voting Yes and No now run in parallel — faster poll embed updates", "emojiKey": null },
{ "text": "Autocomplete filtered by nation for Bringer set command", "emojiKey": null },
{ "text": "Character class now carries full name and emoji — `Force Blader`, `Wizard`, etc.", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`BaseLayout` — shared functions inherited by all layouts, override only what differs", "emojiKey": null },
{ "text": "Layout auto-discovery — drop a file in `layouts/` and it registers automatically", "emojiKey": null },
{ "text": "`Character` type now carries `ownerKey` and full `CharacterClass` object", "emojiKey": null },
{ "text": "`SerializableCharacter` — clean JSON serialization boundary, runtime uses rich types", "emojiKey": null },
{ "text": "`Nation` converted to string enum — serializes cleanly, exhaustiveness checked by TypeScript", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "side-by-side layout — nations displayed inline",
"type": "poll",
"layout": "side-by-side",
"file": "examples/poll-side-by-side.json"
}
]
}

View file

@ -0,0 +1,72 @@
{
"slot": 20,
"locked": true,
"confirmed": null,
"yes": [
{
"userKey": "dey",
"displayName": "Dey",
"characterName": "«Deystroyer»",
"characterClass": "BL",
"characterLevel": 79,
"characterNation": "Capella",
"votedAt": "19:45",
"publicMessage": "Dey is in."
},
{
"userKey": "flash",
"displayName": "Flash",
"characterName": "»Flash«",
"characterClass": "WI",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:45",
"publicMessage": "Flash is having technical issues..."
},
{
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "GL",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:46"
}
],
"no": [],
"leaves": [
{
"characterName": "»Flash«",
"historyKey": "2026-06-11-20"
}
],
"wrank": [
{
"characterName": "«Deystroyer»",
"userKey": "dey",
"nation": "Capella",
"currentRank": 1,
"previousRank": 2,
"weeklyPoints": 7172,
"tgCount": 5
},
{
"characterName": "»Flash«",
"userKey": "flash",
"nation": "Procyon",
"currentRank": 1,
"previousRank": 1,
"weeklyPoints": 11383,
"tgCount": 5
},
{
"characterName": "«MonkeyHunter»",
"userKey": "ayana",
"nation": "Procyon",
"currentRank": 2,
"previousRank": 2,
"weeklyPoints": 6664,
"tgCount": 4
}
]
}

View file

@ -0,0 +1,53 @@
{
"version": "v0.8",
"date": "2026-06-11",
"title": "Framework & Architecture",
"layout": "default",
"messageId": null,
"sections": [
{
"type": "new",
"label": "New Features",
"emoji": "✨",
"items": [
{ "text": "`/tg poll mark-left` — mark a character as having left TG mid-game with 🪲 counter", "emojiKey": "cockroach" },
{ "text": "Leave counter displays total times a character has left TG", "emojiKey": null },
{ "text": "W.Rank no-rank placeholder alignment — players without rank align correctly with ranked players", "emojiKey": "wrank_no_dash" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚡",
"items": [
{ "text": "Config system moved from `.env` to `config.json` — hot-reloadable, no restart needed", "emojiKey": null },
{ "text": "All config organized into sections — `poll`, `wrank`, `channels`, `roles`, etc.", "emojiKey": null },
{ "text": "Scheduler plugin system — cron jobs are self-contained files, drop one in to add a job", "emojiKey": null },
{ "text": "Logger with levels and context icons — structured, filterable output", "emojiKey": null },
{ "text": "Benchmark profiling — vote path performance measured, optimized with `Promise.all`", "emojiKey": null }
]
},
{
"type": "technical",
"label": "Under the Hood",
"emoji": "🔩",
"items": [
{ "text": "`Runtime` lifecycle system — phased startup: load → restore → connect → schedule → ready", "emojiKey": null },
{ "text": "`Config` namespace — `Config.get({ section, key })` with full type safety per section", "emojiKey": null },
{ "text": "`Store` abstraction — centralized JSON file I/O with error handling", "emojiKey": null },
{ "text": "`Paths` helper — no more `path.join(__dirname, ...)` scattered across the codebase", "emojiKey": null },
{ "text": "`Discord` abstraction layer — `Discord.Interaction`, `Discord.Guild`, `Discord.Channel`", "emojiKey": null },
{ "text": "`WRankEntry` hydration — runtime entries carry full `Character` object", "emojiKey": null },
{ "text": "`Leaves` system — character leave tracking keyed by character name and history key", "emojiKey": null }
]
}
],
"examples": [
{
"caption": "🪲 Leave indicator — cockroach with leave count",
"type": "poll",
"layout": "side-by-side",
"file": "examples/poll-leaves.json"
}
]
}

View file

@ -0,0 +1,4 @@
{
"latest": "v0.8",
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"]
}

View file

@ -1,14 +0,0 @@
{
"nunoflashy": {
"file": "flash",
"aliases": ["Flash", "El Flasho"]
},
"staki91": "dey",
"invicjusz": "invicjusz",
"mrsean.": "sean",
"ibenni": "ayana",
"izephyrxy": "zephyr",
"eat.jim.sleep": "keira",
"mar1n1987": "marin",
"coba5539": "cobain"
}

View file

@ -1,17 +0,0 @@
services:
tg-bot:
build:
context: /opt/docker/tg-bot-ts
image: tg-bot-ts:latest
container_name: tg-bot-ts
restart: unless-stopped
env_file:
- /opt/docker/tg-bot-ts/.env
volumes:
- /opt/docker/tg-bot-ts/src:/app/src
- /opt/docker/tg-bot-ts/data:/app/data
- /opt/docker/tg-bot-ts-dev/scripts:/app/scripts
- /opt/docker/tg-bot-ts/messages:/app/messages
- /opt/docker/tg-bot-ts-dev/emoji-uploads:/app/emoji-uploads
- /opt/docker/tg-bot-ts-dev/tsconfig.json:/app/tsconfig.json
- /opt/docker/tg-bot-ts-dev/data/sessionPreferences.json:/app/data/sessionPreferences.json

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -102,5 +102,165 @@
"wrank_8_gold": "<:wrank_8_gold:1512125123874918661>", "wrank_8_gold": "<:wrank_8_gold:1512125123874918661>",
"wrank_9_gold": "<:wrank_9_gold:1512125128299905104>", "wrank_9_gold": "<:wrank_9_gold:1512125128299905104>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>", "wrank_no_rank": "<:wrank_no_rank:1512261782205628606>",
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>" "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>",
"wrank_down_100": "<:wrank_down_100:1513360157923475496>",
"wrank_down_21": "<:wrank_down_21:1513360161446559745>",
"wrank_down_22": "<:wrank_down_22:1513360165443993720>",
"wrank_down_23": "<:wrank_down_23:1513360169453752380>",
"wrank_down_24": "<:wrank_down_24:1513360173241077762>",
"wrank_down_25": "<:wrank_down_25:1513360177087254620>",
"wrank_down_26": "<:wrank_down_26:1513360181054935134>",
"wrank_down_27": "<:wrank_down_27:1513360184758505673>",
"wrank_down_28": "<:wrank_down_28:1513360188353282100>",
"wrank_down_29": "<:wrank_down_29:1513360191826038966>",
"wrank_down_30": "<:wrank_down_30:1513360195387129896>",
"wrank_down_31": "<:wrank_down_31:1513360198981648434>",
"wrank_down_32": "<:wrank_down_32:1513360202492149861>",
"wrank_down_33": "<:wrank_down_33:1513360206166360146>",
"wrank_down_34": "<:wrank_down_34:1513360209790242887>",
"wrank_down_35": "<:wrank_down_35:1513360214429007954>",
"wrank_down_36": "<:wrank_down_36:1513360218250022984>",
"wrank_down_37": "<:wrank_down_37:1513360223484510218>",
"wrank_down_38": "<:wrank_down_38:1513360226873512078>",
"wrank_down_39": "<:wrank_down_39:1513360230606442557>",
"wrank_down_40": "<:wrank_down_40:1513360234469523547>",
"wrank_down_41": "<:wrank_down_41:1513360238399586314>",
"wrank_down_42": "<:wrank_down_42:1513360241834594356>",
"wrank_down_43": "<:wrank_down_43:1513360245609730109>",
"wrank_down_44": "<:wrank_down_44:1513360248667246805>",
"wrank_down_45": "<:wrank_down_45:1513360253025124453>",
"wrank_down_46": "<:wrank_down_46:1513360256946929715>",
"wrank_down_47": "<:wrank_down_47:1513360261128654971>",
"wrank_down_48": "<:wrank_down_48:1513360265935192104>",
"wrank_down_49": "<:wrank_down_49:1513360269768654888>",
"wrank_down_50": "<:wrank_down_50:1513360273468297438>",
"wrank_down_51": "<:wrank_down_51:1513360277117337770>",
"wrank_down_52": "<:wrank_down_52:1513360280808067172>",
"wrank_down_53": "<:wrank_down_53:1513360284033618092>",
"wrank_down_54": "<:wrank_down_54:1513360288081117284>",
"wrank_down_55": "<:wrank_down_55:1513360291398946868>",
"wrank_down_56": "<:wrank_down_56:1513360295630868550>",
"wrank_down_57": "<:wrank_down_57:1513360299078713447>",
"wrank_down_58": "<:wrank_down_58:1513360303360970896>",
"wrank_down_59": "<:wrank_down_59:1513360306993369229>",
"wrank_down_60": "<:wrank_down_60:1513360310583427092>",
"wrank_down_61": "<:wrank_down_61:1513360314203246773>",
"wrank_down_62": "<:wrank_down_62:1513360318410002522>",
"wrank_down_63": "<:wrank_down_63:1513360322268893296>",
"wrank_down_64": "<:wrank_down_64:1513360325771137095>",
"wrank_down_65": "<:wrank_down_65:1513360329676034068>",
"wrank_down_66": "<:wrank_down_66:1513360333518143668>",
"wrank_down_67": "<:wrank_down_67:1513360337112662026>",
"wrank_down_68": "<:wrank_down_68:1513360340635877467>",
"wrank_down_69": "<:wrank_down_69:1513360344318349352>",
"wrank_down_70": "<:wrank_down_70:1513360347925319862>",
"wrank_down_71": "<:wrank_down_71:1513360352023285770>",
"wrank_down_72": "<:wrank_down_72:1513360356070785066>",
"wrank_down_73": "<:wrank_down_73:1513360359803846716>",
"wrank_down_74": "<:wrank_down_74:1513360363595239596>",
"wrank_down_75": "<:wrank_down_75:1513360367538016410>",
"wrank_down_76": "<:wrank_down_76:1513360372302745721>",
"wrank_down_77": "<:wrank_down_77:1513360376035545328>",
"wrank_down_78": "<:wrank_down_78:1513360379810545755>",
"wrank_down_79": "<:wrank_down_79:1513360383451201707>",
"wrank_down_80": "<:wrank_down_80:1513360387012296895>",
"wrank_down_81": "<:wrank_down_81:1513360390573002872>",
"wrank_down_82": "<:wrank_down_82:1513360393983234242>",
"wrank_down_83": "<:wrank_down_83:1513360398076743792>",
"wrank_down_84": "<:wrank_down_84:1513360401763405947>",
"wrank_down_85": "<:wrank_down_85:1513360405681016843>",
"wrank_down_86": "<:wrank_down_86:1513360409929973812>",
"wrank_down_87": "<:wrank_down_87:1513360413985865828>",
"wrank_down_88": "<:wrank_down_88:1513360417802420395>",
"wrank_down_89": "<:wrank_down_89:1513360421430497441>",
"wrank_down_90": "<:wrank_down_90:1513360424882667554>",
"wrank_down_91": "<:wrank_down_91:1513360428900548628>",
"wrank_down_92": "<:wrank_down_92:1513360432856043550>",
"wrank_down_93": "<:wrank_down_93:1513360436706283530>",
"wrank_down_94": "<:wrank_down_94:1513360440879747112>",
"wrank_down_95": "<:wrank_down_95:1513360445728096456>",
"wrank_down_96": "<:wrank_down_96:1513360449276477509>",
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
"wrank_down_99": "<:wrank_down_99:1513360463558082590>",
"wrank_up_100": "<:wrank_up_100:1513360467228364852>",
"wrank_up_21": "<:wrank_up_21:1513360471611408494>",
"wrank_up_22": "<:wrank_up_22:1513360476015296603>",
"wrank_up_23": "<:wrank_up_23:1513360479433785436>",
"wrank_up_24": "<:wrank_up_24:1513360483351007355>",
"wrank_up_25": "<:wrank_up_25:1513360487096651876>",
"wrank_up_26": "<:wrank_up_26:1513360490334654555>",
"wrank_up_27": "<:wrank_up_27:1513360494264713409>",
"wrank_up_28": "<:wrank_up_28:1513360497972609175>",
"wrank_up_29": "<:wrank_up_29:1513360501730709746>",
"wrank_up_30": "<:wrank_up_30:1513360505530745004>",
"wrank_up_31": "<:wrank_up_31:1513360508819079350>",
"wrank_up_32": "<:wrank_up_32:1513360512749142086>",
"wrank_up_33": "<:wrank_up_33:1513360516695986206>",
"wrank_up_34": "<:wrank_up_34:1513360521850785993>",
"wrank_up_35": "<:wrank_up_35:1513360525508087900>",
"wrank_up_36": "<:wrank_up_36:1513360529278632009>",
"wrank_up_37": "<:wrank_up_37:1513360533745569882>",
"wrank_up_38": "<:wrank_up_38:1513360537235226804>",
"wrank_up_39": "<:wrank_up_39:1513360541228470474>",
"wrank_up_40": "<:wrank_up_40:1513360545078579290>",
"wrank_up_41": "<:wrank_up_41:1513360549411422288>",
"wrank_up_42": "<:wrank_up_42:1513360552896888904>",
"wrank_up_43": "<:wrank_up_43:1513360557485326397>",
"wrank_up_44": "<:wrank_up_44:1513360561348280450>",
"wrank_up_45": "<:wrank_up_45:1513360564905312286>",
"wrank_up_46": "<:wrank_up_46:1513360568445046914>",
"wrank_up_47": "<:wrank_up_47:1513360571964067940>",
"wrank_up_48": "<:wrank_up_48:1513360575776817353>",
"wrank_up_49": "<:wrank_up_49:1513360579501232189>",
"wrank_up_50": "<:wrank_up_50:1513360583519371364>",
"wrank_up_51": "<:wrank_up_51:1513360586732343471>",
"wrank_up_52": "<:wrank_up_52:1513360590641299568>",
"wrank_up_53": "<:wrank_up_53:1513360594051399731>",
"wrank_up_54": "<:wrank_up_54:1513360597981335572>",
"wrank_up_55": "<:wrank_up_55:1513360601567723641>",
"wrank_up_56": "<:wrank_up_56:1513360605560438825>",
"wrank_up_57": "<:wrank_up_57:1513360609725382798>",
"wrank_up_58": "<:wrank_up_58:1513360613517037669>",
"wrank_up_59": "<:wrank_up_59:1513360617397031084>",
"wrank_up_60": "<:wrank_up_60:1513360620865454122>",
"wrank_up_61": "<:wrank_up_61:1513360624720281630>",
"wrank_up_62": "<:wrank_up_62:1513360628528447592>",
"wrank_up_63": "<:wrank_up_63:1513360632517365870>",
"wrank_up_64": "<:wrank_up_64:1513360635713552466>",
"wrank_up_65": "<:wrank_up_65:1513360639404544094>",
"wrank_up_66": "<:wrank_up_66:1513360643867021464>",
"wrank_up_67": "<:wrank_up_67:1513360647809667092>",
"wrank_up_68": "<:wrank_up_68:1513360652750684200>",
"wrank_up_69": "<:wrank_up_69:1513360656626094140>",
"wrank_up_70": "<:wrank_up_70:1513360660531118330>",
"wrank_up_71": "<:wrank_up_71:1513360663987355710>",
"wrank_up_72": "<:wrank_up_72:1513360668462551100>",
"wrank_up_73": "<:wrank_up_73:1513360672740737104>",
"wrank_up_74": "<:wrank_up_74:1513360676666478723>",
"wrank_up_75": "<:wrank_up_75:1513360680399409293>",
"wrank_up_76": "<:wrank_up_76:1513360683914367116>",
"wrank_up_77": "<:wrank_up_77:1513360688045887672>",
"wrank_up_78": "<:wrank_up_78:1513360691443138684>",
"wrank_up_79": "<:wrank_up_79:1513360695972987061>",
"wrank_up_80": "<:wrank_up_80:1513360699793997875>",
"wrank_up_81": "<:wrank_up_81:1513360703560618136>",
"wrank_up_82": "<:wrank_up_82:1513360708899967116>",
"wrank_up_83": "<:wrank_up_83:1513360712393560214>",
"wrank_up_84": "<:wrank_up_84:1513360716101582910>",
"wrank_up_85": "<:wrank_up_85:1513360720094302329>",
"wrank_up_86": "<:wrank_up_86:1513360724091473950>",
"wrank_up_87": "<:wrank_up_87:1513360728390897896>",
"wrank_up_88": "<:wrank_up_88:1513360732249391104>",
"wrank_up_89": "<:wrank_up_89:1513360735923736606>",
"wrank_up_90": "<:wrank_up_90:1513360739530833950>",
"wrank_up_91": "<:wrank_up_91:1513360743402176604>",
"wrank_up_92": "<:wrank_up_92:1513360747600805918>",
"wrank_up_93": "<:wrank_up_93:1513360752197767329>",
"wrank_up_94": "<:wrank_up_94:1513360755494359161>",
"wrank_up_95": "<:wrank_up_95:1513360759197794385>",
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>"
} }

View file

@ -1,16 +1,13 @@
{ {
"public": { "public": {
"yes": [ "yes": [
{ "clicks": 1, "random": true, "messages": ["HELP, KURWA! I survive, easy!", "Send emergency services!", "Aiaiaiaiai I'm unmoved, HELP!", "You want beef?! Let's go!", "I don't give a fuuuuuck", "Ayana is in"]}, { "clicks": 1, "random": true, "messages": ["Ayana is in"]},
{ "clicks": 10, "random": true, "messages": ["Ayana..."] } { "clicks": 10, "random": true, "messages": ["Ayana..."] }
], ],
"no": [ "no": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": [
"Went for a kebab", "Went for a kebab",
"Doesn't give a fuck", "Doesn't give a fuck"
"Went outside... for a change",
"Is touching grass",
"Is unavailable, call your local DM hotline"
] ]
} }
] ]

View file

@ -1,21 +1,15 @@
{ {
"public": { "public": {
"yes": [ "yes": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": ["Dey is in"]},
"Dey is in", { "clicks": 2, "random": true, "messages": ["Courageous now, new account afterall"] },
"Dey is in... for now",
"Welcome indeed!",
"A bit of this, a bit of that.",
"Come and see what goods I offer"
]},
{ "clicks": 10, "random": true, "messages": ["Now you're just asking for it."] } { "clicks": 10, "random": true, "messages": ["Now you're just asking for it."] }
], ],
"no": [ "no": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": [
"Everything's for sale", "Everything's for sale",
"Dey roaching out 🪳", "Dey roaching out 🪳",
"Dey said no... shocking", "Dey said no... shocking"
"No more... I yield!"
] ]
} }
] ]

View file

@ -4,11 +4,9 @@
{ {
"clicks": 1, "clicks": 1,
"random": true, "random": true,
"messages": [ "messages": ["The King has arrived. 👑", "Flash is in, bow down.", "👑 Royalty has entered the raid.","{alias[0]} is in"]
"<:wi:1511906503647563807>+<:storm_bringer:1511906496097554594>=<:kd:1511906474497146983>", },
"<:wi:1511906503647563807> Powaaaaaaaaa" { "clicks": 2, "random": true, "messages": ["Flash? Flash? Flash!!"] }
]
}
], ],
"no": [ "no": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": [

View file

@ -2,7 +2,7 @@
"public": { "public": {
"yes": [ "yes": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": [
"Vic is in", "Inviiiiiiiiiiiicjusz" "Vic is in"
] ]
}, },
{ "clicks": 2, "random": true, "messages": ["Vic is really in"] }, { "clicks": 2, "random": true, "messages": ["Vic is really in"] },

View file

@ -5,8 +5,7 @@
"Legend is in", "Legend is in",
"Best FA shows up", "Best FA shows up",
"Healmeister reporting for duty", "Healmeister reporting for duty",
"Capella MVP is up", "Capella MVP is up"
"A wild Zephyr appears!"
] ]
} }
], ],

View file

@ -14,11 +14,11 @@
"node-cron": "^3.0.3" "node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.19.41",
"@types/node-cron": "^3.0.0", "@types/node-cron": "^3.0.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.0", "tsconfig-paths": "^4.2.0",
"tsconfig-paths": "^4.2.0" "typescript": "^5.4.0"
} }
} }

View file

@ -1,22 +1,28 @@
/** /**
* Bulk emoji upload script * Bulk emoji upload script with subdirectory support and round-robin distribution.
* Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
* *
* Distributes emojis across a pool of donor servers (round-robin by available capacity). * Usage:
* Each emoji is unique across all servers no duplicates. * Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
* Automatically updates messages/emojis.json with the uploaded emoji IDs. * Delete: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts --delete <pattern>
* Pattern can be a prefix (e.g. "wrank_up") or exact name (e.g. "wrank_up_1")
* Multiple patterns: --delete wrank_up wrank_down wrank_gold
*
* Directory naming conventions:
* - Files in root dir name = filename without extension
* - Files in subdir name determined by DIR_NAME_MAP or default (dirname_filename)
* - Passthrough dirs name = filename only (no prefix)
* *
* Required .env vars: * Required .env vars:
* DISCORD_TOKEN bot token * DISCORD_TOKEN bot token
* EMOJI_DONOR_GUILDS comma-separated donor server IDs * EMOJI_DONOR_GUILDS comma-separated donor server IDs
* e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333
*/ */
import { REST, Routes } from "discord.js"; import { REST, Routes } from "discord.js";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { Config } from "@systems/config";
// Load .env manually since we're outside the bot runtime // Load .env
const envPath = path.join(__dirname, "../.env"); const envPath = path.join(__dirname, "../.env");
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
@ -26,186 +32,239 @@
} }
const TOKEN = process.env.DISCORD_TOKEN!; const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "") const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (!TOKEN) { if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
console.error("❌ DISCORD_TOKEN must be set in .env"); console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env");
process.exit(1);
}
if (DONOR_GUILD_IDS.length === 0) {
console.error("❌ EMOJI_DONOR_GUILDS must be set in .env (comma-separated guild IDs)");
process.exit(1); process.exit(1);
} }
const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads"); const emojiDir = path.join(__dirname, "../emoji-uploads");
const emojisPath = path.join(__dirname, "../messages/emojis.json"); const emojisPath = path.join(__dirname, "../messages/emojis.json");
const rest = new REST({ version: "10" }).setToken(TOKEN);
if (!fs.existsSync(emojiDir)) { // ─── Naming config ─────────────────────────────────────────────────────────────
console.error(`❌ Emoji directory not found: ${emojiDir}`);
console.error(` Create it and place your emoji PNG files inside.`); // Dirs listed here use filename only — no dir prefix
process.exit(1); const PASSTHROUGH_DIRS: string[] = ["classes", "nations", "misc"];
// Custom naming functions per dir — (filename without ext) → emoji name
const DIR_NAME_MAP: Record<string, (filename: string) => string> = {
"wrank": (f) => `wrank_${f}`,
"wrank_gold": (f) => `wrank_${f}_gold`,
"wrank_up": (f) => `wrank_up_${f}`,
"wrank_down": (f) => `wrank_down_${f}`,
"wrank_x": (f) => `wrank_x_${f}`,
};
function resolveEmojiName(dirName: string, filename: string): string {
if (PASSTHROUGH_DIRS.includes(dirName)) return filename;
if (DIR_NAME_MAP[dirName]) return DIR_NAME_MAP[dirName](filename);
return `${dirName}_${filename}`; // default: dirname_filename
} }
const rest = new REST({ version: "10" }).setToken(TOKEN); // ─── File discovery ────────────────────────────────────────────────────────────
interface GuildEmojiSlot { interface EmojiFile {
guildId: string; emojiName: string;
name: string; // guild name for display filePath: string;
mimeType: string;
}
const IMAGE_EXTS = [".png", ".jpg", ".gif", ".webp"];
function mimeFor(ext: string): string {
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
return "image/png";
}
function scanDir(dir: string, parentDirName?: string): EmojiFile[] {
const results: EmojiFile[] = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...scanDir(fullPath, entry.name));
} else {
const ext = path.extname(entry.name).toLowerCase();
if (!IMAGE_EXTS.includes(ext)) continue;
const filename = path.basename(entry.name, ext);
const emojiName = parentDirName
? resolveEmojiName(parentDirName, filename)
: filename;
results.push({ emojiName, filePath: fullPath, mimeType: mimeFor(ext) });
}
}
return results;
}
// ─── Guild helpers ─────────────────────────────────────────────────────────────
interface GuildSlot {
guildId: string;
name: string;
existing: Map<string, string>; // emojiName → emojiId existing: Map<string, string>; // emojiName → emojiId
capacity: number; capacity: number;
} }
// Compute max emojis based on Nitro boost tier function maxEmojisForTier(tier: number): number {
function maxEmojisForTier(premiumTier: number): number { return [50, 100, 150, 250][tier] ?? 50;
switch (premiumTier) {
case 1: return 100;
case 2: return 150;
case 3: return 250;
default: return 50;
}
} }
async function fetchGuildSlots(guildIds: string[]): Promise<GuildEmojiSlot[]> { async function fetchGuildSlots(): Promise<GuildSlot[]> {
const slots: GuildEmojiSlot[] = []; const slots: GuildSlot[] = [];
for (const guildId of DONOR_GUILD_IDS) {
for (const guildId of guildIds) {
try { try {
const [guild, emojis] = await Promise.all([ const [guild, emojis] = await Promise.all([
rest.get(Routes.guild(guildId)) as Promise<any>, rest.get(Routes.guild(guildId)) as Promise<any>,
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>, rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
]); ]);
const max = maxEmojisForTier(guild.premium_tier ?? 0);
const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0); const existing = new Map(emojis.map((e: any) => [e.name, e.id]));
const existingMap = new Map(emojis.map((e: any) => [e.name, e.id])); const capacity = max - emojis.length;
const capacity = maxEmojis - emojis.length; console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`);
const guildName = guild.name ?? guildId; slots.push({ guildId, name: guild.name, existing, capacity });
console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`);
slots.push({ guildId, name: guildName, existing: existingMap, capacity });
} catch (err: any) { } catch (err: any) {
console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`);
} }
} }
return slots; return slots;
} }
async function uploadEmojis(): Promise<void> { // ─── Upload ────────────────────────────────────────────────────────────────────
const files = fs.readdirSync(emojiDir).filter((f) =>
[".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase())
);
async function upload(): Promise<void> {
const files = scanDir(emojiDir);
if (files.length === 0) { if (files.length === 0) {
console.error("❌ No image files found in the emoji directory."); console.error(`❌ No image files found in ${emojiDir}`);
process.exit(1); process.exit(1);
} }
// Load existing emojis.json
let emojiMap: Record<string, string> = {}; let emojiMap: Record<string, string> = {};
try { try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {}
emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8"));
} catch {
console.warn("⚠️ Could not load emojis.json — will create fresh mapping.");
}
console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`); console.log(`\n📁 Found ${files.length} file(s)\n🔍 Scanning donor servers...\n`);
console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`); const slots = await fetchGuildSlots();
if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); }
const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS); // Build global dedup map
const globalExisting = new Map<string, string>();
if (guildSlots.length === 0) { for (const slot of slots) {
console.error("❌ No accessible donor guilds found. Check EMOJI_DONOR_GUILDS and bot membership.");
process.exit(1);
}
// Build global deduplication map across all donor guilds
const globalExisting = new Map<string, string>(); // emojiName → formatted string
for (const slot of guildSlots) {
for (const [name, id] of slot.existing) { for (const [name, id] of slot.existing) {
globalExisting.set(name, `<:${name}:${id}>`); globalExisting.set(name, `<:${name}:${id}>`);
} }
} }
const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0); const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0);
console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`); console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`);
if (totalCapacity === 0) {
console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS.");
process.exit(1);
}
let uploaded = 0;
let skipped = 0;
let failed = 0;
// Round-robin slot picker — distributes load evenly across guilds
let slotIndex = 0; let slotIndex = 0;
function nextAvailableSlot(): GuildEmojiSlot | null { function nextSlot(): GuildSlot | null {
const start = slotIndex; const start = slotIndex;
do { do {
const slot = guildSlots[slotIndex % guildSlots.length]; const s = slots[slotIndex % slots.length];
slotIndex++; slotIndex++;
if (slot.capacity > 0) return slot; if (s.capacity > 0) return s;
} while (slotIndex % guildSlots.length !== start % guildSlots.length); } while (slotIndex % slots.length !== start % slots.length);
// Fallback: find any with capacity (in case loop exited without finding one) return slots.find((s) => s.capacity > 0) ?? null;
return guildSlots.find((s) => s.capacity > 0) ?? null;
} }
for (const file of files) { let uploaded = 0, skipped = 0, failed = 0;
const emojiName = path.basename(file, path.extname(file));
const filePath = path.join(emojiDir, file);
const ext = path.extname(file).toLowerCase();
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
// Already exists in the pool — update map and skip for (const file of files) {
if (globalExisting.has(emojiName)) { if (globalExisting.has(file.emojiName)) {
emojiMap[emojiName] = globalExisting.get(emojiName)!; emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!;
console.log(`⏭️ Already exists: ${emojiName}${emojiMap[emojiName]}`); console.log(`⏭️ Exists: ${file.emojiName}${emojiMap[file.emojiName]}`);
skipped++; skipped++;
continue; continue;
} }
const slot = nextAvailableSlot(); const slot = nextSlot();
if (!slot) { if (!slot) {
console.error(`❌ All slots full — could not upload: ${emojiName}`); console.error(`❌ No slots available for: ${file.emojiName}`);
console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`);
failed++; failed++;
continue; continue;
} }
try { try {
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`;
const result = await rest.post(Routes.guildEmojis(slot.guildId), { const result = await rest.post(Routes.guildEmojis(slot.guildId), {
body: { name: emojiName, image: base64 }, body: { name: file.emojiName, image: base64 },
}) as any; }) as any;
const formatted = `<:${emojiName}:${result.id}>`; const formatted = `<:${file.emojiName}:${result.id}>`;
emojiMap[emojiName] = formatted; emojiMap[file.emojiName] = formatted;
slot.capacity--; slot.capacity--;
console.log(`✅ Uploaded: ${file.emojiName}${formatted} [${slot.name}]`);
console.log(`✅ Uploaded: ${emojiName}${formatted} [${slot.name}]`);
uploaded++; uploaded++;
// Rate limit buffer
await new Promise((r) => setTimeout(r, 600)); await new Promise((r) => setTimeout(r, 600));
} catch (err: any) { } catch (err: any) {
console.error(`❌ Failed: ${emojiName}${err.message}`); console.error(`❌ Failed: ${file.emojiName}${err.message}`);
failed++; failed++;
} }
} }
fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2));
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
console.log(`💾 messages/emojis.json updated`); console.log(`💾 messages/emojis.json updated`);
console.log(`\nSlot usage after upload:`);
for (const slot of guildSlots) {
const used = slot.existing.size + (uploaded > 0 ? slot.existing.size : 0);
console.log(` ${slot.name}: ${slot.capacity} slots remaining`);
}
} }
uploadEmojis().catch(console.error); // ─── Delete ────────────────────────────────────────────────────────────────────
async function deleteEmojis(patterns: string[]): Promise<void> {
console.log(`\n🗑 Deleting emojis matching: ${patterns.join(", ")}`);
console.log(`🔍 Scanning donor servers...\n`);
const slots = await fetchGuildSlots();
if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); }
let emojiMap: Record<string, string> = {};
try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {}
let deleted = 0, failed = 0;
for (const slot of slots) {
for (const [name, id] of slot.existing) {
const matches = patterns.some((p) => name === p || name.startsWith(`${p}_`) || name.startsWith(p));
if (!matches) continue;
try {
await rest.delete(Routes.guildEmoji(slot.guildId, id));
console.log(`🗑️ Deleted: ${name} [${slot.name}]`);
slot.existing.delete(name);
delete emojiMap[name];
deleted++;
await new Promise((r) => setTimeout(r, 300));
} catch (err: any) {
console.error(`❌ Failed to delete ${name}: ${err.message}`);
failed++;
}
}
}
fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2));
console.log(`\n📊 ${deleted} deleted · ${failed} failed`);
console.log(`💾 messages/emojis.json updated`);
}
// ─── Entry point ───────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
if (args[0] === "--delete") {
const patterns = args.slice(1);
if (patterns.length === 0) {
console.error("❌ Specify at least one pattern: --delete <pattern> [pattern2] ...");
console.error(" Examples:");
console.error(" --delete wrank_up (deletes wrank_up_1, wrank_up_2, ...)");
console.error(" --delete wrank_up_1 (deletes exact match)");
console.error(" --delete wrank_up wrank_down wrank_gold");
process.exit(1);
}
deleteEmojis(patterns).catch(console.error);
} else {
upload().catch(console.error);
}

View file

@ -4,56 +4,58 @@ import {
REST, REST,
Routes, Routes,
} from "discord.js"; } from "discord.js";
import { cfg } from "../systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasOfficerRole } from "../systems/users";
// Poll subcommands // Poll subcommands
import { handleStart } from "../subcommands/poll/start"; import { handleStart } from "@subcommands/poll/start";
import { handleLock } from "../subcommands/poll/lock"; import { handleLock } from "@subcommands/poll/lock";
import { handleUnlock } from "../subcommands/poll/unlock"; import { handleUnlock } from "@subcommands/poll/unlock";
import { handleConfirm } from "../subcommands/poll/confirm"; import { handleConfirm } from "@subcommands/poll/confirm";
import { handleStatus } from "../subcommands/poll/status"; import { handleStatus } from "@subcommands/poll/status";
import { handleReload } from "../subcommands/poll/reload"; import { handleReload } from "@subcommands/poll/reload";
import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "../subcommands/poll/setMessage"; import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "@subcommands/poll/setMessage";
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; import { handleInject, handleRemoveVote } from "@subcommands/poll/inject";
import { handleSeed } from "../subcommands/poll/seed"; import { handleSeed } from "@subcommands/poll/seed";
import { handlePurge } from "../subcommands/poll/purge"; import { handlePurge } from "@subcommands/poll/purge";
import { handleImpersonate } from "../subcommands/impersonate"; import { handleImpersonate } from "@subcommands/impersonate";
// Char subcommands (borrow / sharing system) // Char subcommands (borrow / sharing system)
import { handleCharBorrow } from "../subcommands/char/borrow"; import { handleCharBorrow } from "@subcommands/char/borrow";
import { handleCharAccept } from "../subcommands/char/accept"; import { handleCharAccept } from "@subcommands/char/accept";
import { handleCharDecline } from "../subcommands/char/decline"; import { handleCharDecline } from "@subcommands/char/decline";
import { handleCharShare, handleCharUnshare } from "../subcommands/char/share"; import { handleCharShare, handleCharUnshare } from "@subcommands/char/share";
// Score subcommands // Score subcommands
import { handleScoreSet } from "../subcommands/score/set"; import { handleScoreSet } from "@subcommands/score/set";
import { handleScoreGet } from "../subcommands/score/get"; import { handleScoreGet } from "@subcommands/score/get";
// Rank subcommands // Rank subcommands
import { handleRankGet } from "../subcommands/rank/get"; import { handleRankGet } from "@subcommands/rank/get";
import { handleRankPost } from "../subcommands/rank/post"; import { handleRankPost } from "@subcommands/rank/post";
// Result subcommands // Result subcommands
import { handleResultSet } from "../subcommands/result/set"; import { handleResultSet } from "@subcommands/result/set";
import { handleResultView } from "../subcommands/result/view"; import { handleResultView } from "@subcommands/result/view";
import { handleResultPost } from "../subcommands/result/post"; import { handleResultPost } from "@subcommands/result/post";
// Bringer subcommands // Bringer subcommands
import { handleBringerSet } from "../subcommands/bringer/set"; import { handleBringerSet } from "@subcommands/bringer/set";
import { handleBringerClear } from "../subcommands/bringer/clear"; import { handleBringerClear } from "@subcommands/bringer/clear";
// Other // Other
import { handleSwitch } from "../subcommands/switch"; import { handleSwitch } from "@subcommands/switch";
import { handleHistory } from "../subcommands/history"; import { handleHistory } from "@subcommands/history";
// Import char handlers here to keep tg.ts clean // Import char handlers here to keep tg.ts clean
import { handleCharAdd } from "../subcommands/char/add"; import { handleCharAdd } from "@subcommands/char/add";
import { handleCharRemove } from "../subcommands/char/remove"; import { handleCharRemove } from "@subcommands/char/remove";
import { handleCharSetActive } from "../subcommands/char/setActive"; import { handleCharSetActive } from "@subcommands/char/setActive";
import { handleCharSetNation } from "../subcommands/char/setNation"; import { handleCharSetNation } from "@subcommands/char/setNation";
import { handleCharSetStats } from "../subcommands/char/setStats"; import { handleCharSetStats } from "@subcommands/char/setStats";
import { handleCharActive } from "../subcommands/char/active"; import { handleCharActive } from "@subcommands/char/active";
import { Nation } from "@types";
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
export function buildTgCommand(): SlashCommandBuilder { export function buildTgCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
@ -126,6 +128,16 @@ export function buildTgCommand(): SlashCommandBuilder {
) )
.addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel")) .addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel"))
.addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing")) .addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing"))
.addSubcommand((s) => s
.setName("mark-left")
.setDescription("Mark a character as having left TG")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
)
.addSubcommand((s) => s
.setName("unmark-left")
.setDescription("Remove left mark from a character")
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
)
); );
// ── score group ──────────────────────────────────────────────────────────── // ── score group ────────────────────────────────────────────────────────────
@ -164,7 +176,7 @@ export function buildTgCommand(): SlashCommandBuilder {
.setDescription("TG result management") .setDescription("TG result management")
.addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)") .addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)")
.addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true)) .addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true))
.addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true)) .addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true))
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
@ -180,12 +192,12 @@ export function buildTgCommand(): SlashCommandBuilder {
.setDescription("Bringer management (officer only)") .setDescription("Bringer management (officer only)")
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character ").setRequired(true).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override") .addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon })))
); );
// ── switch ───────────────────────────────────────────────────────────────── // ── switch ─────────────────────────────────────────────────────────────────
@ -214,7 +226,7 @@ export function buildTgCommand(): SlashCommandBuilder {
)) ))
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
) )
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character") .addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
@ -227,7 +239,7 @@ export function buildTgCommand(): SlashCommandBuilder {
) )
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation") .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon }))
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true))
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
) )
@ -280,7 +292,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
const group = interaction.options.getSubcommandGroup(false); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
// Officer-only commands // Officer-only commands
const officerOnlyGroups = ["poll", "result", "bringer"]; const officerOnlyGroups = ["poll", "result", "bringer"];
@ -313,6 +325,8 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
if (sub === "remove-vote") return handleRemoveVote(interaction); if (sub === "remove-vote") return handleRemoveVote(interaction);
if (sub === "purge") return handlePurge(interaction); if (sub === "purge") return handlePurge(interaction);
if (sub === "seed") return handleSeed(interaction); if (sub === "seed") return handleSeed(interaction);
if (sub === "mark-left") return handleMarkLeft(interaction);
if (sub === "unmark-left") return handleUnmarkLeft(interaction);
} }
if (group === "score") { if (group === "score") {
if (sub === "set") return handleScoreSet(interaction); if (sub === "set") return handleScoreSet(interaction);

125
src/commands/tgAdmin.ts Normal file
View file

@ -0,0 +1,125 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import {
handleAdminUserMap,
handleAdminPollFixVoter,
handleAdminPollShowEntry,
} from "@subcommands/admin/userMap";
import { UpdatesCommands } from "@subcommands/admin/updates";
export function buildTgAdminCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder()
.setName("tg-admin")
.setDescription("Administrative commands for TG bot management");
// ── user group ────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("user")
.setDescription("Manage player registrations")
.addSubcommand((s) => s
.setName("map")
.setDescription("Register a Discord user to a userKey")
.addUserOption((o) => o
.setName("user")
.setDescription("Discord user to map")
.setRequired(true)
)
.addStringOption((o) => o
.setName("userkey")
.setDescription("The userKey to map this player to (must exist in characters.json)")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("unmap")
.setDescription("Remove a player's registration")
.addUserOption((o) => o
.setName("user")
.setDescription("Discord user to unmap")
.setRequired(true)
)
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all registered player mappings")
)
);
// ── poll group ─────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("poll")
.setDescription("Manage active poll entries")
.addSubcommand((s) => s
.setName("fix-voter")
.setDescription("Re-resolve a voter's character in the active poll")
.addUserOption((o) => o
.setName("user")
.setDescription("Discord user whose entry needs fixing")
.setRequired(true)
)
)
.addSubcommand((s) => s
.setName("show-entry")
.setDescription("Show raw poll entry for a user")
.addUserOption((o) => o
.setName("user")
.setDescription("Discord user to inspect")
.setRequired(true)
)
)
);
cmd.addSubcommandGroup((g) => g
.setName("updates")
.setDescription("Manage update posts")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit an update embed")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to post (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("preview")
.setDescription("Preview an update embed (ephemeral)")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to preview (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all versions and their post status")
)
)
return cmd;
}
export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> {
const group = interaction.options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand();
if (group === "user") {
if (sub === "map" || sub === "unmap" || sub === "list") {
return handleAdminUserMap(interaction);
}
}
if (group === "poll") {
if (sub === "fix-voter") return handleAdminPollFixVoter(interaction);
if (sub === "show-entry") return handleAdminPollShowEntry(interaction);
}
if (group === "updates") {
if (sub === "post") return UpdatesCommands.post(interaction);
if (sub === "preview") return UpdatesCommands.preview(interaction);
if (sub === "list") return UpdatesCommands.list(interaction);
}
}

View file

@ -1,8 +1,15 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { cfg, setCfg, resetCfg } from "../systems/config"; import { Config, SectionMap } from "../systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasOfficerRole } from "../systems/users";
import { replyAndDelete } from "../utils"; import { replyAndDelete } from "../utils";
import { Nation } from "../types"; import { Nation } from "@types";
import { handleSetLayout } from "@subcommands/tg-config/set-layout";
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = {
officerRoles: "officer",
configRoles: "config",
tagRoles: "tag",
};
export function buildTgConfigCommand(): SlashCommandBuilder { export function buildTgConfigCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
@ -16,7 +23,7 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }); .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" });
const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true) const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }); .addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon });
const roleOpt = strOpt("role", "Role name"); const roleOpt = strOpt("role", "Role name");
const rolesOpt = strOpt("roles", "Comma-separated role names"); const rolesOpt = strOpt("roles", "Comma-separated role names");
@ -101,54 +108,77 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addStringOption(nationOpt)) .addStringOption(nationOpt))
); );
// ── poll group ───────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("poll")
.setDescription("Configure Poll Settings")
.addSubcommand((s) => s
.setName("set-layout")
.setDescription("Change the poll display layout")
.addStringOption((o) => o
.setName("layout")
.setDescription("Layout name")
.setRequired(true)
.setAutocomplete(true)
))
);
return cmd; return cmd;
} }
export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise<void> {
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
// methods like getString/getInteger at the type level despite them existing at runtime
// Needs to be cast as any, since Discord.js has issues with the type
const options = interaction.options as any;
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, cfg("configRoles"))) { if (!hasOfficerRole(member, Config.get({ section: "roles", key: "config" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
const group = interaction.options.getSubcommandGroup(true); const group = options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand(); const sub = options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
const key = ROLE_KEY_MAP[cfgKey];
if (action === "set") { if (action === "set") {
const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean); const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean);
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`);
} }
if (action === "add") { if (action === "add") {
const role = interaction.options.getString("role", true).trim(); const role = options.getString("role", true).trim();
const roles = [...new Set([...cfg(cfgKey), role])]; const roles = [...new Set([...Config.get({ section: "roles", key }), role])];
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`);
} }
if (action === "remove") { if (action === "remove") {
const role = interaction.options.getString("role", true).trim(); const role = options.getString("role", true).trim();
const roles = cfg(cfgKey).filter((r: string) => r !== role); const roles = Config.get({ section: "roles", key }).filter((r: string) => r !== role);
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`);
} }
if (action === "reset") { if (action === "reset") {
resetCfg(cfgKey); Config.reset({ section: "roles", key });
return void replyAndDelete(interaction, `✅ Roles reset to default.`); return void replyAndDelete(interaction, `✅ Roles reset to default.`);
} }
}; };
// ── message ──────────────────────────────────────────────────────────────── // ── message ────────────────────────────────────────────────────────────────
if (group === "message") { if (group === "message") {
if (sub === "set-lock") { setCfg("lockMessage", interaction.options.getString("message", true)); return void replyAndDelete(interaction, "✅ Lock message updated."); } if (sub === "set-lock") { Config.set({ section: "poll", key: "lockMessage", value: options.getString("message", true) }); return void replyAndDelete(interaction, "✅ Lock message updated."); }
if (sub === "reset-lock") { resetCfg("lockMessage"); return void replyAndDelete(interaction, "✅ Lock message reset."); } if (sub === "reset-lock") { Config.reset({ section: "poll", key: "lockMessage" }); return void replyAndDelete(interaction, "✅ Lock message reset."); }
if (sub === "set-confirm") { if (sub === "set-confirm") {
const d = interaction.options.getString("decision", true); const d = options.getString("decision", true);
setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", true)); const key = d === "yes" ? "confirmYes" : "confirmNo";
Config.set({ section: "poll", key, value: options.getString("message", true) });
return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`); return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`);
} }
if (sub === "reset-confirm") { if (sub === "reset-confirm") {
const d = interaction.options.getString("decision", true); const d = options.getString("decision", true);
resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage"); const key = d === "yes" ? "confirmYes" : "confirmNo";
Config.reset({ section: "poll", key });
return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`); return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`);
} }
} }
@ -171,41 +201,45 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
// ── channel ──────────────────────────────────────────────────────────────── // ── channel ────────────────────────────────────────────────────────────────
if (group === "channel") { if (group === "channel") {
if (sub === "set-poll") { setCfg("pollChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Poll channel updated."); } if (sub === "set-poll") { Config.set({ section: "channels", key: "poll", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Poll channel updated."); }
if (sub === "set-results") { setCfg("resultsChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Results channel updated."); } if (sub === "set-results") { Config.set({ section: "channels", key: "results", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Results channel updated."); }
if (sub === "set-score") { setCfg("scoreChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Score channel updated."); } if (sub === "set-score") { Config.set({ section: "channels", key: "score", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Score channel updated."); }
} }
// ── slot ─────────────────────────────────────────────────────────────────── // ── slot ───────────────────────────────────────────────────────────────────
if (group === "slot") { if (group === "slot") {
if (sub === "add") { if (sub === "add") {
const hour = interaction.options.getInteger("hour", true); const hour = options.getInteger("hour", true);
const pollOpens = interaction.options.getString("poll_opens", true); const pollOpens = options.getString("poll_opens", true);
const slots = cfg("slots"); const slots = Config.get({ section: "poll", key: "slots" });
if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`); if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`);
slots.push({ tgHour: hour, pollOpens, closesAfter: cfg("tgDurationMinutes"), active: true }); slots.push({ tgHour: hour, pollOpens, closesAfter: Config.get({ section: "tg", key: "durationMinutes" }), active: true });
setCfg("slots", slots); Config.set({ section: "poll", key: "slots", value: slots });
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`);
} }
if (sub === "remove") { if (sub === "remove") {
const hour = interaction.options.getInteger("hour", true); const hour = options.getInteger("hour", true);
const slots = cfg("slots").filter((s) => s.tgHour !== hour); const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.tgHour !== hour);
setCfg("slots", slots); Config.set({ section: "poll", key: "slots", value: slots });
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`);
} }
} }
// ── wrank ────────────────────────────────────────────────────────────────── // ── wrank ──────────────────────────────────────────────────────────────────
if (group === "wrank") { if (group === "wrank") {
if (sub === "set-goal") { setCfg("wRankGoal", interaction.options.getInteger("goal", true)); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); } if (sub === "set-goal") { Config.set({ section: "wrank", key: "goal", value: options.getInteger("goal", true)! }); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); }
if (sub === "set-post-on-reset") { setCfg("wRankPostOnReset", interaction.options.getBoolean("enabled", true)); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); } if (sub === "set-post-on-reset") { Config.set({ section: "wrank", key: "postOnReset", value: options.getBoolean("enabled", true)! }); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); }
} }
// ── tg ───────────────────────────────────────────────────────────────────── // ── tg ─────────────────────────────────────────────────────────────────────
if (group === "tg") { if (group === "tg") {
if (sub === "set-score-window") { setCfg("scoreWindowHours", interaction.options.getNumber("hours", true)); return void replyAndDelete(interaction, "✅ Score window updated."); } if (sub === "set-score-window") { Config.set({ section: "tg", key: "scoreWindowHours", value: options.getNumber("hours", true)! }); return void replyAndDelete(interaction, "✅ Score window updated."); }
if (sub === "set-duration") { setCfg("tgDurationMinutes", interaction.options.getInteger("minutes", true)); return void replyAndDelete(interaction, "✅ TG duration updated."); } if (sub === "set-duration") { Config.set({ section: "tg", key: "durationMinutes", value: options.getInteger("minutes", true)! }); return void replyAndDelete(interaction, "✅ TG duration updated."); }
if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); } if (sub === "set-no-display") { Config.set({ section: "poll", key: "showNoInNationField", value: options.getString("mode", true) === "inline" }); return void replyAndDelete(interaction, "✅ No voter display updated."); }
if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); } if (sub === "set-nation-source"){ Config.set({ section: "nation", key: "source", value: options.getString("nation", true) as Nation }); return void replyAndDelete(interaction, "✅ Nation source updated."); }
}
if (group === "poll") {
if (sub === "set-layout") return handleSetLayout(interaction);
} }
} }

40
src/discord/channel.ts Normal file
View file

@ -0,0 +1,40 @@
/**
* Discord.Channel channel fetching and messaging.
*/
import {
Client,
TextChannel,
MessageCreateOptions,
MessageEditOptions,
} from "discord.js";
async function fetch({ client, id }: { client: Client; id: string }): Promise<TextChannel> {
return client.channels.fetch(id) as Promise<TextChannel>;
}
async function send({ channel, content, embeds, components }: {
channel: TextChannel;
content?: string;
embeds?: any[];
components?: any[];
}): Promise<void> {
await channel.send({ content, embeds, components } as MessageCreateOptions);
}
async function edit({ channel, messageId, content, embeds, components }: {
channel: TextChannel;
messageId: string;
content?: string;
embeds?: any[];
components?: any[];
}): Promise<void> {
const msg = await channel.messages.fetch(messageId);
await msg.edit({ content, embeds, components } as MessageEditOptions);
}
export const Channel = {
fetch,
send,
edit,
};

29
src/discord/guild.ts Normal file
View file

@ -0,0 +1,29 @@
/**
* Discord.Guild guild and member operations.
*/
import {
ChatInputCommandInteraction,
ButtonInteraction,
GuildMember,
} from "discord.js";
type AnyInteraction = ChatInputCommandInteraction | ButtonInteraction;
async function member({ interaction }: { interaction: AnyInteraction }): Promise<GuildMember> {
return interaction.guild!.members.fetch(interaction.user.id);
}
async function fetchMember({ interaction, userId }: { interaction: AnyInteraction; userId: string }): Promise<GuildMember> {
return interaction.guild!.members.fetch(userId);
}
function hasRole({ member, roles }: { member: GuildMember; roles: string[] }): boolean {
return member.roles.cache.some((r) => roles.includes(r.name));
}
export const Guild = {
member,
fetchMember,
hasRole,
};

25
src/discord/index.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Discord abstraction layer over Discord.js API.
*
* Usage:
* import { Discord } from "@discord";
*
* Discord.Interaction.options<ChatInputCommandInteraction>(i).string({ key: "name" })
* Discord.Guild.member({ interaction })
* Discord.Channel.fetch({ client, id })
*/
export { Interaction } from "./interaction";
export { Guild } from "./guild";
export { Channel } from "./channel";
// Top-level namespace for convenience
import { Interaction } from "./interaction";
import { Guild } from "./guild";
import { Channel } from "./channel";
export const Discord = {
Interaction,
Guild,
Channel,
};

View file

@ -0,0 +1,89 @@
/**
* Discord.Interaction abstracts interaction option reading and replies.
*/
import {
ChatInputCommandInteraction,
ButtonInteraction,
ModalSubmitInteraction,
GuildMember,
InteractionReplyOptions,
MessageFlags,
} from "discord.js";
type AnyInteraction =
| ChatInputCommandInteraction
| ButtonInteraction
| ModalSubmitInteraction;
// ─── Options resolver ─────────────────────────────────────────────────────────
export interface OptionParams {
key: string;
required?: boolean;
}
export interface OptionsResolver {
string(params: OptionParams): string | null;
integer(params: OptionParams): number | null;
number(params: OptionParams): number | null;
boolean(params: OptionParams): boolean | null;
channel(params: OptionParams): any | null;
user(params: OptionParams): any | null;
subcommand(): string | null;
subcommandGroup(): string | null;
}
function options<T extends ChatInputCommandInteraction>(interaction: T): OptionsResolver {
const opts = interaction.options as any;
return {
string: ({ key, required = false }) => opts.getString(key, required),
integer: ({ key, required = false }) => opts.getInteger(key, required),
number: ({ key, required = false }) => opts.getNumber(key, required),
boolean: ({ key, required = false }) => opts.getBoolean(key, required),
channel: ({ key, required = false }) => opts.getChannel(key, required),
user: ({ key, required = false }) => opts.getUser(key, required),
subcommand: () => opts.getSubcommand(false),
subcommandGroup: () => opts.getSubcommandGroup(false),
};
}
// ─── Reply helpers ────────────────────────────────────────────────────────────
interface ReplyParams {
content: string;
ephemeral?: boolean;
components?: any[];
embeds?: any[];
}
async function reply(interaction: AnyInteraction, params: ReplyParams): Promise<void> {
const opts: InteractionReplyOptions = {
content: params.content,
components: params.components,
embeds: params.embeds,
flags: params.ephemeral ? MessageFlags.Ephemeral : undefined,
};
if (interaction.replied || interaction.deferred) {
await interaction.followUp(opts);
} else {
await interaction.reply(opts);
}
}
async function followUp(interaction: AnyInteraction, params: ReplyParams): Promise<void> {
await interaction.followUp({
content: params.content,
components: params.components,
embeds: params.embeds,
flags: params.ephemeral ? MessageFlags.Ephemeral : undefined,
});
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const Interaction = {
options,
reply,
followUp,
};

View file

@ -1,47 +1,73 @@
import { AutocompleteInteraction } from "discord.js"; import { AutocompleteInteraction } from "discord.js";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { getCharacters } from "@systems/characters"; import { getCharacters } from "@systems/characters";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { getNationEmoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { CharacterRegistry } from "@registry/character-registry";
import { UserRegistry } from "@registry/user-registry";
import { Paths } from "@helpers/paths";
import { Nation } from "@types";
import { NATION_UNICODE } from "@systems/nations";
import { autocompleteLayout } from "@subcommands/tg-config/set-layout";
import { UpdatesCommands } from "@subcommands/admin/updates";
import fs from "fs"; import fs from "fs";
import path from "path";
// ─── Usermap cache ────────────────────────────────────────────────────────────
let _usermapCache: Record<string, any> | null = null;
function getUsermapCache(): Record<string, any> {
if (!_usermapCache) {
try { _usermapCache = JSON.parse(fs.readFileSync(Paths.data("usermap.json"), "utf8")); }
catch { _usermapCache = {}; }
}
return _usermapCache!;
}
export function invalidateUsermapCache(): void { _usermapCache = null; }
// ─── Autocomplete subsets ───────────────────────────────────────────────────── // ─── Autocomplete subsets ─────────────────────────────────────────────────────
async function autocompleteCharNames( async function autocompleteCharNames(
interaction: AutocompleteInteraction, interaction: AutocompleteInteraction,
focused: string focused: string,
nation?: Nation | null // optional — if provided, filter by nation
): Promise<void> { ): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const user = await resolveUser(member); const user = await resolveUser(member);
// For bringer set — scan all chars by nation, no user filter needed
if (nation !== undefined) {
const all = CharacterRegistry.all();
const results = all
.filter((c) => !nation || c.nation === nation)
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
.map((c) => {
const nationEmoji = c.nation ? (NATION_UNICODE[c.nation] || c.nation) : "";
return { name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), value: c.name };
})
.slice(0, 25);
return interaction.respond(results);
}
if (!user.userKey) return interaction.respond([]); if (!user.userKey) return interaction.respond([]);
// Own chars
const ownChars = getCharacters(user.userKey).map((c) => { const ownChars = getCharacters(user.userKey).map((c) => {
const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : ""; const nationEmoji = c.nation ? (Emoji.nation(c.nation) || c.nation) : "";
return { return {
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
value: c.name, value: c.name,
}; };
}); });
const sharedChars: { name: string; value: string }[] = []; // Shared chars
try { const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => {
const chars = JSON.parse( const nationEmoji = char.nation ? (Emoji.nation(char.nation) || char.nation) : "";
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") return {
); name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { value: char.name,
if (ownerKey === user.userKey) continue; };
for (const char of data.characters ?? []) { });
if (char.sharedWith?.includes(user.userKey)) {
const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : "";
sharedChars.push({
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
value: char.name,
});
}
}
}
} catch {}
const all = [...ownChars, ...sharedChars] const all = [...ownChars, ...sharedChars]
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
@ -55,9 +81,7 @@ async function autocompleteUserKeys(
focused: string focused: string
): Promise<void> { ): Promise<void> {
try { try {
const usermap = JSON.parse( const usermap = getUsermapCache();
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
);
const choices = Object.entries(usermap) const choices = Object.entries(usermap)
.map(([, entry]: [string, any]) => { .map(([, entry]: [string, any]) => {
const fileKey = typeof entry === "string" ? entry : entry.file; const fileKey = typeof entry === "string" ? entry : entry.file;
@ -76,7 +100,7 @@ async function autocompleteSlots(
interaction: AutocompleteInteraction, interaction: AutocompleteInteraction,
focused: string focused: string
): Promise<void> { ): Promise<void> {
const slots = cfg("slots") const slots = Config.get({ section: "poll", key: "slots" })
.filter((s) => s.active) .filter((s) => s.active)
.map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) })) .map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) }))
.filter((s) => s.name.includes(focused)); .filter((s) => s.name.includes(focused));
@ -90,11 +114,23 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
const focused = interaction.options.getFocused(true); const focused = interaction.options.getFocused(true);
const optionName = focused.name; const optionName = focused.name;
const focusedValue = focused.value as string; const focusedValue = focused.value as string;
const sub = interaction.options.getSubcommand(false);
const subGroup = interaction.options.getSubcommandGroup(false);
if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue); if (optionName === "char_name") {
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); // Bringer set — filter by selected nation
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (sub === "set" && subGroup === "bringer") {
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); const nation = interaction.options.getString("nation") as Nation | null;
return await autocompleteCharNames(interaction, focusedValue, nation);
}
return await autocompleteCharNames(interaction, focusedValue);
}
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
if (optionName === "layout") return await autocompleteLayout(interaction);
if (optionName === "version") return UpdatesCommands.autocomplete(interaction);
await interaction.respond([]); await interaction.respond([]);
} catch (err) { } catch (err) {

View file

@ -5,23 +5,31 @@ import {
ActionRowBuilder, ActionRowBuilder,
TextChannel TextChannel
} from "discord.js"; } from "discord.js";
import { cfg } from "@systems/config";
import { pollReplyAndDelete } from "../utils"; import { pollReplyAndDelete } from "../utils";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveMessage, nowFormatted } from "@systems/messages";
import { resolveNation } from "@systems/nations"; import { Nations } from "@systems/nations";
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { showConflictEmbed } from "@systems/conflict"; import { showConflictEmbed } from "@systems/conflict";
import { getCharacters } from "@systems/characters"; import { getCharacters } from "@systems/characters";
import { getImpersonation } from "@systems/impersonate"; import { getImpersonation } from "@systems/impersonate";
import { format } from "@format"; import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect";
import { getEffectiveCharacter } from "@systems/borrow";
import { Character } from "@src/types"; import { Character } from "@src/types";
import { modals } from "@handlers/modals"; import { modals } from "@handlers/modals";
import { Ephemeral } from "@registry/ephemeral-registry";
import { Nation, CLASSES } from "@types";
import { InteractionLock } from "@helpers/interaction-lock";
import { Benchmark } from "@systems/benchmark";
import { Config } from "@systems/config";
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
const clickCounts = new Map<string, { yes: number; no: number }>(); const LOCK_AT = Config.get({ section: "poll", key: "lockAt" });
const clickCounts = new Map<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>();
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -69,13 +77,8 @@ async function handleCharacterConflict(
} }
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; const slotHour = slot !== undefined ? polls.get(slot)?.slot : Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20;
// await interaction.followUp({
// content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
// // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
// ephemeral: true
// });
const { buildCharSelectButtons } = require("@systems/charSelect"); const { buildCharSelectButtons } = require("@systems/charSelect");
const buttons = buildCharSelectButtons(userKey ?? "", { const buttons = buildCharSelectButtons(userKey ?? "", {
customIdPrefix: `switch_after_reclaim:${userKey}`, customIdPrefix: `switch_after_reclaim:${userKey}`,
@ -93,123 +96,175 @@ async function handleCharacterConflict(
// ─── Main button handler ────────────────────────────────────────────────────── // ─── Main button handler ──────────────────────────────────────────────────────
export async function handleButton(interaction: ButtonInteraction): Promise<void> { export async function handleButton(interaction: ButtonInteraction): Promise<void> {
console.log(`[handleButton] ${interaction.customId} - start`);
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
try { await InteractionLock.with(interaction, async () => {
await interaction.deferUpdate(); const bench = Benchmark.start("handleButton");
} catch {
return;
}
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return; if (slot === undefined) return;
const state = polls.get(slot)!; const state = polls.get(slot)!;
if (state.locked || state.confirmed !== null) return; if (state.locked || state.confirmed !== null) return;
const userId = interaction.user.id;
const member = interaction.guild!.members.cache.get(userId)
?? await interaction.guild!.members.fetch(userId);
bench.mark("fetchMember");
const user = await resolveUser(member);
bench.mark("resolveUser");
const votedYes = interaction.customId === "tg_yes";
const now = nowFormatted();
const impersonating = getImpersonation(userId);
const voteId = impersonating ? `impersonated:${impersonating}` : userId;
const lookupUsername = user.lookupUsername ?? user.discordUsername;
// Nation check
const nation = Nations.resolve(member, user.userKey);
if (!nation) {
const capella = format.nation(Nation.Capella);
const procyon = format.nation(Nation.Procyon);
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
return;
}
// Click tracking
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
const clicks = clickCounts.get(voteId)!;
if (votedYes && clicks.yes >= LOCK_AT) return;
if (!votedYes && clicks.no >= LOCK_AT) return;
// Ignore same vote
if (votedYes && state.yes.has(voteId)) return;
if (!votedYes && state.no.has(voteId)) return;
// Increment click (may be decremented in conflict handler)
if (votedYes) clicks.yes += 1;
else clicks.no += 1;
const clickCount = votedYes ? clicks.yes : clicks.no;
// Resolve messages
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
// Character conflict check — applies to both Yes and No
if (baseEntry.characterName) {
const conflictChar = {
name: baseEntry.characterName!,
class: CLASSES[baseEntry.characterClass!] ?? { key: baseEntry.characterClass!, name: baseEntry.characterClass!, shortName: baseEntry.characterClass! },
level: baseEntry.characterLevel!,
nation: baseEntry.characterNation!,
ownerKey: user.userKey ?? "",
active: false, // not needed for display
};
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
state, baseEntry.characterName, voteId, user.userKey ?? ""
);
if (found) {
await handleCharacterConflict(
interaction, user.userKey, conflictChar,
entryUserKey, clicks, votedYes
);
return;
}
}
// Register vote
if (votedYes) {
const previousNo = state.no.get(voteId);
state.no.delete(voteId);
state.yes.set(voteId, {
...baseEntry,
discordId: userId,
votedAt: now,
previousNoAt: previousNo?.votedAt,
publicMessage: publicMsg ?? undefined,
});
} else {
const previousYes = state.yes.get(voteId);
state.yes.delete(voteId);
state.no.set(voteId, {
...baseEntry,
votedAt: now,
discordId: userId,
previousYesAt: previousYes?.votedAt,
publicMessage: publicMsg ?? undefined,
});
}
const locked = clickCount >= LOCK_AT;
if (locked) state.locked = true;
persist.save(polls);
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
const msgContent = ephemeralMsg
? `${ephemeralMsg}${lockedSuffix}`
: locked ? "🔒 You've been locked in." : null;
await pollReplyAndDelete(interaction, msgContent);
const channel = interaction.channel as TextChannel;
await Promise.all([
showActiveCharSwitching(interaction),
updatePollMessage(channel, slot),
]);
bench.mark("updatePollMessage+companion");
});
}
export async function showActiveCharSwitching(interaction: ButtonInteraction): Promise<void> {
const bench = Benchmark.start("showActiveCharSwitching");
const userId = interaction.user.id; const userId = interaction.user.id;
const member = interaction.guild!.members.cache.get(userId) const member = interaction.guild!.members.cache.get(userId)
?? await interaction.guild!.members.fetch(userId); ?? await interaction.guild!.members.fetch(userId);
const user = await resolveUser(member); const user = await resolveUser(member);
const votedYes = interaction.customId === "tg_yes";
const now = nowFormatted();
const impersonating = getImpersonation(userId); const impersonating = getImpersonation(userId);
const voteId = impersonating ? `impersonated:${impersonating}` : userId; const voteId = impersonating ? `impersonated:${impersonating}` : userId;
const lookupUsername = user.lookupUsername ?? user.discordUsername;
// Nation check
const nation = resolveNation(member, user.userKey);
if (!nation) {
const capella = format.nation("Capella");
const procyon = format.nation("Procyon");
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
return;
}
// Click tracking
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
const clicks = clickCounts.get(voteId)!; const clicks = clickCounts.get(voteId)!;
if (votedYes && clicks.yes >= LOCK_AT) return; const votedYes = interaction.customId === "tg_yes";
if (!votedYes && clicks.no >= LOCK_AT) return;
// Ignore same vote
if (votedYes && state.yes.has(voteId)) return;
if (!votedYes && state.no.has(voteId)) return;
// Increment click (may be decremented in conflict handler)
if (votedYes) clicks.yes += 1;
else clicks.no += 1;
const clickCount = votedYes ? clicks.yes : clicks.no; const clickCount = votedYes ? clicks.yes : clicks.no;
// Resolve messages const locked = clickCount >= LOCK_AT;
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") // Yes vote companion — show active char + switch buttons
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); if (votedYes && user.userKey && !locked) {
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); bench.mark("getEffectiveCharacter");
if (char) {
// Character conflict check — applies to both Yes and No const starEmoji = Config.get({ section: "emoji", key: "activeChar" });
if (baseEntry.characterName) { const borrowNote = borrowedFrom ? ` 🔗` : "";
const conflictChar = { const buttons = buildCharSelectButtons(user.userKey, {
name: baseEntry.characterName!, customIdPrefix: `companion_switch:${user.userKey}`,
class: baseEntry.characterClass!, excludeCharName: char.name,
level: baseEntry.characterLevel!, appendToCustomId: ":yes",
nation: baseEntry.characterNation!, });
active: false, // not needed for display if (buttons.length > 0) {
}; const companionMsg = await interaction.followUp({
content: `${starEmoji} ${format.char(char)}${borrowNote}`,
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( components: buttons,
state, baseEntry.characterName, voteId, user.userKey ?? "" ephemeral: true,
); fetchReply: true,
if (found) { });
await handleCharacterConflict( bench.mark("companion");
interaction, user.userKey, conflictChar, bench.end();
entryUserKey, clicks, votedYes
); Ephemeral.store(voteId, "companion", interaction, companionMsg.id);
return; }
} }
} }
// Register vote
if (votedYes) {
const previousNo = state.no.get(voteId);
state.no.delete(voteId);
state.yes.set(voteId, {
...baseEntry,
discordId: userId,
votedAt: now,
previousNoAt: previousNo?.votedAt,
publicMessage: publicMsg ?? undefined,
});
} else {
const previousYes = state.yes.get(voteId);
state.yes.delete(voteId);
state.no.set(voteId, {
...baseEntry,
votedAt: now,
discordId: userId,
previousYesAt: previousYes?.votedAt,
publicMessage: publicMsg ?? undefined,
});
}
const locked = clickCount >= LOCK_AT;
if (locked) state.locked = true;
persist.save(polls);
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
const msgContent = ephemeralMsg
? `${ephemeralMsg}${lockedSuffix}`
: locked ? "🔒 You've been locked in." : null;
await pollReplyAndDelete(interaction, msgContent);
const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot);
} }
export function resetClickCounts(): void { export function resetClickCounts(): void {
@ -260,8 +315,8 @@ export async function handleScoreSubmitButton(interaction: ButtonInteraction): P
// } // }
// // Build slot selector — all valid slots, with the active TG pre-selected // // Build slot selector — all valid slots, with the active TG pre-selected
// const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; // const validSlots = Config.get({ section: "poll", key: "slots" }).map((s) => s.tgHour) as number[];
// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; // const activeSlot = slot ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
// const select = new StringSelectMenuBuilder() // const select = new StringSelectMenuBuilder()
// .setCustomId(`score_slot_select:${user.userKey}`) // .setCustomId(`score_slot_select:${user.userKey}`)

View file

@ -7,107 +7,73 @@ import { handleBorrowDeclineButton } from "@subcommands/char/decline";
import { handleConflictButton } from "@systems/conflict"; import { handleConflictButton } from "@systems/conflict";
import { handleImpersonateButton } from "@subcommands/impersonate"; import { handleImpersonateButton } from "@subcommands/impersonate";
import { handleAutocomplete } from "@handlers/autocomplete"; import { handleAutocomplete } from "@handlers/autocomplete";
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; import { Ephemeral } from "@registry/ephemeral-registry";
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow"; import { getImpersonation } from "@systems/impersonate";
import { polls, updatePollMessage } from "@systems/poll";
import { cfg } from "@systems/config";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { format } from "@format";
import { modals } from "@handlers/modals"; import { modals } from "@handlers/modals";
import fs from "fs"; import { Character } from "@systems/character";
import path from "path"; import { handleTgAdminCommand } from "@commands/tgAdmin";
import { InteractionLock } from "@helpers/interaction-lock";
import { CharacterRegistry } from "@registry/character-registry";
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> { async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
const parts = btn.customId.split(":"); const prefix = btn.customId.startsWith("companion_switch:") ? "companion_switch:" : "switch_after_reclaim:";
const userKey = parts[1]; const withoutPrefix = btn.customId.slice(prefix.length);
const charName = parts[2]; const firstColon = withoutPrefix.indexOf(":");
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no"; const userKey = withoutPrefix.slice(0, firstColon);
const rest = withoutPrefix.slice(firstColon + 1);
const lastColon = rest.lastIndexOf(":");
const charName = rest.slice(0, lastColon);
const prevVoteType = (rest.slice(lastColon + 1) || "yes") as "yes" | "no";
const chars = JSON.parse( const impersonating = getImpersonation(btn.user.id);
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") const voteId = impersonating ? `impersonated:${impersonating}` : btn.user.id;
);
let resolvedChar: any = null; // Resolve char without switching
let resolvedChar: any = null;
let borrowedFrom: string | null = null; let borrowedFrom: string | null = null;
// Try own char first await InteractionLock.with(btn, async () => {
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName); const ownEntry = CharacterRegistry.findForUser(userKey, charName);
if (ownEntry) {
setActiveCharacter(userKey, charName);
clearSessionBorrowForUser(userKey);
resolvedChar = ownEntry;
} else {
// Try shared char
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
const char = data.characters?.find(
(c: any) => c.name === charName && c.sharedWith?.includes(userKey)
);
if (char) {
setPersistentPreference(userKey, ownerKey, charName);
clearSessionBorrowForUser(userKey);
resolvedChar = char;
borrowedFrom = ownerKey;
break;
}
}
}
if (!resolvedChar) { if (ownEntry) {
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); resolvedChar = ownEntry;
return;
}
// Re-add to poll with previous vote type
const slot = [...polls.keys()][0];
const state = slot !== undefined ? polls.get(slot) : null;
if (state && !state.locked && state.confirmed === null) {
const { char } = getEffectiveCharacter(userKey);
const now = nowFormatted();
const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null);
const voteEntry = {
userKey,
displayName: charName,
characterName: char?.name ?? charName,
characterClass: char?.class ?? resolvedChar.class,
characterLevel: char?.level ?? resolvedChar.level,
characterNation: char?.nation ?? resolvedChar.nation,
borrowedFrom: borrowedFrom ?? undefined,
discordId: btn.user.id,
votedAt: now,
publicMessage: publicMsg ?? undefined,
};
// Find and reuse existing vote ID — avoids duplicate entries
let existingVoteId: string | null = null;
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === userKey) {
if (!existingVoteId) existingVoteId = id;
state.yes.delete(id);
state.no.delete(id);
}
}
const voteId = existingVoteId ?? btn.user.id;
if (prevVoteType === "yes") {
state.yes.set(voteId, voteEntry);
} else { } else {
state.no.set(voteId, voteEntry); const shared = CharacterRegistry.sharedWith(userKey).find(({ char }) => char.name === charName);
if (shared) {
resolvedChar = shared.char;
borrowedFrom = shared.ownerKey;
}
} }
console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`); if (!resolvedChar) {
console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`)); await btn.followUp({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`)); return;
}
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; // Delegate to shared switch logic
await updatePollMessage(channel, slot!); const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, btn, prevVoteType);
}
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName; if (result.replyData) {
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; const { content, components } = result.replyData;
await btn.reply({ console.log(`[switchAfterReclaim] replyData, isCompanion=${btn.customId.startsWith("companion_switch:")}`);
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`, if (btn.customId.startsWith("companion_switch:")) {
ephemeral: true, await Ephemeral.update(voteId, "companion", content, components, { final: false });
} else {
await btn.followUp(result.replyData);
}
return;
}
if (result.success && result.message) {
const companionExists = !!Ephemeral.get(voteId, "companion");
console.log(`[switchAfterReclaim] success, companionExists=${companionExists} voteId=${voteId}`);
await Ephemeral.update(voteId, "companion", result.message, []);
// Ephemeral.delete(voteId, "companion"); // clean up after final switch
if (!companionExists) {
await btn.followUp({ content: result.message, ephemeral: true });
}
}
}); });
} }
@ -119,37 +85,7 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} }
if (interaction.isButton()) { if (interaction.isButton()) {
const btn = interaction as ButtonInteraction; return await handleButtonInteraction(interaction as ButtonInteraction);
console.log("[interactions] interaction btnId:", btn.customId);
if (btn.customId.startsWith("conflict_")) {
console.log("[interactions] routing to conflict handler:", btn.customId);
return await handleConflictButton(btn);
}
if (btn.customId.startsWith("impersonate_")) {
return await handleImpersonateButton(btn);
}
if (btn.customId.startsWith("switch_after_reclaim:")) {
return await handleSwitchAfterReclaim(btn);
}
if (btn.customId.startsWith("borrow_accept:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
}
if (btn.customId.startsWith("borrow_decline:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
}
if (btn.customId === "tg_score_submit") {
return await handleScoreSubmitButton(btn);
}
return await handleButton(btn);
} }
if (interaction.isModalSubmit()) { if (interaction.isModalSubmit()) {
@ -167,9 +103,7 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} }
if (interaction.isChatInputCommand()) { if (interaction.isChatInputCommand()) {
const cmd = interaction as ChatInputCommandInteraction; await handleChatInputCommandInteraction(interaction as ChatInputCommandInteraction);
if (cmd.commandName === "tg") await handleTgCommand(cmd);
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
} }
} catch (err) { } catch (err) {
console.error("Interaction error:", err); console.error("Interaction error:", err);
@ -182,4 +116,46 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} }
} catch {} } catch {}
} }
}
async function handleChatInputCommandInteraction(cmd: ChatInputCommandInteraction): Promise<void> {
if (cmd.commandName === "tg") await handleTgCommand(cmd);
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
if (cmd.commandName === "tg-admin") await handleTgAdminCommand(cmd);
}
async function handleButtonInteraction(btn: ButtonInteraction): Promise<void> {
console.log("[interactions] interaction btnId:", btn.customId);
if (btn.customId.startsWith("conflict_")) {
console.log("[interactions] routing to conflict handler:", btn.customId);
return await handleConflictButton(btn);
}
if (btn.customId.startsWith("impersonate_")) {
return await handleImpersonateButton(btn);
}
if (btn.customId.startsWith("switch_after_reclaim:")) {
return await handleSwitchAfterReclaim(btn);
}
if (btn.customId.startsWith("borrow_accept:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
}
if (btn.customId.startsWith("borrow_decline:")) {
const [, ownerKey, requesterKey] = btn.customId.split(":");
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
}
if (btn.customId === "tg_score_submit") {
return await handleScoreSubmitButton(btn);
}
if (btn.customId.startsWith("companion_switch:")) {
return await handleSwitchAfterReclaim(btn);
}
return await handleButton(btn);
} }

View file

@ -0,0 +1,29 @@
type LockableInteraction = {
user: { id: string };
customId: string;
deferUpdate(): Promise<any>;
};
const _processing = new Set<string>();
export async function withInteractionLock<T>(
interaction: LockableInteraction,
fn: () => Promise<T>
): Promise<T | null> {
const key = `${interaction.user.id}:${interaction.customId}`;
if (_processing.has(key)) {
return null;
}
_processing.add(key);
await interaction.deferUpdate();
try {
return await fn();
} finally {
_processing.delete(key);
}
}
export const InteractionLock = {
with: withInteractionLock,
clear: () => _processing.clear(), // useful for testing
};

13
src/helpers/paths.ts Normal file
View file

@ -0,0 +1,13 @@
import path from "path";
// Resolves from project root regardless of which file imports this
const PROJECT_ROOT = path.resolve(__dirname, "../../");
export const Paths = {
data: (...segments: string[]) => path.join(PROJECT_ROOT, "data", ...segments),
messages: (...segments: string[]) => path.join(PROJECT_ROOT, "messages", ...segments),
emojis: (...segments: string[]) => path.join(PROJECT_ROOT, "data", "emojis", ...segments),
scripts: (...segments: string[]) => path.join(PROJECT_ROOT, "scripts", ...segments),
src: (...segments: string[]) => path.join(PROJECT_ROOT, "src", ...segments),
resolve: (...segments: string[]) => path.join(PROJECT_ROOT, ...segments),
};

View file

@ -1,16 +1,14 @@
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
import { loadConfig, cfg } from "@systems/config"; import { Config } from "@systems/config";
import { loadMessages } from "@systems/messages";
import { loadEmojis } from "@systems/emojis";
import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank";
import { scheduleSlots } from "@systems/slots";
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
import { handleInteraction } from "@handlers/interactions"; import { handleInteraction } from "@handlers/interactions";
import { buildTgCommand } from "@commands/tg"; import { buildTgCommand } from "@commands/tg";
import { buildTgConfigCommand } from "@commands/tgConfig"; import { buildTgConfigCommand } from "@commands/tgConfig";
import { TGSlot } from "@src/types"; import { TGSlot } from "@src/types";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { buildTgAdminCommand } from "@commands/tgAdmin";
import { Scheduler } from "@systems/scheduler";
import { Runtime } from "@systems/runtime";
const TOKEN = process.env.DISCORD_TOKEN!; const TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.CLIENT_ID!; const CLIENT_ID = process.env.CLIENT_ID!;
@ -23,13 +21,18 @@ const client = new Client({
async function registerCommands(): Promise<void> { async function registerCommands(): Promise<void> {
const rest = new REST({ version: "10" }).setToken(TOKEN); const rest = new REST({ version: "10" }).setToken(TOKEN);
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
body: [buildTgCommand().toJSON(), buildTgConfigCommand().toJSON()], body: [
buildTgCommand().toJSON(),
buildTgConfigCommand().toJSON(),
buildTgAdminCommand().toJSON(),
],
}); });
console.log("Slash commands registered."); console.log("Slash commands registered.");
} }
async function onPollOpen(slot: TGSlot): Promise<void> { async function onPollOpen(slot: TGSlot): Promise<void> {
const channel = await client.channels.fetch(cfg("pollChannelId")) as any; const channelId = Config.get({ section: "channels", key: "poll" });
const channel = await client.channels.fetch(channelId) as any;
if (!channel) return console.error("Poll channel not found."); if (!channel) return console.error("Poll channel not found.");
await postPoll(channel, slot); await postPoll(channel, slot);
} }
@ -41,7 +44,8 @@ async function onPollLock(slot: TGSlot): Promise<void> {
lockPoll(slot.tgHour); lockPoll(slot.tgHour);
const channel = await client.channels.fetch(cfg("pollChannelId")) as any; const channelId = Config.get({ section: "channels", key: "poll" });
const channel = await client.channels.fetch(channelId) as any;
if (!channel) return; if (!channel) return;
// Buttons disabled, no submit button yet — that comes at close // Buttons disabled, no submit button yet — that comes at close
@ -54,7 +58,8 @@ async function onPollClose(slot: TGSlot): Promise<void> {
const state = polls.get(slot.tgHour); const state = polls.get(slot.tgHour);
if (!state) return; if (!state) return;
const channel = await client.channels.fetch(cfg("pollChannelId")) as any; const channelId = Config.get({ section: "channels", key: "poll" });
const channel = await client.channels.fetch(channelId) as any;
if (!channel) return; if (!channel) return;
await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true
@ -66,18 +71,15 @@ client.on("interactionCreate", handleInteraction);
client.once("clientReady", async () => { client.once("clientReady", async () => {
console.log(`Logged in as ${client.user!.tag}`); console.log(`Logged in as ${client.user!.tag}`);
loadConfig(); await Runtime.start();
loadMessages();
loadEmojis();
loadCharacters();
loadWRank();
const restored = persist.load(); const restored = persist.load();
if (restored) { if (restored) {
for (const [slot, state] of restored) polls.set(slot, state); for (const [slot, state] of restored) polls.set(slot, state);
// Re-render all restored poll messages // Re-render all restored poll messages
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; const channelId = Config.get({ section: "channels", key: "poll" });
const channel = await client.channels.fetch(channelId) as any;
for (const slot of polls.keys()) { for (const slot of polls.keys()) {
const state = polls.get(slot)!; const state = polls.get(slot)!;
await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null);
@ -93,7 +95,7 @@ client.once("clientReady", async () => {
await registerCommands(); await registerCommands();
} }
scheduleSlots(client, onPollOpen, onPollLock, onPollClose); Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose);
console.log("Bot ready."); console.log("Bot ready.");
}); });

View file

@ -1,33 +0,0 @@
import cron from "node-cron";
import { TextChannel } from "discord.js";
// Lock poll at TG start (20:00), reveal Submit Score button at TG end (20:35)
// Runs daily — no-ops silently if no poll is active for that slot.
cron.schedule("0 20 * * *", async () => {
const slot = 20;
const state = polls.get(slot);
if (!state || state.locked) return;
lockPoll(slot);
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel;
await updatePollMessage(channel, slot, cfg("lockMessage"));
console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`);
});
cron.schedule("35 20 * * *", async () => {
const slot = 20;
const state = polls.get(slot);
if (!state) return;
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel;
await updatePollMessage(channel, slot, undefined, true); // showSubmit = true
console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`);
});
// ─── NOTE on future slots ─────────────────────────────────────────────────────
//
// Right now only slot 20 has an active poll. When we add more votable slots,
// pull the active slot from cfg("slots").filter(s => s.active) and schedule
// dynamically, or make the cron time configurable in config.json.

View file

@ -0,0 +1,66 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Updates } from "@systems/updates";
import { replyAndDelete } from "@utils";
export async function handleUpdatesPost(interaction: ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply({ ephemeral: true });
const options = interaction.options as any;
const version = options.getString("version") ?? Updates.latest();
if (!version) {
await interaction.editReply("❌ No versions found.");
return;
}
await Updates.post({ version, client: interaction.client });
await interaction.editReply(`✅ Update \`${version}\` posted.`);
}
export async function handleUpdatesPreview(interaction: ChatInputCommandInteraction): Promise<void> {
const options = interaction.options as any;
const version = options.getString("version") ?? Updates.latest();
if (!version) {
await interaction.reply({ content: "❌ No versions found.", ephemeral: true });
return;
}
await Updates.preview({ version, interaction });
}
export async function handleUpdatesList(interaction: ChatInputCommandInteraction): Promise<void> {
const versions = Updates.list();
const latest = Updates.latest();
const lines = versions.map((v) => {
const entry = Updates.get({ version: v });
const posted = entry?.messageId ? "✅" : "⬜";
const tag = v === latest ? " ← latest" : "";
return `${posted} \`${v}\`${entry?.title ?? ""}${tag}`;
});
await interaction.reply({
content: lines.length > 0 ? lines.join("\n") : "No versions found.",
ephemeral: true,
});
}
export async function autocompleteVersion(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const versions = Updates.list();
const choices = versions
.filter((v) => v.toLowerCase().includes(focused))
.map((v) => {
const entry = Updates.get({ version: v });
return { name: `${v}${entry?.title ?? ""}`, value: v };
})
.slice(0, 25);
await interaction.respond(choices);
}
export const UpdatesCommands = {
post: handleUpdatesPost,
preview: handleUpdatesPreview,
list: handleUpdatesList,
autocomplete: autocompleteVersion,
};

View file

@ -0,0 +1,173 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users";
import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages";
import { replyAndDelete } from "@utils";
import { Nations } from "@systems/nations";
import { polls, updatePollMessage } from "@systems/poll";
import { getEffectiveCharacter } from "@systems/borrow";
export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
}
const sub = interaction.options.getSubcommand();
// ── map ──────────────────────────────────────────────────────────────────────
if (sub === "map") {
const target = interaction.options.getUser("user", true);
const userKey = interaction.options.getString("userkey", true);
// Check if userKey exists in characters.json
const fs = require("fs");
const path = require("path");
const chars = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/characters.json"), "utf8"));
if (!chars[userKey]) {
return void replyAndDelete(interaction, `❌ No characters found for userKey **${userKey}**. Make sure it exists in characters.json first.`, true);
}
// Check if this Discord ID is already mapped
const existing = getUsermapEntryById(target.id, target.username);
const entry = existing ?? { file: userKey, aliases: [target.globalName ?? target.username] };
entry.file = userKey;
setUsermapEntry(target.id, entry);
return void replyAndDelete(interaction, `✅ Mapped **${target.username}** (${target.id}) → **${userKey}**`, true);
}
// ── unmap ────────────────────────────────────────────────────────────────────
if (sub === "unmap") {
const target = interaction.options.getUser("user", true);
const entry = getUsermapEntryById(target.id, target.username);
if (!entry) {
return void replyAndDelete(interaction, `❌ No mapping found for **${target.username}**.`, true);
}
removeUsermapEntry(target.id);
// Also remove old username-based entry if it exists
removeUsermapEntry(target.username);
return void replyAndDelete(interaction, `✅ Removed mapping for **${target.username}** (was → **${entry.file}**)`, true);
}
// ── list ─────────────────────────────────────────────────────────────────────
if (sub === "list") {
const fs = require("fs");
const path = require("path");
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8"));
const lines: string[] = [];
for (const [key, value] of Object.entries(raw)) {
const file = typeof value === "string" ? value : (value as any).file;
const aliases = typeof value === "object" ? ((value as any).aliases ?? []) : [];
const isId = /^\d{17,20}$/.test(key);
const type = isId ? "🆔" : "👤";
lines.push(`${type} \`${key}\` → **${file}**${aliases.length ? ` *(${aliases.join(", ")})*` : ""}`);
}
if (lines.length === 0) return void replyAndDelete(interaction, "No usermap entries found.", true);
// Split into chunks to avoid Discord's 2000 char limit
const chunks: string[] = [];
let current = "";
for (const line of lines) {
if (current.length + line.length + 1 > 1900) {
chunks.push(current);
current = line;
} else {
current += (current ? "\n" : "") + line;
}
}
if (current) chunks.push(current);
await interaction.reply({ content: chunks[0], ephemeral: true });
for (const chunk of chunks.slice(1)) {
await interaction.followUp({ content: chunk, ephemeral: true });
}
return;
}
}
export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
}
const target = interaction.options.getUser("user", true);
const targetMember = await interaction.guild!.members.fetch(target.id);
const entry = getUsermapEntryById(target.id, target.username);
if (!entry) {
return void replyAndDelete(interaction, `❌ **${target.username}** is not registered in usermap.json. Use \`/tg-admin user map\` first.`, true);
}
const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const state = polls.get(slot)!;
// Find voter entry by discord ID or by userKey
let foundId: string | null = null;
for (const [id, e] of [...state.yes.entries(), ...state.no.entries()]) {
if ((e as any).discordId === target.id || e.userKey === entry.file) {
foundId = id;
break;
}
}
if (!foundId) return void replyAndDelete(interaction, `❌ **${target.username}** has not voted in the active poll.`, true);
const { char, borrowedFrom } = getEffectiveCharacter(entry.file);
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${entry.file}**.`, true);
const nation = Nations.resolve(targetMember, entry.file);
const updateEntry = (map: Map<string, any>) => {
const e = map.get(foundId!);
if (e) {
e.userKey = entry.file;
e.characterName = char.name;
e.characterClass = char.class;
e.characterLevel = char.level;
e.characterNation = char.nation ?? nation;
e.borrowedFrom = borrowedFrom ?? undefined;
e.discordId = target.id;
}
};
updateEntry(state.yes);
updateEntry(state.no);
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as any;
await updatePollMessage(channel, slot);
const { format } = require("@format");
return void replyAndDelete(interaction, `✅ Fixed poll entry for **${target.username}** → ${format.char(char)}`, true);
}
export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
}
const target = interaction.options.getUser("user", true);
const { polls } = require("@systems/poll");
const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const state = polls.get(slot)!;
let found: any = null;
for (const [id, e] of [...state.yes.entries(), ...state.no.entries()]) {
if ((e as any).discordId === target.id || e.userKey === target.id) {
found = { voteId: id, ...e };
break;
}
}
if (!found) return void replyAndDelete(interaction, `❌ No poll entry found for **${target.username}**.`, true);
return void replyAndDelete(interaction, `\`\`\`json\n${JSON.stringify(found, null, 2)}\n\`\`\``, true);
}

View file

@ -1,10 +1,12 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { clearBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { WRank } from "@systems/wrank";
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
clearBringerOverride(nation); Bringer.clearOverride({ nation });
WRank.save();
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
} }

View file

@ -1,12 +1,28 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { setBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { CharacterRegistry } from "@registry/character-registry";
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("name", true); const charName = interaction.options.getString("char_name", true);
setBringerOverride(nation, charName); // Resolve character
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`); const char = CharacterRegistry.find(charName);
if (!char) {
return void replyAndDelete(interaction, `❌ Character **${charName}** not found.`, true);
}
// Validate nation matches
if (char.nation !== nation) {
return void replyAndDelete(interaction,
`❌ **${charName}** is a ${char.nation} character, not ${nation}.`, true
);
}
Bringer.override({ nation, character: char });
return void replyAndDelete(interaction,
`✅ **${charName}** set as ${nation === Nation.Capella ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`, true
);
} }

View file

@ -1,13 +1,14 @@
import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "@systems/config";
import { getCharacterByName } from "../../systems/characters"; import { getCharacterByName } from "@systems/characters";
import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "../../systems/borrow"; import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { replyAndDelete } from "../../utils"; import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
import { replyAndDelete } from "@src/utils";
export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise<void> {
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); const ownerMember = await interaction.guild!.members.fetch(interaction.user.id);
const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); const ownerEntry = getUsermapEntryById(ownerMember.user.id, ownerMember.user.username);
const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
@ -50,14 +51,14 @@ async function acceptBorrow(
for (const [, entry] of map) { for (const [, entry] of map) {
if (entry.userKey === requesterKey) { if (entry.userKey === requesterKey) {
entry.characterName = char.name; entry.characterName = char.name;
entry.characterClass = char.class; entry.characterClass = char.class.key;
entry.characterLevel = char.level; entry.characterLevel = char.level;
entry.characterNation = char.nation; entry.characterNation = char.nation;
} }
} }
} }
try { try {
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot); await updatePollMessage(channel, slot);
} catch {} } catch {}
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
@ -11,7 +11,7 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
const group = interaction.options.getSubcommandGroup(false); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
if (nameArg && !isOfficer) { if (nameArg && !isOfficer) {
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { addCharacter } from "../../systems/characters"; import { addCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -7,7 +7,7 @@ import { ClassKey, Nation } from "../../types";
export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const cls = interaction.options.getString("class", true) as ClassKey; const cls = interaction.options.getString("class", true) as ClassKey;

View file

@ -1,14 +1,15 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const requester = await resolveUser(member); const requester = await resolveUser(member);
// Args: owner, charname, [username] (officer only — grants directly) // Args: owner, charname, [username] (officer only — grants directly)
@ -39,12 +40,12 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
if (slot !== undefined) { if (slot !== undefined) {
const state = polls.get(slot)!; const state = polls.get(slot)!;
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
// Find the voter entry and update their character // Find the voter entry and update their character
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === requesterKey) { if (entry.userKey === requesterKey) {
entry.characterName = char.name; entry.characterName = char.name;
entry.characterClass = char.class; entry.characterClass = char.class.key;
entry.characterLevel = char.level; entry.characterLevel = char.level;
entry.characterNation = char.nation; entry.characterNation = char.nation;
entry.publicMessage = undefined; entry.publicMessage = undefined;
@ -69,15 +70,15 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
const guild = interaction.guild!; const guild = interaction.guild!;
await guild.members.fetch(); await guild.members.fetch();
const ownerMember = guild.members.cache.find((m) => { const ownerMember = guild.members.cache.find((m) => {
const entry = (require("../../systems/messages") as any).getUsermapEntry(m.user.username); const entry = getUsermapEntryById(m.user.id, m.user.username);
return entry?.file === ownerArg || entry === ownerArg; return entry?.file === ownerArg;
}); });
if (!ownerMember) { if (!ownerMember) {
return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`); return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`);
} }
const fallbackChannel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; const fallbackChannel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await sendBorrowRequestDM( await sendBorrowRequestDM(
interaction.client, interaction.client,
ownerMember.user.id, ownerMember.user.id,
@ -85,7 +86,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
ownerArg, ownerArg,
requesterKey, requesterKey,
char.name, char.name,
char.class, char.class.key,
char.level, char.level,
fallbackChannel fallbackChannel
); );

View file

@ -1,10 +1,11 @@
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow"; import { getPendingRequest, removePendingRequest, updateBorrowDM } from "@systems/borrow";
import { replyAndDelete } from "../../utils"; import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
import { replyAndDelete } from "@src/utils";
export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> {
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); const ownerMember = await interaction.guild!.members.fetch(interaction.user.id);
const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); const ownerEntry = getUsermapEntryById(ownerMember.user.id, ownerMember.user.username);
const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");

View file

@ -1,12 +1,12 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { removeCharacter } from "../../systems/characters"; import { removeCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setActiveCharacter } from "../../systems/characters"; import { setActiveCharacter } from "../../systems/characters";
import { setSessionBorrow } from "../../systems/borrow"; import { setSessionBorrow } from "../../systems/borrow";
@ -25,7 +25,7 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setCharacterNation, getActiveCharacter } from "../../systems/characters"; import { setCharacterNation, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -7,7 +7,7 @@ import { Nation } from "../../types";
export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("char_name"); // optional, defaults to active const charName = interaction.options.getString("char_name"); // optional, defaults to active

View file

@ -1,34 +1,36 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setCharacterStats, getActiveCharacter } from "../../systems/characters"; import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true);
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name");
const atk = interaction.options.getInteger("atk") ?? undefined;
const def = interaction.options.getInteger("def") ?? undefined;
const heal = interaction.options.getInteger("heal") ?? undefined;
let userKey: string | null; // const member = await interaction.guild!.members.fetch(interaction.user.id);
if (nameArg) { // const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); // const nameArg = interaction.options.getString("name");
userKey = nameArg; // const charName = interaction.options.getString("char_name");
} else { // const atk = interaction.options.getInteger("atk") ?? undefined;
const user = await resolveUser(member); // const def = interaction.options.getInteger("def") ?? undefined;
userKey = user.userKey; // const heal = interaction.options.getInteger("heal") ?? undefined;
}
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); // let userKey: string | null;
// if (nameArg) {
// if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
// userKey = nameArg;
// } else {
// const user = await resolveUser(member);
// userKey = user.userKey;
// }
const targetName = charName ?? getActiveCharacter(userKey)?.name; // if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
const set = setCharacterStats(userKey, targetName, { atk, def, heal }); // const targetName = charName ?? getActiveCharacter(userKey)?.name;
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); // if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`); // const set = setCharacterStats(userKey, targetName, { atk, def, heal });
// if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
// return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`);
} }

Some files were not shown because too many files have changed in this diff Show more