initial commit
This commit is contained in:
commit
1446cd10fc
79 changed files with 4304 additions and 0 deletions
7
.env
Normal file
7
.env
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
DISCORD_TOKEN=MTUxMDc3NjgwNDE4NzU3NDMwMw.GgKLwR.eWiKvwpr03kVlUGquWxMzlOPC9h4Y_zvMh7KJM
|
||||
POLL_CHANNEL_ID=1510761562997133333
|
||||
RESULTS_CHANNEL_ID=1510761595809304687
|
||||
SCORE_CHANNEL_ID=1510761785794232442
|
||||
CLIENT_ID=1510776804187574303
|
||||
GUILD_ID=402115662149058561
|
||||
EPHEMERAL_ENABLED=false
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DISCORD_TOKEN=your_bot_token_here
|
||||
CHANNEL_ID=your_channel_id_here
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY src/ ./src/
|
||||
COPY tsconfig.json ./
|
||||
|
||||
CMD ["npx", "ts-node", "src/index.ts"]
|
||||
1
data/accounts.json
Normal file
1
data/accounts.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
5
data/bringer.json
Normal file
5
data/bringer.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"currentWeek": "",
|
||||
"capella": null,
|
||||
"procyon": null
|
||||
}
|
||||
107
data/characters.json
Normal file
107
data/characters.json
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
{
|
||||
"flash": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "«Flash»",
|
||||
"class": "FB",
|
||||
"level": 79,
|
||||
"nation": "Procyon",
|
||||
"active": false,
|
||||
"sharedWith": [
|
||||
"invicjusz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "»Flash«",
|
||||
"class": "WI",
|
||||
"level": 79,
|
||||
"nation": "Procyon",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"zephyr": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "XefronYokuda",
|
||||
"class": "FA",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"dey": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "«Deystroyer»",
|
||||
"class": "BL",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"keira": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "«Keira»",
|
||||
"class": "WI",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"ayana": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "«MonkeyHunter»",
|
||||
"class": "DM",
|
||||
"level": 79,
|
||||
"nation": "Procyon",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"invicjusz": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "ElementalEnchant",
|
||||
"class": "FB",
|
||||
"level": 76,
|
||||
"nation": "Procyon",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"marin": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "iMarieLaveau",
|
||||
"class": "DM",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"sean": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "»No.1«",
|
||||
"class": "FB",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "«No.1»",
|
||||
"class": "GL",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4
data/config.json
Normal file
4
data/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"showLevelInMessages": true,
|
||||
"showClassInMessages": true
|
||||
}
|
||||
29
data/tg-history/2026-06-01-00.json
Normal file
29
data/tg-history/2026-06-01-00.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 0,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 2000,
|
||||
"submittedAt": "2026-06-01T03:22:25.475Z",
|
||||
"slot": 0,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
29
data/tg-history/2026-06-01-02.json
Normal file
29
data/tg-history/2026-06-01-02.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 2,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 100,
|
||||
"submittedAt": "2026-06-01T03:22:43.115Z",
|
||||
"slot": 2,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
29
data/tg-history/2026-06-01-04.json
Normal file
29
data/tg-history/2026-06-01-04.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 4,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 100,
|
||||
"submittedAt": "2026-06-01T03:22:48.373Z",
|
||||
"slot": 4,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
29
data/tg-history/2026-06-01-06.json
Normal file
29
data/tg-history/2026-06-01-06.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 6,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 100,
|
||||
"submittedAt": "2026-06-01T03:22:54.521Z",
|
||||
"slot": 6,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
29
data/tg-history/2026-06-01-08.json
Normal file
29
data/tg-history/2026-06-01-08.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 8,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 100,
|
||||
"submittedAt": "2026-06-01T03:23:03.650Z",
|
||||
"slot": 8,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
40
data/tg-history/2026-06-01-20.json
Normal file
40
data/tg-history/2026-06-01-20.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"slot": 20,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 4000,
|
||||
"submittedAt": "2026-06-01T03:18:24.563Z",
|
||||
"slot": 20,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": true
|
||||
},
|
||||
{
|
||||
"usermapKey": "invicjusz",
|
||||
"characterName": "ElementalEnchant",
|
||||
"class": "FB",
|
||||
"nation": "Procyon",
|
||||
"pts": 5000,
|
||||
"submittedAt": "2026-06-01T03:19:12.073Z",
|
||||
"slot": 20,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
29
data/tg-history/2026-06-01-22.json
Normal file
29
data/tg-history/2026-06-01-22.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"slot": 22,
|
||||
"date": "2026-06-01",
|
||||
"confirmed": false,
|
||||
"nationKD": {
|
||||
"source": "Procyon",
|
||||
"capella": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
},
|
||||
"procyon": {
|
||||
"k": 0,
|
||||
"d": 0
|
||||
}
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
"pts": 2000,
|
||||
"submittedAt": "2026-06-01T03:22:14.287Z",
|
||||
"slot": 22,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
13
data/usermap.json
Normal file
13
data/usermap.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"nunoflashy": {
|
||||
"file": "flash",
|
||||
"aliases": ["Flash", "El Flasho"]
|
||||
},
|
||||
"staki91": "dey",
|
||||
"invicjusz": "invicjusz",
|
||||
"mrsean.": "sean",
|
||||
"ibenni": "ayana",
|
||||
"zephyr_74135": "zephyr",
|
||||
"eat.jim.sleep": "keira",
|
||||
"mar1n1987": "marin"
|
||||
}
|
||||
16
data/wrank.json
Normal file
16
data/wrank.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"2026-W23": {
|
||||
"weekKey": "2026-W23",
|
||||
"entries": {
|
||||
"capella": [],
|
||||
"procyon": []
|
||||
},
|
||||
"scoreIndex": {},
|
||||
"bringer": {
|
||||
"capella": null,
|
||||
"procyon": null,
|
||||
"procyonOverride": "flash",
|
||||
"capellaOverride": "zephyr"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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/messages:/app/messages
|
||||
- /opt/docker/tg-bot-ts/tsconfig.json:/app/tsconfig.json
|
||||
43
messages/emojis.json
Normal file
43
messages/emojis.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"capella": "<:Capella:1477082112560726238>",
|
||||
"procyon": "<:Procyon:1477082175181426738>",
|
||||
"bl": "<:bl:1510827912767475742>",
|
||||
"fb": "<:fb:1510825907374395452>",
|
||||
"fs": "<:fs:1510828058112954501>",
|
||||
"fa": "<:fa:1510823955034935326>",
|
||||
"fg": "<:fg:1510822372373037207>",
|
||||
"gl": "<:gl:1510826513484873909>",
|
||||
"dm": "<:dm:1510820686971670538>",
|
||||
"wi": "<:wi:1510805237047230464>",
|
||||
"wa": "<:wa:1510827932376109218>",
|
||||
"wrank_up": "",
|
||||
"wrank_down": "",
|
||||
"atk": "",
|
||||
"def": "",
|
||||
"heal": "",
|
||||
"wrank_neutral": "",
|
||||
"wrank_1": "",
|
||||
"wrank_1_gold": "",
|
||||
"wrank_2": "",
|
||||
"wrank_2_gold": "",
|
||||
"wrank_3": "",
|
||||
"wrank_3_gold": "",
|
||||
"wrank_4": "",
|
||||
"wrank_4_gold": "",
|
||||
"wrank_5": "",
|
||||
"wrank_5_gold": "",
|
||||
"wrank_6": "",
|
||||
"wrank_6_gold": "",
|
||||
"wrank_7": "",
|
||||
"wrank_7_gold": "",
|
||||
"wrank_8": "",
|
||||
"wrank_8_gold": "",
|
||||
"wrank_9": "",
|
||||
"wrank_9_gold": "",
|
||||
"wrank_10": "",
|
||||
"wrank_10_gold": "",
|
||||
"kd": "",
|
||||
"score": "",
|
||||
"rank": "",
|
||||
"borrowed": "🔗"
|
||||
}
|
||||
44
messages/global.json
Normal file
44
messages/global.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{
|
||||
"clicks": 1,
|
||||
"random": true,
|
||||
"messages": ["{nickname} is in! ⚔️", "Let's go {nickname}!", "{nickname} ready to fight!"],
|
||||
"days": {
|
||||
"friday": { "random": true, "messages": ["It's {Day}! {nickname} is in, let's end the week strong! 🎉"] }
|
||||
},
|
||||
"dates": {
|
||||
"25-12": { "messages": ["Merry Christmas {nickname}! Still showing up! 🎄⚔️"] },
|
||||
"01-01": { "messages": ["Happy New Year! {nickname} is in for the first TG of {date_full}! 🎆"] }
|
||||
}
|
||||
},
|
||||
{ "clicks": 5, "messages": ["{nickname} is really committed."] },
|
||||
{ "clicks": 10, "messages": ["{nickname} is locked in forever."] }
|
||||
],
|
||||
"no": [
|
||||
{
|
||||
"clicks": 1,
|
||||
"random": true,
|
||||
"messages": ["{nickname} is sitting this one out... 🪳", "{nickname} roaching out!", "No from {nickname}... 👀"],
|
||||
"days": {
|
||||
"friday": { "messages": ["{nickname} skipping TG on a {Day}? Shameful. 🪳"] }
|
||||
}
|
||||
},
|
||||
{ "clicks": 5, "messages": ["{nickname} said no 5 times. Wow."] },
|
||||
{ "clicks": 10, "messages": ["{nickname} is a certified roach. 🪳"] }
|
||||
]
|
||||
},
|
||||
"ephemeral": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": ["✅ You voted **Yes** for TG tonight!", "✅ Let's go {nickname}!"] },
|
||||
{ "clicks": 5, "messages": ["✅ Still clicking Yes, we see you {nickname}."] },
|
||||
{ "clicks": 10, "messages": ["✅ Alright {nickname}, you're locked in for good."] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": ["❌ Roaching out {nickname}?", "❌ Really {nickname}? No?"] },
|
||||
{ "clicks": 5, "messages": ["❌ Five times no. Really, {nickname}?"] },
|
||||
{ "clicks": 10, "messages": ["❌ Locked out, absolute coward."] }
|
||||
]
|
||||
}
|
||||
}
|
||||
16
messages/users/ayana.json
Normal file
16
messages/users/ayana.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": ["Ayana is in"]},
|
||||
{ "clicks": 10, "random": true, "messages": ["Ayana..."] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Went for a kebab",
|
||||
"Doesn't give a fuck"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
18
messages/users/dey.json
Normal file
18
messages/users/dey.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": ["Dey is in"]},
|
||||
{ "clicks": 2, "random": true, "messages": ["Courageous now, new account afterall"] },
|
||||
{ "clicks": 10, "random": true, "messages": ["Now you're just asking for it."] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Everything's for sale",
|
||||
"Dey roaching out 🪳",
|
||||
"Dey said no... shocking"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
30
messages/users/flash.json
Normal file
30
messages/users/flash.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{
|
||||
"clicks": 1,
|
||||
"random": true,
|
||||
"messages": ["The King has arrived. 👑", "Flash is in, bow down.", "👑 Royalty has entered the raid.","{alias[0]} is in"]
|
||||
},
|
||||
{ "clicks": 2, "random": true, "messages": ["Flash? Flash? Flash!!"] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Can't come."
|
||||
],
|
||||
"days": {
|
||||
"sunday": {"messages": ["{alias[0]} has to work again... Wait... on a {Day}?!"]}
|
||||
}
|
||||
},
|
||||
{ "clicks": 2, "random": true, "messages": ["Flash has to work again..."] }
|
||||
]
|
||||
},
|
||||
"ephemeral": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "messages": ["✅ King is in! 👑"] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "messages": ["❌ Really Flash? Roaching again, smh."] }
|
||||
]
|
||||
}
|
||||
}
|
||||
19
messages/users/invicjusz.json
Normal file
19
messages/users/invicjusz.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Vic is in"
|
||||
]
|
||||
},
|
||||
{ "clicks": 2, "random": true, "messages": ["Vic is really in"] },
|
||||
{ "clicks": 10, "random": true, "messages": ["Stop it Vic, you're in."] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": ["Needs to check if the water works"]},
|
||||
{ "clicks": 2, "random": true, "messages": ["Needs to check if the tap has water"]},
|
||||
{ "clicks": 3, "random": true, "messages": ["Checking if the water is wet..."]},
|
||||
{ "clicks": 4, "random": true, "messages": ["The water really is wet!"] }
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
18
messages/users/keira.json
Normal file
18
messages/users/keira.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Keira is in"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"No chasey for capella",
|
||||
"Says no... going to the gym?"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
20
messages/users/marin.json
Normal file
20
messages/users/marin.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Marin is in",
|
||||
"Marin is in, silence 24/7",
|
||||
"Marin is in, get your silence pots ready! Wait... they dont exist"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"No petrify in this TG",
|
||||
"Says no"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
19
messages/users/sean.json
Normal file
19
messages/users/sean.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": ["Sean is in"]},
|
||||
{ "clicks": 5, "random": true, "messages": ["Sean is probably drunk"] },
|
||||
{ "clicks": 10, "random": true, "messages": ["Stop it Sean."] }
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Went out drinking" ,
|
||||
"Went out partying" ,
|
||||
"Is drunk",
|
||||
"Is on vaca— building collection"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
21
messages/users/zephyr.json
Normal file
21
messages/users/zephyr.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"public": {
|
||||
"yes": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Legend is in",
|
||||
"Best FA shows up",
|
||||
"Healmeister reporting for duty",
|
||||
"Capella MVP is up"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no": [
|
||||
{ "clicks": 1, "random": true, "messages": [
|
||||
"Cannot come, TG lost" ,
|
||||
"Says no... are you okay Zephyr?"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephemeral": {}
|
||||
}
|
||||
6
nodemon.json
Normal file
6
nodemon.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "ts-node src/index.ts"
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "tg-bot",
|
||||
"version": "2.0.0",
|
||||
"description": "Cabal Online TG planning and tracking bot",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts",
|
||||
"dev": "nodemon",
|
||||
"register": "ts-node src/index.ts --register"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.15.3",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node-cron": "^3.0.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
297
src/commands/tg.ts
Normal file
297
src/commands/tg.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import {
|
||||
ChatInputCommandInteraction,
|
||||
SlashCommandBuilder,
|
||||
REST,
|
||||
Routes,
|
||||
} from "discord.js";
|
||||
import { cfg } from "../systems/config";
|
||||
import { hasOfficerRole } from "../systems/users";
|
||||
|
||||
// Poll subcommands
|
||||
import { handleStart } from "../subcommands/poll/start";
|
||||
import { handleLock } from "../subcommands/poll/lock";
|
||||
import { handleUnlock } from "../subcommands/poll/unlock";
|
||||
import { handleConfirm } from "../subcommands/poll/confirm";
|
||||
import { handleStatus } from "../subcommands/poll/status";
|
||||
import { handleReload } from "../subcommands/poll/reload";
|
||||
import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "../subcommands/poll/setMessage";
|
||||
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject";
|
||||
import { handleSeed } from "../subcommands/poll/seed";
|
||||
import { handlePurge } from "../subcommands/poll/purge";
|
||||
|
||||
// Char subcommands (borrow / sharing system)
|
||||
import { handleCharBorrow } from "../subcommands/char/borrow";
|
||||
import { handleCharAccept } from "../subcommands/char/accept";
|
||||
import { handleCharDecline } from "../subcommands/char/decline";
|
||||
import { handleCharShare, handleCharUnshare } from "../subcommands/char/share";
|
||||
|
||||
// Score subcommands
|
||||
import { handleScoreSet } from "../subcommands/score/set";
|
||||
import { handleScoreGet } from "../subcommands/score/get";
|
||||
|
||||
// Rank subcommands
|
||||
import { handleRankGet } from "../subcommands/rank/get";
|
||||
import { handleRankPost } from "../subcommands/rank/post";
|
||||
|
||||
// Result subcommands
|
||||
import { handleResultSet } from "../subcommands/result/set";
|
||||
import { handleResultView } from "../subcommands/result/view";
|
||||
import { handleResultPost } from "../subcommands/result/post";
|
||||
|
||||
// Bringer subcommands
|
||||
import { handleBringerSet } from "../subcommands/bringer/set";
|
||||
import { handleBringerClear } from "../subcommands/bringer/clear";
|
||||
|
||||
// Other
|
||||
import { handleSwitch } from "../subcommands/switch";
|
||||
import { handleHistory } from "../subcommands/history";
|
||||
|
||||
export function buildTgCommand(): SlashCommandBuilder {
|
||||
const cmd = new SlashCommandBuilder()
|
||||
.setName("tg")
|
||||
.setDescription("TG planning and tracking");
|
||||
|
||||
// ── poll group ─────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("poll")
|
||||
.setDescription("Manage the TG poll")
|
||||
.addSubcommand((s) => s.setName("start").setDescription("Post a fresh TG poll")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
||||
.addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll"))
|
||||
.addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening")
|
||||
.addStringOption((o) => o.setName("decision").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false))
|
||||
.addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk"))
|
||||
.addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status"))
|
||||
.addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })))
|
||||
.addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
||||
.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"))
|
||||
);
|
||||
|
||||
// ── score group ────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("score")
|
||||
.setDescription("Score management")
|
||||
.addSubcommand((s) => s.setName("set").setDescription("Submit a score")
|
||||
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("get").setDescription("View a score")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
);
|
||||
|
||||
// ── rank group ─────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("rank")
|
||||
.setDescription("W.Rank management")
|
||||
.addSubcommand((s) => s.setName("get").setDescription("View W.Rank")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)"))
|
||||
);
|
||||
|
||||
// ── result group ───────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("result")
|
||||
.setDescription("TG result management")
|
||||
.addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true))
|
||||
.addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true))
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("view").setDescription("View result for a slot")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("post").setDescription("Post result publicly (officer only)")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)))
|
||||
);
|
||||
|
||||
// ── bringer group ──────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("bringer")
|
||||
.setDescription("Bringer management (officer only)")
|
||||
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })))
|
||||
);
|
||||
|
||||
// ── switch ─────────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))
|
||||
);
|
||||
|
||||
// ── char group ─────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("char")
|
||||
.setDescription("Character management")
|
||||
.addSubcommand((s) => s.setName("add").setDescription("Add a character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("class").setDescription("Class").setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Blader (BL)", value: "BL" },
|
||||
{ name: "Force Blader (FB)", value: "FB" },
|
||||
{ name: "Force Shielder (FS)", value: "FS" },
|
||||
{ name: "Force Archer (FA)", value: "FA" },
|
||||
{ name: "Force Gunner (FG)", value: "FG" },
|
||||
{ name: "Gladiator (GL)", value: "GL" },
|
||||
{ name: "Dark Mage (DM)", value: "DM" },
|
||||
{ name: "Wizard (WI)", value: "WI" },
|
||||
{ name: "Warrior (WA)", value: "WA" },
|
||||
))
|
||||
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("set-active").setDescription("Set active character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session")
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("share").setDescription("Permanently share a character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
||||
.addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
||||
);
|
||||
|
||||
// ── history ────────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommand((s) => s.setName("history").setDescription("View TG history (officer only)")
|
||||
.addStringOption((o) => o.setName("date").setDescription("Date (YYYY-MM-DD)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export async function handleTgCommand(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
|
||||
// Officer-only commands
|
||||
const officerOnlyGroups = ["poll", "result", "bringer"];
|
||||
const officerOnlySubs = ["history"];
|
||||
const officerOnlyRankSubs = ["post"];
|
||||
|
||||
if (group && officerOnlyGroups.includes(group) && !isOfficer) {
|
||||
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
|
||||
}
|
||||
if (!group && officerOnlySubs.includes(sub) && !isOfficer) {
|
||||
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
|
||||
}
|
||||
if (group === "rank" && officerOnlyRankSubs.includes(sub) && !isOfficer) {
|
||||
return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true });
|
||||
}
|
||||
|
||||
// Route
|
||||
if (group === "poll") {
|
||||
if (sub === "start") return handleStart(interaction);
|
||||
if (sub === "lock") return handleLock(interaction);
|
||||
if (sub === "unlock") return handleUnlock(interaction);
|
||||
if (sub === "confirm") return handleConfirm(interaction);
|
||||
if (sub === "reload") return handleReload(interaction);
|
||||
if (sub === "status") return handleStatus(interaction);
|
||||
if (sub === "set-message") return handleSetMessage(interaction);
|
||||
if (sub === "clear-message") return handleClearMessage(interaction);
|
||||
if (sub === "set-ephemeral") return handleSetEphemeral(interaction);
|
||||
if (sub === "clear-ephemeral") return handleClearEphemeral(interaction);
|
||||
if (sub === "inject") return handleInject(interaction);
|
||||
if (sub === "remove-vote") return handleRemoveVote(interaction);
|
||||
if (sub === "purge") return handlePurge(interaction);
|
||||
if (sub === "seed") return handleSeed(interaction);
|
||||
}
|
||||
if (group === "score") {
|
||||
if (sub === "set") return handleScoreSet(interaction);
|
||||
if (sub === "get") return handleScoreGet(interaction);
|
||||
}
|
||||
if (group === "rank") {
|
||||
if (sub === "get") return handleRankGet(interaction);
|
||||
if (sub === "post") return handleRankPost(interaction);
|
||||
}
|
||||
if (group === "result") {
|
||||
if (sub === "set") return handleResultSet(interaction);
|
||||
if (sub === "view") return handleResultView(interaction);
|
||||
if (sub === "post") return handleResultPost(interaction);
|
||||
}
|
||||
if (group === "bringer") {
|
||||
if (sub === "set") return handleBringerSet(interaction);
|
||||
if (sub === "clear") return handleBringerClear(interaction);
|
||||
}
|
||||
if (group === "char") {
|
||||
if (sub === "add") return handleCharAdd(interaction);
|
||||
if (sub === "remove") return handleCharRemove(interaction);
|
||||
if (sub === "set-active") return handleCharSetActive(interaction);
|
||||
if (sub === "set-nation") return handleCharSetNation(interaction);
|
||||
if (sub === "set-stats") return handleCharSetStats(interaction);
|
||||
if (sub === "borrow") return handleCharBorrow(interaction);
|
||||
if (sub === "accept") return handleCharAccept(interaction);
|
||||
if (sub === "decline") return handleCharDecline(interaction);
|
||||
if (sub === "share") return handleCharShare(interaction);
|
||||
if (sub === "unshare") return handleCharUnshare(interaction);
|
||||
}
|
||||
if (!group && sub === "switch") return handleSwitch(interaction);
|
||||
if (!group && sub === "history") return handleHistory(interaction);
|
||||
}
|
||||
|
||||
// Import char handlers here to keep tg.ts clean
|
||||
import { handleCharAdd } from "../subcommands/char/add";
|
||||
import { handleCharRemove } from "../subcommands/char/remove";
|
||||
import { handleCharSetActive } from "../subcommands/char/setActive";
|
||||
import { handleCharSetNation } from "../subcommands/char/setNation";
|
||||
import { handleCharSetStats } from "../subcommands/char/setStats";
|
||||
211
src/commands/tgConfig.ts
Normal file
211
src/commands/tgConfig.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
import { cfg, setCfg, resetCfg } from "../systems/config";
|
||||
import { hasOfficerRole } from "../systems/users";
|
||||
import { replyAndDelete } from "../utils";
|
||||
import { Nation } from "../types";
|
||||
|
||||
export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||
const cmd = new SlashCommandBuilder()
|
||||
.setName("tg-config")
|
||||
.setDescription("Configure the TG bot");
|
||||
|
||||
const strOpt = (name: string, desc: string, req = true) =>
|
||||
(o: any) => o.setName(name).setDescription(desc).setRequired(req);
|
||||
|
||||
const decisionOpt = (o: any) => o.setName("decision").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" });
|
||||
|
||||
const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" });
|
||||
|
||||
const roleOpt = strOpt("role", "Role name");
|
||||
const rolesOpt = strOpt("roles", "Comma-separated role names");
|
||||
|
||||
// ── message group ──────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("message")
|
||||
.setDescription("Configure bot messages")
|
||||
.addSubcommand((s) => s.setName("set-lock").setDescription("Set default lock message")
|
||||
.addStringOption(strOpt("message", "New lock message")))
|
||||
.addSubcommand((s) => s.setName("reset-lock").setDescription("Reset lock message to default"))
|
||||
.addSubcommand((s) => s.setName("set-confirm").setDescription("Set default confirm message")
|
||||
.addStringOption(decisionOpt)
|
||||
.addStringOption(strOpt("message", "New confirm message")))
|
||||
.addSubcommand((s) => s.setName("reset-confirm").setDescription("Reset confirm message to default")
|
||||
.addStringOption(decisionOpt))
|
||||
);
|
||||
|
||||
// ── roles group ────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("roles")
|
||||
.setDescription("Configure bot roles")
|
||||
.addSubcommand((s) => s.setName("set-officer").setDescription("Set officer roles (comma-separated)").addStringOption(rolesOpt))
|
||||
.addSubcommand((s) => s.setName("add-officer").setDescription("Add an officer role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("remove-officer").setDescription("Remove an officer role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("reset-officer").setDescription("Reset officer roles to default"))
|
||||
.addSubcommand((s) => s.setName("set-config").setDescription("Set config roles (comma-separated)").addStringOption(rolesOpt))
|
||||
.addSubcommand((s) => s.setName("add-config").setDescription("Add a config role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("remove-config").setDescription("Remove a config role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("reset-config").setDescription("Reset config roles to default"))
|
||||
.addSubcommand((s) => s.setName("set-tag").setDescription("Set tag roles (comma-separated)").addStringOption(rolesOpt))
|
||||
.addSubcommand((s) => s.setName("add-tag").setDescription("Add a tag role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("remove-tag").setDescription("Remove a tag role").addStringOption(roleOpt))
|
||||
.addSubcommand((s) => s.setName("reset-tag").setDescription("Reset tag roles to default"))
|
||||
);
|
||||
|
||||
// ── channel group ──────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("channel")
|
||||
.setDescription("Configure bot channels")
|
||||
.addSubcommand((s) => s.setName("set-poll").setDescription("Set poll channel")
|
||||
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("set-results").setDescription("Set results channel")
|
||||
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("set-score").setDescription("Set score channel")
|
||||
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
||||
);
|
||||
|
||||
// ── slot group ─────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("slot")
|
||||
.setDescription("Configure TG slots")
|
||||
.addSubcommand((s) => s.setName("add").setDescription("Add a TG slot")
|
||||
.addIntegerOption((o) => o.setName("hour").setDescription("TG hour (0-23)").setRequired(true))
|
||||
.addStringOption(strOpt("poll_opens", "Poll open time e.g. 10:00")))
|
||||
.addSubcommand((s) => s.setName("remove").setDescription("Remove a TG slot")
|
||||
.addIntegerOption((o) => o.setName("hour").setDescription("TG hour").setRequired(true)))
|
||||
);
|
||||
|
||||
// ── wrank group ────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("wrank")
|
||||
.setDescription("Configure W.Rank settings")
|
||||
.addSubcommand((s) => s.setName("set-goal").setDescription("Set W.Rank TG goal")
|
||||
.addIntegerOption((o) => o.setName("goal").setDescription("Number of TGs").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("set-post-on-reset").setDescription("Post leaderboard on weekly reset?")
|
||||
.addBooleanOption((o) => o.setName("enabled").setDescription("true/false").setRequired(true)))
|
||||
);
|
||||
|
||||
// ── tg group ───────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("tg")
|
||||
.setDescription("Configure TG settings")
|
||||
.addSubcommand((s) => s.setName("set-score-window").setDescription("Set score submission window (hours)")
|
||||
.addNumberOption((o) => o.setName("hours").setDescription("Hours").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("set-duration").setDescription("Set TG duration (minutes)")
|
||||
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes").setRequired(true)))
|
||||
.addSubcommand((s) => s.setName("set-no-display").setDescription("Where to show No voters")
|
||||
.addStringOption((o) => o.setName("mode").setDescription("inline or messages").setRequired(true)
|
||||
.addChoices({ name: "Inline under nation", value: "inline" }, { name: "Messages section only", value: "messages" })))
|
||||
.addSubcommand((s) => s.setName("set-nation-source").setDescription("Set source of truth nation")
|
||||
.addStringOption(nationOpt))
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, cfg("configRoles"))) {
|
||||
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
||||
}
|
||||
|
||||
const group = interaction.options.getSubcommandGroup(true);
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
|
||||
if (action === "set") {
|
||||
const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean);
|
||||
setCfg(cfgKey, roles);
|
||||
return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`);
|
||||
}
|
||||
if (action === "add") {
|
||||
const role = interaction.options.getString("role", true).trim();
|
||||
const roles = [...new Set([...cfg(cfgKey), role])];
|
||||
setCfg(cfgKey, roles);
|
||||
return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`);
|
||||
}
|
||||
if (action === "remove") {
|
||||
const role = interaction.options.getString("role", true).trim();
|
||||
const roles = cfg(cfgKey).filter((r: string) => r !== role);
|
||||
setCfg(cfgKey, roles);
|
||||
return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`);
|
||||
}
|
||||
if (action === "reset") {
|
||||
resetCfg(cfgKey);
|
||||
return void replyAndDelete(interaction, `✅ Roles reset to default.`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── message ────────────────────────────────────────────────────────────────
|
||||
if (group === "message") {
|
||||
if (sub === "set-lock") { setCfg("lockMessage", interaction.options.getString("message", true)); return void replyAndDelete(interaction, "✅ Lock message updated."); }
|
||||
if (sub === "reset-lock") { resetCfg("lockMessage"); return void replyAndDelete(interaction, "✅ Lock message reset."); }
|
||||
if (sub === "set-confirm") {
|
||||
const d = interaction.options.getString("decision", true);
|
||||
setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", true));
|
||||
return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`);
|
||||
}
|
||||
if (sub === "reset-confirm") {
|
||||
const d = interaction.options.getString("decision", true);
|
||||
resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage");
|
||||
return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── roles ──────────────────────────────────────────────────────────────────
|
||||
if (group === "roles") {
|
||||
if (sub === "set-officer") return roleSubcommand("officerRoles", "set");
|
||||
if (sub === "add-officer") return roleSubcommand("officerRoles", "add");
|
||||
if (sub === "remove-officer") return roleSubcommand("officerRoles", "remove");
|
||||
if (sub === "reset-officer") return roleSubcommand("officerRoles", "reset");
|
||||
if (sub === "set-config") return roleSubcommand("configRoles", "set");
|
||||
if (sub === "add-config") return roleSubcommand("configRoles", "add");
|
||||
if (sub === "remove-config") return roleSubcommand("configRoles", "remove");
|
||||
if (sub === "reset-config") return roleSubcommand("configRoles", "reset");
|
||||
if (sub === "set-tag") return roleSubcommand("tagRoles", "set");
|
||||
if (sub === "add-tag") return roleSubcommand("tagRoles", "add");
|
||||
if (sub === "remove-tag") return roleSubcommand("tagRoles", "remove");
|
||||
if (sub === "reset-tag") return roleSubcommand("tagRoles", "reset");
|
||||
}
|
||||
|
||||
// ── channel ────────────────────────────────────────────────────────────────
|
||||
if (group === "channel") {
|
||||
if (sub === "set-poll") { setCfg("pollChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Poll channel updated."); }
|
||||
if (sub === "set-results") { setCfg("resultsChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Results channel updated."); }
|
||||
if (sub === "set-score") { setCfg("scoreChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Score channel updated."); }
|
||||
}
|
||||
|
||||
// ── slot ───────────────────────────────────────────────────────────────────
|
||||
if (group === "slot") {
|
||||
if (sub === "add") {
|
||||
const hour = interaction.options.getInteger("hour", true);
|
||||
const pollOpens = interaction.options.getString("poll_opens", true);
|
||||
const slots = cfg("slots");
|
||||
if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`);
|
||||
slots.push({ tgHour: hour, pollOpens, closesAfter: cfg("tgDurationMinutes"), active: true });
|
||||
setCfg("slots", slots);
|
||||
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`);
|
||||
}
|
||||
if (sub === "remove") {
|
||||
const hour = interaction.options.getInteger("hour", true);
|
||||
const slots = cfg("slots").filter((s) => s.tgHour !== hour);
|
||||
setCfg("slots", slots);
|
||||
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── wrank ──────────────────────────────────────────────────────────────────
|
||||
if (group === "wrank") {
|
||||
if (sub === "set-goal") { setCfg("wRankGoal", interaction.options.getInteger("goal", true)); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); }
|
||||
if (sub === "set-post-on-reset") { setCfg("wRankPostOnReset", interaction.options.getBoolean("enabled", true)); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); }
|
||||
}
|
||||
|
||||
// ── tg ─────────────────────────────────────────────────────────────────────
|
||||
if (group === "tg") {
|
||||
if (sub === "set-score-window") { setCfg("scoreWindowHours", interaction.options.getNumber("hours", true)); return void replyAndDelete(interaction, "✅ Score window updated."); }
|
||||
if (sub === "set-duration") { setCfg("tgDurationMinutes", interaction.options.getInteger("minutes", true)); return void replyAndDelete(interaction, "✅ TG duration updated."); }
|
||||
if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); }
|
||||
if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); }
|
||||
}
|
||||
}
|
||||
109
src/handlers/buttons.ts
Normal file
109
src/handlers/buttons.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { ButtonInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../systems/config";
|
||||
import { resolveUser } from "../systems/users";
|
||||
import { resolveMessage, nowFormatted } from "../systems/messages";
|
||||
import { resolveNation } from "../systems/nations";
|
||||
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "../systems/poll";
|
||||
|
||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||
|
||||
const clickCounts = new Map<string, { yes: number; no: number }>();
|
||||
|
||||
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
|
||||
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
|
||||
|
||||
// Defer immediately to avoid 3s timeout
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
||||
if (slot === undefined) return;
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
if (state.locked || state.confirmed !== null) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const member = await interaction.guild!.members.fetch(userId);
|
||||
const user = await resolveUser(member);
|
||||
const votedYes = interaction.customId === "tg_yes";
|
||||
const now = nowFormatted();
|
||||
|
||||
// Check nation — block if no nation
|
||||
const nation = resolveNation(member, user.usermapKey);
|
||||
if (!nation) {
|
||||
if (EPHEMERAL_ENABLED) {
|
||||
const reply = await interaction.followUp({ content: "❌ You must be in Capella or Procyon to vote.", ephemeral: true });
|
||||
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Click tracking
|
||||
if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 });
|
||||
const clicks = clickCounts.get(userId)!;
|
||||
|
||||
if (votedYes && clicks.yes >= LOCK_AT) return;
|
||||
if (!votedYes && clicks.no >= LOCK_AT) return;
|
||||
|
||||
// Ignore same vote
|
||||
if (votedYes && state.yes.has(userId)) return;
|
||||
if (!votedYes && state.no.has(userId)) return;
|
||||
|
||||
if (votedYes) clicks.yes += 1;
|
||||
else clicks.no += 1;
|
||||
|
||||
const clickCount = votedYes ? clicks.yes : clicks.no;
|
||||
|
||||
// Resolve messages — officer override takes priority
|
||||
const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
||||
|
||||
const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
||||
|
||||
const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername);
|
||||
|
||||
if (votedYes) {
|
||||
const previousNo = state.no.get(userId);
|
||||
state.no.delete(userId);
|
||||
state.yes.set(userId, {
|
||||
...baseEntry,
|
||||
votedAt: now,
|
||||
previousNoAt: previousNo?.votedAt,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const previousYes = state.yes.get(userId);
|
||||
state.yes.delete(userId);
|
||||
state.no.set(userId, {
|
||||
...baseEntry,
|
||||
votedAt: now,
|
||||
previousYesAt: previousYes?.votedAt,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const locked = clickCount >= LOCK_AT;
|
||||
if (locked) state.locked = true;
|
||||
|
||||
// Send ephemeral follow-up (since we already deferred with deferUpdate)
|
||||
if (EPHEMERAL_ENABLED) {
|
||||
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
||||
const content = ephemeralMsg
|
||||
? `${ephemeralMsg}${lockedSuffix}`
|
||||
: locked ? "🔒 You've been locked in." : null;
|
||||
|
||||
if (content) {
|
||||
const reply = await interaction.followUp({ content, ephemeral: true });
|
||||
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const channel = interaction.channel as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
}
|
||||
|
||||
export function resetClickCounts(): void {
|
||||
clickCounts.clear();
|
||||
}
|
||||
42
src/handlers/interactions.ts
Normal file
42
src/handlers/interactions.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Interaction, ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
import { handleButton } from "./buttons";
|
||||
import { handleTgCommand } from "../commands/tg";
|
||||
import { handleTgConfigCommand } from "../commands/tgConfig";
|
||||
import { handleBorrowAcceptButton } from "../subcommands/char/accept";
|
||||
import { handleBorrowDeclineButton } from "../subcommands/char/decline";
|
||||
|
||||
export async function handleInteraction(interaction: Interaction): Promise<void> {
|
||||
try {
|
||||
if (interaction.isButton()) {
|
||||
const btn = interaction as ButtonInteraction;
|
||||
|
||||
// Borrow accept/decline buttons from DM
|
||||
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);
|
||||
}
|
||||
|
||||
return await handleButton(btn);
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
const cmd = interaction as ChatInputCommandInteraction;
|
||||
if (cmd.commandName === "tg") await handleTgCommand(cmd);
|
||||
if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Interaction error:", err);
|
||||
try {
|
||||
const msg = { content: "❌ An error occurred.", ephemeral: true };
|
||||
if ((interaction as any).replied || (interaction as any).deferred) {
|
||||
await (interaction as any).followUp(msg);
|
||||
} else {
|
||||
await (interaction as any).reply(msg);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
72
src/index.ts
Normal file
72
src/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Client, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
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 } from "./systems/poll";
|
||||
import { handleInteraction } from "./handlers/interactions";
|
||||
import { buildTgCommand } from "./commands/tg";
|
||||
import { buildTgConfigCommand } from "./commands/tgConfig";
|
||||
import { TGSlot } from "./types";
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
const GUILD_ID = process.env.GUILD_ID!;
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
|
||||
});
|
||||
|
||||
async function registerCommands(): Promise<void> {
|
||||
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
||||
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
|
||||
body: [buildTgCommand().toJSON(), buildTgConfigCommand().toJSON()],
|
||||
});
|
||||
console.log("Slash commands registered.");
|
||||
}
|
||||
|
||||
async function onPollOpen(slot: TGSlot): Promise<void> {
|
||||
const channelId = cfg("pollChannelId");
|
||||
const channel = await client.channels.fetch(channelId) as any;
|
||||
if (!channel) return console.error("Poll channel not found.");
|
||||
await postPoll(channel, slot);
|
||||
}
|
||||
|
||||
async function onPollClose(slot: TGSlot): Promise<void> {
|
||||
const state = polls.get(slot.tgHour);
|
||||
if (!state) return;
|
||||
state.locked = true;
|
||||
const channelId = cfg("pollChannelId");
|
||||
const channel = await client.channels.fetch(channelId) as any;
|
||||
if (!channel) return;
|
||||
const { updatePollMessage } = require("./systems/poll");
|
||||
await updatePollMessage(channel, slot.tgHour);
|
||||
console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`);
|
||||
}
|
||||
|
||||
client.on("interactionCreate", handleInteraction);
|
||||
|
||||
client.once("clientReady", async () => {
|
||||
console.log(`Logged in as ${client.user!.tag}`);
|
||||
|
||||
// Load all data
|
||||
loadConfig();
|
||||
loadMessages();
|
||||
loadEmojis();
|
||||
loadCharacters();
|
||||
loadWRank();
|
||||
|
||||
// Register commands if --register flag passed
|
||||
if (process.argv.includes("--register")) {
|
||||
await registerCommands();
|
||||
}
|
||||
|
||||
// Schedule slots
|
||||
scheduleSlots(client, onPollOpen, onPollClose);
|
||||
|
||||
console.log("Bot ready.");
|
||||
});
|
||||
|
||||
client.login(TOKEN);
|
||||
10
src/subcommands/bringer/clear.ts
Normal file
10
src/subcommands/bringer/clear.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { clearBringerOverride } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
clearBringerOverride(nation);
|
||||
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
|
||||
}
|
||||
12
src/subcommands/bringer/set.ts
Normal file
12
src/subcommands/bringer/set.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { setBringerOverride } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
|
||||
setBringerOverride(nation, usermapKey);
|
||||
return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`);
|
||||
}
|
||||
68
src/subcommands/char/accept.ts
Normal file
68
src/subcommands/char/accept.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { getCharacterByName } from "../../systems/characters";
|
||||
import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "../../systems/borrow";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username);
|
||||
const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const requesterKey = interaction.options.getString("name", true);
|
||||
await acceptBorrow(interaction, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
export async function handleBorrowAcceptButton(interaction: ButtonInteraction, ownerKey: string, requesterKey: string): Promise<void> {
|
||||
await acceptBorrow(interaction, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
async function acceptBorrow(
|
||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||
ownerKey: string,
|
||||
requesterKey: string
|
||||
): Promise<void> {
|
||||
const request = getPendingRequest(ownerKey, requesterKey);
|
||||
if (!request) {
|
||||
const msg = "❌ No pending borrow request found.";
|
||||
if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true });
|
||||
return void replyAndDelete(interaction as ChatInputCommandInteraction, msg);
|
||||
}
|
||||
|
||||
const char = getCharacterByName(ownerKey, request.charName);
|
||||
if (!char) {
|
||||
const msg = `❌ Character **«${request.charName}»** no longer exists.`;
|
||||
if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true });
|
||||
return void replyAndDelete(interaction as ChatInputCommandInteraction, msg);
|
||||
}
|
||||
|
||||
setSessionBorrow(requesterKey, ownerKey, request.charName);
|
||||
removePendingRequest(ownerKey, requesterKey);
|
||||
await updateBorrowDM(interaction.client, ownerKey, requesterKey, true);
|
||||
|
||||
// Update poll embed if requester has already voted
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
for (const map of [state.yes, state.no]) {
|
||||
for (const [, entry] of map) {
|
||||
if (entry.usermapKey === requesterKey) {
|
||||
entry.characterName = char.name;
|
||||
entry.characterClass = char.class;
|
||||
entry.characterLevel = char.level;
|
||||
entry.characterNation = char.nation;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const msg = `✅ Accepted — **${requesterKey}** can now use **«${request.charName}»** for this session.`;
|
||||
if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true });
|
||||
return void replyAndDelete(interaction as ChatInputCommandInteraction, msg);
|
||||
}
|
||||
32
src/subcommands/char/add.ts
Normal file
32
src/subcommands/char/add.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { addCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { ClassKey, Nation } from "../../types";
|
||||
|
||||
export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const cls = interaction.options.getString("class", true) as ClassKey;
|
||||
const level = interaction.options.getInteger("level", true);
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const added = addCharacter(usermapKey, { name: charName, class: cls, level, nation });
|
||||
if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`);
|
||||
}
|
||||
94
src/subcommands/char/borrow.ts
Normal file
94
src/subcommands/char/borrow.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
|
||||
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const requester = await resolveUser(member);
|
||||
|
||||
// Args: owner, charname, [username] (officer only — grants directly)
|
||||
const ownerArg = interaction.options.getString("owner", true);
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const targetArg = interaction.options.getString("name"); // officer: grant to this user
|
||||
|
||||
if (targetArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly.");
|
||||
}
|
||||
|
||||
const requesterKey = targetArg ?? requester.usermapKey;
|
||||
if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getCharacterByName(ownerArg, charName);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found for **${ownerArg}**.`);
|
||||
|
||||
// Already has access?
|
||||
if (canUseCharacter(requesterKey, ownerArg, charName)) {
|
||||
return void replyAndDelete(interaction, `❌ **${requesterKey}** already has access to **«${charName}»**.`);
|
||||
}
|
||||
|
||||
// Officer bypasses request — grant directly
|
||||
if (isOfficer && targetArg) {
|
||||
setSessionBorrow(requesterKey, ownerArg, charName);
|
||||
|
||||
// Update poll if the user has already voted
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
// Find the voter entry and update their character
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === requesterKey) {
|
||||
entry.characterName = char.name;
|
||||
entry.characterClass = char.class;
|
||||
entry.characterLevel = char.level;
|
||||
entry.characterNation = char.nation;
|
||||
entry.publicMessage = undefined;
|
||||
}
|
||||
}
|
||||
await updatePollMessage(channel, slot);
|
||||
}
|
||||
|
||||
return void replyAndDelete(interaction, `✅ **${requesterKey}** can now borrow **«${charName}»** for this session.`);
|
||||
}
|
||||
|
||||
// Regular player — send request to owner
|
||||
addPendingRequest({
|
||||
requesterKey,
|
||||
ownerKey: ownerArg,
|
||||
charName,
|
||||
requestedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Find owner's Discord ID from guild members
|
||||
// We need to reverse-lookup: find the guild member whose discord username maps to ownerArg
|
||||
const guild = interaction.guild!;
|
||||
await guild.members.fetch();
|
||||
const ownerMember = guild.members.cache.find((m) => {
|
||||
const entry = (require("../../systems/messages") as any).getUsermapEntry(m.user.username);
|
||||
return entry?.file === ownerArg || entry === ownerArg;
|
||||
});
|
||||
|
||||
if (!ownerMember) {
|
||||
return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`);
|
||||
}
|
||||
|
||||
const fallbackChannel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await sendBorrowRequestDM(
|
||||
interaction.client,
|
||||
ownerMember.user.id,
|
||||
requester.displayName,
|
||||
ownerArg,
|
||||
requesterKey,
|
||||
char.name,
|
||||
char.class,
|
||||
char.level,
|
||||
fallbackChannel
|
||||
);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ Borrow request sent to **${ownerArg}** for **«${charName}»**.`);
|
||||
}
|
||||
37
src/subcommands/char/decline.ts
Normal file
37
src/subcommands/char/decline.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const ownerMember = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username);
|
||||
const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const requesterKey = interaction.options.getString("name", true);
|
||||
await declineBorrow(interaction, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
export async function handleBorrowDeclineButton(interaction: ButtonInteraction, ownerKey: string, requesterKey: string): Promise<void> {
|
||||
await declineBorrow(interaction, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
async function declineBorrow(
|
||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||
ownerKey: string,
|
||||
requesterKey: string
|
||||
): Promise<void> {
|
||||
const request = getPendingRequest(ownerKey, requesterKey);
|
||||
if (!request) {
|
||||
const msg = "❌ No pending borrow request found.";
|
||||
if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true });
|
||||
return void replyAndDelete(interaction as ChatInputCommandInteraction, msg);
|
||||
}
|
||||
|
||||
removePendingRequest(ownerKey, requesterKey);
|
||||
await updateBorrowDM(interaction.client, ownerKey, requesterKey, false);
|
||||
|
||||
const msg = `❌ Borrow request for **«${request.charName}»** from **${requesterKey}** declined.`;
|
||||
if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true });
|
||||
return void replyAndDelete(interaction as ChatInputCommandInteraction, msg);
|
||||
}
|
||||
28
src/subcommands/char/remove.ts
Normal file
28
src/subcommands/char/remove.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { removeCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const removed = removeCharacter(usermapKey, charName);
|
||||
if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`);
|
||||
}
|
||||
55
src/subcommands/char/setActive.ts
Normal file
55
src/subcommands/char/setActive.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { setActiveCharacter } from "../../systems/characters";
|
||||
import { setSessionBorrow } from "../../systems/borrow";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||
|
||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; charName: string } | null {
|
||||
try {
|
||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === usermapKey) continue;
|
||||
const char = data.characters?.find(
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
||||
);
|
||||
if (char) return { ownerKey, charName: char.name };
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
// Try own characters first
|
||||
const set = setActiveCharacter(usermapKey, charName);
|
||||
if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`);
|
||||
|
||||
// Fall back to shared characters
|
||||
const shared = findSharedChar(usermapKey, charName);
|
||||
if (shared) {
|
||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.charName);
|
||||
return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`);
|
||||
}
|
||||
|
||||
return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||
}
|
||||
33
src/subcommands/char/setNation.ts
Normal file
33
src/subcommands/char/setNation.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { setCharacterNation, getActiveCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const charName = interaction.options.getString("char_name"); // optional, defaults to active
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||
|
||||
const set = setCharacterNation(usermapKey, targetName, nation);
|
||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`);
|
||||
}
|
||||
34
src/subcommands/char/setStats.ts
Normal file
34
src/subcommands/char/setStats.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharSetStats(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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;
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||
|
||||
const set = setCharacterStats(usermapKey, targetName, { atk, def, heal });
|
||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`);
|
||||
}
|
||||
82
src/subcommands/char/share.ts
Normal file
82
src/subcommands/char/share.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { getCharacterByName } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||
|
||||
function saveCharacters(chars: any): void {
|
||||
fs.writeFileSync(CHARS_PATH, JSON.stringify(chars, null, 2));
|
||||
}
|
||||
|
||||
function loadRawChars(): any {
|
||||
return JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
}
|
||||
|
||||
export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const user = await resolveUser(member);
|
||||
|
||||
const ownerArg = interaction.options.getString("owner");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const targetKey = interaction.options.getString("name", true);
|
||||
|
||||
if (ownerArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.usermapKey;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getCharacterByName(ownerKey, charName);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found.`);
|
||||
|
||||
if (char.sharedWith?.includes(targetKey)) {
|
||||
return void replyAndDelete(interaction, `❌ **${targetKey}** already has access to **«${charName}»**.`);
|
||||
}
|
||||
|
||||
// Write directly to characters.json
|
||||
const raw = loadRawChars();
|
||||
const charEntry = raw[ownerKey]?.characters?.find((c: any) => c.name.toLowerCase() === charName.toLowerCase());
|
||||
if (!charEntry) return void replyAndDelete(interaction, `❌ Character not found in data.`);
|
||||
|
||||
if (!charEntry.sharedWith) charEntry.sharedWith = [];
|
||||
charEntry.sharedWith.push(targetKey);
|
||||
saveCharacters(raw);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`);
|
||||
}
|
||||
|
||||
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const user = await resolveUser(member);
|
||||
|
||||
const ownerArg = interaction.options.getString("owner");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
const targetKey = interaction.options.getString("name", true);
|
||||
|
||||
if (ownerArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.usermapKey;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const raw = loadRawChars();
|
||||
const charEntry = raw[ownerKey]?.characters?.find((c: any) => c.name.toLowerCase() === charName.toLowerCase());
|
||||
if (!charEntry) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found.`);
|
||||
|
||||
if (!charEntry.sharedWith?.includes(targetKey)) {
|
||||
return void replyAndDelete(interaction, `❌ **${targetKey}** does not have access to **«${charName}»**.`);
|
||||
}
|
||||
|
||||
charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey);
|
||||
saveCharacters(raw);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`);
|
||||
}
|
||||
34
src/subcommands/history.ts
Normal file
34
src/subcommands/history.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { loadResult, listRecentResults } from "../systems/history";
|
||||
import { normalizeSlot } from "../systems/scores";
|
||||
import { replyAndDelete } from "../utils";
|
||||
|
||||
export async function handleHistory(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const dateArg = interaction.options.getString("date");
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
if (dateArg && slotArg) {
|
||||
const slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
|
||||
const result = loadResult(dateArg, slot);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${dateArg} ${slot}:00.`);
|
||||
|
||||
const kd = result.nationKD;
|
||||
const lines = [
|
||||
`**TG ${slot}:00 — ${result.date}**`,
|
||||
`Capella: ${kd.capella.k}K/${kd.capella.d}D | Procyon: ${kd.procyon.k}K/${kd.procyon.d}D`,
|
||||
`Scores: ${result.scores.length} submitted`,
|
||||
].join("\n");
|
||||
return void replyAndDelete(interaction, lines);
|
||||
}
|
||||
|
||||
const recent = listRecentResults(10);
|
||||
if (recent.length === 0) return void replyAndDelete(interaction, "❌ No TG history found.");
|
||||
|
||||
const lines = recent.map((r) =>
|
||||
`**${r.date} ${r.slot}:00** — Cap: ${r.nationKD.capella.k}K/${r.nationKD.capella.d}D | Pro: ${r.nationKD.procyon.k}K/${r.nationKD.procyon.d}D`
|
||||
).join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, `**Recent TG History:**\n${lines}`);
|
||||
}
|
||||
46
src/subcommands/poll/confirm.ts
Normal file
46
src/subcommands/poll/confirm.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg, setCfg, resetCfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleConfirm(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const decision = interaction.options.getString("decision", true) as "yes" | "no";
|
||||
const oneTimeMsg = interaction.options.getString("message") ?? null;
|
||||
const doTag = interaction.options.getBoolean("tag") ?? false;
|
||||
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
state.confirmed = decision;
|
||||
state.locked = true;
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
|
||||
if (oneTimeMsg) {
|
||||
const cfgKey = decision === "yes" ? "confirmYesMessage" : "confirmNoMessage";
|
||||
const saved = cfg(cfgKey);
|
||||
setCfg(cfgKey, oneTimeMsg);
|
||||
await updatePollMessage(channel, slot);
|
||||
if (saved !== undefined) setCfg(cfgKey, saved);
|
||||
else resetCfg(cfgKey);
|
||||
} else {
|
||||
await updatePollMessage(channel, slot);
|
||||
}
|
||||
|
||||
if (doTag) await tagRoles(channel, interaction);
|
||||
return void replyAndDelete(interaction, `${decision === "yes" ? "✅" : "❌"} TG confirmed as **${decision}**.`);
|
||||
}
|
||||
|
||||
async function tagRoles(channel: TextChannel, interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const roles = cfg("tagRoles");
|
||||
const guild = interaction.guild!;
|
||||
await guild.roles.fetch();
|
||||
const mentions = roles
|
||||
.map((name) => guild.roles.cache.find((r) => r.name === name))
|
||||
.filter(Boolean)
|
||||
.map((r) => `<@&${r!.id}>`)
|
||||
.join(" ");
|
||||
if (mentions) await channel.send(mentions);
|
||||
}
|
||||
80
src/subcommands/poll/inject.ts
Normal file
80
src/subcommands/poll/inject.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { getActiveCharacter } from "../../systems/characters";
|
||||
import { resolveNation } from "../../systems/nations";
|
||||
import { nowFormatted, resolveMessage } from "../../systems/messages";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { VoteEntry } from "../../types";
|
||||
|
||||
export async function handleInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
||||
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
if (state.locked || state.confirmed !== null) {
|
||||
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
|
||||
}
|
||||
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
console.log(`[DEBUG inject] usermapKey=${usermapKey} char=${JSON.stringify(char)}`);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${usermapKey}**.`);
|
||||
|
||||
// Use a synthetic userId based on usermapKey to avoid collisions
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
const now = nowFormatted();
|
||||
|
||||
const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null);
|
||||
|
||||
const entry: VoteEntry = {
|
||||
usermapKey,
|
||||
displayName: char.name,
|
||||
characterName: char.name,
|
||||
characterClass: char.class,
|
||||
characterLevel: char.level,
|
||||
characterNation: char.nation,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
};
|
||||
|
||||
if (voteType === "yes") {
|
||||
state.no.delete(syntheticId);
|
||||
state.yes.set(syntheticId, entry);
|
||||
} else {
|
||||
state.yes.delete(syntheticId);
|
||||
state.no.set(syntheticId, entry);
|
||||
}
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`);
|
||||
}
|
||||
|
||||
export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
|
||||
// Also try removing real votes by scanning for usermapKey
|
||||
let removed = false;
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === usermapKey || id === syntheticId) {
|
||||
state.yes.delete(id);
|
||||
state.no.delete(id);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`);
|
||||
}
|
||||
21
src/subcommands/poll/lock.ts
Normal file
21
src/subcommands/poll/lock.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const oneTimeMsg = interaction.options.getString("message") ?? undefined;
|
||||
const slot = getActiveSlot();
|
||||
if (!slot) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
state.locked = true;
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot, oneTimeMsg);
|
||||
return void replyAndDelete(interaction, "🔒 Poll locked.");
|
||||
}
|
||||
|
||||
function getActiveSlot(): number | undefined {
|
||||
return [...polls.keys()][0];
|
||||
}
|
||||
32
src/subcommands/poll/purge.ts
Normal file
32
src/subcommands/poll/purge.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { ChatInputCommandInteraction, TextChannel, Collection, Message } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handlePurge(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const channelId = cfg("pollChannelId");
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found.");
|
||||
|
||||
// Clear active poll state
|
||||
polls.clear();
|
||||
|
||||
// Fetch and delete bot messages in bulk (Discord allows bulk delete for messages < 14 days old)
|
||||
let deleted = 0;
|
||||
let messages: Collection<string, Message>;
|
||||
|
||||
do {
|
||||
messages = await channel.messages.fetch({ limit: 100 });
|
||||
const botMessages = messages.filter((m) => m.author.id === interaction.client.user!.id);
|
||||
if (botMessages.size === 0) break;
|
||||
|
||||
if (botMessages.size === 1) {
|
||||
await botMessages.first()!.delete();
|
||||
} else {
|
||||
await channel.bulkDelete(botMessages, true); // true = skip messages older than 14 days
|
||||
}
|
||||
deleted += botMessages.size;
|
||||
} while (messages.size === 100);
|
||||
|
||||
return void replyAndDelete(interaction, `🗑️ Deleted **${deleted}** bot message(s) from <#${channelId}>.`);
|
||||
}
|
||||
12
src/subcommands/poll/reload.ts
Normal file
12
src/subcommands/poll/reload.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { loadMessages } from "../../systems/messages";
|
||||
import { loadEmojis } from "../../systems/emojis";
|
||||
import { loadCharacters } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleReload(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
loadMessages(); // reloads global.json, usermap.json, users/*.json
|
||||
loadEmojis(); // reloads emojis.json
|
||||
loadCharacters(); // reloads characters.json and accounts.json
|
||||
return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk.");
|
||||
}
|
||||
61
src/subcommands/poll/seed.ts
Normal file
61
src/subcommands/poll/seed.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { nowFormatted, resolveMessage } from "../../systems/messages";
|
||||
import { getEffectiveCharacter } from "../../systems/borrow";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { VoteEntry, UsermapEntry } from "../../types";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function handleSeed(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
if (state.locked || state.confirmed !== null) {
|
||||
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
|
||||
}
|
||||
|
||||
let usermap: Record<string, UsermapEntry | string> = {};
|
||||
try {
|
||||
usermap = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8"));
|
||||
} catch {
|
||||
return void replyAndDelete(interaction, "❌ Could not load usermap.json.");
|
||||
}
|
||||
|
||||
const now = nowFormatted();
|
||||
let injected = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [discordUsername, entry] of Object.entries(usermap)) {
|
||||
const usermapKey = typeof entry === "string" ? entry : entry.file;
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(usermapKey);
|
||||
|
||||
if (!char) { skipped++; continue; }
|
||||
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; }
|
||||
|
||||
const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null);
|
||||
|
||||
const voteEntry: VoteEntry = {
|
||||
usermapKey,
|
||||
displayName: char.name,
|
||||
characterName: char.name,
|
||||
characterClass: char.class,
|
||||
characterLevel: char.level,
|
||||
characterNation: char.nation,
|
||||
borrowedFrom: borrowedFrom ?? undefined,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
};
|
||||
|
||||
state.yes.set(syntheticId, voteEntry);
|
||||
injected++;
|
||||
}
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, `✅ Seeded **${injected}** player(s)${skipped > 0 ? `, skipped **${skipped}** (no active character)` : ""}.`);
|
||||
}
|
||||
84
src/subcommands/poll/setMessage.ts
Normal file
84
src/subcommands/poll/setMessage.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { hasOfficerRole } from "../../systems/users";
|
||||
|
||||
export async function handleSetMessage(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
||||
const message = interaction.options.getString("message", true);
|
||||
|
||||
// Resolve target user
|
||||
let targetUserId: string;
|
||||
if (nameArg) {
|
||||
const member = interaction.guild!.members.cache.find(
|
||||
(m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg
|
||||
);
|
||||
if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`);
|
||||
targetUserId = member.user.id;
|
||||
} else {
|
||||
targetUserId = interaction.user.id;
|
||||
}
|
||||
|
||||
setPublicOverride(targetUserId, voteType, message);
|
||||
return void replyAndDelete(interaction, `✅ Public message set for **${voteType}** vote.`);
|
||||
}
|
||||
|
||||
export async function handleClearMessage(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const voteType = interaction.options.getString("vote_type") as "yes" | "no" | null;
|
||||
|
||||
let targetUserId: string;
|
||||
if (nameArg) {
|
||||
const member = interaction.guild!.members.cache.find(
|
||||
(m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg
|
||||
);
|
||||
if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`);
|
||||
targetUserId = member.user.id;
|
||||
} else {
|
||||
targetUserId = interaction.user.id;
|
||||
}
|
||||
|
||||
clearPublicOverride(targetUserId, voteType ?? undefined);
|
||||
return void replyAndDelete(interaction, `✅ Public message override cleared.`);
|
||||
}
|
||||
|
||||
export async function handleSetEphemeral(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
||||
const message = interaction.options.getString("message", true);
|
||||
|
||||
let targetUserId: string;
|
||||
if (nameArg) {
|
||||
const member = interaction.guild!.members.cache.find(
|
||||
(m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg
|
||||
);
|
||||
if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`);
|
||||
targetUserId = member.user.id;
|
||||
} else {
|
||||
targetUserId = interaction.user.id;
|
||||
}
|
||||
|
||||
setEphemeralOverride(targetUserId, voteType, message);
|
||||
return void replyAndDelete(interaction, `✅ Ephemeral message set for **${voteType}** vote.`);
|
||||
}
|
||||
|
||||
export async function handleClearEphemeral(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const voteType = interaction.options.getString("vote_type") as "yes" | "no" | null;
|
||||
|
||||
let targetUserId: string;
|
||||
if (nameArg) {
|
||||
const member = interaction.guild!.members.cache.find(
|
||||
(m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg
|
||||
);
|
||||
if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`);
|
||||
targetUserId = member.user.id;
|
||||
} else {
|
||||
targetUserId = interaction.user.id;
|
||||
}
|
||||
|
||||
clearEphemeralOverride(targetUserId, voteType ?? undefined);
|
||||
return void replyAndDelete(interaction, `✅ Ephemeral message override cleared.`);
|
||||
}
|
||||
31
src/subcommands/poll/start.ts
Normal file
31
src/subcommands/poll/start.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { postPoll } from "../../systems/poll";
|
||||
import { resetClickCounts } from "../../handlers/buttons";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { TGSlot } from "../../types";
|
||||
|
||||
export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
const slots = cfg("slots").filter((s) => s.active);
|
||||
|
||||
let slot: TGSlot | undefined;
|
||||
if (slotArg) {
|
||||
const hour = parseInt(slotArg);
|
||||
slot = slots.find((s) => s.tgHour === hour);
|
||||
if (!slot) return void replyAndDelete(interaction, `❌ No active slot for hour ${slotArg}.`);
|
||||
} else {
|
||||
slot = slots[0];
|
||||
}
|
||||
if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured.");
|
||||
|
||||
const channelId = cfg("pollChannelId");
|
||||
console.log("pollChannelId:", channelId);
|
||||
console.log("POLL_CHANNEL_ID env:", process.env.POLL_CHANNEL_ID);
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found.");
|
||||
|
||||
resetClickCounts();
|
||||
await replyAndDelete(interaction, `⚔️ Posting TG poll for ${slot.tgHour}:00...`);
|
||||
await postPoll(channel, slot);
|
||||
}
|
||||
30
src/subcommands/poll/status.ts
Normal file
30
src/subcommands/poll/status.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleStatus(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const activePolls = [...polls.entries()].map(([slot, s]) =>
|
||||
`Slot ${slot}:00 — locked: ${s.locked}, confirmed: ${s.confirmed ?? "no"}, yes: ${s.yes.size}, no: ${s.no.size}`
|
||||
).join("\n") || "None";
|
||||
|
||||
const status = [
|
||||
`**Officer roles:** ${cfg("officerRoles").join(", ")}`,
|
||||
`**Config roles:** ${cfg("configRoles").join(", ")}`,
|
||||
`**Tag roles:** ${cfg("tagRoles").join(", ")}`,
|
||||
`**Lock message:** ${cfg("lockMessage")}`,
|
||||
`**Confirm yes:** ${cfg("confirmYesMessage")}`,
|
||||
`**Confirm no:** ${cfg("confirmNoMessage")}`,
|
||||
`**Poll channel:** ${cfg("pollChannelId") || "not set"}`,
|
||||
`**Results channel:** ${cfg("resultsChannelId") || "not set"}`,
|
||||
`**Score channel:** ${cfg("scoreChannelId") || "not set"}`,
|
||||
`**Score window:** ${cfg("scoreWindowHours")}h`,
|
||||
`**TG duration:** ${cfg("tgDurationMinutes")}min`,
|
||||
`**Nation source:** ${cfg("nationSource")}`,
|
||||
`**W.Rank goal:** ${cfg("wRankGoal")} TGs`,
|
||||
`**Timezone:** ${process.env.TZ ?? "Etc/GMT-2"}`,
|
||||
`**Active polls:**\n${activePolls}`,
|
||||
].join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, status);
|
||||
}
|
||||
20
src/subcommands/poll/unlock.ts
Normal file
20
src/subcommands/poll/unlock.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { loadMessages } from "../../systems/messages";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleUnlock(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
if (!state.locked) return void replyAndDelete(interaction, "ℹ️ The poll isn't locked.");
|
||||
|
||||
state.locked = false;
|
||||
loadMessages();
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, "🔓 Poll unlocked!");
|
||||
}
|
||||
52
src/subcommands/rank/get.ts
Normal file
52
src/subcommands/rank/get.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks.");
|
||||
}
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const week = getCurrentWeek();
|
||||
const goal = cfg("wRankGoal");
|
||||
const weekKey = getWeekKey();
|
||||
|
||||
for (const nation of ["capella", "procyon"] as const) {
|
||||
const entry = week.entries[nation].find((e) => e.usermapKey === usermapKey);
|
||||
if (!entry) continue;
|
||||
|
||||
const isDone = entry.tgCount >= goal;
|
||||
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
|
||||
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : "";
|
||||
const bringer = getBringer(entry.nation);
|
||||
const isBringer = bringer === usermapKey && isDone;
|
||||
|
||||
const lines = [
|
||||
`**${entry.characterName}** · ${entry.nation}`,
|
||||
`**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""}`,
|
||||
`**Points:** ${entry.weeklyPoints}`,
|
||||
`**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`,
|
||||
`**Week:** ${weekKey}`,
|
||||
].join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, lines);
|
||||
}
|
||||
|
||||
return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`);
|
||||
}
|
||||
39
src/subcommands/rank/post.ts
Normal file
39
src/subcommands/rank/post.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const week = getCurrentWeek();
|
||||
const goal = cfg("wRankGoal");
|
||||
const weekKey = getWeekKey();
|
||||
|
||||
const formatNation = (nation: "capella" | "procyon"): string => {
|
||||
const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank);
|
||||
if (entries.length === 0) return "—";
|
||||
const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon");
|
||||
return entries.map((e) => {
|
||||
const isDone = e.tgCount >= goal;
|
||||
const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0;
|
||||
const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : "";
|
||||
const bringerStr = bringer === e.usermapKey && isDone
|
||||
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
||||
: "";
|
||||
return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
||||
}).join("\n");
|
||||
};
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`)
|
||||
.setColor(0xe8a317)
|
||||
.addFields(
|
||||
{ name: "🔵 Capella", value: formatNation("capella"), inline: true },
|
||||
{ name: "🔴 Procyon", value: formatNation("procyon"), inline: true },
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const channelId = cfg("resultsChannelId") || cfg("pollChannelId");
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
await channel.send({ embeds: [embed] });
|
||||
return void replyAndDelete(interaction, "✅ Leaderboard posted.");
|
||||
}
|
||||
46
src/subcommands/result/post.ts
Normal file
46
src/subcommands/result/post.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { loadResult, todayString } from "../../systems/history";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
const result = loadResult(todayString(), slot);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG.`);
|
||||
|
||||
const kd = result.nationKD;
|
||||
|
||||
const formatScores = (nation: "Capella" | "Procyon"): string => {
|
||||
const scores = result.scores.filter((s) => s.nation === nation);
|
||||
if (scores.length === 0) return "—";
|
||||
return scores
|
||||
.sort((a, b) => b.pts - a.pts)
|
||||
.map((s) => `**${s.characterName}** (${s.class}) — ${s.pts} pts`)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ TG Results — ${result.date} ${slot}:00`)
|
||||
.setColor(0xe8a317)
|
||||
.addFields(
|
||||
{ name: "🔵 Capella", value: `${kd.capella.k}K / ${kd.capella.d}D\n${formatScores("Capella")}`, inline: true },
|
||||
{ name: "🔴 Procyon", value: `${kd.procyon.k}K / ${kd.procyon.d}D\n${formatScores("Procyon")}`, inline: true },
|
||||
)
|
||||
.setFooter({ text: `Source of truth: ${kd.source}` })
|
||||
.setTimestamp();
|
||||
|
||||
const channelId = cfg("resultsChannelId") || cfg("pollChannelId");
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
await channel.send({ embeds: [embed] });
|
||||
return void replyAndDelete(interaction, "✅ Results posted.");
|
||||
}
|
||||
30
src/subcommands/result/set.ts
Normal file
30
src/subcommands/result/set.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { setNationKD } from "../../systems/history";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { todayString } from "../../systems/history";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
|
||||
export async function handleResultSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const kills = interaction.options.getInteger("kills", true);
|
||||
const deaths = interaction.options.getInteger("deaths", true);
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
const result = setNationKD(todayString(), slot, nation, kills, deaths);
|
||||
const other = nation === "Capella" ? "Procyon" : "Capella";
|
||||
const otherKD = result.nationKD[other.toLowerCase() as "capella" | "procyon"];
|
||||
|
||||
return void replyAndDelete(interaction,
|
||||
`✅ **${nation}** K/D set: ${kills}/${deaths}\n**${other}** K/D (auto): ${otherKD.k}/${otherKD.d}`
|
||||
);
|
||||
}
|
||||
31
src/subcommands/result/view.ts
Normal file
31
src/subcommands/result/view.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { loadResult, todayString } from "../../systems/history";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleResultView(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
const result = loadResult(todayString(), slot);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`);
|
||||
|
||||
const kd = result.nationKD;
|
||||
const lines = [
|
||||
`**TG ${slot}:00 — ${result.date}**`,
|
||||
`**Capella:** ${kd.capella.k}K / ${kd.capella.d}D`,
|
||||
`**Procyon:** ${kd.procyon.k}K / ${kd.procyon.d}D`,
|
||||
`**Scores submitted:** ${result.scores.length}`,
|
||||
result.scores.map((s) => `• ${s.characterName} (${s.class}) — ${s.pts} pts`).join("\n"),
|
||||
].join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, lines);
|
||||
}
|
||||
52
src/subcommands/score/get.ts
Normal file
52
src/subcommands/score/get.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { loadResult, todayString } from "../../systems/history";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.");
|
||||
}
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
const result = loadResult(todayString(), slot);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`);
|
||||
|
||||
const score = result.scores.find((s) => s.usermapKey === usermapKey);
|
||||
if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${usermapKey}** in the ${slot}:00 TG.`);
|
||||
|
||||
const lines = [
|
||||
`**${score.characterName}** (${score.class} · ${score.nation})`,
|
||||
`**Points:** ${score.pts}`,
|
||||
score.atk !== undefined ? `**ATK:** ${score.atk}` : null,
|
||||
score.def !== undefined ? `**DEF:** ${score.def}` : null,
|
||||
score.heal !== undefined ? `**HEAL:** ${score.heal}` : null,
|
||||
`**Submitted:** ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, lines);
|
||||
}
|
||||
57
src/subcommands/score/set.ts
Normal file
57
src/subcommands/score/set.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { submitScore, detectSlot, normalizeSlot } from "../../systems/scores";
|
||||
import { getActiveCharacter } from "../../systems/characters";
|
||||
import { resolveNation } from "../../systems/nations";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const ptsArg = interaction.options.getInteger("pts", true);
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
// Officers can specify a name, players cannot
|
||||
let usermapKey: string | null;
|
||||
let targetMember = member;
|
||||
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
||||
|
||||
// Resolve slot
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot();
|
||||
if (slot === null) {
|
||||
return void replyAndDelete(interaction, "❌ No active score window detected. Specify a slot explicitly.");
|
||||
}
|
||||
}
|
||||
|
||||
await submitScore({
|
||||
usermapKey,
|
||||
characterName: char.name,
|
||||
cls: char.class,
|
||||
nation: char.nation,
|
||||
pts: ptsArg,
|
||||
slot,
|
||||
submittedByOfficer: isOfficer && !!nameArg,
|
||||
});
|
||||
|
||||
return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`);
|
||||
}
|
||||
102
src/subcommands/switch.ts
Normal file
102
src/subcommands/switch.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../systems/users";
|
||||
import { setActiveCharacter, getActiveCharacter, getCharacterByName } from "../systems/characters";
|
||||
import { setSessionBorrow, getSessionBorrow } from "../systems/borrow";
|
||||
import { polls, updatePollMessage } from "../systems/poll";
|
||||
import { replyAndDelete } from "../utils";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
||||
|
||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; char: any } | null {
|
||||
try {
|
||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === usermapKey) continue;
|
||||
const char = data.characters?.find(
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
||||
);
|
||||
if (char) return { ownerKey, char };
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reverse-lookup: find Discord userId for a usermapKey from current poll voters
|
||||
function findUserIdInPoll(state: any, usermapKey: string): string | null {
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === usermapKey) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
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", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
let resolvedChar: any = null;
|
||||
let borrowedFrom: string | null = null;
|
||||
|
||||
// Try own characters first
|
||||
const set = setActiveCharacter(usermapKey, charName);
|
||||
if (set) {
|
||||
resolvedChar = getActiveCharacter(usermapKey);
|
||||
} else {
|
||||
// Fall back to shared characters
|
||||
const shared = findSharedChar(usermapKey, charName);
|
||||
if (shared) {
|
||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.char.name);
|
||||
resolvedChar = shared.char;
|
||||
borrowedFrom = shared.ownerKey;
|
||||
console.log(`[borrow] Session borrow set: ${usermapKey} → ${shared.ownerKey}:${shared.char.name}`);
|
||||
console.log(`[borrow] Current borrows:`, getSessionBorrow(usermapKey));
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||
|
||||
// Update poll embed if user has voted
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
const userId = nameArg
|
||||
? findUserIdInPoll(state, usermapKey)
|
||||
: interaction.user.id;
|
||||
|
||||
if (userId && (state.yes.has(userId) || state.no.has(userId))) {
|
||||
const updateEntry = (map: Map<string, any>) => {
|
||||
const entry = map.get(userId);
|
||||
if (entry) {
|
||||
entry.characterName = resolvedChar.name;
|
||||
entry.characterClass = resolvedChar.class;
|
||||
entry.characterLevel = resolvedChar.level;
|
||||
entry.characterNation = resolvedChar.nation;
|
||||
entry.borrowedFrom = borrowedFrom ?? undefined;
|
||||
}
|
||||
};
|
||||
updateEntry(state.yes);
|
||||
updateEntry(state.no);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
}
|
||||
}
|
||||
|
||||
const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : "";
|
||||
return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`);
|
||||
}
|
||||
158
src/systems/borrow.ts
Normal file
158
src/systems/borrow.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { BorrowRequest } from "../types";
|
||||
import { cfg } from "./config";
|
||||
import { getCharacterByName } from "./characters";
|
||||
|
||||
// Active borrow requests (pending accept/decline)
|
||||
const pendingRequests: Map<string, BorrowRequest> = new Map(); // key: `${ownerKey}:${requesterKey}`
|
||||
|
||||
// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start
|
||||
const sessionBorrows: Map<string, { ownerKey: string; charName: string }> = new Map();
|
||||
|
||||
// DM message IDs for updating borrow request messages
|
||||
const borrowDmMessages: Map<string, { channelId: string; messageId: string }> = new Map();
|
||||
|
||||
function requestKey(ownerKey: string, requesterKey: string): string {
|
||||
return `${ownerKey}:${requesterKey}`;
|
||||
}
|
||||
|
||||
export function getPendingRequest(ownerKey: string, requesterKey: string): BorrowRequest | null {
|
||||
return pendingRequests.get(requestKey(ownerKey, requesterKey)) ?? null;
|
||||
}
|
||||
|
||||
export function getPendingRequestByKey(key: string): BorrowRequest | null {
|
||||
return pendingRequests.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
|
||||
return [...pendingRequests.values()].filter((r) => r.ownerKey === ownerKey);
|
||||
}
|
||||
|
||||
export function addPendingRequest(request: BorrowRequest): void {
|
||||
const key = requestKey(request.ownerKey, request.requesterKey);
|
||||
const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
|
||||
|
||||
pendingRequests.set(key, request);
|
||||
|
||||
if (expiry > 0) {
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.get(key)?.requestedAt === request.requestedAt) {
|
||||
pendingRequests.delete(key);
|
||||
console.log(`[borrow] Request ${key} expired.`);
|
||||
}
|
||||
}, expiry);
|
||||
}
|
||||
}
|
||||
|
||||
export function removePendingRequest(ownerKey: string, requesterKey: string): void {
|
||||
pendingRequests.delete(requestKey(ownerKey, requesterKey));
|
||||
}
|
||||
|
||||
export function storeDmMessage(ownerKey: string, requesterKey: string, channelId: string, messageId: string): void {
|
||||
borrowDmMessages.set(requestKey(ownerKey, requesterKey), { channelId, messageId });
|
||||
}
|
||||
|
||||
export function getDmMessage(ownerKey: string, requesterKey: string): { channelId: string; messageId: string } | null {
|
||||
return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null;
|
||||
}
|
||||
|
||||
// Session borrow management
|
||||
export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void {
|
||||
sessionBorrows.set(requesterKey, { ownerKey, charName });
|
||||
}
|
||||
|
||||
export function getSessionBorrow(requesterKey: string): { ownerKey: string; charName: string } | null {
|
||||
return sessionBorrows.get(requesterKey) ?? null;
|
||||
}
|
||||
|
||||
export function clearSessionBorrows(): void {
|
||||
sessionBorrows.clear();
|
||||
borrowDmMessages.clear();
|
||||
}
|
||||
|
||||
// Check if a user can use a character (owns it or has share/borrow access)
|
||||
export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean {
|
||||
if (requesterKey === ownerKey) return true;
|
||||
|
||||
// Check persistent share
|
||||
const char = getCharacterByName(ownerKey, charName);
|
||||
if (char?.sharedWith?.includes(requesterKey)) return true;
|
||||
|
||||
// Check session borrow
|
||||
const borrow = getSessionBorrow(requesterKey);
|
||||
if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send borrow request DM to owner, fall back to poll channel ephemeral
|
||||
export async function sendBorrowRequestDM(
|
||||
client: Client,
|
||||
ownerDiscordId: string,
|
||||
requesterDisplayName: string,
|
||||
ownerKey: string,
|
||||
requesterKey: string,
|
||||
charName: string,
|
||||
charClass: string,
|
||||
charLevel: number,
|
||||
fallbackChannel?: TextChannel
|
||||
): Promise<void> {
|
||||
const content = `🔔 **Borrow Request**\n**${requesterDisplayName}** wants to borrow **«${charName}»** (${charClass} · Lv${charLevel}) for tonight's TG.`;
|
||||
|
||||
const acceptBtn = new ButtonBuilder()
|
||||
.setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`)
|
||||
.setLabel("✅ Accept")
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const declineBtn = new ButtonBuilder()
|
||||
.setCustomId(`borrow_decline:${ownerKey}:${requesterKey}`)
|
||||
.setLabel("❌ Decline")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(acceptBtn, declineBtn);
|
||||
|
||||
try {
|
||||
const ownerUser = await client.users.fetch(ownerDiscordId);
|
||||
const dm = await ownerUser.createDM();
|
||||
const msg = await dm.send({ content, components: [row] });
|
||||
storeDmMessage(ownerKey, requesterKey, dm.id, msg.id);
|
||||
} catch {
|
||||
// DM failed — fall back to poll channel ephemeral
|
||||
if (fallbackChannel) {
|
||||
await fallbackChannel.send({
|
||||
content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update DM after accept/decline to disable buttons
|
||||
export async function updateBorrowDM(
|
||||
client: Client,
|
||||
ownerKey: string,
|
||||
requesterKey: string,
|
||||
accepted: boolean
|
||||
): Promise<void> {
|
||||
const dm = getDmMessage(ownerKey, requesterKey);
|
||||
if (!dm) return;
|
||||
try {
|
||||
const channel = await client.channels.fetch(dm.channelId) as any;
|
||||
const message = await channel.messages.fetch(dm.messageId);
|
||||
const status = accepted ? "✅ Accepted" : "❌ Declined";
|
||||
await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] });
|
||||
} catch {
|
||||
// DM may have been deleted, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the effective active character for a user — session borrow takes priority over own active char
|
||||
export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } {
|
||||
const { getActiveCharacter, getCharacterByName } = require("./characters");
|
||||
const borrow = getSessionBorrow(usermapKey);
|
||||
if (borrow) {
|
||||
const char = getCharacterByName(borrow.ownerKey, borrow.charName);
|
||||
if (char) return { char, borrowedFrom: borrow.ownerKey };
|
||||
}
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
return { char: char ?? null, borrowedFrom: null };
|
||||
}
|
||||
111
src/systems/characters.ts
Normal file
111
src/systems/characters.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { CharacterMap, Character, ClassKey, Nation, AccountMap, AccountData } from "../types";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
||||
const ACCOUNTS_PATH = path.join(__dirname, "../../data/accounts.json");
|
||||
|
||||
let _chars: CharacterMap = {};
|
||||
let _accounts: AccountMap = {};
|
||||
|
||||
export function loadCharacters(): void {
|
||||
try { _chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); }
|
||||
catch { _chars = {}; }
|
||||
try { _accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf8")); }
|
||||
catch { _accounts = {}; }
|
||||
}
|
||||
|
||||
function saveCharacters(): void {
|
||||
fs.writeFileSync(CHARS_PATH, JSON.stringify(_chars, null, 2));
|
||||
}
|
||||
|
||||
function saveAccounts(): void {
|
||||
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2));
|
||||
}
|
||||
|
||||
export function getCharacters(usermapKey: string): Character[] {
|
||||
return _chars[usermapKey]?.characters ?? [];
|
||||
}
|
||||
|
||||
export function getActiveCharacter(usermapKey: string): Character | null {
|
||||
return getCharacters(usermapKey).find((c) => c.active) ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterByName(usermapKey: string, name: string): Character | null {
|
||||
return getCharacters(usermapKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null {
|
||||
// Returns the active character of that class, or first found
|
||||
const chars = getCharacters(usermapKey).filter((c) => c.class === cls);
|
||||
return chars.find((c) => c.active) ?? chars[0] ?? null;
|
||||
}
|
||||
|
||||
export function addCharacter(usermapKey: string, char: Omit<Character, "active">): boolean {
|
||||
if (!_chars[usermapKey]) _chars[usermapKey] = { characters: [] };
|
||||
const exists = _chars[usermapKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
|
||||
if (exists) return false;
|
||||
// If no active character, set this one as active
|
||||
const hasActive = _chars[usermapKey].characters.some((c) => c.active);
|
||||
_chars[usermapKey].characters.push({ ...char, active: !hasActive });
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeCharacter(usermapKey: string, name: string): boolean {
|
||||
if (!_chars[usermapKey]) return false;
|
||||
const before = _chars[usermapKey].characters.length;
|
||||
_chars[usermapKey].characters = _chars[usermapKey].characters.filter(
|
||||
(c) => c.name.toLowerCase() !== name.toLowerCase()
|
||||
);
|
||||
if (_chars[usermapKey].characters.length === before) return false;
|
||||
// If we removed the active one, set the first remaining as active
|
||||
if (!_chars[usermapKey].characters.some((c) => c.active) && _chars[usermapKey].characters.length > 0) {
|
||||
_chars[usermapKey].characters[0].active = true;
|
||||
}
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setActiveCharacter(usermapKey: string, name: string): boolean {
|
||||
const chars = _chars[usermapKey]?.characters;
|
||||
if (!chars) return false;
|
||||
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||
if (!target) return false;
|
||||
chars.forEach((c) => (c.active = false));
|
||||
target.active = true;
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean {
|
||||
const char = getCharacterByName(usermapKey, name);
|
||||
if (!char) return false;
|
||||
char.nation = nation;
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setCharacterStats(
|
||||
usermapKey: string,
|
||||
name: string,
|
||||
stats: { atk?: number; def?: number; heal?: number }
|
||||
): boolean {
|
||||
const char = getCharacterByName(usermapKey, name);
|
||||
if (!char) return false;
|
||||
if (!char.stats) char.stats = {};
|
||||
Object.assign(char.stats, stats);
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Account data ─────────────────────────────────────────────────────────────
|
||||
export function getAccountData(usermapKey: string): AccountData {
|
||||
return _accounts[usermapKey] ?? {};
|
||||
}
|
||||
|
||||
export function setAccountData(usermapKey: string, data: Partial<AccountData>): void {
|
||||
if (!_accounts[usermapKey]) _accounts[usermapKey] = {};
|
||||
Object.assign(_accounts[usermapKey], data);
|
||||
saveAccounts();
|
||||
}
|
||||
66
src/systems/config.ts
Normal file
66
src/systems/config.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { BotConfig, Nation } from "../types";
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, "../../data/config.json");
|
||||
|
||||
// Function instead of const so env vars are read lazily at call time
|
||||
function getDefaults(): Required<BotConfig> {
|
||||
return {
|
||||
officerRoles: ["Ice King"],
|
||||
configRoles: ["Ice King"],
|
||||
tagRoles: ["Ice King", "Ice", "Rebellion"],
|
||||
lockMessage: "🔒 This poll has been locked.",
|
||||
confirmYesMessage: "⚔️ TG is confirmed for tonight!",
|
||||
confirmNoMessage: "❌ TG is cancelled for tonight.",
|
||||
pollChannelId: process.env.POLL_CHANNEL_ID ?? "",
|
||||
resultsChannelId: process.env.RESULTS_CHANNEL_ID ?? "",
|
||||
scoreChannelId: process.env.SCORE_CHANNEL_ID ?? "",
|
||||
slots: [
|
||||
{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true },
|
||||
],
|
||||
scoreWindowHours: 2,
|
||||
tgDurationMinutes: 35,
|
||||
nationSource: "Procyon" as Nation,
|
||||
wRankPostOnReset: false,
|
||||
wRankGoal: 7,
|
||||
wRankYellowColor: "#BA7517",
|
||||
wRankGrayColor: "#888888",
|
||||
deltaUpColor: "#A32D2D",
|
||||
deltaDownColor: "#185FA5",
|
||||
stormBringerColor: "#185FA5",
|
||||
luminousBringerColor: "#8B4CB8",
|
||||
showClassInMessages: false,
|
||||
showLevelInMessages: false,
|
||||
charDisplayFormat: "{wrank} {class} {level} {name}",
|
||||
showNationTotalsInHeader: false,
|
||||
showNoInNationField: false,
|
||||
borrowRequestExpiryMs: 0, // 0 = never expire
|
||||
};
|
||||
}
|
||||
|
||||
let _cfg: BotConfig = {};
|
||||
|
||||
export function loadConfig(): void {
|
||||
try { _cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
|
||||
catch { _cfg = {}; }
|
||||
}
|
||||
|
||||
export function saveConfig(): void {
|
||||
try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cfg, null, 2)); }
|
||||
catch (err) { console.error("Failed to save config.json:", err); }
|
||||
}
|
||||
|
||||
export function cfg<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
|
||||
return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required<BotConfig>[K];
|
||||
}
|
||||
|
||||
export function setCfg<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
|
||||
_cfg[key] = value;
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
export function resetCfg<K extends keyof BotConfig>(key: K): void {
|
||||
delete _cfg[key];
|
||||
saveConfig();
|
||||
}
|
||||
27
src/systems/emojis.ts
Normal file
27
src/systems/emojis.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { EmojiMap, ClassKey } from "../types";
|
||||
|
||||
const EMOJI_PATH = path.join(__dirname, "../../messages/emojis.json");
|
||||
let _emojis: EmojiMap = {};
|
||||
|
||||
export function loadEmojis(): void {
|
||||
try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); }
|
||||
catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; }
|
||||
}
|
||||
|
||||
export function getEmoji(key: string): string {
|
||||
return _emojis[key] ?? "";
|
||||
}
|
||||
|
||||
export function getClassEmoji(cls: ClassKey): string {
|
||||
return getEmoji(cls.toLowerCase());
|
||||
}
|
||||
|
||||
export function getNationEmoji(nation: string): string {
|
||||
return getEmoji(nation.toLowerCase());
|
||||
}
|
||||
|
||||
export function resolveEmojiTokens(text: string): string {
|
||||
return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key));
|
||||
}
|
||||
92
src/systems/history.ts
Normal file
92
src/systems/history.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { TGResult, TGScore, Nation } from "../types";
|
||||
import { oppositeNation } from "./nations";
|
||||
|
||||
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
|
||||
|
||||
function historyKey(date: string, slot: number): string {
|
||||
return `${date}-${String(slot).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function historyPath(key: string): string {
|
||||
return path.join(HISTORY_DIR, `${key}.json`);
|
||||
}
|
||||
|
||||
export function loadResult(date: string, slot: number): TGResult | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(historyPath(historyKey(date, slot)), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveResult(result: TGResult): void {
|
||||
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||||
const key = historyKey(result.date, result.slot);
|
||||
fs.writeFileSync(historyPath(key), JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
export function upsertScore(score: TGScore): void {
|
||||
const result = loadResult(score.date, score.slot) ?? {
|
||||
slot: score.slot,
|
||||
date: score.date,
|
||||
confirmed: false,
|
||||
nationKD: {
|
||||
source: "Procyon" as Nation,
|
||||
capella: { k: 0, d: 0 },
|
||||
procyon: { k: 0, d: 0 },
|
||||
},
|
||||
scores: [],
|
||||
};
|
||||
|
||||
// Overwrite existing score for this player+slot
|
||||
result.scores = result.scores.filter(
|
||||
(s) => !(s.usermapKey === score.usermapKey && s.slot === score.slot && s.date === score.date)
|
||||
);
|
||||
result.scores.push(score);
|
||||
saveResult(result);
|
||||
}
|
||||
|
||||
export function setNationKD(
|
||||
date: string,
|
||||
slot: number,
|
||||
sourceNation: Nation,
|
||||
k: number,
|
||||
d: number
|
||||
): TGResult {
|
||||
const result = loadResult(date, slot) ?? {
|
||||
slot,
|
||||
date,
|
||||
confirmed: false,
|
||||
nationKD: { source: sourceNation, capella: { k: 0, d: 0 }, procyon: { k: 0, d: 0 } },
|
||||
scores: [],
|
||||
};
|
||||
|
||||
result.nationKD.source = sourceNation;
|
||||
const other = oppositeNation(sourceNation);
|
||||
|
||||
result.nationKD[sourceNation.toLowerCase() as "capella" | "procyon"] = { k, d };
|
||||
result.nationKD[other.toLowerCase() as "capella" | "procyon"] = { k: d, d: k };
|
||||
|
||||
saveResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function listRecentResults(limit = 10): TGResult[] {
|
||||
if (!fs.existsSync(HISTORY_DIR)) return [];
|
||||
return fs.readdirSync(HISTORY_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((f) => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(HISTORY_DIR, f), "utf8")) as TGResult; }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean) as TGResult[];
|
||||
}
|
||||
|
||||
export function todayString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
164
src/systems/messages.ts
Normal file
164
src/systems/messages.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "../types";
|
||||
import { resolveEmojiTokens } from "./emojis";
|
||||
|
||||
const MESSAGES_DIR = path.join(__dirname, "../../messages");
|
||||
const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json");
|
||||
|
||||
interface LoadedMessages {
|
||||
global: MessagesFile;
|
||||
users: Record<string, MessagesFile>;
|
||||
userMap: Usermap;
|
||||
}
|
||||
|
||||
let MESSAGES: LoadedMessages = {
|
||||
global: { public: { yes: [], no: [] }, ephemeral: { yes: [], no: [] } },
|
||||
users: {},
|
||||
userMap: {},
|
||||
};
|
||||
|
||||
export function loadMessages(): void {
|
||||
try {
|
||||
MESSAGES.global = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, "global.json"), "utf8"));
|
||||
} catch (err) {
|
||||
console.error("Failed to load global.json:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
MESSAGES.userMap = JSON.parse(fs.readFileSync(USERMAP_PATH, "utf8"));
|
||||
} catch {
|
||||
MESSAGES.userMap = {};
|
||||
}
|
||||
|
||||
const usersDir = path.join(MESSAGES_DIR, "users");
|
||||
MESSAGES.users = {};
|
||||
if (fs.existsSync(usersDir)) {
|
||||
for (const file of fs.readdirSync(usersDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const key = path.basename(file, ".json");
|
||||
try {
|
||||
MESSAGES.users[key] = JSON.parse(fs.readFileSync(path.join(usersDir, file), "utf8"));
|
||||
} catch (err) {
|
||||
console.error(`Failed to load user message file ${file}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Messages loaded — ${Object.keys(MESSAGES.users).length} user file(s).`);
|
||||
}
|
||||
|
||||
// ─── Date/time helpers ───────────────────────────────────────────────────────
|
||||
const DAY_NAMES = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
|
||||
const MONTH_NAMES = ["january","february","march","april","may","june","july","august","september","october","november","december"];
|
||||
const TIMEZONE = process.env.TZ ?? "Etc/GMT-2";
|
||||
|
||||
export function getNow() {
|
||||
const now = new Date();
|
||||
const weekdayStr = now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, weekday: "long" }).toLowerCase();
|
||||
const dayNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, day: "numeric" }));
|
||||
const monthNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, month: "numeric" }));
|
||||
const yearNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, year: "numeric" }));
|
||||
return {
|
||||
dayName: weekdayStr,
|
||||
dayNum,
|
||||
monthNum,
|
||||
yearNum,
|
||||
dateKey: `${String(dayNum).padStart(2,"0")}-${String(monthNum).padStart(2,"0")}`,
|
||||
monthName: MONTH_NAMES[monthNum - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export function nowFormatted(): string {
|
||||
return new Date().toLocaleTimeString("en-GB", { timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── Interpolation ───────────────────────────────────────────────────────────
|
||||
export function interpolate(
|
||||
message: string,
|
||||
username: string,
|
||||
serverNickname: string | null,
|
||||
globalNickname: string | null,
|
||||
aliases: string[]
|
||||
): string {
|
||||
const displayName = serverNickname ?? globalNickname ?? username;
|
||||
const dt = getNow();
|
||||
|
||||
let result = message
|
||||
.replace(/\{username\}/g, username)
|
||||
.replace(/\{server_nickname\}/g, serverNickname ?? displayName)
|
||||
.replace(/\{nickname\}/g, globalNickname ?? username)
|
||||
.replace(/\{alias\[random\]\}/g, () =>
|
||||
aliases.length > 0 ? aliases[Math.floor(Math.random() * aliases.length)] : displayName
|
||||
)
|
||||
.replace(/\{alias\[(\d+)\]\}/g, (_, i: string) => aliases[parseInt(i)] ?? displayName)
|
||||
.replace(/\{alias\}/g, aliases[0] ?? displayName)
|
||||
.replace(/\{DAY\}/g, dt.dayName.toUpperCase())
|
||||
.replace(/\{Day\}/g, dt.dayName.charAt(0).toUpperCase() + dt.dayName.slice(1))
|
||||
.replace(/\{day\}/g, dt.dayName)
|
||||
.replace(/\{MONTH\}/g, dt.monthName.toUpperCase())
|
||||
.replace(/\{Month\}/g, dt.monthName.charAt(0).toUpperCase() + dt.monthName.slice(1))
|
||||
.replace(/\{month\}/g, dt.monthName)
|
||||
.replace(/\{month_num\}/g, String(dt.monthNum).padStart(2, "0"))
|
||||
.replace(/\{date_full\}/g, `${String(dt.dayNum).padStart(2,"0")}-${String(dt.monthNum).padStart(2,"0")}-${dt.yearNum}`)
|
||||
.replace(/\{date\}/g, dt.dateKey)
|
||||
.replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0"));
|
||||
|
||||
return resolveEmojiTokens(result);
|
||||
}
|
||||
|
||||
// ─── Resolution ──────────────────────────────────────────────────────────────
|
||||
function pickFromEntry(entry: MessageEntry, dayName: string, dateKey: string): string | null {
|
||||
const datePool = entry.dates?.[dateKey];
|
||||
const dayPool = entry.days?.[dayName];
|
||||
|
||||
for (const pool of [datePool, dayPool, entry]) {
|
||||
if (!pool) continue;
|
||||
const msgs = (pool as { messages?: string[]; message?: string; random?: boolean }).messages;
|
||||
const msg = (pool as { message?: string }).message;
|
||||
if (Array.isArray(msgs) && msgs.length > 0) {
|
||||
return (pool as { random?: boolean }).random
|
||||
? msgs[Math.floor(Math.random() * msgs.length)]
|
||||
: msgs[0];
|
||||
}
|
||||
if (msg) return msg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bestMatch(pool: MessageEntry[], clicks: number, dayName: string, dateKey: string): string | null {
|
||||
if (!pool || pool.length === 0) return null;
|
||||
const sorted = [...pool].filter((e) => e.clicks <= clicks).sort((a, b) => b.clicks - a.clicks);
|
||||
if (!sorted[0]) return null;
|
||||
return pickFromEntry(sorted[0], dayName, dateKey);
|
||||
}
|
||||
|
||||
export function resolveMessage(
|
||||
block: "public" | "ephemeral",
|
||||
voteType: "yes" | "no",
|
||||
clicks: number,
|
||||
discordUsername: string,
|
||||
serverNickname: string | null,
|
||||
globalNickname: string | null
|
||||
): string | null {
|
||||
const entry = MESSAGES.userMap[discordUsername];
|
||||
const fileKey = typeof entry === "string" ? entry : (entry as UsermapEntry)?.file ?? discordUsername;
|
||||
const aliases = typeof entry === "object" && entry !== null ? (entry as UsermapEntry).aliases ?? [] : [];
|
||||
|
||||
const userPool = MESSAGES.users[fileKey]?.[block]?.[voteType] ?? [];
|
||||
const globalPool = MESSAGES.global?.[block]?.[voteType] ?? [];
|
||||
|
||||
const dt = getNow();
|
||||
const raw = bestMatch(userPool, clicks, dt.dayName, dt.dateKey)
|
||||
?? bestMatch(globalPool, clicks, dt.dayName, dt.dateKey)
|
||||
?? null;
|
||||
|
||||
return raw ? interpolate(raw, discordUsername, serverNickname, globalNickname, aliases) : null;
|
||||
}
|
||||
|
||||
export function getUsermapEntry(discordUsername: string): UsermapEntry | null {
|
||||
const entry = MESSAGES.userMap[discordUsername];
|
||||
if (!entry) return null;
|
||||
if (typeof entry === "string") return { file: entry, aliases: [] };
|
||||
return entry as UsermapEntry;
|
||||
}
|
||||
22
src/systems/nations.ts
Normal file
22
src/systems/nations.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { GuildMember } from "discord.js";
|
||||
import { Nation } from "../types";
|
||||
import { getActiveCharacter } from "./characters";
|
||||
|
||||
// Resolve a user's nation — character nation takes priority over Discord role
|
||||
export function resolveNation(member: GuildMember, usermapKey: string | null): Nation | null {
|
||||
// 1. Active character's nation
|
||||
if (usermapKey) {
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
if (char) return char.nation;
|
||||
}
|
||||
|
||||
// 2. Discord role fallback
|
||||
if (member.roles.cache.some((r) => r.name === "Capella")) return "Capella";
|
||||
if (member.roles.cache.some((r) => r.name === "Procyon")) return "Procyon";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function oppositeNation(nation: Nation): Nation {
|
||||
return nation === "Capella" ? "Procyon" : "Capella";
|
||||
}
|
||||
266
src/systems/poll.ts
Normal file
266
src/systems/poll.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import {
|
||||
EmbedBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ActionRowBuilder,
|
||||
TextChannel,
|
||||
GuildMember,
|
||||
} from "discord.js";
|
||||
import { PollState, VoteEntry, Nation, TGSlot } from "../types";
|
||||
import { cfg } from "./config";
|
||||
import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
|
||||
import { getActiveCharacter, getCharacterByName } from "./characters";
|
||||
import { resolveNation } from "./nations";
|
||||
import { getEntry, getBringer } from "./wrank";
|
||||
import { nowFormatted } from "./messages";
|
||||
|
||||
// ─── Poll state ───────────────────────────────────────────────────────────────
|
||||
export const polls: Map<number, PollState> = new Map();
|
||||
|
||||
const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
|
||||
const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
|
||||
|
||||
export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void {
|
||||
const e = publicOverrides.get(userId) ?? {};
|
||||
e[voteType] = message;
|
||||
publicOverrides.set(userId, e);
|
||||
}
|
||||
export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void {
|
||||
if (!voteType) { publicOverrides.delete(userId); return; }
|
||||
const e = publicOverrides.get(userId);
|
||||
if (e) delete e[voteType];
|
||||
}
|
||||
export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void {
|
||||
const e = ephemeralOverrides.get(userId) ?? {};
|
||||
e[voteType] = message;
|
||||
ephemeralOverrides.set(userId, e);
|
||||
}
|
||||
export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void {
|
||||
if (!voteType) { ephemeralOverrides.delete(userId); return; }
|
||||
const e = ephemeralOverrides.get(userId);
|
||||
if (e) delete e[voteType];
|
||||
}
|
||||
export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
|
||||
return publicOverrides.get(userId)?.[voteType];
|
||||
}
|
||||
export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
|
||||
return ephemeralOverrides.get(userId)?.[voteType];
|
||||
}
|
||||
export function resetPollOverrides(): void {
|
||||
publicOverrides.clear();
|
||||
ephemeralOverrides.clear();
|
||||
}
|
||||
|
||||
// ─── Character display ────────────────────────────────────────────────────────
|
||||
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||
const format = cfg("charDisplayFormat");
|
||||
const nation = entry.characterNation;
|
||||
const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null;
|
||||
|
||||
let wrank = "";
|
||||
if (wRankEntry) {
|
||||
const goal = cfg("wRankGoal");
|
||||
const isDone = wRankEntry.tgCount >= goal;
|
||||
const rank = wRankEntry.currentRank;
|
||||
const prev = wRankEntry.previousRank;
|
||||
const delta = prev !== undefined ? rank - prev : 0;
|
||||
// W.Rank emoji with text fallback
|
||||
const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`;
|
||||
const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`);
|
||||
|
||||
// Delta arrows with text fallback
|
||||
let deltaStr = "";
|
||||
if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`;
|
||||
else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`;
|
||||
else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`;
|
||||
|
||||
wrank = `${rankStr}${deltaStr}`;
|
||||
}
|
||||
|
||||
const classStr = entry.characterClass
|
||||
? (getClassEmoji(entry.characterClass) || entry.characterClass)
|
||||
: "";
|
||||
|
||||
const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
|
||||
? `${entry.characterLevel}`
|
||||
: "";
|
||||
|
||||
let row = format
|
||||
.replace("{wrank}", wrank)
|
||||
.replace("{class}", classStr)
|
||||
.replace("{level}", levelStr)
|
||||
.replace("{name}", entry.characterName ?? entry.displayName)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Bringer title — independent of W.Rank so override always shows
|
||||
if (nation && entry.usermapKey) {
|
||||
const bringer = getBringer(nation);
|
||||
if (bringer && bringer === entry.usermapKey) {
|
||||
const emoji = nation === "Capella"
|
||||
? (getEmoji("luminous_bringer") || "🔆")
|
||||
: (getEmoji("storm_bringer") || "⚡");
|
||||
const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
|
||||
row += ` · ${emoji} **${title}**`;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.borrowedFrom) {
|
||||
row += ` ${getEmoji("borrowed") || "🔗"}`;
|
||||
}
|
||||
|
||||
if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── Embed building ───────────────────────────────────────────────────────────
|
||||
export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
|
||||
const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
|
||||
const noVoters: VoteEntry[] = [];
|
||||
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
||||
const showNoInline = (cfg as any)("showNoInNationField") ?? false;
|
||||
|
||||
for (const entry of state.yes.values()) {
|
||||
const nation = entry.characterNation ?? "Capella";
|
||||
yesByNation[nation].push(entry);
|
||||
allMessages.push({ entry, voteType: "yes" });
|
||||
}
|
||||
for (const entry of state.no.values()) {
|
||||
noVoters.push(entry);
|
||||
allMessages.push({ entry, voteType: "no" });
|
||||
}
|
||||
|
||||
const capellaEmoji = getEmoji("capella");
|
||||
const procyonEmoji = getEmoji("procyon");
|
||||
|
||||
const formatNationField = (nation: Nation): string => {
|
||||
const yesEntries = yesByNation[nation];
|
||||
const noEntries = showNoInline
|
||||
? noVoters.filter((e) => e.characterNation === nation)
|
||||
: [];
|
||||
const lines = [
|
||||
...yesEntries.map((e) => formatCharRow(e)),
|
||||
...noEntries.map((e) => `❌ ${formatCharRow(e)}`),
|
||||
];
|
||||
return lines.length > 0 ? lines.join("\n") : "—";
|
||||
};
|
||||
|
||||
const formatMessages = (): string => {
|
||||
if (allMessages.length === 0) return "";
|
||||
return allMessages
|
||||
.map((m) => {
|
||||
const name = m.entry.characterName ?? m.entry.displayName;
|
||||
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
|
||||
const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : "";
|
||||
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const locked = state.locked;
|
||||
const confirmed = state.confirmed;
|
||||
|
||||
const color =
|
||||
confirmed === "yes" ? 0x57f287 :
|
||||
confirmed === "no" ? 0xed4245 :
|
||||
locked ? 0x888888 :
|
||||
0xe8a317;
|
||||
|
||||
// Title with nation + no counts (hidden when confirmed or locked)
|
||||
const counts = !locked && confirmed === null
|
||||
? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
|
||||
: "";
|
||||
const statusSuffix =
|
||||
locked ? " 🔒" :
|
||||
confirmed === "yes" ? " ✅" :
|
||||
confirmed === "no" ? " ❌" : "";
|
||||
|
||||
const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false },
|
||||
{ name: "\u200b", value: "\u200b", inline: false },
|
||||
{ name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false },
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const msgSection = formatMessages();
|
||||
if (msgSection) {
|
||||
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||
}
|
||||
|
||||
let footer: string;
|
||||
if (confirmed === "yes") footer = cfg("confirmYesMessage");
|
||||
else if (confirmed === "no") footer = cfg("confirmNoMessage");
|
||||
else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
|
||||
else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
||||
embed.setFooter({ text: footer });
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
|
||||
const yesBtn = new ButtonBuilder()
|
||||
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
|
||||
const noBtn = new ButtonBuilder()
|
||||
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
|
||||
}
|
||||
|
||||
export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
|
||||
const state = polls.get(slot);
|
||||
if (!state?.messageId) return;
|
||||
try {
|
||||
const msg = await channel.messages.fetch(state.messageId);
|
||||
const disabled = state.locked || state.confirmed !== null;
|
||||
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
|
||||
} catch (err) {
|
||||
console.error("Failed to update poll message:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
|
||||
resetPollOverrides();
|
||||
const { clearSessionBorrows } = require("./borrow");
|
||||
clearSessionBorrows();
|
||||
|
||||
const state: PollState = {
|
||||
messageId: null, slot: slot.tgHour,
|
||||
yes: new Map(), no: new Map(),
|
||||
locked: false, confirmed: null,
|
||||
};
|
||||
polls.set(slot.tgHour, state);
|
||||
const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
|
||||
state.messageId = msg.id;
|
||||
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
|
||||
}
|
||||
|
||||
export function createVoteEntry(
|
||||
userId: string,
|
||||
member: GuildMember,
|
||||
usermapKey: string | null,
|
||||
discordUsername: string
|
||||
): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
|
||||
const serverNickname = member.nickname ?? null;
|
||||
const globalNickname = member.user.globalName ?? null;
|
||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||
|
||||
const { getEffectiveCharacter } = require("./borrow");
|
||||
const { char, borrowedFrom: bf } = usermapKey
|
||||
? getEffectiveCharacter(usermapKey)
|
||||
: { char: null, borrowedFrom: null };
|
||||
|
||||
return {
|
||||
usermapKey: usermapKey ?? (undefined as any),
|
||||
displayName,
|
||||
characterName: char?.name,
|
||||
characterClass: char?.class,
|
||||
characterLevel: char?.level,
|
||||
characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined),
|
||||
borrowedFrom: bf ?? undefined,
|
||||
};
|
||||
}
|
||||
90
src/systems/scores.ts
Normal file
90
src/systems/scores.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { TGScore, Nation, ClassKey } from "../types";
|
||||
import { cfg } from "./config";
|
||||
import { upsertScore, todayString } from "./history";
|
||||
import { recordScore } from "./wrank";
|
||||
|
||||
// Normalize a slot string to a 24h integer hour
|
||||
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
|
||||
export function normalizeSlot(input: string): number | null {
|
||||
const s = input.trim().toLowerCase();
|
||||
if (s === "midnight") return 0;
|
||||
if (s === "midday" || s === "noon") return 12;
|
||||
|
||||
const pmMatch = s.match(/^(\d{1,2})pm$/);
|
||||
if (pmMatch) {
|
||||
const h = parseInt(pmMatch[1]);
|
||||
return h === 12 ? 12 : h + 12;
|
||||
}
|
||||
|
||||
const amMatch = s.match(/^(\d{1,2})am$/);
|
||||
if (amMatch) {
|
||||
const h = parseInt(amMatch[1]);
|
||||
return h === 12 ? 0 : h;
|
||||
}
|
||||
|
||||
const colonMatch = s.match(/^(\d{1,2}):\d{2}$/);
|
||||
if (colonMatch) return parseInt(colonMatch[1]);
|
||||
|
||||
const numMatch = s.match(/^(\d{1,2})$/);
|
||||
if (numMatch) return parseInt(numMatch[1]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect which slot a submission belongs to based on current time
|
||||
export function detectSlot(): number | null {
|
||||
const slots = cfg("slots").filter((s) => s.active);
|
||||
const windowMs = cfg("scoreWindowHours") * 60 * 60 * 1000;
|
||||
const durationMs = cfg("tgDurationMinutes") * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const slot of slots) {
|
||||
const today = new Date();
|
||||
const tgTime = new Date(today);
|
||||
tgTime.setHours(slot.tgHour, 0, 0, 0);
|
||||
const closeTime = tgTime.getTime() + durationMs;
|
||||
const windowEnd = closeTime + windowMs;
|
||||
|
||||
if (now >= closeTime && now <= windowEnd) {
|
||||
return slot.tgHour;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ScoreSubmission {
|
||||
usermapKey: string;
|
||||
characterName: string;
|
||||
cls: ClassKey;
|
||||
nation: Nation;
|
||||
pts: number;
|
||||
slot: number;
|
||||
date?: string;
|
||||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
submittedByOfficer: boolean;
|
||||
}
|
||||
|
||||
export function submitScore(sub: ScoreSubmission): void {
|
||||
const date = sub.date ?? todayString();
|
||||
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
|
||||
|
||||
const score: TGScore = {
|
||||
usermapKey: sub.usermapKey,
|
||||
characterName: sub.characterName,
|
||||
class: sub.cls,
|
||||
nation: sub.nation,
|
||||
pts: sub.pts,
|
||||
atk: sub.atk,
|
||||
def: sub.def,
|
||||
heal: sub.heal,
|
||||
submittedAt: new Date().toISOString(),
|
||||
slot: sub.slot,
|
||||
date,
|
||||
submittedByOfficer: sub.submittedByOfficer,
|
||||
};
|
||||
|
||||
upsertScore(score);
|
||||
recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
||||
}
|
||||
57
src/systems/slots.ts
Normal file
57
src/systems/slots.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import cron from "node-cron";
|
||||
import { Client } from "discord.js";
|
||||
import { cfg } from "./config";
|
||||
import { TGSlot } from "../types";
|
||||
|
||||
type PollCallback = (slot: TGSlot) => Promise<void>;
|
||||
type CloseCallback = (slot: TGSlot) => Promise<void>;
|
||||
|
||||
let _scheduledTasks: cron.ScheduledTask[] = [];
|
||||
|
||||
export function scheduleSlots(
|
||||
client: Client,
|
||||
onPollOpen: PollCallback,
|
||||
onPollClose: CloseCallback
|
||||
): void {
|
||||
// Clear existing schedules
|
||||
_scheduledTasks.forEach((t) => t.stop());
|
||||
_scheduledTasks = [];
|
||||
|
||||
const tz = process.env.TZ ?? "Etc/GMT-2";
|
||||
const slots = cfg("slots").filter((s) => s.active);
|
||||
|
||||
for (const slot of slots) {
|
||||
// Parse poll open time
|
||||
const [openHour, openMin] = slot.pollOpens.split(":").map(Number);
|
||||
|
||||
// Schedule poll open
|
||||
const openTask = cron.schedule(
|
||||
`${openMin} ${openHour} * * *`,
|
||||
() => onPollOpen(slot),
|
||||
{ timezone: tz }
|
||||
);
|
||||
_scheduledTasks.push(openTask);
|
||||
|
||||
// Schedule poll close (tgHour + closesAfter minutes)
|
||||
const closeMinTotal = slot.tgHour * 60 + slot.closesAfter;
|
||||
const closeHour = Math.floor(closeMinTotal / 60) % 24;
|
||||
const closeMin = closeMinTotal % 60;
|
||||
|
||||
const closeTask = cron.schedule(
|
||||
`${closeMin} ${closeHour} * * *`,
|
||||
() => onPollClose(slot),
|
||||
{ timezone: tz }
|
||||
);
|
||||
_scheduledTasks.push(closeTask);
|
||||
}
|
||||
|
||||
// Weekly reset — Monday 00:00
|
||||
const resetTask = cron.schedule("0 0 * * 1", () => {
|
||||
const { resetWeek } = require("./wrank");
|
||||
resetWeek();
|
||||
console.log("W.Rank weekly reset complete.");
|
||||
}, { timezone: tz });
|
||||
_scheduledTasks.push(resetTask);
|
||||
|
||||
console.log(`Scheduled ${slots.length} slot(s).`);
|
||||
}
|
||||
40
src/systems/users.ts
Normal file
40
src/systems/users.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { GuildMember } from "discord.js";
|
||||
import { ResolvedUser } from "../types";
|
||||
import { getUsermapEntry } from "./messages";
|
||||
import { getActiveCharacter } from "./characters";
|
||||
|
||||
// Resolves a full user context from a GuildMember + discord username
|
||||
export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
|
||||
const discordUsername = member.user.username;
|
||||
const serverNickname = member.nickname ?? null;
|
||||
const globalNickname = member.user.globalName ?? null;
|
||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||
|
||||
const entry = getUsermapEntry(discordUsername);
|
||||
const usermapKey = entry?.file ?? null;
|
||||
const aliases = entry?.aliases ?? [];
|
||||
const activeChar = usermapKey ? getActiveCharacter(usermapKey) : null;
|
||||
|
||||
return {
|
||||
userId: member.user.id,
|
||||
discordUsername,
|
||||
usermapKey,
|
||||
displayName,
|
||||
serverNickname,
|
||||
globalNickname,
|
||||
aliases,
|
||||
activeCharacter: activeChar,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve a user by their usermap key (for officer commands using <name> arg)
|
||||
export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
|
||||
return {
|
||||
usermapKey: key,
|
||||
activeCharacter: getActiveCharacter(key),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
|
||||
return member.roles.cache.some((r) => officerRoles.includes(r.name));
|
||||
}
|
||||
156
src/systems/wrank.ts
Normal file
156
src/systems/wrank.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types";
|
||||
import { cfg } from "./config";
|
||||
|
||||
const WRANK_PATH = path.join(__dirname, "../../data/wrank.json");
|
||||
let _data: WRankData = {};
|
||||
|
||||
export function loadWRank(): void {
|
||||
try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); }
|
||||
catch { _data = {}; }
|
||||
}
|
||||
|
||||
function saveWRank(): void {
|
||||
fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2));
|
||||
}
|
||||
|
||||
export function getWeekKey(date: Date = new Date()): string {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function ensureWeek(weekKey: string): WRankWeek {
|
||||
if (!_data[weekKey]) {
|
||||
_data[weekKey] = {
|
||||
weekKey,
|
||||
entries: { capella: [], procyon: [] },
|
||||
scoreIndex: {},
|
||||
bringer: { capella: null, procyon: null },
|
||||
};
|
||||
}
|
||||
return _data[weekKey];
|
||||
}
|
||||
|
||||
export function getCurrentWeek(): WRankWeek {
|
||||
return ensureWeek(getWeekKey());
|
||||
}
|
||||
|
||||
export function getWeek(weekKey: string): WRankWeek | null {
|
||||
return _data[weekKey] ?? null;
|
||||
}
|
||||
|
||||
// Add or update a score submission for a player
|
||||
export function recordScore(
|
||||
usermapKey: string,
|
||||
characterName: string,
|
||||
cls: ClassKey,
|
||||
nation: Nation,
|
||||
pts: number,
|
||||
historyKey: string // e.g. "2026-05-31-20"
|
||||
): void {
|
||||
const weekKey = getWeekKey();
|
||||
const week = ensureWeek(weekKey);
|
||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
|
||||
const existing = list.find((e) => e.usermapKey === usermapKey);
|
||||
|
||||
if (existing) {
|
||||
// Check if this slot was already counted
|
||||
const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey);
|
||||
if (!alreadyCounted) {
|
||||
existing.weeklyPoints += pts;
|
||||
existing.tgCount += 1;
|
||||
} else {
|
||||
// Overwrite: recalculate by removing old pts for this slot
|
||||
// We'll just set the new pts — full recalc would require reading history
|
||||
// For now, simple overwrite of total is handled at score submission level
|
||||
existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts;
|
||||
}
|
||||
existing.characterName = characterName;
|
||||
existing.class = cls;
|
||||
existing.nation = nation;
|
||||
} else {
|
||||
list.push({
|
||||
usermapKey,
|
||||
characterName,
|
||||
class: cls,
|
||||
nation,
|
||||
weeklyPoints: pts,
|
||||
tgCount: 1,
|
||||
currentRank: 0,
|
||||
previousRank: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Update score index
|
||||
if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = [];
|
||||
if (!week.scoreIndex[usermapKey].includes(historyKey)) {
|
||||
week.scoreIndex[usermapKey].push(historyKey);
|
||||
}
|
||||
|
||||
recomputeRanks(week, nation);
|
||||
updateBringer(week);
|
||||
saveWRank();
|
||||
}
|
||||
|
||||
function recomputeRanks(week: WRankWeek, nation: Nation): void {
|
||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
|
||||
sorted.forEach((entry, i) => {
|
||||
const live = list.find((e) => e.usermapKey === entry.usermapKey)!;
|
||||
live.previousRank = live.currentRank || undefined;
|
||||
live.currentRank = i + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function updateBringer(week: WRankWeek): void {
|
||||
const goal = cfg("wRankGoal");
|
||||
for (const nation of ["capella", "procyon"] as const) {
|
||||
// Don't overwrite manual override
|
||||
if (nation === "capella" && week.bringer.capellaOverride) continue;
|
||||
if (nation === "procyon" && week.bringer.procyonOverride) continue;
|
||||
|
||||
const qualified = week.entries[nation]
|
||||
.filter((e) => e.tgCount >= goal)
|
||||
.sort((a, b) => a.currentRank - b.currentRank);
|
||||
week.bringer[nation] = qualified[0]?.usermapKey ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBringerOverride(nation: Nation, usermapKey: string): void {
|
||||
const week = ensureWeek(getWeekKey());
|
||||
if (nation === "Capella") week.bringer.capellaOverride = usermapKey;
|
||||
else week.bringer.procyonOverride = usermapKey;
|
||||
saveWRank();
|
||||
}
|
||||
|
||||
export function clearBringerOverride(nation: Nation): void {
|
||||
const week = ensureWeek(getWeekKey());
|
||||
if (nation === "Capella") delete week.bringer.capellaOverride;
|
||||
else delete week.bringer.procyonOverride;
|
||||
updateBringer(week);
|
||||
saveWRank();
|
||||
}
|
||||
|
||||
export function getBringer(nation: Nation): string | null {
|
||||
const week = getCurrentWeek();
|
||||
if (nation === "Capella") return week.bringer.capellaOverride ?? week.bringer.capella;
|
||||
return week.bringer.procyonOverride ?? week.bringer.procyon;
|
||||
}
|
||||
|
||||
export function getEntry(usermapKey: string, nation: Nation): WRankEntry | null {
|
||||
const week = getCurrentWeek();
|
||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
return list.find((e) => e.usermapKey === usermapKey) ?? null;
|
||||
}
|
||||
|
||||
// Called every Monday 00:00 by cron
|
||||
export function resetWeek(): void {
|
||||
// Week is already archived in _data by weekKey — just ensure next week exists
|
||||
ensureWeek(getWeekKey(new Date()));
|
||||
saveWRank();
|
||||
}
|
||||
275
src/types.ts
Normal file
275
src/types.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Nation = "Capella" | "Procyon";
|
||||
|
||||
export type ClassKey =
|
||||
| "BL" // Blader
|
||||
| "FB" // Force Blader
|
||||
| "FS" // Force Shielder
|
||||
| "FA" // Force Archer
|
||||
| "FG" // Force Gunner
|
||||
| "GL" // Gladiator
|
||||
| "DM" // Dark Mage
|
||||
| "WI" // Wizard
|
||||
| "WA"; // Warrior
|
||||
|
||||
export const CLASS_NAMES: Record<ClassKey, string> = {
|
||||
BL: "Blader",
|
||||
FB: "Force Blader",
|
||||
FS: "Force Shielder",
|
||||
FA: "Force Archer",
|
||||
FG: "Force Gunner",
|
||||
GL: "Gladiator",
|
||||
DM: "Dark Mage",
|
||||
WI: "Wizard",
|
||||
WA: "Warrior",
|
||||
};
|
||||
|
||||
// ─── Character ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CharacterStats {
|
||||
str?: number;
|
||||
int?: number;
|
||||
dex?: number;
|
||||
honorRank?: number;
|
||||
honorPoints?: number;
|
||||
custom?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
name: string;
|
||||
class: ClassKey;
|
||||
level: number;
|
||||
nation: Nation;
|
||||
active: boolean;
|
||||
stats?: CharacterStats;
|
||||
sharedWith?: string[]; // usermap keys with permanent access
|
||||
}
|
||||
|
||||
export interface CharacterMap {
|
||||
[usermapKey: string]: {
|
||||
characters: Character[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface BorrowRequest {
|
||||
requesterKey: string; // who wants to borrow
|
||||
ownerKey: string; // who owns the character
|
||||
charName: string; // which character
|
||||
requestedAt: number; // timestamp for expiry
|
||||
}
|
||||
|
||||
// ─── Account ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AccountData {
|
||||
collection?: Record<string, number>;
|
||||
animaMastery?: Record<string, number>;
|
||||
custom?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AccountMap {
|
||||
[usermapKey: string]: AccountData;
|
||||
}
|
||||
|
||||
// ─── Usermap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UsermapEntry {
|
||||
file: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface Usermap {
|
||||
[discordUsername: string]: UsermapEntry | string; // string = legacy format
|
||||
}
|
||||
|
||||
// ─── TG Slots ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TGSlot {
|
||||
tgHour: number; // 20
|
||||
pollOpens: string; // "10:00"
|
||||
closesAfter: number; // minutes after tgHour (default 35)
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// ─── Poll ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VoteEntry {
|
||||
usermapKey: string;
|
||||
displayName: string; // server nickname → global nickname → username
|
||||
characterName?: string; // active character name at time of vote
|
||||
characterClass?: ClassKey; // snapshotted
|
||||
characterLevel?: number; // snapshotted
|
||||
characterNation?: Nation; // snapshotted at vote time
|
||||
votedAt: string; // HH:MM formatted
|
||||
previousYesAt?: string;
|
||||
previousNoAt?: string;
|
||||
publicMessage?: string; // resolved from message system or officer override
|
||||
publicMessageOverride?: string;// set by officer via /tg poll set-message
|
||||
ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral
|
||||
borrowedFrom?: string // Borrowed character from who
|
||||
}
|
||||
|
||||
export interface PollState {
|
||||
messageId: string | null;
|
||||
slot: number;
|
||||
yes: Map<string, VoteEntry>; // userId → VoteEntry
|
||||
no: Map<string, VoteEntry>;
|
||||
locked: boolean;
|
||||
confirmed: "yes" | "no" | null;
|
||||
lockMessage?: string;
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
// ─── Scores ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TGScore {
|
||||
usermapKey: string;
|
||||
characterName: string;
|
||||
class: ClassKey;
|
||||
nation: Nation; // snapshotted at submission time
|
||||
pts: number;
|
||||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
submittedAt: string; // ISO timestamp
|
||||
slot: number; // TG hour
|
||||
date: string; // YYYY-MM-DD
|
||||
submittedByOfficer: boolean;
|
||||
}
|
||||
|
||||
// ─── TG Result ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NationKD {
|
||||
k: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
export interface TGResult {
|
||||
slot: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
closedAt?: string; // ISO timestamp
|
||||
confirmed: boolean;
|
||||
nationKD: {
|
||||
source: Nation; // which nation is source of truth
|
||||
capella: NationKD;
|
||||
procyon: NationKD;
|
||||
};
|
||||
scores: TGScore[];
|
||||
}
|
||||
|
||||
// ─── W.Rank ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WRankEntry {
|
||||
usermapKey: string;
|
||||
characterName: string; // snapshotted
|
||||
class: ClassKey; // snapshotted
|
||||
nation: Nation; // snapshotted
|
||||
weeklyPoints: number; // cumulative pts this week
|
||||
tgCount: number; // number of slots with submission this week
|
||||
currentRank: number; // computed after each submission
|
||||
previousRank?: number; // before latest recomputation
|
||||
}
|
||||
|
||||
export interface WRankWeek {
|
||||
weekKey: string; // "2026-W22"
|
||||
entries: {
|
||||
capella: WRankEntry[];
|
||||
procyon: WRankEntry[];
|
||||
};
|
||||
scoreIndex: {
|
||||
[usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"]
|
||||
};
|
||||
bringer: {
|
||||
capella: string | null; // usermapKey of bringer, null if none qualified
|
||||
procyon: string | null;
|
||||
capellaOverride?: string; // manually set by officer
|
||||
procyonOverride?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WRankData {
|
||||
[weekKey: string]: WRankWeek;
|
||||
}
|
||||
|
||||
// ─── Bringer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BringerState {
|
||||
currentWeek: string; // "2026-W22"
|
||||
capella: string | null; // usermapKey
|
||||
procyon: string | null;
|
||||
capellaOverride?: string;
|
||||
procyonOverride?: string;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BotConfig {
|
||||
officerRoles?: string[];
|
||||
configRoles?: string[];
|
||||
tagRoles?: string[];
|
||||
lockMessage?: string;
|
||||
confirmYesMessage?: string;
|
||||
confirmNoMessage?: string;
|
||||
pollChannelId?: string;
|
||||
resultsChannelId?: string;
|
||||
scoreChannelId?: string;
|
||||
slots?: TGSlot[];
|
||||
scoreWindowHours?: number;
|
||||
tgDurationMinutes?: number;
|
||||
nationSource?: Nation;
|
||||
wRankPostOnReset?: boolean;
|
||||
wRankGoal?: number; // default 7
|
||||
wRankYellowColor?: string; // hex
|
||||
wRankGrayColor?: string; // hex
|
||||
deltaUpColor?: string; // hex
|
||||
deltaDownColor?: string; // hex
|
||||
stormBringerColor?: string; // hex
|
||||
luminousBringerColor?: string; // hex
|
||||
showClassInMessages?: boolean;
|
||||
showLevelInMessages?: boolean;
|
||||
charDisplayFormat?: string; // "{wrank} {class} {name}"
|
||||
showNationTotalsInHeader?: boolean;
|
||||
showNoInNationField?: boolean;
|
||||
borrowRequestExpiryMs?: number; // 0 = never expire (default)
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MessageEntry {
|
||||
clicks: number;
|
||||
message?: string; // single message (legacy)
|
||||
messages?: string[]; // array of messages
|
||||
random?: boolean;
|
||||
days?: Record<string, { messages: string[]; random?: boolean }>;
|
||||
dates?: Record<string, { messages: string[]; random?: boolean }>;
|
||||
}
|
||||
|
||||
export interface MessageBlock {
|
||||
yes: MessageEntry[];
|
||||
no: MessageEntry[];
|
||||
users?: Record<string, { yes: MessageEntry[]; no: MessageEntry[] }>;
|
||||
}
|
||||
|
||||
export interface MessagesFile {
|
||||
public: MessageBlock;
|
||||
ephemeral: MessageBlock;
|
||||
}
|
||||
|
||||
// ─── Emojis ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmojiMap {
|
||||
[key: string]: string; // e.g. "capella" → "<:Capella:1477082112560726238>"
|
||||
}
|
||||
|
||||
// ─── Interaction context ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedUser {
|
||||
userId: string;
|
||||
discordUsername: string; // interaction.user.username
|
||||
usermapKey: string | null; // resolved from usermap
|
||||
displayName: string; // server nickname → global nickname → username
|
||||
serverNickname: string | null;
|
||||
globalNickname: string | null;
|
||||
aliases: string[];
|
||||
activeCharacter: Character | null;
|
||||
}
|
||||
18
src/utils.ts
Normal file
18
src/utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
|
||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
|
||||
export async function replyAndDelete(
|
||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||
content: string | null
|
||||
): Promise<void> {
|
||||
if (!content || !EPHEMERAL_ENABLED) {
|
||||
if (interaction.isButton()) return void interaction.deferUpdate();
|
||||
return void interaction.deferReply({ ephemeral: true }).then(() => interaction.deleteReply()).catch(() => {});
|
||||
}
|
||||
const reply = await interaction.reply({ content, ephemeral: true, fetchReply: true });
|
||||
if (EPHEMERAL_DELETE_MS > 0) {
|
||||
setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
}
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue