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