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

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

View file

@ -1,13 +1,16 @@
{ {
"public": { "public": {
"yes": [ "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..."] } { "clicks": 10, "random": true, "messages": ["Ayana..."] }
], ],
"no": [ "no": [
{ "clicks": 1, "random": true, "messages": [ { "clicks": 1, "random": true, "messages": [
"Went for a kebab", "Went for a kebab",
"Doesn't give a fuck" "Doesn't give a fuck",
"Went outside... for a change",
"Is touching grass",
"Is unavailable, call your local DM hotline"
] ]
} }
] ]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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