Compare commits

..

4 commits

Author SHA1 Message Date
Nuno Duque Nunes
f2972567c4 change messages for user 2026-06-09 23:15:44 +01:00
Nuno Duque Nunes
88f53dff32 add norank to wrank formatting 2026-06-05 03:59:26 +01:00
Nuno Duque Nunes
77c065114c merge dev 2026-06-04 03:35:22 +01:00
Nuno Duque Nunes
4c60ff20a6 update before dev merge 2026-06-04 02:00:50 +01:00
171 changed files with 1612 additions and 6576 deletions

13
.gitignore vendored
View file

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

0
data/.gitkeep Normal file
View file

1
data/accounts.json Normal file
View file

@ -0,0 +1 @@
{}

5
data/bringer.json Normal file
View file

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

5
data/config.json Normal file
View file

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

View file

@ -1,11 +0,0 @@
{
"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>"
}

View file

@ -1,14 +0,0 @@
{
"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>"
}

View file

@ -1,102 +0,0 @@
{
"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

@ -1,22 +0,0 @@
{
"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

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

View file

@ -1,102 +0,0 @@
{
"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>"
}

View file

@ -1,22 +0,0 @@
{
"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

@ -1,25 +0,0 @@
{
"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

@ -1,34 +0,0 @@
{
"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

@ -1,39 +0,0 @@
{
"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

@ -1,38 +0,0 @@
{
"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

@ -1,82 +0,0 @@
{
"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

@ -1,47 +0,0 @@
{
"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

@ -1,40 +0,0 @@
{
"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

@ -1,116 +0,0 @@
{
"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

@ -1,50 +0,0 @@
{
"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

@ -1,72 +0,0 @@
{
"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

@ -1,53 +0,0 @@
{
"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

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

14
data/usermap.json Normal file
View file

@ -0,0 +1,14 @@
{
"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"
}

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
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

BIN
emoji-uploads/bl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
emoji-uploads/borrowed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
emoji-uploads/capella.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
emoji-uploads/dm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
emoji-uploads/fa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
emoji-uploads/fb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
emoji-uploads/fg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
emoji-uploads/fs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

BIN
emoji-uploads/gl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
emoji-uploads/kd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
emoji-uploads/procyon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
emoji-uploads/rank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
emoji-uploads/score.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
emoji-uploads/wa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
emoji-uploads/wi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
emoji-uploads/wrank_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

BIN
emoji-uploads/wrank_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

BIN
emoji-uploads/wrank_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
emoji-uploads/wrank_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
emoji-uploads/wrank_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
emoji-uploads/wrank_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -102,165 +102,5 @@
"wrank_8_gold": "<:wrank_8_gold:1512125123874918661>",
"wrank_9_gold": "<:wrank_9_gold:1512125128299905104>",
"wrank_no_rank": "<:wrank_no_rank:1512261782205628606>",
"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>"
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>"
}

View file

@ -1,13 +1,16 @@
{
"public": {
"yes": [
{ "clicks": 1, "random": true, "messages": ["Ayana is in"]},
{ "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": 10, "random": true, "messages": ["Ayana..."] }
],
"no": [
{ "clicks": 1, "random": true, "messages": [
"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,15 +1,21 @@
{
"public": {
"yes": [
{ "clicks": 1, "random": true, "messages": ["Dey is in"]},
{ "clicks": 2, "random": true, "messages": ["Courageous now, new account afterall"] },
{ "clicks": 1, "random": true, "messages": [
"Dey is in",
"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."] }
],
"no": [
{ "clicks": 1, "random": true, "messages": [
"Everything's for sale",
"Dey roaching out 🪳",
"Dey said no... shocking"
"Dey said no... shocking",
"No more... I yield!"
]
}
]

View file

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

View file

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

View file

@ -5,7 +5,8 @@
"Legend is in",
"Best FA shows up",
"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"
},
"devDependencies": {
"@types/node": "^20.19.41",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.0",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.0"
"typescript": "^5.4.0",
"tsconfig-paths": "^4.2.0"
}
}

View file

@ -1,28 +1,22 @@
/**
* Bulk emoji upload script with subdirectory support and round-robin distribution.
* Bulk emoji upload script
* Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
*
* Usage:
* Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
* 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)
* Distributes emojis across a pool of donor servers (round-robin by available capacity).
* Each emoji is unique across all servers no duplicates.
* Automatically updates messages/emojis.json with the uploaded emoji IDs.
*
* Required .env vars:
* DISCORD_TOKEN bot token
* EMOJI_DONOR_GUILDS comma-separated donor server IDs
* DISCORD_TOKEN bot token
* EMOJI_DONOR_GUILDS comma-separated donor server IDs
* e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333
*/
import { REST, Routes } from "discord.js";
import fs from "fs";
import path from "path";
import { Config } from "@systems/config";
// Load .env
// Load .env manually since we're outside the bot runtime
const envPath = path.join(__dirname, "../.env");
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
@ -32,239 +26,186 @@
}
const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env");
if (!TOKEN) {
console.error("❌ DISCORD_TOKEN 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);
}
const emojiDir = path.join(__dirname, "../emoji-uploads");
const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads");
const emojisPath = path.join(__dirname, "../messages/emojis.json");
const rest = new REST({ version: "10" }).setToken(TOKEN);
// ─── Naming config ─────────────────────────────────────────────────────────────
// Dirs listed here use filename only — no dir prefix
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
if (!fs.existsSync(emojiDir)) {
console.error(`❌ Emoji directory not found: ${emojiDir}`);
console.error(` Create it and place your emoji PNG files inside.`);
process.exit(1);
}
// ─── File discovery ────────────────────────────────────────────────────────────
const rest = new REST({ version: "10" }).setToken(TOKEN);
interface EmojiFile {
emojiName: string;
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;
interface GuildEmojiSlot {
guildId: string;
name: string; // guild name for display
existing: Map<string, string>; // emojiName → emojiId
capacity: number;
}
function maxEmojisForTier(tier: number): number {
return [50, 100, 150, 250][tier] ?? 50;
// Compute max emojis based on Nitro boost tier
function maxEmojisForTier(premiumTier: number): number {
switch (premiumTier) {
case 1: return 100;
case 2: return 150;
case 3: return 250;
default: return 50;
}
}
async function fetchGuildSlots(): Promise<GuildSlot[]> {
const slots: GuildSlot[] = [];
for (const guildId of DONOR_GUILD_IDS) {
async function fetchGuildSlots(guildIds: string[]): Promise<GuildEmojiSlot[]> {
const slots: GuildEmojiSlot[] = [];
for (const guildId of guildIds) {
try {
const [guild, emojis] = await Promise.all([
rest.get(Routes.guild(guildId)) as Promise<any>,
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
]);
const max = maxEmojisForTier(guild.premium_tier ?? 0);
const existing = new Map(emojis.map((e: any) => [e.name, e.id]));
const capacity = max - emojis.length;
console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`);
slots.push({ guildId, name: guild.name, existing, capacity });
const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0);
const existingMap = new Map(emojis.map((e: any) => [e.name, e.id]));
const capacity = maxEmojis - emojis.length;
const guildName = guild.name ?? guildId;
console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`);
slots.push({ guildId, name: guildName, existing: existingMap, capacity });
} catch (err: any) {
console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`);
}
}
return slots;
}
// ─── Upload ────────────────────────────────────────────────────────────────────
async function uploadEmojis(): Promise<void> {
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) {
console.error(`❌ No image files found in ${emojiDir}`);
console.error("❌ No image files found in the emoji directory.");
process.exit(1);
}
// Load existing emojis.json
let emojiMap: Record<string, string> = {};
try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {}
try {
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)\n🔍 Scanning donor servers...\n`);
const slots = await fetchGuildSlots();
if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); }
console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`);
console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`);
// Build global dedup map
const globalExisting = new Map<string, string>();
for (const slot of slots) {
const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS);
if (guildSlots.length === 0) {
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) {
globalExisting.set(name, `<:${name}:${id}>`);
}
}
const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0);
console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`);
const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0);
console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`);
let slotIndex = 0;
function nextSlot(): GuildSlot | null {
const start = slotIndex;
do {
const s = slots[slotIndex % slots.length];
slotIndex++;
if (s.capacity > 0) return s;
} while (slotIndex % slots.length !== start % slots.length);
return slots.find((s) => s.capacity > 0) ?? null;
if (totalCapacity === 0) {
console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS.");
process.exit(1);
}
let uploaded = 0, skipped = 0, failed = 0;
let uploaded = 0;
let skipped = 0;
let failed = 0;
// Round-robin slot picker — distributes load evenly across guilds
let slotIndex = 0;
function nextAvailableSlot(): GuildEmojiSlot | null {
const start = slotIndex;
do {
const slot = guildSlots[slotIndex % guildSlots.length];
slotIndex++;
if (slot.capacity > 0) return slot;
} while (slotIndex % guildSlots.length !== start % guildSlots.length);
// Fallback: find any with capacity (in case loop exited without finding one)
return guildSlots.find((s) => s.capacity > 0) ?? null;
}
for (const file of files) {
if (globalExisting.has(file.emojiName)) {
emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!;
console.log(`⏭️ Exists: ${file.emojiName}${emojiMap[file.emojiName]}`);
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
if (globalExisting.has(emojiName)) {
emojiMap[emojiName] = globalExisting.get(emojiName)!;
console.log(`⏭️ Already exists: ${emojiName}${emojiMap[emojiName]}`);
skipped++;
continue;
}
const slot = nextSlot();
const slot = nextAvailableSlot();
if (!slot) {
console.error(`❌ No slots available for: ${file.emojiName}`);
console.error(`❌ All slots full — could not upload: ${emojiName}`);
console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`);
failed++;
continue;
}
try {
const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`;
const result = await rest.post(Routes.guildEmojis(slot.guildId), {
body: { name: file.emojiName, image: base64 },
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
const result = await rest.post(Routes.guildEmojis(slot.guildId), {
body: { name: emojiName, image: base64 },
}) as any;
const formatted = `<:${file.emojiName}:${result.id}>`;
emojiMap[file.emojiName] = formatted;
const formatted = `<:${emojiName}:${result.id}>`;
emojiMap[emojiName] = formatted;
slot.capacity--;
console.log(`✅ Uploaded: ${file.emojiName}${formatted} [${slot.name}]`);
console.log(`✅ Uploaded: ${emojiName}${formatted} [${slot.name}]`);
uploaded++;
// Rate limit buffer
await new Promise((r) => setTimeout(r, 600));
} catch (err: any) {
console.error(`❌ Failed: ${file.emojiName}${err.message}`);
console.error(`❌ Failed: ${emojiName}${err.message}`);
failed++;
}
}
fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2));
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
console.log(`💾 messages/emojis.json updated`);
}
// ─── 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++;
}
}
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`);
}
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);
}
uploadEmojis().catch(console.error);

View file

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

View file

@ -1,125 +0,0 @@
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,15 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import { Config, SectionMap } from "../systems/config";
import { cfg, setCfg, resetCfg } from "../systems/config";
import { hasOfficerRole } from "../systems/users";
import { replyAndDelete } from "../utils";
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",
};
import { Nation } from "../types";
export function buildTgConfigCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder()
@ -23,7 +16,7 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" });
const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true)
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon });
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" });
const roleOpt = strOpt("role", "Role name");
const rolesOpt = strOpt("roles", "Comma-separated role names");
@ -108,77 +101,54 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
.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;
}
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);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "config" }))) {
if (!hasOfficerRole(member, cfg("configRoles"))) {
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
}
const group = options.getSubcommandGroup(true);
const sub = options.getSubcommand();
const group = interaction.options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
const key = ROLE_KEY_MAP[cfgKey];
if (action === "set") {
const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean);
Config.set({ section: "roles", key, value: roles });
const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean);
setCfg(cfgKey, roles);
return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`);
}
if (action === "add") {
const role = options.getString("role", true).trim();
const roles = [...new Set([...Config.get({ section: "roles", key }), role])];
Config.set({ section: "roles", key, value: roles });
const role = interaction.options.getString("role", true).trim();
const roles = [...new Set([...cfg(cfgKey), role])];
setCfg(cfgKey, roles);
return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`);
}
if (action === "remove") {
const role = options.getString("role", true).trim();
const roles = Config.get({ section: "roles", key }).filter((r: string) => r !== role);
Config.set({ section: "roles", key, value: roles });
const role = interaction.options.getString("role", true).trim();
const roles = cfg(cfgKey).filter((r: string) => r !== role);
setCfg(cfgKey, roles);
return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`);
}
if (action === "reset") {
Config.reset({ section: "roles", key });
resetCfg(cfgKey);
return void replyAndDelete(interaction, `✅ Roles reset to default.`);
}
};
// ── message ────────────────────────────────────────────────────────────────
if (group === "message") {
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") { Config.reset({ section: "poll", key: "lockMessage" }); return void replyAndDelete(interaction, "✅ Lock message reset."); }
if (sub === "set-lock") { setCfg("lockMessage", interaction.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 === "set-confirm") {
const d = options.getString("decision", true);
const key = d === "yes" ? "confirmYes" : "confirmNo";
Config.set({ section: "poll", key, value: options.getString("message", true) });
const d = interaction.options.getString("decision", true);
setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", true));
return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`);
}
if (sub === "reset-confirm") {
const d = options.getString("decision", true);
const key = d === "yes" ? "confirmYes" : "confirmNo";
Config.reset({ section: "poll", key });
const d = interaction.options.getString("decision", true);
resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage");
return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`);
}
}
@ -201,45 +171,41 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
// ── channel ────────────────────────────────────────────────────────────────
if (group === "channel") {
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") { Config.set({ section: "channels", key: "results", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Results channel updated."); }
if (sub === "set-score") { Config.set({ section: "channels", key: "score", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Score channel updated."); }
if (sub === "set-poll") { setCfg("pollChannelId", interaction.options.getChannel("channel", true).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-score") { setCfg("scoreChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Score channel updated."); }
}
// ── slot ───────────────────────────────────────────────────────────────────
if (group === "slot") {
if (sub === "add") {
const hour = options.getInteger("hour", true);
const pollOpens = options.getString("poll_opens", true);
const slots = Config.get({ section: "poll", key: "slots" });
const hour = interaction.options.getInteger("hour", true);
const pollOpens = interaction.options.getString("poll_opens", true);
const slots = cfg("slots");
if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`);
slots.push({ tgHour: hour, pollOpens, closesAfter: Config.get({ section: "tg", key: "durationMinutes" }), active: true });
Config.set({ section: "poll", key: "slots", value: slots });
slots.push({ tgHour: hour, pollOpens, closesAfter: cfg("tgDurationMinutes"), active: true });
setCfg("slots", slots);
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`);
}
if (sub === "remove") {
const hour = options.getInteger("hour", true);
const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.tgHour !== hour);
Config.set({ section: "poll", key: "slots", value: slots });
const hour = interaction.options.getInteger("hour", true);
const slots = cfg("slots").filter((s) => s.tgHour !== hour);
setCfg("slots", slots);
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`);
}
}
// ── wrank ──────────────────────────────────────────────────────────────────
if (group === "wrank") {
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") { Config.set({ section: "wrank", key: "postOnReset", value: options.getBoolean("enabled", true)! }); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); }
if (sub === "set-goal") { setCfg("wRankGoal", interaction.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."); }
}
// ── tg ─────────────────────────────────────────────────────────────────────
if (group === "tg") {
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") { Config.set({ section: "tg", key: "durationMinutes", value: options.getInteger("minutes", true)! }); return void replyAndDelete(interaction, "✅ TG duration 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"){ 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);
if (sub === "set-score-window") { setCfg("scoreWindowHours", interaction.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-no-display") { setCfg("showNoInNationField" as any, interaction.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."); }
}
}

View file

@ -1,40 +0,0 @@
/**
* 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,
};

View file

@ -1,29 +0,0 @@
/**
* 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,
};

View file

@ -1,25 +0,0 @@
/**
* 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

@ -1,89 +0,0 @@
/**
* 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,73 +1,47 @@
import { AutocompleteInteraction } from "discord.js";
import { resolveUser } from "@systems/users";
import { getCharacters } from "@systems/characters";
import { Config } from "@systems/config";
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 { cfg } from "@systems/config";
import { getNationEmoji } from "@systems/emojis";
import fs from "fs";
// ─── 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; }
import path from "path";
// ─── Autocomplete subsets ─────────────────────────────────────────────────────
async function autocompleteCharNames(
interaction: AutocompleteInteraction,
focused: string,
nation?: Nation | null // optional — if provided, filter by nation
focused: string
): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
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([]);
// Own chars
const ownChars = getCharacters(user.userKey).map((c) => {
const nationEmoji = c.nation ? (Emoji.nation(c.nation) || c.nation) : "";
const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : "";
return {
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
value: c.name,
};
});
// Shared chars
const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => {
const nationEmoji = char.nation ? (Emoji.nation(char.nation) || char.nation) : "";
return {
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
value: char.name,
};
});
const sharedChars: { name: string; value: string }[] = [];
try {
const chars = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
);
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
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]
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
@ -81,7 +55,9 @@ async function autocompleteUserKeys(
focused: string
): Promise<void> {
try {
const usermap = getUsermapCache();
const usermap = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
);
const choices = Object.entries(usermap)
.map(([, entry]: [string, any]) => {
const fileKey = typeof entry === "string" ? entry : entry.file;
@ -100,7 +76,7 @@ async function autocompleteSlots(
interaction: AutocompleteInteraction,
focused: string
): Promise<void> {
const slots = Config.get({ section: "poll", key: "slots" })
const slots = cfg("slots")
.filter((s) => s.active)
.map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) }))
.filter((s) => s.name.includes(focused));
@ -114,23 +90,11 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
const focused = interaction.options.getFocused(true);
const optionName = focused.name;
const focusedValue = focused.value as string;
const sub = interaction.options.getSubcommand(false);
const subGroup = interaction.options.getSubcommandGroup(false);
if (optionName === "char_name") {
// Bringer set — filter by selected nation
if (sub === "set" && subGroup === "bringer") {
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);
if (optionName === "char_name") 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);
await interaction.respond([]);
} catch (err) {

View file

@ -5,31 +5,23 @@ import {
ActionRowBuilder,
TextChannel
} from "discord.js";
import { cfg } from "@systems/config";
import { pollReplyAndDelete } from "../utils";
import { resolveUser } from "@systems/users";
import { resolveMessage, nowFormatted } from "@systems/messages";
import { Nations } from "@systems/nations";
import { resolveNation } from "@systems/nations";
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
import { persist } from "@systems/pollPersistence"
import { showConflictEmbed } from "@systems/conflict";
import { getCharacters } from "@systems/characters";
import { getImpersonation } from "@systems/impersonate";
import { format } from "@format";
import { buildCharSelectButtons } from "@systems/charSelect";
import { getEffectiveCharacter } from "@systems/borrow";
import { Character } from "@src/types";
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 LOCK_AT = Config.get({ section: "poll", key: "lockAt" });
const clickCounts = new Map<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>();
const clickCounts = new Map<string, { yes: number; no: number }>();
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -77,8 +69,13 @@ async function handleCharacterConflict(
}
const slot = [...polls.keys()][0];
const slotHour = slot !== undefined ? polls.get(slot)?.slot : Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20;
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("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 buttons = buildCharSelectButtons(userKey ?? "", {
customIdPrefix: `switch_after_reclaim:${userKey}`,
@ -96,175 +93,123 @@ async function handleCharacterConflict(
// ─── Main button handler ──────────────────────────────────────────────────────
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
console.log(`[handleButton] ${interaction.customId} - start`);
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
await InteractionLock.with(interaction, async () => {
const bench = Benchmark.start("handleButton");
try {
await interaction.deferUpdate();
} catch {
return;
}
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return;
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
if (slot === undefined) return;
const state = polls.get(slot)!;
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 state = polls.get(slot)!;
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);
const user = await resolveUser(member);
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 = 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 votedYes = interaction.customId === "tg_yes";
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;
const locked = clickCount >= LOCK_AT;
// Resolve messages
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
// Yes vote companion — show active char + switch buttons
if (votedYes && user.userKey && !locked) {
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
bench.mark("getEffectiveCharacter");
if (char) {
const starEmoji = Config.get({ section: "emoji", key: "activeChar" });
const borrowNote = borrowedFrom ? ` 🔗` : "";
const buttons = buildCharSelectButtons(user.userKey, {
customIdPrefix: `companion_switch:${user.userKey}`,
excludeCharName: char.name,
appendToCustomId: ":yes",
});
if (buttons.length > 0) {
const companionMsg = await interaction.followUp({
content: `${starEmoji} ${format.char(char)}${borrowNote}`,
components: buttons,
ephemeral: true,
fetchReply: true,
});
bench.mark("companion");
bench.end();
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
Ephemeral.store(voteId, "companion", interaction, companionMsg.id);
}
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: baseEntry.characterClass!,
level: baseEntry.characterLevel!,
nation: baseEntry.characterNation!,
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 updatePollMessage(channel, slot);
}
export function resetClickCounts(): void {
@ -315,8 +260,8 @@ export async function handleScoreSubmitButton(interaction: ButtonInteraction): P
// }
// // Build slot selector — all valid slots, with the active TG pre-selected
// const validSlots = Config.get({ section: "poll", key: "slots" }).map((s) => s.tgHour) as number[];
// const activeSlot = slot ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
// const validSlots = cfg("slots").map((s) => s.tgHour) as number[];
// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
// const select = new StringSelectMenuBuilder()
// .setCustomId(`score_slot_select:${user.userKey}`)

View file

@ -7,73 +7,107 @@ import { handleBorrowDeclineButton } from "@subcommands/char/decline";
import { handleConflictButton } from "@systems/conflict";
import { handleImpersonateButton } from "@subcommands/impersonate";
import { handleAutocomplete } from "@handlers/autocomplete";
import { Ephemeral } from "@registry/ephemeral-registry";
import { getImpersonation } from "@systems/impersonate";
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow";
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 { Character } from "@systems/character";
import { handleTgAdminCommand } from "@commands/tgAdmin";
import { InteractionLock } from "@helpers/interaction-lock";
import { CharacterRegistry } from "@registry/character-registry";
import fs from "fs";
import path from "path";
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
const prefix = btn.customId.startsWith("companion_switch:") ? "companion_switch:" : "switch_after_reclaim:";
const withoutPrefix = btn.customId.slice(prefix.length);
const firstColon = withoutPrefix.indexOf(":");
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 parts = btn.customId.split(":");
const userKey = parts[1];
const charName = parts[2];
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no";
const impersonating = getImpersonation(btn.user.id);
const voteId = impersonating ? `impersonated:${impersonating}` : btn.user.id;
const chars = JSON.parse(
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
);
// Resolve char without switching
let resolvedChar: any = null;
let resolvedChar: any = null;
let borrowedFrom: string | null = null;
await InteractionLock.with(btn, async () => {
const ownEntry = CharacterRegistry.findForUser(userKey, charName);
// Try own char first
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === 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 (ownEntry) {
resolvedChar = ownEntry;
if (!resolvedChar) {
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
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 {
const shared = CharacterRegistry.sharedWith(userKey).find(({ char }) => char.name === charName);
if (shared) {
resolvedChar = shared.char;
borrowedFrom = shared.ownerKey;
}
state.no.set(voteId, voteEntry);
}
if (!resolvedChar) {
await btn.followUp({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
return;
}
console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`);
console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`));
console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`));
// Delegate to shared switch logic
const result = await Character.performSwitch(userKey, resolvedChar, borrowedFrom, btn, prevVoteType);
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
await updatePollMessage(channel, slot!);
}
if (result.replyData) {
const { content, components } = result.replyData;
console.log(`[switchAfterReclaim] replyData, isCompanion=${btn.customId.startsWith("companion_switch:")}`);
if (btn.customId.startsWith("companion_switch:")) {
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 });
}
}
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName;
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
await btn.reply({
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`,
ephemeral: true,
});
}
@ -85,7 +119,37 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
}
if (interaction.isButton()) {
return await handleButtonInteraction(interaction as ButtonInteraction);
const btn = 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()) {
@ -103,7 +167,9 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
}
if (interaction.isChatInputCommand()) {
await handleChatInputCommandInteraction(interaction as ChatInputCommandInteraction);
const cmd = interaction as ChatInputCommandInteraction;
if (cmd.commandName === "tg") await handleTgCommand(cmd);
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
}
} catch (err) {
console.error("Interaction error:", err);
@ -117,45 +183,3 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
} 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

@ -1,29 +0,0 @@
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
};

View file

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

33
src/scheduler.ts Normal file
View file

@ -0,0 +1,33 @@
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

@ -1,66 +0,0 @@
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

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

View file

@ -1,28 +1,12 @@
import { ChatInputCommandInteraction } from "discord.js";
import { setBringerOverride } from "@systems/wrank";
import { replyAndDelete } from "@utils";
import { Nation } from "@types";
import { Bringer } from "@systems/bringer";
import { CharacterRegistry } from "@registry/character-registry";
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("char_name", true);
const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("name", true);
// Resolve character
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
);
setBringerOverride(nation, charName);
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`);
}

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config";
import { cfg } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
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 sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
if (nameArg && !isOfficer) {
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
}

View file

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

View file

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

View file

@ -1,11 +1,10 @@
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
import { getPendingRequest, removePendingRequest, updateBorrowDM } from "@systems/borrow";
import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
import { replyAndDelete } from "@src/utils";
import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow";
import { replyAndDelete } from "../../utils";
export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> {
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id);
const ownerEntry = getUsermapEntryById(ownerMember.user.id, ownerMember.user.username);
const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username);
const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
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 { Config } from "../../systems/config";
import { cfg } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { removeCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils";
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "../../systems/config";
import { cfg } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setActiveCharacter } from "../../systems/characters";
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> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true);

View file

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

View file

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

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