various features done, bug fixes on char conflicts
20
.env
|
|
@ -1,7 +1,13 @@
|
||||||
DISCORD_TOKEN=MTUxMDc3NjgwNDE4NzU3NDMwMw.GgKLwR.eWiKvwpr03kVlUGquWxMzlOPC9h4Y_zvMh7KJM
|
DISCORD_TOKEN=MTUxMDk1OTgxNDYyMzEwNTA0NA.GNY7A9.Boq4MruKRqvo1UZ5JmsCkYN7q1xCTNKuqyh1oA
|
||||||
POLL_CHANNEL_ID=1510761562997133333
|
POLL_CHANNEL_ID=1511006387293917355
|
||||||
RESULTS_CHANNEL_ID=1510761595809304687
|
RESULTS_CHANNEL_ID=1511006410627088544
|
||||||
SCORE_CHANNEL_ID=1510761785794232442
|
SCORE_CHANNEL_ID=1511006435079884991
|
||||||
CLIENT_ID=1510776804187574303
|
CLIENT_ID=1510959814623105044
|
||||||
GUILD_ID=402115662149058561
|
GUILD_ID=1511006171681652858
|
||||||
EPHEMERAL_ENABLED=false
|
EPHEMERAL_DELETE_MS=0
|
||||||
|
POLL_EPHEMERAL_ENABLED=true # voting messages (disabled during testing)
|
||||||
|
COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on)
|
||||||
|
AUTO_VOTE_ON_CONFLICT_SWITCH=true
|
||||||
|
IMPERSONATE_RESET_ON_POLL=false
|
||||||
|
IMPERSONATE_INDICATOR=true
|
||||||
|
RECLAIM_NOTIFY_BORROWER=true
|
||||||
40
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment variables — never commit these
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Runtime data — server-specific, never commit
|
||||||
|
data/config.json
|
||||||
|
data/characters.json
|
||||||
|
data/accounts.json
|
||||||
|
data/usermap.json
|
||||||
|
data/wrank.json
|
||||||
|
data/bringer.json
|
||||||
|
data/sessionPreferences.json
|
||||||
|
data/tg-history/
|
||||||
|
|
||||||
|
# Keep the data directory structure but not the contents
|
||||||
|
!data/.gitkeep
|
||||||
|
!data/tg-history/.gitkeep
|
||||||
|
|
||||||
|
# Messages — user-specific files stay local
|
||||||
|
messages/users/
|
||||||
|
|
||||||
|
# Keep the users directory structure
|
||||||
|
!messages/users/.gitkeep
|
||||||
|
|
||||||
|
# TypeScript build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
@ -8,4 +8,4 @@ RUN npm install
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
CMD ["npx", "ts-node", "src/index.ts"]
|
CMD ["npx", "ts-node", "-r", "tsconfig-paths/register", "src/index.ts"]
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"class": "FB",
|
"class": "FB",
|
||||||
"level": 79,
|
"level": 79,
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"active": false,
|
"active": true,
|
||||||
"sharedWith": [
|
"sharedWith": [
|
||||||
"invicjusz"
|
"invicjusz"
|
||||||
]
|
]
|
||||||
|
|
@ -16,7 +16,10 @@
|
||||||
"class": "WI",
|
"class": "WI",
|
||||||
"level": 79,
|
"level": 79,
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"active": true
|
"active": false,
|
||||||
|
"sharedWith": [
|
||||||
|
"invicjusz"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -38,7 +41,10 @@
|
||||||
"class": "BL",
|
"class": "BL",
|
||||||
"level": 79,
|
"level": 79,
|
||||||
"nation": "Capella",
|
"nation": "Capella",
|
||||||
"active": true
|
"active": true,
|
||||||
|
"sharedWith": [
|
||||||
|
"flash"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scores": [
|
"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",
|
"usermapKey": "invicjusz",
|
||||||
"characterName": "ElementalEnchant",
|
"characterName": "ElementalEnchant",
|
||||||
|
|
@ -35,6 +24,17 @@
|
||||||
"slot": 20,
|
"slot": 20,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"submittedByOfficer": true
|
"submittedByOfficer": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"usermapKey": "flash",
|
||||||
|
"characterName": "«Flash»",
|
||||||
|
"class": "FB",
|
||||||
|
"nation": "Procyon",
|
||||||
|
"pts": 2000,
|
||||||
|
"submittedAt": "2026-06-01T22:07:39.907Z",
|
||||||
|
"slot": 20,
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"submittedByOfficer": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
"characterName": "»Flash«",
|
"characterName": "»Flash«",
|
||||||
"class": "WI",
|
"class": "WI",
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"pts": 2000,
|
"pts": 1000,
|
||||||
"submittedAt": "2026-06-01T03:22:14.287Z",
|
"submittedAt": "2026-06-01T22:05:28.186Z",
|
||||||
"slot": 22,
|
"slot": 22,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"submittedByOfficer": false
|
"submittedByOfficer": false
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,31 @@
|
||||||
"weekKey": "2026-W23",
|
"weekKey": "2026-W23",
|
||||||
"entries": {
|
"entries": {
|
||||||
"capella": [],
|
"capella": [],
|
||||||
"procyon": []
|
"procyon": [
|
||||||
|
{
|
||||||
|
"usermapKey": "flash",
|
||||||
|
"characterName": "»Flash«",
|
||||||
|
"class": "WI",
|
||||||
|
"nation": "Procyon",
|
||||||
|
"weeklyPoints": 1861.1111111111113,
|
||||||
|
"tgCount": 3,
|
||||||
|
"currentRank": 1,
|
||||||
|
"previousRank": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scoreIndex": {
|
||||||
|
"flash": [
|
||||||
|
"2026-06-01-20",
|
||||||
|
"2026-06-01-22",
|
||||||
|
"2026-06-02-20"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scoreIndex": {},
|
|
||||||
"bringer": {
|
"bringer": {
|
||||||
"capella": null,
|
"capella": null,
|
||||||
"procyon": null,
|
"procyon": null,
|
||||||
"procyonOverride": "flash",
|
"procyonOverride": "»Flash«",
|
||||||
"capellaOverride": "zephyr"
|
"capellaOverride": "XefronYokuda"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
services:
|
services:
|
||||||
tg-bot:
|
tg-bot-dev:
|
||||||
build:
|
build:
|
||||||
context: /opt/docker/tg-bot-ts
|
context: /opt/docker/tg-bot-ts-dev
|
||||||
image: tg-bot-ts:latest
|
image: tg-bot-ts-dev:latest
|
||||||
container_name: tg-bot-ts
|
container_name: tg-bot-ts-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- /opt/docker/tg-bot-ts/.env
|
- /opt/docker/tg-bot-ts-dev/.env
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/docker/tg-bot-ts/src:/app/src
|
- /opt/docker/tg-bot-ts-dev/src:/app/src
|
||||||
- /opt/docker/tg-bot-ts/data:/app/data
|
- /opt/docker/tg-bot-ts-dev/data:/app/data
|
||||||
- /opt/docker/tg-bot-ts/messages:/app/messages
|
- /opt/docker/tg-bot-ts-dev/scripts:/app/scripts
|
||||||
- /opt/docker/tg-bot-ts/tsconfig.json:/app/tsconfig.json
|
- /opt/docker/tg-bot-ts-dev/messages:/app/messages
|
||||||
|
- /opt/docker/tg-bot-ts-dev/emoji-uploads:/app/emoji-uploads
|
||||||
|
- /opt/docker/tg-bot-ts-dev/tsconfig.json:/app/tsconfig.json
|
||||||
|
- /opt/docker/tg-bot-ts-dev/data/sessionPreferences.json:/app/data/sessionPreferences.json
|
||||||
|
|
|
||||||
BIN
emoji-uploads/bl.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/borrowed.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
emoji-uploads/capella.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
emoji-uploads/dm.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
emoji-uploads/fa.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
emoji-uploads/fb.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
emoji-uploads/fg.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
emoji-uploads/fs.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/gl.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/kd.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/procyon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
emoji-uploads/rank.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
emoji-uploads/score.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
emoji-uploads/wa.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/wi.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -1,15 +1,15 @@
|
||||||
{
|
{
|
||||||
"capella": "<:Capella:1477082112560726238>",
|
"capella": "<:capella:1511020911027814453>",
|
||||||
"procyon": "<:Procyon:1477082175181426738>",
|
"procyon": "<:procyon:1511020943323955301>",
|
||||||
"bl": "<:bl:1510827912767475742>",
|
"bl": "<:bl:1511014332685881364>",
|
||||||
"fb": "<:fb:1510825907374395452>",
|
"fb": "<:fb:1511020923510194428>",
|
||||||
"fs": "<:fs:1510828058112954501>",
|
"fs": "<:fs:1511020931542417459>",
|
||||||
"fa": "<:fa:1510823955034935326>",
|
"fa": "<:fa:1511020918929887434>",
|
||||||
"fg": "<:fg:1510822372373037207>",
|
"fg": "<:fg:1511020927461097482>",
|
||||||
"gl": "<:gl:1510826513484873909>",
|
"gl": "<:gl:1511020935463833711>",
|
||||||
"dm": "<:dm:1510820686971670538>",
|
"dm": "<:dm:1511020914974658612>",
|
||||||
"wi": "<:wi:1510805237047230464>",
|
"wi": "<:wi:1511020959706910913>",
|
||||||
"wa": "<:wa:1510827932376109218>",
|
"wa": "<:wa:1511020955231715448>",
|
||||||
"wrank_up": "",
|
"wrank_up": "",
|
||||||
"wrank_down": "",
|
"wrank_down": "",
|
||||||
"atk": "",
|
"atk": "",
|
||||||
|
|
@ -36,8 +36,8 @@
|
||||||
"wrank_9_gold": "",
|
"wrank_9_gold": "",
|
||||||
"wrank_10": "",
|
"wrank_10": "",
|
||||||
"wrank_10_gold": "",
|
"wrank_10_gold": "",
|
||||||
"kd": "",
|
"kd": "<:kd:1511020939226124339>",
|
||||||
"score": "",
|
"score": "<:score:1511020950718513172>",
|
||||||
"rank": "",
|
"rank": "<:rank:1511020947019137187>",
|
||||||
"borrowed": "🔗"
|
"borrowed": "<:borrowed:1511020906754085057>"
|
||||||
}
|
}
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
"watch": ["src"],
|
"watch": ["src"],
|
||||||
"ext": "ts",
|
"ext": "ts",
|
||||||
"ignore": ["src/**/*.spec.ts"],
|
"ignore": ["src/**/*.spec.ts"],
|
||||||
"exec": "ts-node src/index.ts"
|
"exec": "ts-node -r tsconfig-paths/register src/index.ts"
|
||||||
}
|
}
|
||||||
10
package.json
|
|
@ -4,9 +4,10 @@
|
||||||
"description": "Cabal Online TG planning and tracking bot",
|
"description": "Cabal Online TG planning and tracking bot",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ts-node src/index.ts",
|
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"register": "ts-node src/index.ts --register"
|
"register": "ts-node -r tsconfig-paths/register src/index.ts --register",
|
||||||
|
"aliases": "ts-node scripts/generate-aliases.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.15.3",
|
"discord.js": "^14.15.3",
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"@types/node-cron": "^3.0.0",
|
"@types/node-cron": "^3.0.0",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0",
|
||||||
|
"tsconfig-paths": "^4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
76
scripts/generate-aliases.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Generates path aliases in tsconfig.json for every directory under src/
|
||||||
|
* and optionally data/ and messages/.
|
||||||
|
*
|
||||||
|
* Usage: npx ts-node scripts/generate-aliases.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const TSCONFIG_PATH = path.join(__dirname, "../tsconfig.json");
|
||||||
|
const SRC_DIR = path.join(__dirname, "../src");
|
||||||
|
|
||||||
|
function getDirs(dir: string, prefix: string): Record<string, string[]> {
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
if (!fs.existsSync(dir)) return result;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const relPath = path.join(prefix, entry.name);
|
||||||
|
const aliasKey = `@${entry.name}/*`;
|
||||||
|
const aliasVal = [`src/${relPath}/*`];
|
||||||
|
result[aliasKey] = aliasVal;
|
||||||
|
|
||||||
|
// Also add a direct alias for files in this dir (e.g. @utils → src/utils)
|
||||||
|
const directKey = `@${entry.name}`;
|
||||||
|
result[directKey] = [`src/${relPath}`];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also alias every .ts file directly under src/ (e.g. @utils → src/utils)
|
||||||
|
function getFiles(dir: string): Record<string, string[]> {
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
if (!fs.existsSync(dir)) return result;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
|
||||||
|
const name = path.basename(entry.name, ".ts");
|
||||||
|
result[`@${name}`] = [`src/${name}`];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsconfig = JSON.parse(fs.readFileSync(TSCONFIG_PATH, "utf8"));
|
||||||
|
|
||||||
|
// Generate aliases for all dirs under src/
|
||||||
|
const newPaths: Record<string, string[]> = {
|
||||||
|
"@src/*": ["src/*"],
|
||||||
|
...getDirs(SRC_DIR, ""),
|
||||||
|
...getFiles(SRC_DIR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also scan subdirs (systems, subcommands, handlers, commands)
|
||||||
|
for (const subdir of ["systems", "subcommands", "handlers", "commands"]) {
|
||||||
|
const full = path.join(SRC_DIR, subdir);
|
||||||
|
if (!fs.existsSync(full)) continue;
|
||||||
|
for (const entry of fs.readdirSync(full, { withFileTypes: true })) {
|
||||||
|
const name = entry.isDirectory() ? entry.name : path.basename(entry.name, ".ts");
|
||||||
|
if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue;
|
||||||
|
// Don't override parent alias
|
||||||
|
if (!newPaths[`@${name}`]) {
|
||||||
|
newPaths[`@${name}`] = entry.isDirectory()
|
||||||
|
? [`src/${subdir}/${name}/*`]
|
||||||
|
: [`src/${subdir}/${name}`];
|
||||||
|
}
|
||||||
|
if (entry.isDirectory() && !newPaths[`@${name}/*`]) {
|
||||||
|
newPaths[`@${name}/*`] = [`src/${subdir}/${name}/*`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tsconfig.compilerOptions.paths = newPaths;
|
||||||
|
fs.writeFileSync(TSCONFIG_PATH, JSON.stringify(tsconfig, null, 2));
|
||||||
|
|
||||||
|
console.log(`✅ Generated ${Object.keys(newPaths).length} path aliases in tsconfig.json`);
|
||||||
|
console.log(Object.keys(newPaths).sort().join("\n"));
|
||||||
110
scripts/upload-emojis.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Bulk emoji upload script
|
||||||
|
* Usage: npx ts-node scripts/upload-emojis.ts [emoji_dir]
|
||||||
|
*
|
||||||
|
* Place emoji PNG files in a directory named after the emoji key.
|
||||||
|
* Example: fb.png, wi.png, capella.png, wrank_1.png, wrank_1_gold.png
|
||||||
|
*
|
||||||
|
* Automatically updates messages/emojis.json with the uploaded emoji IDs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { REST, Routes } from "discord.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Load .env manually since we're outside the bot
|
||||||
|
const envPath = path.join(__dirname, "../.env");
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||||
|
const [key, ...rest] = line.split("=");
|
||||||
|
if (key && rest.length) process.env[key.trim()] = rest.join("=").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
|
const GUILD_ID = process.env.GUILD_ID!;
|
||||||
|
|
||||||
|
if (!TOKEN || !GUILD_ID) {
|
||||||
|
console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads");
|
||||||
|
const emojisPath = path.join(__dirname, "../messages/emojis.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(emojiDir)) {
|
||||||
|
console.error(`❌ Emoji directory not found: ${emojiDir}`);
|
||||||
|
console.error(` Create it and place your emoji PNG files inside.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
||||||
|
|
||||||
|
async function uploadEmojis(): Promise<void> {
|
||||||
|
const files = fs.readdirSync(emojiDir).filter((f) =>
|
||||||
|
[".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("❌ No image files found in the emoji directory.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing emojis.json
|
||||||
|
let emojiMap: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
console.warn("⚠️ Could not load emojis.json — will create fresh mapping.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📁 Found ${files.length} file(s) in ${emojiDir}\n`);
|
||||||
|
|
||||||
|
// Fetch existing guild emojis to skip duplicates
|
||||||
|
const existing = await rest.get(Routes.guildEmojis(GUILD_ID)) as any[];
|
||||||
|
const existingMap = new Map(existing.map((e: any) => [e.name, e.id]));
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const emojiName = path.basename(file, path.extname(file));
|
||||||
|
const filePath = path.join(emojiDir, file);
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
|
||||||
|
|
||||||
|
if (existingMap.has(emojiName)) {
|
||||||
|
const formatted = `<:${emojiName}:${existingMap.get(emojiName)}>`;
|
||||||
|
emojiMap[emojiName] = formatted;
|
||||||
|
console.log(`⏭️ Already exists: ${emojiName} → ${formatted}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||||
|
const result = await rest.post(Routes.guildEmojis(GUILD_ID), {
|
||||||
|
body: { name: emojiName, image: base64 },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const formatted = `<:${emojiName}:${result.id}>`;
|
||||||
|
emojiMap[emojiName] = formatted;
|
||||||
|
console.log(`✅ Uploaded: ${emojiName} → ${formatted}`);
|
||||||
|
uploaded++;
|
||||||
|
|
||||||
|
// Avoid rate limiting
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`❌ Failed: ${emojiName} — ${err.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2));
|
||||||
|
|
||||||
|
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
|
||||||
|
console.log(`💾 messages/emojis.json updated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadEmojis().catch(console.error);
|
||||||
|
|
@ -18,6 +18,7 @@ import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEp
|
||||||
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject";
|
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject";
|
||||||
import { handleSeed } from "../subcommands/poll/seed";
|
import { handleSeed } from "../subcommands/poll/seed";
|
||||||
import { handlePurge } from "../subcommands/poll/purge";
|
import { handlePurge } from "../subcommands/poll/purge";
|
||||||
|
import { handleImpersonate } from "../subcommands/impersonate";
|
||||||
|
|
||||||
// Char subcommands (borrow / sharing system)
|
// Char subcommands (borrow / sharing system)
|
||||||
import { handleCharBorrow } from "../subcommands/char/borrow";
|
import { handleCharBorrow } from "../subcommands/char/borrow";
|
||||||
|
|
@ -46,6 +47,14 @@ import { handleBringerClear } from "../subcommands/bringer/clear";
|
||||||
import { handleSwitch } from "../subcommands/switch";
|
import { handleSwitch } from "../subcommands/switch";
|
||||||
import { handleHistory } from "../subcommands/history";
|
import { handleHistory } from "../subcommands/history";
|
||||||
|
|
||||||
|
// Import char handlers here to keep tg.ts clean
|
||||||
|
import { handleCharAdd } from "../subcommands/char/add";
|
||||||
|
import { handleCharRemove } from "../subcommands/char/remove";
|
||||||
|
import { handleCharSetActive } from "../subcommands/char/setActive";
|
||||||
|
import { handleCharSetNation } from "../subcommands/char/setNation";
|
||||||
|
import { handleCharSetStats } from "../subcommands/char/setStats";
|
||||||
|
import { handleCharActive } from "../subcommands/char/active";
|
||||||
|
|
||||||
export function buildTgCommand(): SlashCommandBuilder {
|
export function buildTgCommand(): SlashCommandBuilder {
|
||||||
const cmd = new SlashCommandBuilder()
|
const cmd = new SlashCommandBuilder()
|
||||||
.setName("tg")
|
.setName("tg")
|
||||||
|
|
@ -56,41 +65,49 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.setName("poll")
|
.setName("poll")
|
||||||
.setDescription("Manage the TG poll")
|
.setDescription("Manage the TG poll")
|
||||||
.addSubcommand((s) => s.setName("start").setDescription("Post a fresh 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)))
|
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
||||||
.addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)))
|
.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("unlock").setDescription("Unlock the active poll"))
|
||||||
.addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening")
|
.addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening")
|
||||||
.addStringOption((o) => o.setName("decision").setDescription("yes or no").setRequired(true)
|
.addStringOption((o) => o.setName("decision").setDescription("yes or no").setRequired(true)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||||
.addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false))
|
.addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false))
|
||||||
.addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").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("reload").setDescription("Reload messages and emojis from disk"))
|
||||||
.addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status"))
|
.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")
|
.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)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override")
|
.addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override")
|
||||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user")
|
.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)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override")
|
.addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override")
|
||||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user")
|
.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("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })))
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })))
|
||||||
.addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user")
|
.addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user")
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel"))
|
.addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel"))
|
||||||
.addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing"))
|
.addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing"))
|
||||||
);
|
);
|
||||||
|
|
@ -102,10 +119,17 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.addSubcommand((s) => s.setName("set").setDescription("Submit a score")
|
.addSubcommand((s) => s.setName("set").setDescription("Submit a score")
|
||||||
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
|
.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("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false))
|
||||||
|
.addIntegerOption((o) => o.setName("d").setDescription("Deaths").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 (FA only)").setRequired(false))
|
||||||
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("get").setDescription("View a score")
|
.addSubcommand((s) => s.setName("get").setDescription("View a score")
|
||||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── rank group ─────────────────────────────────────────────────────────────
|
// ── rank group ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -113,7 +137,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.setName("rank")
|
.setName("rank")
|
||||||
.setDescription("W.Rank management")
|
.setDescription("W.Rank management")
|
||||||
.addSubcommand((s) => s.setName("get").setDescription("View W.Rank")
|
.addSubcommand((s) => s.setName("get").setDescription("View W.Rank")
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)"))
|
.addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -140,7 +165,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
|
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
|
||||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
|
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
|
||||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })))
|
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })))
|
||||||
|
|
@ -148,8 +174,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
|
|
||||||
// ── switch ─────────────────────────────────────────────────────────────────
|
// ── switch ─────────────────────────────────────────────────────────────────
|
||||||
cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character")
|
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("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── char group ─────────────────────────────────────────────────────────────
|
// ── char group ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -173,40 +199,53 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
|
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
|
||||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
|
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
|
||||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("set-active").setDescription("Set active character")
|
.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("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
|
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
|
||||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
|
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats")
|
.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))
|
.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("atk").setDescription("Attack score").setRequired(false))
|
||||||
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
|
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
|
||||||
.addIntegerOption((o) => o.setName("heal").setDescription("Healing 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)))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session")
|
.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("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request")
|
.addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request")
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request")
|
.addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request")
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("share").setDescription("Permanently share a character")
|
.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("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
.addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share")
|
.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("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true))
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true))
|
||||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => s.setName("active").setDescription("Check active character for a user")
|
||||||
|
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer: check others)").setRequired(false).setAutocomplete(true))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── history ────────────────────────────────────────────────────────────────
|
// ── history ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -215,6 +254,9 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── impersonate ────────────────────────────────────────────────────────────────
|
||||||
|
cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (officer only)"));
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,14 +326,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
||||||
if (sub === "decline") return handleCharDecline(interaction);
|
if (sub === "decline") return handleCharDecline(interaction);
|
||||||
if (sub === "share") return handleCharShare(interaction);
|
if (sub === "share") return handleCharShare(interaction);
|
||||||
if (sub === "unshare") return handleCharUnshare(interaction);
|
if (sub === "unshare") return handleCharUnshare(interaction);
|
||||||
|
if (sub === "active") return handleCharActive(interaction);
|
||||||
}
|
}
|
||||||
if (!group && sub === "switch") return handleSwitch(interaction);
|
if (!group && sub === "switch") return handleSwitch(interaction);
|
||||||
if (!group && sub === "history") return handleHistory(interaction);
|
if (!group && sub === "history") return handleHistory(interaction);
|
||||||
|
if (!group && sub === "impersonate") return handleImpersonate(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";
|
|
||||||
104
src/handlers/autocomplete.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
|
import { resolveUser } from "@systems/users";
|
||||||
|
import { getCharacters } from "@systems/characters";
|
||||||
|
import { cfg } from "@systems/config";
|
||||||
|
import { getNationEmoji } from "@systems/emojis";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// ─── Autocomplete subsets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function autocompleteCharNames(
|
||||||
|
interaction: AutocompleteInteraction,
|
||||||
|
focused: string
|
||||||
|
): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
const user = await resolveUser(member);
|
||||||
|
if (!user.userKey) return interaction.respond([]);
|
||||||
|
|
||||||
|
const ownChars = getCharacters(user.userKey).map((c) => {
|
||||||
|
const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : "";
|
||||||
|
return {
|
||||||
|
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
|
||||||
|
value: c.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharedChars: { name: string; value: string }[] = [];
|
||||||
|
try {
|
||||||
|
const chars = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
|
||||||
|
);
|
||||||
|
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||||
|
if (ownerKey === user.userKey) continue;
|
||||||
|
for (const char of data.characters ?? []) {
|
||||||
|
if (char.sharedWith?.includes(user.userKey)) {
|
||||||
|
const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : "";
|
||||||
|
sharedChars.push({
|
||||||
|
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
|
||||||
|
value: char.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const all = [...ownChars, ...sharedChars]
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
||||||
|
.slice(0, 25);
|
||||||
|
|
||||||
|
await interaction.respond(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autocompleteUserKeys(
|
||||||
|
interaction: AutocompleteInteraction,
|
||||||
|
focused: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const usermap = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
|
||||||
|
);
|
||||||
|
const choices = Object.entries(usermap)
|
||||||
|
.map(([, entry]: [string, any]) => {
|
||||||
|
const fileKey = typeof entry === "string" ? entry : entry.file;
|
||||||
|
const alias = typeof entry === "object" ? (entry.aliases?.[0] ?? fileKey) : fileKey;
|
||||||
|
return { name: `${alias} (${fileKey})`, value: fileKey };
|
||||||
|
})
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
||||||
|
.slice(0, 25);
|
||||||
|
await interaction.respond(choices);
|
||||||
|
} catch {
|
||||||
|
await interaction.respond([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autocompleteSlots(
|
||||||
|
interaction: AutocompleteInteraction,
|
||||||
|
focused: string
|
||||||
|
): Promise<void> {
|
||||||
|
const slots = cfg("slots")
|
||||||
|
.filter((s) => s.active)
|
||||||
|
.map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) }))
|
||||||
|
.filter((s) => s.name.includes(focused));
|
||||||
|
await interaction.respond(slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const focused = interaction.options.getFocused(true);
|
||||||
|
const optionName = focused.name;
|
||||||
|
const focusedValue = focused.value as string;
|
||||||
|
|
||||||
|
if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue);
|
||||||
|
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
|
||||||
|
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
|
||||||
|
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
|
||||||
|
|
||||||
|
await interaction.respond([]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[autocomplete] error:", err);
|
||||||
|
try { await interaction.respond([]); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,86 @@
|
||||||
import { ButtonInteraction, TextChannel } from "discord.js";
|
import { ButtonInteraction, TextChannel } from "discord.js";
|
||||||
import { cfg } from "../systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { resolveUser } from "../systems/users";
|
import { pollReplyAndDelete } from "../utils";
|
||||||
import { resolveMessage, nowFormatted } from "../systems/messages";
|
import { resolveUser } from "@systems/users";
|
||||||
import { resolveNation } from "../systems/nations";
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "../systems/poll";
|
import { resolveNation } from "@systems/nations";
|
||||||
|
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
|
||||||
|
import { showConflictEmbed } from "@systems/conflict";
|
||||||
|
import { getCharacters } from "@systems/characters";
|
||||||
|
import { getImpersonation } from "@systems/impersonate";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Character } from "@src/types";
|
||||||
|
|
||||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||||
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 }>();
|
const clickCounts = new Map<string, { yes: number; no: number }>();
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isCharacterInPoll(
|
||||||
|
state: ReturnType<typeof polls.get>,
|
||||||
|
charName: string,
|
||||||
|
excludeVoteId: string,
|
||||||
|
excludeUserKey: string = ""
|
||||||
|
): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } {
|
||||||
|
if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined };
|
||||||
|
for (const [otherId, entry] of state.yes.entries()) {
|
||||||
|
if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) {
|
||||||
|
return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: false, entryUserKey: null, borrowedFrom: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCharacterOwner(userKey: string | null, charName: string): boolean {
|
||||||
|
if (!userKey) return false;
|
||||||
|
return getCharacters(userKey).some((c) => c.name === charName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCharacterConflict(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
userKey: string | null,
|
||||||
|
char: Character,
|
||||||
|
entryUserKey: string | null,
|
||||||
|
clicks: { yes: number; no: number },
|
||||||
|
votedYes: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Decrement click since we're blocking this vote
|
||||||
|
if (votedYes) clicks.yes -= 1;
|
||||||
|
else clicks.no -= 1;
|
||||||
|
|
||||||
|
const isOwner = isCharacterOwner(userKey, char.name);
|
||||||
|
|
||||||
|
if (isOwner && userKey) {
|
||||||
|
const allChars = getCharacters(userKey);
|
||||||
|
const borrowedChar = allChars.find((c) => c.name === char.name);
|
||||||
|
if (borrowedChar && entryUserKey) {
|
||||||
|
await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = [...polls.keys()][0];
|
||||||
|
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
|
||||||
|
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||||
|
// content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main button handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
|
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
|
||||||
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
|
if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
|
||||||
|
|
||||||
// Defer immediately to avoid 3s timeout
|
try {
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
||||||
if (slot === undefined) return;
|
if (slot === undefined) return;
|
||||||
|
|
@ -24,61 +89,91 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
||||||
if (state.locked || state.confirmed !== null) return;
|
if (state.locked || state.confirmed !== null) return;
|
||||||
|
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
const member = await interaction.guild!.members.fetch(userId);
|
const member = interaction.guild!.members.cache.get(userId)
|
||||||
|
?? await interaction.guild!.members.fetch(userId);
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
const votedYes = interaction.customId === "tg_yes";
|
const votedYes = interaction.customId === "tg_yes";
|
||||||
const now = nowFormatted();
|
const now = nowFormatted();
|
||||||
|
|
||||||
// Check nation — block if no nation
|
const impersonating = getImpersonation(userId);
|
||||||
const nation = resolveNation(member, user.usermapKey);
|
const voteId = impersonating ? `impersonated:${impersonating}` : userId;
|
||||||
|
const lookupUsername = user.lookupUsername ?? user.discordUsername;
|
||||||
|
|
||||||
|
// Nation check
|
||||||
|
const nation = resolveNation(member, user.userKey);
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
if (EPHEMERAL_ENABLED) {
|
const capella = format.nation("Capella");
|
||||||
const reply = await interaction.followUp({ content: "❌ You must be in Capella or Procyon to vote.", ephemeral: true });
|
const procyon = format.nation("Procyon");
|
||||||
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click tracking
|
// Click tracking
|
||||||
if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 });
|
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
|
||||||
const clicks = clickCounts.get(userId)!;
|
const clicks = clickCounts.get(voteId)!;
|
||||||
|
|
||||||
if (votedYes && clicks.yes >= LOCK_AT) return;
|
if (votedYes && clicks.yes >= LOCK_AT) return;
|
||||||
if (!votedYes && clicks.no >= LOCK_AT) return;
|
if (!votedYes && clicks.no >= LOCK_AT) return;
|
||||||
|
|
||||||
// Ignore same vote
|
// Ignore same vote
|
||||||
if (votedYes && state.yes.has(userId)) return;
|
if (votedYes && state.yes.has(voteId)) return;
|
||||||
if (!votedYes && state.no.has(userId)) return;
|
if (!votedYes && state.no.has(voteId)) return;
|
||||||
|
|
||||||
|
// Increment click (may be decremented in conflict handler)
|
||||||
if (votedYes) clicks.yes += 1;
|
if (votedYes) clicks.yes += 1;
|
||||||
else clicks.no += 1;
|
else clicks.no += 1;
|
||||||
|
|
||||||
const clickCount = votedYes ? clicks.yes : clicks.no;
|
const clickCount = votedYes ? clicks.yes : clicks.no;
|
||||||
|
|
||||||
// Resolve messages — officer override takes priority
|
// Resolve messages
|
||||||
const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no")
|
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
|
||||||
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
|
||||||
|
|
||||||
const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no")
|
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
|
||||||
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
|
||||||
|
|
||||||
const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername);
|
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
|
||||||
|
|
||||||
|
// Character conflict check — applies to both Yes and No
|
||||||
|
if (baseEntry.characterName) {
|
||||||
|
const conflictChar = {
|
||||||
|
name: baseEntry.characterName!,
|
||||||
|
class: baseEntry.characterClass!,
|
||||||
|
level: baseEntry.characterLevel!,
|
||||||
|
nation: baseEntry.characterNation!,
|
||||||
|
active: false, // not needed for display
|
||||||
|
};
|
||||||
|
|
||||||
|
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
|
||||||
|
state, baseEntry.characterName, voteId, user.userKey ?? ""
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
await handleCharacterConflict(
|
||||||
|
interaction, user.userKey, conflictChar,
|
||||||
|
entryUserKey, clicks, votedYes
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register vote
|
||||||
if (votedYes) {
|
if (votedYes) {
|
||||||
const previousNo = state.no.get(userId);
|
const previousNo = state.no.get(voteId);
|
||||||
state.no.delete(userId);
|
state.no.delete(voteId);
|
||||||
state.yes.set(userId, {
|
state.yes.set(voteId, {
|
||||||
...baseEntry,
|
...baseEntry,
|
||||||
|
discordId: userId,
|
||||||
votedAt: now,
|
votedAt: now,
|
||||||
previousNoAt: previousNo?.votedAt,
|
previousNoAt: previousNo?.votedAt,
|
||||||
publicMessage: publicMsg ?? undefined,
|
publicMessage: publicMsg ?? undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const previousYes = state.yes.get(userId);
|
const previousYes = state.yes.get(voteId);
|
||||||
state.yes.delete(userId);
|
state.yes.delete(voteId);
|
||||||
state.no.set(userId, {
|
state.no.set(voteId, {
|
||||||
...baseEntry,
|
...baseEntry,
|
||||||
votedAt: now,
|
votedAt: now,
|
||||||
|
discordId: userId,
|
||||||
previousYesAt: previousYes?.votedAt,
|
previousYesAt: previousYes?.votedAt,
|
||||||
publicMessage: publicMsg ?? undefined,
|
publicMessage: publicMsg ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -87,18 +182,11 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
||||||
const locked = clickCount >= LOCK_AT;
|
const locked = clickCount >= LOCK_AT;
|
||||||
if (locked) state.locked = true;
|
if (locked) state.locked = true;
|
||||||
|
|
||||||
// Send ephemeral follow-up (since we already deferred with deferUpdate)
|
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
||||||
if (EPHEMERAL_ENABLED) {
|
const msgContent = ephemeralMsg
|
||||||
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
? `${ephemeralMsg}${lockedSuffix}`
|
||||||
const content = ephemeralMsg
|
: locked ? "🔒 You've been locked in." : null;
|
||||||
? `${ephemeralMsg}${lockedSuffix}`
|
await pollReplyAndDelete(interaction, msgContent);
|
||||||
: 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;
|
const channel = interaction.channel as TextChannel;
|
||||||
await updatePollMessage(channel, slot);
|
await updatePollMessage(channel, slot);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,134 @@
|
||||||
import { Interaction, ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
|
||||||
import { handleButton } from "./buttons";
|
import { handleButton } from "@handlers/buttons";
|
||||||
import { handleTgCommand } from "../commands/tg";
|
import { handleTgCommand } from "@commands/tg";
|
||||||
import { handleTgConfigCommand } from "../commands/tgConfig";
|
import { handleTgConfigCommand } from "@commands/tgConfig";
|
||||||
import { handleBorrowAcceptButton } from "../subcommands/char/accept";
|
import { handleBorrowAcceptButton } from "@subcommands/char/accept";
|
||||||
import { handleBorrowDeclineButton } from "../subcommands/char/decline";
|
import { handleBorrowDeclineButton } from "@subcommands/char/decline";
|
||||||
|
import { handleConflictButton } from "@systems/conflict";
|
||||||
|
import { handleImpersonateButton } from "@subcommands/impersonate";
|
||||||
|
import { handleAutocomplete } from "@handlers/autocomplete";
|
||||||
|
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
|
||||||
|
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
import { cfg } from "@systems/config";
|
||||||
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
|
import { format } from "@format";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
|
||||||
|
const parts = btn.customId.split(":");
|
||||||
|
const userKey = parts[1];
|
||||||
|
const charName = parts[2];
|
||||||
|
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no";
|
||||||
|
|
||||||
|
const chars = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolvedChar: any = null;
|
||||||
|
let borrowedFrom: string | null = null;
|
||||||
|
|
||||||
|
// Try own char first
|
||||||
|
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName);
|
||||||
|
if (ownEntry) {
|
||||||
|
setActiveCharacter(userKey, charName);
|
||||||
|
clearSessionBorrowForUser(userKey);
|
||||||
|
resolvedChar = ownEntry;
|
||||||
|
} else {
|
||||||
|
// Try shared char
|
||||||
|
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||||
|
const char = data.characters?.find(
|
||||||
|
(c: any) => c.name === charName && c.sharedWith?.includes(userKey)
|
||||||
|
);
|
||||||
|
if (char) {
|
||||||
|
setPersistentPreference(userKey, ownerKey, charName);
|
||||||
|
clearSessionBorrowForUser(userKey);
|
||||||
|
resolvedChar = char;
|
||||||
|
borrowedFrom = ownerKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedChar) {
|
||||||
|
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-add to poll with previous vote type
|
||||||
|
const slot = [...polls.keys()][0];
|
||||||
|
const state = slot !== undefined ? polls.get(slot) : null;
|
||||||
|
|
||||||
|
if (state && !state.locked && state.confirmed === null) {
|
||||||
|
const { char } = getEffectiveCharacter(userKey);
|
||||||
|
const now = nowFormatted();
|
||||||
|
const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null);
|
||||||
|
const voteEntry = {
|
||||||
|
userKey,
|
||||||
|
displayName: charName,
|
||||||
|
characterName: char?.name ?? charName,
|
||||||
|
characterClass: char?.class ?? resolvedChar.class,
|
||||||
|
characterLevel: char?.level ?? resolvedChar.level,
|
||||||
|
characterNation: char?.nation ?? resolvedChar.nation,
|
||||||
|
borrowedFrom: borrowedFrom ?? undefined,
|
||||||
|
discordId: btn.user.id,
|
||||||
|
votedAt: now,
|
||||||
|
publicMessage: publicMsg ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
|
if (entry.userKey === userKey) {
|
||||||
|
state.yes.delete(id);
|
||||||
|
state.no.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevVoteType === "yes") {
|
||||||
|
state.yes.set(`switch_reclaim:${userKey}`, voteEntry);
|
||||||
|
} else {
|
||||||
|
state.no.set(`switch_reclaim:${userKey}`, voteEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
|
await updatePollMessage(channel, slot!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName;
|
||||||
|
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||||
|
await btn.reply({
|
||||||
|
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleInteraction(interaction: Interaction): Promise<void> {
|
export async function handleInteraction(interaction: Interaction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
await handleAutocomplete(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.isButton()) {
|
if (interaction.isButton()) {
|
||||||
const btn = interaction as ButtonInteraction;
|
const btn = interaction as ButtonInteraction;
|
||||||
|
|
||||||
// Borrow accept/decline buttons from DM
|
if (btn.customId.startsWith("conflict_")) {
|
||||||
|
return await handleConflictButton(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn.customId.startsWith("impersonate_")) {
|
||||||
|
return await handleImpersonateButton(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn.customId.startsWith("switch_after_reclaim:")) {
|
||||||
|
return await handleSwitchAfterReclaim(btn);
|
||||||
|
}
|
||||||
|
|
||||||
if (btn.customId.startsWith("borrow_accept:")) {
|
if (btn.customId.startsWith("borrow_accept:")) {
|
||||||
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
||||||
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
|
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn.customId.startsWith("borrow_decline:")) {
|
if (btn.customId.startsWith("borrow_decline:")) {
|
||||||
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
||||||
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
|
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ client.once("clientReady", async () => {
|
||||||
loadCharacters();
|
loadCharacters();
|
||||||
loadWRank();
|
loadWRank();
|
||||||
|
|
||||||
|
// Warm member cache
|
||||||
|
const guild = await client.guilds.fetch(GUILD_ID);
|
||||||
|
await guild.members.fetch();
|
||||||
|
console.log(`Member cache warmed: ${guild.members.cache.size} members`);
|
||||||
|
|
||||||
// Register commands if --register flag passed
|
// Register commands if --register flag passed
|
||||||
if (process.argv.includes("--register")) {
|
if (process.argv.includes("--register")) {
|
||||||
await registerCommands();
|
await registerCommands();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { clearBringerOverride } from "../../systems/wrank";
|
import { clearBringerOverride } from "@systems/wrank";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "@utils";
|
||||||
import { Nation } from "../../types";
|
import { Nation } from "@types";
|
||||||
|
|
||||||
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const nation = interaction.options.getString("nation", true) as Nation;
|
const nation = interaction.options.getString("nation", true) as Nation;
|
||||||
clearBringerOverride(nation);
|
clearBringerOverride(nation);
|
||||||
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
|
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { setBringerOverride } from "../../systems/wrank";
|
import { setBringerOverride } from "@systems/wrank";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "@utils";
|
||||||
import { Nation } from "../../types";
|
import { Nation } from "@types";
|
||||||
|
|
||||||
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const nation = interaction.options.getString("nation", true) as Nation;
|
const nation = interaction.options.getString("nation", true) as Nation;
|
||||||
const usermapKey = interaction.options.getString("name", true);
|
const charName = interaction.options.getString("name", true);
|
||||||
|
|
||||||
setBringerOverride(nation, usermapKey);
|
setBringerOverride(nation, charName);
|
||||||
return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`);
|
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`);
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ async function acceptBorrow(
|
||||||
const state = polls.get(slot)!;
|
const state = polls.get(slot)!;
|
||||||
for (const map of [state.yes, state.no]) {
|
for (const map of [state.yes, state.no]) {
|
||||||
for (const [, entry] of map) {
|
for (const [, entry] of map) {
|
||||||
if (entry.usermapKey === requesterKey) {
|
if (entry.userKey === requesterKey) {
|
||||||
entry.characterName = char.name;
|
entry.characterName = char.name;
|
||||||
entry.characterClass = char.class;
|
entry.characterClass = char.class;
|
||||||
entry.characterLevel = char.level;
|
entry.characterLevel = char.level;
|
||||||
|
|
|
||||||
29
src/subcommands/char/active.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 handleCharActive(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const nameArg = interaction.options.getString("name");
|
||||||
|
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"));
|
||||||
|
if (nameArg && !isOfficer) {
|
||||||
|
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
|
||||||
|
}
|
||||||
|
const targetKey = nameArg ?? (await resolveUser(member)).userKey;
|
||||||
|
if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
|
const { getEffectiveCharacter } = require("../../systems/borrow");
|
||||||
|
const { char, borrowedFrom } = getEffectiveCharacter(targetKey);
|
||||||
|
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`);
|
||||||
|
|
||||||
|
const { getClassEmoji } = require("../../systems/emojis");
|
||||||
|
const classEmoji = getClassEmoji(char.class) || char.class;
|
||||||
|
const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||||
|
return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`);
|
||||||
|
}
|
||||||
|
|
@ -14,18 +14,18 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P
|
||||||
const level = interaction.options.getInteger("level", true);
|
const level = interaction.options.getInteger("level", true);
|
||||||
const nation = interaction.options.getString("nation", true) as Nation;
|
const nation = interaction.options.getString("nation", true) as Nation;
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const added = addCharacter(usermapKey, { name: charName, class: cls, level, nation });
|
const added = addCharacter(userKey, { name: charName, class: cls, level, nation });
|
||||||
if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`);
|
if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`);
|
||||||
|
|
||||||
return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`);
|
return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly.");
|
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const requesterKey = targetArg ?? requester.usermapKey;
|
const requesterKey = targetArg ?? requester.userKey;
|
||||||
if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const char = getCharacterByName(ownerArg, charName);
|
const char = getCharacterByName(ownerArg, charName);
|
||||||
|
|
@ -42,7 +42,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
|
||||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
// Find the voter entry and update their character
|
// Find the voter entry and update their character
|
||||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
if (entry.usermapKey === requesterKey) {
|
if (entry.userKey === requesterKey) {
|
||||||
entry.characterName = char.name;
|
entry.characterName = char.name;
|
||||||
entry.characterClass = char.class;
|
entry.characterClass = char.class;
|
||||||
entry.characterLevel = char.level;
|
entry.characterLevel = char.level;
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,18 @@ export async function handleCharRemove(interaction: ChatInputCommandInteraction)
|
||||||
const nameArg = interaction.options.getString("name");
|
const nameArg = interaction.options.getString("name");
|
||||||
const charName = interaction.options.getString("char_name", true);
|
const charName = interaction.options.getString("char_name", true);
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const removed = removeCharacter(usermapKey, charName);
|
const removed = removeCharacter(userKey, charName);
|
||||||
if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||||
|
|
||||||
return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`);
|
return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`);
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ import path from "path";
|
||||||
|
|
||||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||||
|
|
||||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; charName: string } | null {
|
function findSharedChar(userKey: string, charName: string): { ownerKey: string; charName: string } | null {
|
||||||
try {
|
try {
|
||||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||||
if (ownerKey === usermapKey) continue;
|
if (ownerKey === userKey) continue;
|
||||||
const char = data.characters?.find(
|
const char = data.characters?.find(
|
||||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey)
|
||||||
);
|
);
|
||||||
if (char) return { ownerKey, charName: char.name };
|
if (char) return { ownerKey, charName: char.name };
|
||||||
}
|
}
|
||||||
|
|
@ -29,25 +29,25 @@ export async function handleCharSetActive(interaction: ChatInputCommandInteracti
|
||||||
const nameArg = interaction.options.getString("name");
|
const nameArg = interaction.options.getString("name");
|
||||||
const charName = interaction.options.getString("char_name", true);
|
const charName = interaction.options.getString("char_name", true);
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
// Try own characters first
|
// Try own characters first
|
||||||
const set = setActiveCharacter(usermapKey, charName);
|
const set = setActiveCharacter(userKey, charName);
|
||||||
if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`);
|
if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`);
|
||||||
|
|
||||||
// Fall back to shared characters
|
// Fall back to shared characters
|
||||||
const shared = findSharedChar(usermapKey, charName);
|
const shared = findSharedChar(userKey, charName);
|
||||||
if (shared) {
|
if (shared) {
|
||||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.charName);
|
setSessionBorrow(userKey, shared.ownerKey, shared.charName);
|
||||||
return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`);
|
return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,21 @@ export async function handleCharSetNation(interaction: ChatInputCommandInteracti
|
||||||
const nation = interaction.options.getString("nation", true) as Nation;
|
const nation = interaction.options.getString("nation", true) as Nation;
|
||||||
const charName = interaction.options.getString("char_name"); // optional, defaults to active
|
const charName = interaction.options.getString("char_name"); // optional, defaults to active
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
const targetName = charName ?? getActiveCharacter(userKey)?.name;
|
||||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||||
|
|
||||||
const set = setCharacterNation(usermapKey, targetName, nation);
|
const set = setCharacterNation(userKey, targetName, nation);
|
||||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||||
|
|
||||||
return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`);
|
return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`);
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,21 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
|
||||||
const def = interaction.options.getInteger("def") ?? undefined;
|
const def = interaction.options.getInteger("def") ?? undefined;
|
||||||
const heal = interaction.options.getInteger("heal") ?? undefined;
|
const heal = interaction.options.getInteger("heal") ?? undefined;
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
const targetName = charName ?? getActiveCharacter(userKey)?.name;
|
||||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||||
|
|
||||||
const set = setCharacterStats(usermapKey, targetName, { atk, def, heal });
|
const set = setCharacterStats(userKey, targetName, { atk, def, heal });
|
||||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||||
|
|
||||||
return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`);
|
return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getCharacterByName } from "../../systems/characters";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "../../utils";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { clearPersistentPreference } from "../../systems/borrow";
|
||||||
|
|
||||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters.");
|
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerKey = ownerArg ?? user.usermapKey;
|
const ownerKey = ownerArg ?? user.userKey;
|
||||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const char = getCharacterByName(ownerKey, charName);
|
const char = getCharacterByName(ownerKey, charName);
|
||||||
|
|
@ -48,7 +49,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
|
||||||
charEntry.sharedWith.push(targetKey);
|
charEntry.sharedWith.push(targetKey);
|
||||||
saveCharacters(raw);
|
saveCharacters(raw);
|
||||||
|
|
||||||
return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`);
|
return void replyAndDelete(interaction, `✅ **${charName}** is now permanently shared with **${targetKey}**.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
|
@ -64,7 +65,7 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares.");
|
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerKey = ownerArg ?? user.usermapKey;
|
const ownerKey = ownerArg ?? user.userKey;
|
||||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const raw = loadRawChars();
|
const raw = loadRawChars();
|
||||||
|
|
@ -78,5 +79,8 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction
|
||||||
charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey);
|
charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey);
|
||||||
saveCharacters(raw);
|
saveCharacters(raw);
|
||||||
|
|
||||||
|
// Clear persistent preference if the borrower was using this char
|
||||||
|
clearPersistentPreference(targetKey);
|
||||||
|
|
||||||
return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`);
|
return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`);
|
||||||
}
|
}
|
||||||
126
src/subcommands/impersonate.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import {
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
ButtonInteraction,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ActionRowBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
} from "discord.js";
|
||||||
|
import { cfg } from "@systems/config";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
|
||||||
|
import { replyAndDelete } from "@utils";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5; // users per page (1 button each + nav row)
|
||||||
|
|
||||||
|
function buildImpersonateEmbed(users: { userKey: string; aliases: string[] }[], page: number, currentImpersonation: string | null): EmbedBuilder {
|
||||||
|
const start = page * PAGE_SIZE;
|
||||||
|
const pageUsers = users.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
const lines = pageUsers.map((u, i) => {
|
||||||
|
const display = u.aliases[0] ?? u.userKey;
|
||||||
|
const current = u.userKey === currentImpersonation ? " ← *active*" : "";
|
||||||
|
return `${start + i + 1}. **${display}** (${u.userKey})${current}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("🎭 Impersonate User")
|
||||||
|
.setDescription(
|
||||||
|
(currentImpersonation
|
||||||
|
? `Currently impersonating: **${currentImpersonation}**\n\n`
|
||||||
|
: "Not impersonating anyone.\n\n") +
|
||||||
|
lines.join("\n")
|
||||||
|
)
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setFooter({ text: `Page ${page + 1} of ${Math.ceil(users.length / PAGE_SIZE)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImpersonateButtons(
|
||||||
|
users: { userKey: string; aliases: string[] }[],
|
||||||
|
page: number,
|
||||||
|
realDiscordId: string
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
const start = page * PAGE_SIZE;
|
||||||
|
const pageUsers = users.slice(start, start + PAGE_SIZE);
|
||||||
|
const hasNext = users.length > (page + 1) * PAGE_SIZE;
|
||||||
|
const hasPrev = page > 0;
|
||||||
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
// One button per user on this page
|
||||||
|
const userButtons = pageUsers.map((u) =>
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`impersonate_set:${u.userKey}:${page}`)
|
||||||
|
.setLabel(`${u.aliases[0] ?? u.userKey}`)
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
if (userButtons.length > 0) {
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...userButtons));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav + release row
|
||||||
|
const navBtns: ButtonBuilder[] = [];
|
||||||
|
if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary));
|
||||||
|
if (hasNext) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page + 1}`).setLabel("Next →").setStyle(ButtonStyle.Secondary));
|
||||||
|
navBtns.push(new ButtonBuilder().setCustomId("impersonate_release").setLabel("🚫 Release").setStyle(ButtonStyle.Danger));
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navBtns));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash command handler
|
||||||
|
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
if (!hasOfficerRole(member, cfg("officerRoles"))) {
|
||||||
|
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = getRegisteredUsers();
|
||||||
|
if (users.length === 0) return void replyAndDelete(interaction, "❌ No registered users found in usermap.json.");
|
||||||
|
|
||||||
|
const current = getImpersonation(interaction.user.id);
|
||||||
|
const embed = buildImpersonateEmbed(users, 0, current);
|
||||||
|
const buttons = buildImpersonateButtons(users, 0, interaction.user.id);
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], components: buttons, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button handler
|
||||||
|
export async function handleImpersonateButton(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
const { customId } = interaction;
|
||||||
|
const realId = interaction.user.id;
|
||||||
|
|
||||||
|
if (customId.startsWith("impersonate_set:")) {
|
||||||
|
const parts = customId.split(":");
|
||||||
|
const userKey = parts[1];
|
||||||
|
const page = parseInt(parts[2] ?? "0");
|
||||||
|
|
||||||
|
setImpersonation(realId, userKey);
|
||||||
|
const users = getRegisteredUsers();
|
||||||
|
const embed = buildImpersonateEmbed(users, page, userKey);
|
||||||
|
const buttons = buildImpersonateButtons(users, page, realId);
|
||||||
|
|
||||||
|
await interaction.update({ embeds: [embed], components: buttons });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customId.startsWith("impersonate_page:")) {
|
||||||
|
const page = parseInt(customId.split(":")[1]);
|
||||||
|
const current = getImpersonation(realId);
|
||||||
|
const users = getRegisteredUsers();
|
||||||
|
const embed = buildImpersonateEmbed(users, page, current);
|
||||||
|
const buttons = buildImpersonateButtons(users, page, realId);
|
||||||
|
|
||||||
|
await interaction.update({ embeds: [embed], components: buttons });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customId === "impersonate_release") {
|
||||||
|
clearImpersonation(realId);
|
||||||
|
const users = getRegisteredUsers();
|
||||||
|
const embed = buildImpersonateEmbed(users, 0, null);
|
||||||
|
const buttons = buildImpersonateButtons(users, 0, realId);
|
||||||
|
|
||||||
|
await interaction.update({ embeds: [embed], components: buttons });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
import { cfg } from "../../systems/config";
|
import { cfg } from "../../systems/config";
|
||||||
import { polls, updatePollMessage } from "../../systems/poll";
|
import { polls, updatePollMessage } from "../../systems/poll";
|
||||||
import { getActiveCharacter } from "../../systems/characters";
|
import { getEffectiveCharacter } from "../../systems/borrow";
|
||||||
import { resolveNation } from "../../systems/nations";
|
|
||||||
import { nowFormatted, resolveMessage } from "../../systems/messages";
|
import { nowFormatted, resolveMessage } from "../../systems/messages";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "../../utils";
|
||||||
import { VoteEntry } from "../../types";
|
import { VoteEntry } from "../../types";
|
||||||
|
|
||||||
export async function handleInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const usermapKey = interaction.options.getString("name", true);
|
const userKey = interaction.options.getString("name", true);
|
||||||
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
||||||
|
|
||||||
|
console.log("[inject] called");
|
||||||
const slot = [...polls.keys()][0];
|
const slot = [...polls.keys()][0];
|
||||||
|
console.log("[inject] slot:", slot);
|
||||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||||
|
|
||||||
const state = polls.get(slot)!;
|
const state = polls.get(slot)!;
|
||||||
|
|
@ -19,23 +20,24 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr
|
||||||
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
|
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const char = getActiveCharacter(usermapKey);
|
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||||
console.log(`[DEBUG inject] usermapKey=${usermapKey} char=${JSON.stringify(char)}`);
|
console.log(`[DEBUG inject] userKey=${userKey} char=${JSON.stringify(char)} borrowedFrom=${JSON.stringify(borrowedFrom)}`);
|
||||||
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${usermapKey}**.`);
|
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${userKey}**.`);
|
||||||
|
|
||||||
// Use a synthetic userId based on usermapKey to avoid collisions
|
// Use a synthetic userId based on userKey to avoid collisions
|
||||||
const syntheticId = `injected:${usermapKey}`;
|
const syntheticId = `injected:${userKey}`;
|
||||||
const now = nowFormatted();
|
const now = nowFormatted();
|
||||||
|
|
||||||
const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null);
|
const publicMsg = resolveMessage("public", voteType, 1, userKey, null, null);
|
||||||
|
|
||||||
const entry: VoteEntry = {
|
const entry: VoteEntry = {
|
||||||
usermapKey,
|
userKey,
|
||||||
displayName: char.name,
|
displayName: char.name,
|
||||||
characterName: char.name,
|
characterName: char.name,
|
||||||
characterClass: char.class,
|
characterClass: char.class,
|
||||||
characterLevel: char.level,
|
characterLevel: char.level,
|
||||||
characterNation: char.nation,
|
characterNation: char.nation,
|
||||||
|
borrowedFrom: borrowedFrom ?? undefined,
|
||||||
votedAt: now,
|
votedAt: now,
|
||||||
publicMessage: publicMsg ?? undefined,
|
publicMessage: publicMsg ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
@ -50,31 +52,31 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr
|
||||||
|
|
||||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
await updatePollMessage(channel, slot);
|
await updatePollMessage(channel, slot);
|
||||||
return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`);
|
return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const usermapKey = interaction.options.getString("name", true);
|
const userKey = interaction.options.getString("name", true);
|
||||||
|
|
||||||
const slot = [...polls.keys()][0];
|
const slot = [...polls.keys()][0];
|
||||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||||
|
|
||||||
const state = polls.get(slot)!;
|
const state = polls.get(slot)!;
|
||||||
const syntheticId = `injected:${usermapKey}`;
|
const syntheticId = `injected:${userKey}`;
|
||||||
|
|
||||||
// Also try removing real votes by scanning for usermapKey
|
// Also try removing real votes by scanning for userKey
|
||||||
let removed = false;
|
let removed = false;
|
||||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
if (entry.usermapKey === usermapKey || id === syntheticId) {
|
if (entry.userKey === userKey || id === syntheticId) {
|
||||||
state.yes.delete(id);
|
state.yes.delete(id);
|
||||||
state.no.delete(id);
|
state.no.delete(id);
|
||||||
removed = true;
|
removed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`);
|
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`);
|
||||||
|
|
||||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
await updatePollMessage(channel, slot);
|
await updatePollMessage(channel, slot);
|
||||||
return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`);
|
return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`);
|
||||||
}
|
}
|
||||||
|
|
@ -29,18 +29,18 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
for (const [discordUsername, entry] of Object.entries(usermap)) {
|
for (const [discordUsername, entry] of Object.entries(usermap)) {
|
||||||
const usermapKey = typeof entry === "string" ? entry : entry.file;
|
const userKey = typeof entry === "string" ? entry : entry.file;
|
||||||
const { char, borrowedFrom } = getEffectiveCharacter(usermapKey);
|
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||||
|
|
||||||
if (!char) { skipped++; continue; }
|
if (!char) { skipped++; continue; }
|
||||||
|
|
||||||
const syntheticId = `injected:${usermapKey}`;
|
const syntheticId = `injected:${userKey}`;
|
||||||
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; }
|
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; }
|
||||||
|
|
||||||
const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null);
|
const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null);
|
||||||
|
|
||||||
const voteEntry: VoteEntry = {
|
const voteEntry: VoteEntry = {
|
||||||
usermapKey,
|
userKey,
|
||||||
displayName: char.name,
|
displayName: char.name,
|
||||||
characterName: char.name,
|
characterName: char.name,
|
||||||
characterClass: char.class,
|
characterClass: char.class,
|
||||||
|
|
|
||||||
|
|
@ -13,29 +13,29 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks.");
|
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const week = getCurrentWeek();
|
const week = getCurrentWeek();
|
||||||
const goal = cfg("wRankGoal");
|
const goal = cfg("wRankGoal");
|
||||||
const weekKey = getWeekKey();
|
const weekKey = getWeekKey();
|
||||||
|
|
||||||
for (const nation of ["capella", "procyon"] as const) {
|
for (const nation of ["capella", "procyon"] as const) {
|
||||||
const entry = week.entries[nation].find((e) => e.usermapKey === usermapKey);
|
const entry = week.entries[nation].find((e) => e.userKey === userKey);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
const isDone = entry.tgCount >= goal;
|
const isDone = entry.tgCount >= goal;
|
||||||
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
|
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
|
||||||
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : "";
|
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : "";
|
||||||
const bringer = getBringer(entry.nation);
|
const bringer = getBringer(entry.nation);
|
||||||
const isBringer = bringer === usermapKey && isDone;
|
const isBringer = bringer === userKey && isDone;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`**${entry.characterName}** · ${entry.nation}`,
|
`**${entry.characterName}** · ${entry.nation}`,
|
||||||
|
|
@ -48,5 +48,5 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
|
||||||
return void replyAndDelete(interaction, lines);
|
return void replyAndDelete(interaction, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`);
|
return void replyAndDelete(interaction, `❌ No rank found for **${userKey}** this week.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
|
||||||
const isDone = e.tgCount >= goal;
|
const isDone = e.tgCount >= goal;
|
||||||
const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0;
|
const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0;
|
||||||
const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : "";
|
const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : "";
|
||||||
const bringerStr = bringer === e.usermapKey && isDone
|
const bringerStr = bringer === e.userKey && isDone
|
||||||
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
||||||
: "";
|
: "";
|
||||||
return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { cfg } from "../../systems/config";
|
||||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||||
import { loadResult, todayString } from "../../systems/history";
|
import { loadResult, todayString } from "../../systems/history";
|
||||||
|
import { getEmoji } from "../../systems/emojis";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "../../utils";
|
||||||
|
|
||||||
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
|
@ -12,41 +13,51 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
|
||||||
const slotArg = interaction.options.getString("slot");
|
const slotArg = interaction.options.getString("slot");
|
||||||
|
|
||||||
if (nameArg && !isOfficer) {
|
if (nameArg && !isOfficer) {
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.");
|
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.", true);
|
||||||
|
|
||||||
let slot: number | null = null;
|
let slot: number | null = null;
|
||||||
if (slotArg) {
|
if (slotArg) {
|
||||||
slot = normalizeSlot(slotArg);
|
slot = normalizeSlot(slotArg);
|
||||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true);
|
||||||
} else {
|
} else {
|
||||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = loadResult(todayString(), slot);
|
const result = loadResult(todayString(), slot);
|
||||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`);
|
if (!result) return void replyAndDelete(interaction, `❌ No result found for **${slot}:00** TG today.`, true);
|
||||||
|
|
||||||
const score = result.scores.find((s) => s.usermapKey === usermapKey);
|
// Find score — check both direct ownership and borrowed (playedBy)
|
||||||
if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${usermapKey}** in the ${slot}:00 TG.`);
|
const score = result.scores.find(
|
||||||
|
(s) => s.userKey === userKey || (s as any).playedBy === userKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${userKey}** in the **${slot}:00** TG.`, true);
|
||||||
|
|
||||||
|
const scoreEmoji = getEmoji("score") || "📊";
|
||||||
|
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||||
|
const playedBy = (score as any).playedBy && (score as any).playedBy !== score.userKey
|
||||||
|
? `\n*(played by ${(score as any).playedBy})*`
|
||||||
|
: "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`**${score.characterName}** (${score.class} · ${score.nation})`,
|
`**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`,
|
||||||
`**Points:** ${score.pts}`,
|
`${scoreEmoji} **${score.pts}** pts`,
|
||||||
score.atk !== undefined ? `**ATK:** ${score.atk}` : null,
|
score.atk !== undefined ? `ATK: ${score.atk}` : null,
|
||||||
score.def !== undefined ? `**DEF:** ${score.def}` : null,
|
score.def !== undefined ? `DEF: ${score.def}` : null,
|
||||||
score.heal !== undefined ? `**HEAL:** ${score.heal}` : null,
|
score.heal !== undefined ? `HEAL: ${score.heal}` : null,
|
||||||
`**Submitted:** ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}`,
|
`*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`,
|
||||||
].filter(Boolean).join("\n");
|
].filter(Boolean).join("\n");
|
||||||
|
|
||||||
return void replyAndDelete(interaction, lines);
|
return void replyAndDelete(interaction, lines, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1,57 +1,75 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { cfg } from "../../systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||||
import { submitScore, detectSlot, normalizeSlot } from "../../systems/scores";
|
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
||||||
import { getActiveCharacter } from "../../systems/characters";
|
import { getEffectiveCharacter } from "@systems/borrow";
|
||||||
import { resolveNation } from "../../systems/nations";
|
import { replyAndDelete } from "@utils";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { getEmoji } from "@systems/emojis";
|
||||||
|
|
||||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||||
|
const nameArg = interaction.options.getString("name");
|
||||||
const nameArg = interaction.options.getString("name");
|
const ptsArg = interaction.options.getInteger("pts", true);
|
||||||
const ptsArg = interaction.options.getInteger("pts", true);
|
const slotArg = interaction.options.getString("slot");
|
||||||
const slotArg = interaction.options.getString("slot");
|
const kills = interaction.options.getInteger("k") ?? undefined;
|
||||||
|
const deaths = interaction.options.getInteger("d") ?? undefined;
|
||||||
// Officers can specify a name, players cannot
|
const k = interaction.options.getInteger("k") ?? undefined;
|
||||||
let usermapKey: string | null;
|
const d = interaction.options.getInteger("d") ?? undefined;
|
||||||
let targetMember = member;
|
const atk = interaction.options.getInteger("atk") ?? undefined;
|
||||||
|
const def = interaction.options.getInteger("def") ?? undefined;
|
||||||
|
const heal = interaction.options.getInteger("heal") ?? undefined;
|
||||||
|
|
||||||
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players.");
|
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
const char = getActiveCharacter(usermapKey);
|
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||||
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
||||||
|
|
||||||
// Resolve slot
|
|
||||||
let slot: number | null = null;
|
let slot: number | null = null;
|
||||||
if (slotArg) {
|
if (slotArg) {
|
||||||
slot = normalizeSlot(slotArg);
|
slot = normalizeSlot(slotArg);
|
||||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||||
} else {
|
} else {
|
||||||
slot = detectSlot();
|
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||||
if (slot === null) {
|
|
||||||
return void replyAndDelete(interaction, "❌ No active score window detected. Specify a slot explicitly.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await submitScore({
|
await submitScore({
|
||||||
usermapKey,
|
userKey: borrowedFrom ?? userKey,
|
||||||
|
playedBy: borrowedFrom ? userKey : undefined,
|
||||||
characterName: char.name,
|
characterName: char.name,
|
||||||
cls: char.class,
|
cls: char.class,
|
||||||
nation: char.nation,
|
nation: char.nation,
|
||||||
pts: ptsArg,
|
pts: ptsArg,
|
||||||
|
k,
|
||||||
|
d,
|
||||||
slot,
|
slot,
|
||||||
|
atk,
|
||||||
|
def,
|
||||||
|
heal,
|
||||||
submittedByOfficer: isOfficer && !!nameArg,
|
submittedByOfficer: isOfficer && !!nameArg,
|
||||||
});
|
});
|
||||||
|
|
||||||
return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`);
|
const scoreEmoji = getEmoji("score") || "📊";
|
||||||
}
|
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||||
|
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
|
||||||
|
const kdNote = kills !== undefined && deaths !== undefined ? `\n${kdEmoji} ${kills}/${deaths}` : "";
|
||||||
|
const statsNote = [
|
||||||
|
atk !== undefined ? `ATK: ${atk}` : null,
|
||||||
|
def !== undefined ? `DEF: ${def}` : null,
|
||||||
|
heal !== undefined ? `HEAL: ${heal}` : null,
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
return void replyAndDelete(interaction,
|
||||||
|
`✅ ${scoreEmoji} **${ptsArg}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
import { cfg } from "../systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { resolveUser, hasOfficerRole } from "../systems/users";
|
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||||
import { setActiveCharacter, getActiveCharacter, getCharacterByName } from "../systems/characters";
|
import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
|
||||||
import { setSessionBorrow, getSessionBorrow } from "../systems/borrow";
|
import {
|
||||||
import { polls, updatePollMessage } from "../systems/poll";
|
getEffectiveCharacter,
|
||||||
import { replyAndDelete } from "../utils";
|
setSessionBorrow,
|
||||||
|
setPersistentPreference,
|
||||||
|
clearPersistentPreference,
|
||||||
|
clearSessionBorrowForUser,
|
||||||
|
} from "@systems/borrow";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
import { getClassEmoji } from "@systems/emojis";
|
||||||
|
import { replyAndDelete } from "@src/utils";
|
||||||
|
import { format } from "@format";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
||||||
|
|
||||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; char: any } | null {
|
function findSharedChar(userKey: string, charName: string): { ownerKey: string; char: any } | null {
|
||||||
try {
|
try {
|
||||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||||
if (ownerKey === usermapKey) continue;
|
if (ownerKey === userKey) continue;
|
||||||
const char = data.characters?.find(
|
const char = data.characters?.find(
|
||||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey)
|
||||||
);
|
);
|
||||||
if (char) return { ownerKey, char };
|
if (char) return { ownerKey, char };
|
||||||
}
|
}
|
||||||
|
|
@ -24,10 +32,9 @@ function findSharedChar(usermapKey: string, charName: string): { ownerKey: strin
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse-lookup: find Discord userId for a usermapKey from current poll voters
|
function findVoteIdInPoll(state: any, userKey: string): string | null {
|
||||||
function findUserIdInPoll(state: any, usermapKey: string): string | null {
|
|
||||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
if (entry.usermapKey === usermapKey) return id;
|
if (entry.userKey === userKey) return id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -38,49 +45,85 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
|
||||||
const nameArg = interaction.options.getString("name");
|
const nameArg = interaction.options.getString("name");
|
||||||
const charName = interaction.options.getString("char_name", true);
|
const charName = interaction.options.getString("char_name", true);
|
||||||
|
|
||||||
let usermapKey: string | null;
|
let userKey: string | null;
|
||||||
if (nameArg) {
|
if (nameArg) {
|
||||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters.");
|
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters.");
|
||||||
usermapKey = nameArg;
|
userKey = nameArg;
|
||||||
} else {
|
} else {
|
||||||
const user = await resolveUser(member);
|
const user = await resolveUser(member);
|
||||||
usermapKey = user.usermapKey;
|
userKey = user.userKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||||
|
|
||||||
|
// Resolve the target character without switching yet
|
||||||
let resolvedChar: any = null;
|
let resolvedChar: any = null;
|
||||||
let borrowedFrom: string | null = null;
|
let borrowedFrom: string | null = null;
|
||||||
|
|
||||||
// Try own characters first
|
const ownChar = getCharacterByName(userKey, charName);
|
||||||
const set = setActiveCharacter(usermapKey, charName);
|
if (ownChar) {
|
||||||
if (set) {
|
resolvedChar = ownChar;
|
||||||
resolvedChar = getActiveCharacter(usermapKey);
|
|
||||||
} else {
|
} else {
|
||||||
// Fall back to shared characters
|
const shared = findSharedChar(userKey, charName);
|
||||||
const shared = findSharedChar(usermapKey, charName);
|
|
||||||
if (shared) {
|
if (shared) {
|
||||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.char.name);
|
resolvedChar = shared.char;
|
||||||
resolvedChar = shared.char;
|
borrowedFrom = shared.ownerKey;
|
||||||
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.`);
|
if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||||
|
|
||||||
// Update poll embed if user has voted
|
// If already active — just show current state without switching
|
||||||
|
const current = getEffectiveCharacter(userKey);
|
||||||
|
if (current.char?.name === resolvedChar.name) {
|
||||||
|
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
|
||||||
|
const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : "";
|
||||||
|
return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target character is already in the active poll by another player
|
||||||
const slot = [...polls.keys()][0];
|
const slot = [...polls.keys()][0];
|
||||||
if (slot !== undefined) {
|
if (slot !== undefined) {
|
||||||
const state = polls.get(slot)!;
|
const state = polls.get(slot)!;
|
||||||
const userId = nameArg
|
for (const [id, entry] of state.yes.entries()) {
|
||||||
? findUserIdInPoll(state, usermapKey)
|
const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`;
|
||||||
: interaction.user.id;
|
if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) {
|
||||||
|
const slotHour = state.slot;
|
||||||
|
const charDisplay = format.char(resolvedChar);
|
||||||
|
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
|
||||||
|
if (isOwner) {
|
||||||
|
return void replyAndDelete(interaction,
|
||||||
|
`⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return void replyAndDelete(interaction,
|
||||||
|
`❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (userId && (state.yes.has(userId) || state.no.has(userId))) {
|
// Now actually switch
|
||||||
|
if (borrowedFrom) {
|
||||||
|
setSessionBorrow(userKey, borrowedFrom, resolvedChar.name);
|
||||||
|
setPersistentPreference(userKey, borrowedFrom, resolvedChar.name);
|
||||||
|
} else {
|
||||||
|
setActiveCharacter(userKey, charName);
|
||||||
|
clearPersistentPreference(userKey);
|
||||||
|
clearSessionBorrowForUser(userKey);
|
||||||
|
resolvedChar = getActiveCharacter(userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update poll embed if user has already voted
|
||||||
|
if (slot !== undefined) {
|
||||||
|
const state = polls.get(slot)!;
|
||||||
|
const voteId = findVoteIdInPoll(state, userKey);
|
||||||
|
|
||||||
|
if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) {
|
||||||
const updateEntry = (map: Map<string, any>) => {
|
const updateEntry = (map: Map<string, any>) => {
|
||||||
const entry = map.get(userId);
|
const entry = map.get(voteId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.characterName = resolvedChar.name;
|
entry.characterName = resolvedChar.name;
|
||||||
entry.characterClass = resolvedChar.class;
|
entry.characterClass = resolvedChar.class;
|
||||||
|
|
@ -97,6 +140,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : "";
|
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
|
||||||
return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`);
|
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||||
|
return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,44 @@
|
||||||
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import { BorrowRequest } from "../types";
|
import { BorrowRequest } from "../types";
|
||||||
import { cfg } from "./config";
|
import { cfg } from "./config";
|
||||||
import { getCharacterByName } from "./characters";
|
import { getCharacterByName } from "./characters";
|
||||||
|
|
||||||
// Active borrow requests (pending accept/decline)
|
const PREFS_PATH = path.join(__dirname, "../../data/sessionPreferences.json");
|
||||||
const pendingRequests: Map<string, BorrowRequest> = new Map(); // key: `${ownerKey}:${requesterKey}`
|
|
||||||
|
|
||||||
// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start
|
// ─── Persistent preferences ───────────────────────────────────────────────────
|
||||||
|
let _prefs: Record<string, { ownerKey: string; charName: string }> = {};
|
||||||
|
|
||||||
|
function loadPrefs(): void {
|
||||||
|
try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); }
|
||||||
|
catch { _prefs = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(): void {
|
||||||
|
try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); }
|
||||||
|
catch (err) { console.error("Failed to save sessionPreferences.json:", err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrefs();
|
||||||
|
|
||||||
|
export function setPersistentPreference(userKey: string, ownerKey: string, charName: string): void {
|
||||||
|
_prefs[userKey] = { ownerKey, charName };
|
||||||
|
savePrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPersistentPreference(userKey: string): void {
|
||||||
|
delete _prefs[userKey];
|
||||||
|
savePrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistentPreference(userKey: string): { ownerKey: string; charName: string } | null {
|
||||||
|
return _prefs[userKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active borrow requests ───────────────────────────────────────────────────
|
||||||
|
const pendingRequests: Map<string, BorrowRequest> = new Map();
|
||||||
const sessionBorrows: Map<string, { ownerKey: string; charName: string }> = new Map();
|
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();
|
const borrowDmMessages: Map<string, { channelId: string; messageId: string }> = new Map();
|
||||||
|
|
||||||
function requestKey(ownerKey: string, requesterKey: string): string {
|
function requestKey(ownerKey: string, requesterKey: string): string {
|
||||||
|
|
@ -31,9 +60,7 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
|
||||||
export function addPendingRequest(request: BorrowRequest): void {
|
export function addPendingRequest(request: BorrowRequest): void {
|
||||||
const key = requestKey(request.ownerKey, request.requesterKey);
|
const key = requestKey(request.ownerKey, request.requesterKey);
|
||||||
const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
|
const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
|
||||||
|
|
||||||
pendingRequests.set(key, request);
|
pendingRequests.set(key, request);
|
||||||
|
|
||||||
if (expiry > 0) {
|
if (expiry > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pendingRequests.get(key)?.requestedAt === request.requestedAt) {
|
if (pendingRequests.get(key)?.requestedAt === request.requestedAt) {
|
||||||
|
|
@ -56,7 +83,7 @@ export function getDmMessage(ownerKey: string, requesterKey: string): { channelI
|
||||||
return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null;
|
return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session borrow management
|
// ─── Session borrows ──────────────────────────────────────────────────────────
|
||||||
export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void {
|
export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void {
|
||||||
sessionBorrows.set(requesterKey, { ownerKey, charName });
|
sessionBorrows.set(requesterKey, { ownerKey, charName });
|
||||||
}
|
}
|
||||||
|
|
@ -70,22 +97,20 @@ export function clearSessionBorrows(): void {
|
||||||
borrowDmMessages.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 {
|
export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean {
|
||||||
if (requesterKey === ownerKey) return true;
|
if (requesterKey === ownerKey) return true;
|
||||||
|
|
||||||
// Check persistent share
|
|
||||||
const char = getCharacterByName(ownerKey, charName);
|
const char = getCharacterByName(ownerKey, charName);
|
||||||
if (char?.sharedWith?.includes(requesterKey)) return true;
|
if (char?.sharedWith?.includes(requesterKey)) return true;
|
||||||
|
|
||||||
// Check session borrow
|
|
||||||
const borrow = getSessionBorrow(requesterKey);
|
const borrow = getSessionBorrow(requesterKey);
|
||||||
if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
|
if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send borrow request DM to owner, fall back to poll channel ephemeral
|
export function clearSessionBorrowForUser(userKey: string): void {
|
||||||
|
sessionBorrows.delete(userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DM notifications ─────────────────────────────────────────────────────────
|
||||||
export async function sendBorrowRequestDM(
|
export async function sendBorrowRequestDM(
|
||||||
client: Client,
|
client: Client,
|
||||||
ownerDiscordId: string,
|
ownerDiscordId: string,
|
||||||
|
|
@ -117,7 +142,6 @@ export async function sendBorrowRequestDM(
|
||||||
const msg = await dm.send({ content, components: [row] });
|
const msg = await dm.send({ content, components: [row] });
|
||||||
storeDmMessage(ownerKey, requesterKey, dm.id, msg.id);
|
storeDmMessage(ownerKey, requesterKey, dm.id, msg.id);
|
||||||
} catch {
|
} catch {
|
||||||
// DM failed — fall back to poll channel ephemeral
|
|
||||||
if (fallbackChannel) {
|
if (fallbackChannel) {
|
||||||
await fallbackChannel.send({
|
await fallbackChannel.send({
|
||||||
content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`,
|
content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`,
|
||||||
|
|
@ -126,7 +150,6 @@ export async function sendBorrowRequestDM(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update DM after accept/decline to disable buttons
|
|
||||||
export async function updateBorrowDM(
|
export async function updateBorrowDM(
|
||||||
client: Client,
|
client: Client,
|
||||||
ownerKey: string,
|
ownerKey: string,
|
||||||
|
|
@ -140,19 +163,30 @@ export async function updateBorrowDM(
|
||||||
const message = await channel.messages.fetch(dm.messageId);
|
const message = await channel.messages.fetch(dm.messageId);
|
||||||
const status = accepted ? "✅ Accepted" : "❌ Declined";
|
const status = accepted ? "✅ Accepted" : "❌ Declined";
|
||||||
await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] });
|
await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] });
|
||||||
} catch {
|
} catch {}
|
||||||
// DM may have been deleted, ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the effective active character for a user — session borrow takes priority over own active char
|
// ─── Effective character resolution ──────────────────────────────────────────
|
||||||
export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } {
|
export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } {
|
||||||
const { getActiveCharacter, getCharacterByName } = require("./characters");
|
const { getActiveCharacter, getCharacterByName: getChar } = require("./characters");
|
||||||
const borrow = getSessionBorrow(usermapKey);
|
|
||||||
|
// 1. Session borrow (temporary, resets on poll start)
|
||||||
|
const borrow = getSessionBorrow(userKey);
|
||||||
if (borrow) {
|
if (borrow) {
|
||||||
const char = getCharacterByName(borrow.ownerKey, borrow.charName);
|
const char = getChar(borrow.ownerKey, borrow.charName);
|
||||||
if (char) return { char, borrowedFrom: borrow.ownerKey };
|
if (char) return { char, borrowedFrom: borrow.ownerKey };
|
||||||
}
|
}
|
||||||
const char = getActiveCharacter(usermapKey);
|
|
||||||
|
// 2. Persistent preference (survives restarts and poll resets)
|
||||||
|
const pref = getPersistentPreference(userKey);
|
||||||
|
console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`);
|
||||||
|
if (pref) {
|
||||||
|
const char = getChar(pref.ownerKey, pref.charName);
|
||||||
|
if (char) return { char, borrowedFrom: pref.ownerKey };
|
||||||
|
clearPersistentPreference(userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Own active character
|
||||||
|
const char = getActiveCharacter(userKey);
|
||||||
return { char: char ?? null, borrowedFrom: null };
|
return { char: char ?? null, borrowedFrom: null };
|
||||||
}
|
}
|
||||||
|
|
@ -23,52 +23,52 @@ function saveAccounts(): void {
|
||||||
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2));
|
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCharacters(usermapKey: string): Character[] {
|
export function getCharacters(userKey: string): Character[] {
|
||||||
return _chars[usermapKey]?.characters ?? [];
|
return _chars[userKey]?.characters ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveCharacter(usermapKey: string): Character | null {
|
export function getActiveCharacter(userKey: string): Character | null {
|
||||||
return getCharacters(usermapKey).find((c) => c.active) ?? null;
|
return getCharacters(userKey).find((c) => c.active) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCharacterByName(usermapKey: string, name: string): Character | null {
|
export function getCharacterByName(userKey: string, name: string): Character | null {
|
||||||
return getCharacters(usermapKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
|
return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null {
|
export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null {
|
||||||
// Returns the active character of that class, or first found
|
// Returns the active character of that class, or first found
|
||||||
const chars = getCharacters(usermapKey).filter((c) => c.class === cls);
|
const chars = getCharacters(userKey).filter((c) => c.class === cls);
|
||||||
return chars.find((c) => c.active) ?? chars[0] ?? null;
|
return chars.find((c) => c.active) ?? chars[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addCharacter(usermapKey: string, char: Omit<Character, "active">): boolean {
|
export function addCharacter(userKey: string, char: Omit<Character, "active">): boolean {
|
||||||
if (!_chars[usermapKey]) _chars[usermapKey] = { characters: [] };
|
if (!_chars[userKey]) _chars[userKey] = { characters: [] };
|
||||||
const exists = _chars[usermapKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
|
const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
|
||||||
if (exists) return false;
|
if (exists) return false;
|
||||||
// If no active character, set this one as active
|
// If no active character, set this one as active
|
||||||
const hasActive = _chars[usermapKey].characters.some((c) => c.active);
|
const hasActive = _chars[userKey].characters.some((c) => c.active);
|
||||||
_chars[usermapKey].characters.push({ ...char, active: !hasActive });
|
_chars[userKey].characters.push({ ...char, active: !hasActive });
|
||||||
saveCharacters();
|
saveCharacters();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCharacter(usermapKey: string, name: string): boolean {
|
export function removeCharacter(userKey: string, name: string): boolean {
|
||||||
if (!_chars[usermapKey]) return false;
|
if (!_chars[userKey]) return false;
|
||||||
const before = _chars[usermapKey].characters.length;
|
const before = _chars[userKey].characters.length;
|
||||||
_chars[usermapKey].characters = _chars[usermapKey].characters.filter(
|
_chars[userKey].characters = _chars[userKey].characters.filter(
|
||||||
(c) => c.name.toLowerCase() !== name.toLowerCase()
|
(c) => c.name.toLowerCase() !== name.toLowerCase()
|
||||||
);
|
);
|
||||||
if (_chars[usermapKey].characters.length === before) return false;
|
if (_chars[userKey].characters.length === before) return false;
|
||||||
// If we removed the active one, set the first remaining as active
|
// 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) {
|
if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) {
|
||||||
_chars[usermapKey].characters[0].active = true;
|
_chars[userKey].characters[0].active = true;
|
||||||
}
|
}
|
||||||
saveCharacters();
|
saveCharacters();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setActiveCharacter(usermapKey: string, name: string): boolean {
|
export function setActiveCharacter(userKey: string, name: string): boolean {
|
||||||
const chars = _chars[usermapKey]?.characters;
|
const chars = _chars[userKey]?.characters;
|
||||||
if (!chars) return false;
|
if (!chars) return false;
|
||||||
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
|
|
@ -78,8 +78,8 @@ export function setActiveCharacter(usermapKey: string, name: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean {
|
export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean {
|
||||||
const char = getCharacterByName(usermapKey, name);
|
const char = getCharacterByName(userKey, name);
|
||||||
if (!char) return false;
|
if (!char) return false;
|
||||||
char.nation = nation;
|
char.nation = nation;
|
||||||
saveCharacters();
|
saveCharacters();
|
||||||
|
|
@ -87,11 +87,11 @@ export function setCharacterNation(usermapKey: string, name: string, nation: Nat
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCharacterStats(
|
export function setCharacterStats(
|
||||||
usermapKey: string,
|
userKey: string,
|
||||||
name: string,
|
name: string,
|
||||||
stats: { atk?: number; def?: number; heal?: number }
|
stats: { atk?: number; def?: number; heal?: number }
|
||||||
): boolean {
|
): boolean {
|
||||||
const char = getCharacterByName(usermapKey, name);
|
const char = getCharacterByName(userKey, name);
|
||||||
if (!char) return false;
|
if (!char) return false;
|
||||||
if (!char.stats) char.stats = {};
|
if (!char.stats) char.stats = {};
|
||||||
Object.assign(char.stats, stats);
|
Object.assign(char.stats, stats);
|
||||||
|
|
@ -100,12 +100,12 @@ export function setCharacterStats(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Account data ─────────────────────────────────────────────────────────────
|
// ─── Account data ─────────────────────────────────────────────────────────────
|
||||||
export function getAccountData(usermapKey: string): AccountData {
|
export function getAccountData(userKey: string): AccountData {
|
||||||
return _accounts[usermapKey] ?? {};
|
return _accounts[userKey] ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAccountData(usermapKey: string, data: Partial<AccountData>): void {
|
export function setAccountData(userKey: string, data: Partial<AccountData>): void {
|
||||||
if (!_accounts[usermapKey]) _accounts[usermapKey] = {};
|
if (!_accounts[userKey]) _accounts[userKey] = {};
|
||||||
Object.assign(_accounts[usermapKey], data);
|
Object.assign(_accounts[userKey], data);
|
||||||
saveAccounts();
|
saveAccounts();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function getDefaults(): Required<BotConfig> {
|
||||||
showNationTotalsInHeader: false,
|
showNationTotalsInHeader: false,
|
||||||
showNoInNationField: false,
|
showNoInNationField: false,
|
||||||
borrowRequestExpiryMs: 0, // 0 = never expire
|
borrowRequestExpiryMs: 0, // 0 = never expire
|
||||||
|
conflictReclaimBehavior: "revert",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
311
src/systems/conflict.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
import {
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ActionRowBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
TextChannel,
|
||||||
|
} from "discord.js";
|
||||||
|
import { cfg } from "@systems/config";
|
||||||
|
import { getCharacters, setActiveCharacter } from "@systems/characters";
|
||||||
|
import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
|
||||||
|
import { getImpersonation } from "@systems/impersonate";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
|
import { getClassEmoji } from "@systems/emojis";
|
||||||
|
import { format } from "@systems/format";
|
||||||
|
import { Character } from "@types";
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
||||||
|
const SWITCH_STYLE = ButtonStyle.Secondary;
|
||||||
|
const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
|
||||||
|
const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false";
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
const pendingConflicts = new Map<string, {
|
||||||
|
ownerKey: string;
|
||||||
|
borrowerKey: string;
|
||||||
|
charName: string;
|
||||||
|
ownerId: string;
|
||||||
|
page: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
|
||||||
|
const emojiStr = getClassEmoji(char.class);
|
||||||
|
const emoji = format.emoji(emojiStr);
|
||||||
|
btn.setLabel(`${char.level} ${char.name}`);
|
||||||
|
if (emoji) btn.setEmoji(emoji as any);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConflictEmbed(borrowerKey: string, char: Character): EmbedBuilder {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("⚠️ Character Conflict")
|
||||||
|
.setDescription(
|
||||||
|
`**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.`
|
||||||
|
)
|
||||||
|
.setColor(0xe8a317);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConflictButtons(
|
||||||
|
ownerKey: string,
|
||||||
|
borrowerKey: string,
|
||||||
|
borrowedCharName: string,
|
||||||
|
ownerId: string,
|
||||||
|
allChars: Character[],
|
||||||
|
page: number
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
const PAGE_SIZE = 4;
|
||||||
|
const otherChars = allChars.filter((c) => c.name !== borrowedCharName);
|
||||||
|
const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
const hasMore = otherChars.length > (page + 1) * PAGE_SIZE;
|
||||||
|
const hasPrev = page > 0;
|
||||||
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
// Row 1: Reclaim button
|
||||||
|
const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`;
|
||||||
|
pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
||||||
|
const borrowed = allChars.find((c) => c.name === borrowedCharName);
|
||||||
|
const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE);
|
||||||
|
if (borrowed) {
|
||||||
|
reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`);
|
||||||
|
const emojiStr = getClassEmoji(borrowed.class);
|
||||||
|
const emoji = format.emoji(emojiStr);
|
||||||
|
if (emoji) reclaimBtn.setEmoji(emoji as any);
|
||||||
|
} else {
|
||||||
|
reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`);
|
||||||
|
}
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(reclaimBtn));
|
||||||
|
|
||||||
|
// Row 2: Switch buttons
|
||||||
|
const charButtons = pageChars.map((char) => {
|
||||||
|
const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`;
|
||||||
|
pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
||||||
|
return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char);
|
||||||
|
});
|
||||||
|
if (charButtons.length > 0) {
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...charButtons));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 3: Pagination
|
||||||
|
const navButtons: ButtonBuilder[] = [];
|
||||||
|
if (hasPrev) {
|
||||||
|
const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`;
|
||||||
|
pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 });
|
||||||
|
navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary));
|
||||||
|
}
|
||||||
|
if (hasMore) {
|
||||||
|
const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`;
|
||||||
|
pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 });
|
||||||
|
navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary));
|
||||||
|
}
|
||||||
|
if (navButtons.length > 0) {
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navButtons));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
export async function showConflictEmbed(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
ownerKey: string,
|
||||||
|
borrowerKey: string,
|
||||||
|
borrowedChar: Character,
|
||||||
|
allOwnerChars: Character[]
|
||||||
|
): Promise<void> {
|
||||||
|
const embed = buildConflictEmbed(borrowerKey, borrowedChar);
|
||||||
|
const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0);
|
||||||
|
await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
const { customId } = interaction;
|
||||||
|
|
||||||
|
// ── Pagination ──────────────────────────────────────────────────────────────
|
||||||
|
if (customId.startsWith("conflict_page:")) {
|
||||||
|
const parts = customId.split(":");
|
||||||
|
const ownerKey = parts[1];
|
||||||
|
const borrowerKey = parts[2];
|
||||||
|
const charName = parts[3];
|
||||||
|
const ownerId = parts[4];
|
||||||
|
const page = parseInt(parts[5]);
|
||||||
|
|
||||||
|
const allChars = getCharacters(ownerKey);
|
||||||
|
const borrowed = allChars.find((c) => c.name === charName);
|
||||||
|
if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true });
|
||||||
|
|
||||||
|
const embed = buildConflictEmbed(borrowerKey, borrowed);
|
||||||
|
const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page);
|
||||||
|
await interaction.update({ embeds: [embed], components: buttons });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch to another char ──────────────────────────────────────────────────
|
||||||
|
if (customId.startsWith("conflict_switch:")) {
|
||||||
|
const parts = customId.split(":");
|
||||||
|
const ownerKey = parts[1];
|
||||||
|
const borrowerKey = parts[2];
|
||||||
|
const newCharName = parts[3];
|
||||||
|
const ownerId = parts[4];
|
||||||
|
|
||||||
|
setActiveCharacter(ownerKey, newCharName);
|
||||||
|
clearSessionBorrowForUser(ownerKey);
|
||||||
|
|
||||||
|
const impersonating = getImpersonation(ownerId);
|
||||||
|
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
||||||
|
const slot = [...polls.keys()][0];
|
||||||
|
const state = slot !== undefined ? polls.get(slot) : null;
|
||||||
|
|
||||||
|
if (state && AUTO_VOTE_ON_SWITCH) {
|
||||||
|
const guild = interaction.guild!;
|
||||||
|
const member = await guild.members.fetch(ownerId);
|
||||||
|
const { char } = getEffectiveCharacter(ownerKey);
|
||||||
|
const now = nowFormatted();
|
||||||
|
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
||||||
|
|
||||||
|
state.yes.set(voteId, {
|
||||||
|
userKey: ownerKey,
|
||||||
|
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
||||||
|
characterName: char?.name,
|
||||||
|
characterClass: char?.class,
|
||||||
|
characterLevel: char?.level,
|
||||||
|
characterNation: char?.nation,
|
||||||
|
votedAt: now,
|
||||||
|
publicMessage: publicMsg ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
|
await updatePollMessage(channel, slot!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName);
|
||||||
|
const charDisplay = newChar ? format.char(newChar) : newCharName;
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [new EmbedBuilder()
|
||||||
|
.setTitle("🔄 Switched")
|
||||||
|
.setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`)
|
||||||
|
.setColor(0x57f287)],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reclaim ─────────────────────────────────────────────────────────────────
|
||||||
|
if (customId.startsWith("conflict_reclaim:")) {
|
||||||
|
const parts = customId.split(":");
|
||||||
|
const ownerKey = parts[1];
|
||||||
|
const borrowerKey = parts[2];
|
||||||
|
const charName = parts[3];
|
||||||
|
const ownerId = parts[4];
|
||||||
|
|
||||||
|
const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert";
|
||||||
|
const slot = [...polls.keys()][0];
|
||||||
|
const state = slot !== undefined ? polls.get(slot) : null;
|
||||||
|
|
||||||
|
let borrowerDiscordId: string | undefined;
|
||||||
|
let borrowerVoteType: "yes" | "no" = "yes"; // default to yes
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
|
const isYes = state.yes.has(id);
|
||||||
|
if (entry.userKey === borrowerKey) {
|
||||||
|
borrowerVoteType = isYes ? "yes" : "no";
|
||||||
|
// Capture borrower's Discord ID for notification
|
||||||
|
borrowerDiscordId = (entry as any).discordId;
|
||||||
|
|
||||||
|
if (reclaimBehavior === "remove") {
|
||||||
|
state.yes.delete(id);
|
||||||
|
state.no.delete(id);
|
||||||
|
} else {
|
||||||
|
// Clear borrow so getEffectiveCharacter returns own char
|
||||||
|
clearSessionBorrowForUser(borrowerKey);
|
||||||
|
clearPersistentPreference(borrowerKey);
|
||||||
|
const { char: ownChar } = getEffectiveCharacter(borrowerKey);
|
||||||
|
if (ownChar) {
|
||||||
|
entry.characterName = ownChar.name;
|
||||||
|
entry.characterClass = ownChar.class;
|
||||||
|
entry.characterLevel = ownChar.level;
|
||||||
|
entry.characterNation = ownChar.nation;
|
||||||
|
entry.borrowedFrom = undefined;
|
||||||
|
} else {
|
||||||
|
state.yes.delete(id);
|
||||||
|
state.no.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner joins with their reclaimed char
|
||||||
|
const guild = interaction.guild!;
|
||||||
|
const member = await guild.members.fetch(ownerId);
|
||||||
|
const impersonating = getImpersonation(ownerId);
|
||||||
|
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
||||||
|
|
||||||
|
setActiveCharacter(ownerKey, charName);
|
||||||
|
clearSessionBorrowForUser(ownerKey);
|
||||||
|
const { char } = getEffectiveCharacter(ownerKey);
|
||||||
|
const now = nowFormatted();
|
||||||
|
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
||||||
|
|
||||||
|
state.yes.set(voteId, {
|
||||||
|
userKey: ownerKey,
|
||||||
|
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
||||||
|
characterName: char?.name,
|
||||||
|
characterClass: char?.class,
|
||||||
|
characterLevel: char?.level,
|
||||||
|
characterNation: char?.nation,
|
||||||
|
votedAt: now,
|
||||||
|
publicMessage: publicMsg ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
|
await updatePollMessage(channel, slot!);
|
||||||
|
|
||||||
|
// Notify borrower if enabled and we have their Discord ID
|
||||||
|
if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) {
|
||||||
|
try {
|
||||||
|
const borrowerMember = await guild.members.fetch(borrowerDiscordId);
|
||||||
|
const borrowerChars = getCharacters(borrowerKey);
|
||||||
|
|
||||||
|
if (borrowerChars.length > 0) {
|
||||||
|
const btns = borrowerChars.slice(0, 5).map((c) =>
|
||||||
|
applyCharToButton(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`switch_after_reclaim:${borrowerKey}:${c.name}:${borrowerVoteType}`)
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await borrowerMember.send({
|
||||||
|
content: `⚠️ **${format.char({ class: char?.class ?? "FB" as any, level: char?.level ?? 0, name: charName })}** was reclaimed by **${ownerKey}**. Pick another character:`,
|
||||||
|
components: [new ActionRowBuilder<ButtonBuilder>().addComponents(...btns)],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await borrowerMember.send({
|
||||||
|
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DM may be disabled — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const borrowed = getCharacters(ownerKey).find((c) => c.name === charName);
|
||||||
|
const charDisplay = borrowed ? format.char(borrowed) : charName;
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [new EmbedBuilder()
|
||||||
|
.setTitle("↩️ Reclaimed")
|
||||||
|
.setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`)
|
||||||
|
.setColor(0x57f287)],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/systems/format.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { ClassKey, Nation } from "@src/types";
|
||||||
|
import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis";
|
||||||
|
|
||||||
|
// ─── Individual formatters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CharDisplayOptions {
|
||||||
|
emoji?: boolean; // show class emoji (default: true)
|
||||||
|
level?: boolean; // show level (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a character for display in embeds and messages.
|
||||||
|
* Output: <:class:> 79 «Flash»
|
||||||
|
*/
|
||||||
|
function char(
|
||||||
|
c: { class: ClassKey; level: number; name: string },
|
||||||
|
options?: CharDisplayOptions
|
||||||
|
): string {
|
||||||
|
const showEmoji = options?.emoji ?? true;
|
||||||
|
const showLevel = options?.level ?? true;
|
||||||
|
const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class;
|
||||||
|
const levelStr = showLevel ? `${c.level} ` : "";
|
||||||
|
return `${classStr} ${levelStr}${c.name}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a nation name with its emoji.
|
||||||
|
* Output: <:capella:> Capella
|
||||||
|
*/
|
||||||
|
function nation(n: Nation): string {
|
||||||
|
const emoji = getNationEmoji(n);
|
||||||
|
return emoji ? `${emoji} ${n}` : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a score line.
|
||||||
|
* Output: <:score:> 3000 <:kd:> 32/18
|
||||||
|
*/
|
||||||
|
function score(pts: number, k?: number, d?: number): string {
|
||||||
|
const scoreEmoji = getEmoji("score") || "📊";
|
||||||
|
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||||
|
const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : "";
|
||||||
|
return `${scoreEmoji} ${pts}${kdStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Discord custom emoji string to an object for ButtonBuilder.setEmoji()
|
||||||
|
* Input: "<:fb:1511020923510194428>"
|
||||||
|
* Output: { name: "fb", id: "1511020923510194428" }
|
||||||
|
*/
|
||||||
|
function emoji(emojiStr: string): { name: string; id: string } | string | null {
|
||||||
|
if (!emojiStr) return null;
|
||||||
|
const match = emojiStr.match(/^<:(\w+):(\d+)>$/);
|
||||||
|
if (match) return { name: match[1], id: match[2] };
|
||||||
|
return emojiStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const format = {
|
||||||
|
char,
|
||||||
|
nation,
|
||||||
|
score,
|
||||||
|
emoji,
|
||||||
|
};
|
||||||
|
|
@ -42,7 +42,7 @@ export function upsertScore(score: TGScore): void {
|
||||||
|
|
||||||
// Overwrite existing score for this player+slot
|
// Overwrite existing score for this player+slot
|
||||||
result.scores = result.scores.filter(
|
result.scores = result.scores.filter(
|
||||||
(s) => !(s.usermapKey === score.usermapKey && s.slot === score.slot && s.date === score.date)
|
(s) => !(s.userKey === score.userKey && s.slot === score.slot && s.date === score.date)
|
||||||
);
|
);
|
||||||
result.scores.push(score);
|
result.scores.push(score);
|
||||||
saveResult(result);
|
saveResult(result);
|
||||||
|
|
|
||||||
44
src/systems/impersonate.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
|
||||||
|
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
|
||||||
|
|
||||||
|
// realDiscordId → userKey being impersonated
|
||||||
|
const impersonations = new Map<string, string>();
|
||||||
|
|
||||||
|
export function setImpersonation(realDiscordId: string, userKey: string): void {
|
||||||
|
impersonations.set(realDiscordId, userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearImpersonation(realDiscordId: string): void {
|
||||||
|
impersonations.delete(realDiscordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImpersonation(realDiscordId: string): string | null {
|
||||||
|
return impersonations.get(realDiscordId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllImpersonations(): void {
|
||||||
|
if (IMPERSONATE_RESET_ON_POLL) impersonations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowIndicator(): boolean {
|
||||||
|
return IMPERSONATE_INDICATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns all registered userKeys from usermap.json
|
||||||
|
export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] {
|
||||||
|
try {
|
||||||
|
const usermap = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
|
||||||
|
);
|
||||||
|
return Object.entries(usermap).map(([, entry]: [string, any]) => {
|
||||||
|
const fileKey = typeof entry === "string" ? entry : entry.file;
|
||||||
|
const aliases = typeof entry === "object" ? (entry.aliases ?? []) : [];
|
||||||
|
return { userKey: fileKey, aliases };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,10 @@ import { Nation } from "../types";
|
||||||
import { getActiveCharacter } from "./characters";
|
import { getActiveCharacter } from "./characters";
|
||||||
|
|
||||||
// Resolve a user's nation — character nation takes priority over Discord role
|
// Resolve a user's nation — character nation takes priority over Discord role
|
||||||
export function resolveNation(member: GuildMember, usermapKey: string | null): Nation | null {
|
export function resolveNation(member: GuildMember, userKey: string | null): Nation | null {
|
||||||
// 1. Active character's nation
|
// 1. Active character's nation
|
||||||
if (usermapKey) {
|
if (userKey) {
|
||||||
const char = getActiveCharacter(usermapKey);
|
const char = getActiveCharacter(userKey);
|
||||||
if (char) return char.nation;
|
if (char) return char.nation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function resetPollOverrides(): void {
|
||||||
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||||
const format = cfg("charDisplayFormat");
|
const format = cfg("charDisplayFormat");
|
||||||
const nation = entry.characterNation;
|
const nation = entry.characterNation;
|
||||||
const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null;
|
const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
|
||||||
|
|
||||||
let wrank = "";
|
let wrank = "";
|
||||||
if (wRankEntry) {
|
if (wRankEntry) {
|
||||||
|
|
@ -94,9 +94,9 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Bringer title — independent of W.Rank so override always shows
|
// Bringer title — independent of W.Rank so override always shows
|
||||||
if (nation && entry.usermapKey) {
|
if (nation && entry.userKey) {
|
||||||
const bringer = getBringer(nation);
|
const bringer = getBringer(nation);
|
||||||
if (bringer && bringer === entry.usermapKey) {
|
if (bringer && bringer === entry.characterName) {
|
||||||
const emoji = nation === "Capella"
|
const emoji = nation === "Capella"
|
||||||
? (getEmoji("luminous_bringer") || "🔆")
|
? (getEmoji("luminous_bringer") || "🔆")
|
||||||
: (getEmoji("storm_bringer") || "⚡");
|
: (getEmoji("storm_bringer") || "⚡");
|
||||||
|
|
@ -225,8 +225,11 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over
|
||||||
|
|
||||||
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
|
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
|
||||||
resetPollOverrides();
|
resetPollOverrides();
|
||||||
const { clearSessionBorrows } = require("./borrow");
|
const { clearSessionBorrows } = require("@systems/borrow");
|
||||||
|
const { clearAllImpersonations } = require("@systems/impersonate");
|
||||||
|
|
||||||
clearSessionBorrows();
|
clearSessionBorrows();
|
||||||
|
clearAllImpersonations();
|
||||||
|
|
||||||
const state: PollState = {
|
const state: PollState = {
|
||||||
messageId: null, slot: slot.tgHour,
|
messageId: null, slot: slot.tgHour,
|
||||||
|
|
@ -242,7 +245,7 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void
|
||||||
export function createVoteEntry(
|
export function createVoteEntry(
|
||||||
userId: string,
|
userId: string,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
usermapKey: string | null,
|
userKey: string | null,
|
||||||
discordUsername: string
|
discordUsername: string
|
||||||
): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
|
): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
|
||||||
const serverNickname = member.nickname ?? null;
|
const serverNickname = member.nickname ?? null;
|
||||||
|
|
@ -250,17 +253,18 @@ export function createVoteEntry(
|
||||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||||
|
|
||||||
const { getEffectiveCharacter } = require("./borrow");
|
const { getEffectiveCharacter } = require("./borrow");
|
||||||
const { char, borrowedFrom: bf } = usermapKey
|
const { char, borrowedFrom: bf } = userKey
|
||||||
? getEffectiveCharacter(usermapKey)
|
? getEffectiveCharacter(userKey)
|
||||||
: { char: null, borrowedFrom: null };
|
: { char: null, borrowedFrom: null };
|
||||||
|
console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
usermapKey: usermapKey ?? (undefined as any),
|
userKey: userKey ?? (undefined as any),
|
||||||
displayName,
|
displayName,
|
||||||
characterName: char?.name,
|
characterName: char?.name,
|
||||||
characterClass: char?.class,
|
characterClass: char?.class,
|
||||||
characterLevel: char?.level,
|
characterLevel: char?.level,
|
||||||
characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined),
|
characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined),
|
||||||
borrowedFrom: bf ?? undefined,
|
borrowedFrom: bf ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -53,11 +53,14 @@ export function detectSlot(): number | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScoreSubmission {
|
export interface ScoreSubmission {
|
||||||
usermapKey: string;
|
userKey: string; // owner's key (score goes here)
|
||||||
|
playedBy?: string; // borrower's key if different from owner
|
||||||
characterName: string;
|
characterName: string;
|
||||||
cls: ClassKey;
|
cls: ClassKey;
|
||||||
nation: Nation;
|
nation: Nation;
|
||||||
pts: number;
|
pts: number;
|
||||||
|
k?: number;
|
||||||
|
d?: number;
|
||||||
slot: number;
|
slot: number;
|
||||||
date?: string;
|
date?: string;
|
||||||
atk?: number;
|
atk?: number;
|
||||||
|
|
@ -71,11 +74,14 @@ export function submitScore(sub: ScoreSubmission): void {
|
||||||
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
|
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
|
||||||
|
|
||||||
const score: TGScore = {
|
const score: TGScore = {
|
||||||
usermapKey: sub.usermapKey,
|
userKey: sub.userKey,
|
||||||
|
playedBy: sub.playedBy,
|
||||||
characterName: sub.characterName,
|
characterName: sub.characterName,
|
||||||
class: sub.cls,
|
class: sub.cls,
|
||||||
nation: sub.nation,
|
nation: sub.nation,
|
||||||
pts: sub.pts,
|
pts: sub.pts,
|
||||||
|
k: sub.k,
|
||||||
|
d: sub.d,
|
||||||
atk: sub.atk,
|
atk: sub.atk,
|
||||||
def: sub.def,
|
def: sub.def,
|
||||||
heal: sub.heal,
|
heal: sub.heal,
|
||||||
|
|
@ -86,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void {
|
||||||
};
|
};
|
||||||
|
|
||||||
upsertScore(score);
|
upsertScore(score);
|
||||||
recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { GuildMember } from "discord.js";
|
import { GuildMember } from "discord.js";
|
||||||
|
import { getImpersonation } from "./impersonate";
|
||||||
import { ResolvedUser } from "../types";
|
import { ResolvedUser } from "../types";
|
||||||
import { getUsermapEntry } from "./messages";
|
import { getUsermapEntry } from "./messages";
|
||||||
import { getActiveCharacter } from "./characters";
|
import { getActiveCharacter } from "./characters";
|
||||||
|
|
@ -10,15 +11,21 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
|
||||||
const globalNickname = member.user.globalName ?? null;
|
const globalNickname = member.user.globalName ?? null;
|
||||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||||
|
|
||||||
const entry = getUsermapEntry(discordUsername);
|
// Check for active impersonation
|
||||||
const usermapKey = entry?.file ?? null;
|
const impersonatedKey = getImpersonation(member.user.id);
|
||||||
const aliases = entry?.aliases ?? [];
|
|
||||||
const activeChar = usermapKey ? getActiveCharacter(usermapKey) : null;
|
const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntry(discordUsername);
|
||||||
|
const userKey = entry?.file ?? null;
|
||||||
|
const aliases = entry?.aliases ?? [];
|
||||||
|
const activeChar = userKey ? getActiveCharacter(userKey) : null;
|
||||||
|
// lookupUsername is used for message system lookups — use impersonated key if impersonating
|
||||||
|
const lookupUsername = impersonatedKey ?? discordUsername;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
discordUsername,
|
discordUsername,
|
||||||
usermapKey,
|
lookupUsername,
|
||||||
|
userKey,
|
||||||
displayName,
|
displayName,
|
||||||
serverNickname,
|
serverNickname,
|
||||||
globalNickname,
|
globalNickname,
|
||||||
|
|
@ -28,13 +35,13 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a user by their usermap key (for officer commands using <name> arg)
|
// Resolve a user by their usermap key (for officer commands using <name> arg)
|
||||||
export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
|
export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
|
||||||
return {
|
return {
|
||||||
usermapKey: key,
|
userKey: key,
|
||||||
activeCharacter: getActiveCharacter(key),
|
activeCharacter: getActiveCharacter(key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
|
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
|
||||||
return member.roles.cache.some((r) => officerRoles.includes(r.name));
|
return member.roles.cache.some((r) => officerRoles.includes(r.name));
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ export function getWeek(weekKey: string): WRankWeek | null {
|
||||||
|
|
||||||
// Add or update a score submission for a player
|
// Add or update a score submission for a player
|
||||||
export function recordScore(
|
export function recordScore(
|
||||||
usermapKey: string,
|
userKey: string,
|
||||||
characterName: string,
|
characterName: string,
|
||||||
cls: ClassKey,
|
cls: ClassKey,
|
||||||
nation: Nation,
|
nation: Nation,
|
||||||
|
|
@ -56,11 +56,11 @@ export function recordScore(
|
||||||
const week = ensureWeek(weekKey);
|
const week = ensureWeek(weekKey);
|
||||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||||
|
|
||||||
const existing = list.find((e) => e.usermapKey === usermapKey);
|
const existing = list.find((e) => e.characterName === characterName);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Check if this slot was already counted
|
// Check if this slot was already counted
|
||||||
const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey);
|
const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey);
|
||||||
if (!alreadyCounted) {
|
if (!alreadyCounted) {
|
||||||
existing.weeklyPoints += pts;
|
existing.weeklyPoints += pts;
|
||||||
existing.tgCount += 1;
|
existing.tgCount += 1;
|
||||||
|
|
@ -75,7 +75,7 @@ export function recordScore(
|
||||||
existing.nation = nation;
|
existing.nation = nation;
|
||||||
} else {
|
} else {
|
||||||
list.push({
|
list.push({
|
||||||
usermapKey,
|
userKey,
|
||||||
characterName,
|
characterName,
|
||||||
class: cls,
|
class: cls,
|
||||||
nation,
|
nation,
|
||||||
|
|
@ -87,9 +87,10 @@ export function recordScore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update score index
|
// Update score index
|
||||||
if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = [];
|
const indexKey = characterName;
|
||||||
if (!week.scoreIndex[usermapKey].includes(historyKey)) {
|
if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = [];
|
||||||
week.scoreIndex[usermapKey].push(historyKey);
|
if (!week.scoreIndex[indexKey].includes(historyKey)) {
|
||||||
|
week.scoreIndex[indexKey].push(historyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
recomputeRanks(week, nation);
|
recomputeRanks(week, nation);
|
||||||
|
|
@ -101,7 +102,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void {
|
||||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||||
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
|
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
|
||||||
sorted.forEach((entry, i) => {
|
sorted.forEach((entry, i) => {
|
||||||
const live = list.find((e) => e.usermapKey === entry.usermapKey)!;
|
const live = list.find((e) => e.characterName === entry.characterName)!;
|
||||||
live.previousRank = live.currentRank || undefined;
|
live.previousRank = live.currentRank || undefined;
|
||||||
live.currentRank = i + 1;
|
live.currentRank = i + 1;
|
||||||
});
|
});
|
||||||
|
|
@ -117,14 +118,14 @@ function updateBringer(week: WRankWeek): void {
|
||||||
const qualified = week.entries[nation]
|
const qualified = week.entries[nation]
|
||||||
.filter((e) => e.tgCount >= goal)
|
.filter((e) => e.tgCount >= goal)
|
||||||
.sort((a, b) => a.currentRank - b.currentRank);
|
.sort((a, b) => a.currentRank - b.currentRank);
|
||||||
week.bringer[nation] = qualified[0]?.usermapKey ?? null;
|
week.bringer[nation] = qualified[0]?.characterName ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBringerOverride(nation: Nation, usermapKey: string): void {
|
export function setBringerOverride(nation: Nation, charName: string): void {
|
||||||
const week = ensureWeek(getWeekKey());
|
const week = ensureWeek(getWeekKey());
|
||||||
if (nation === "Capella") week.bringer.capellaOverride = usermapKey;
|
if (nation === "Capella") week.bringer.capellaOverride = charName;
|
||||||
else week.bringer.procyonOverride = usermapKey;
|
else week.bringer.procyonOverride = charName;
|
||||||
saveWRank();
|
saveWRank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,10 +143,10 @@ export function getBringer(nation: Nation): string | null {
|
||||||
return week.bringer.procyonOverride ?? week.bringer.procyon;
|
return week.bringer.procyonOverride ?? week.bringer.procyon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEntry(usermapKey: string, nation: Nation): WRankEntry | null {
|
export function getEntry(characterName: string, nation: Nation): WRankEntry | null {
|
||||||
const week = getCurrentWeek();
|
const week = getCurrentWeek();
|
||||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||||
return list.find((e) => e.usermapKey === usermapKey) ?? null;
|
return list.find((e) => e.characterName === characterName) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called every Monday 00:00 by cron
|
// Called every Monday 00:00 by cron
|
||||||
|
|
@ -153,4 +154,4 @@ export function resetWeek(): void {
|
||||||
// Week is already archived in _data by weekKey — just ensure next week exists
|
// Week is already archived in _data by weekKey — just ensure next week exists
|
||||||
ensureWeek(getWeekKey(new Date()));
|
ensureWeek(getWeekKey(new Date()));
|
||||||
saveWRank();
|
saveWRank();
|
||||||
}
|
}
|
||||||
24
src/types.ts
|
|
@ -47,7 +47,7 @@ export interface Character {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CharacterMap {
|
export interface CharacterMap {
|
||||||
[usermapKey: string]: {
|
[userKey: string]: {
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +68,7 @@ export interface AccountData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountMap {
|
export interface AccountMap {
|
||||||
[usermapKey: string]: AccountData;
|
[userKey: string]: AccountData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Usermap ─────────────────────────────────────────────────────────────────
|
// ─── Usermap ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -94,7 +94,7 @@ export interface TGSlot {
|
||||||
// ─── Poll ────────────────────────────────────────────────────────────────────
|
// ─── Poll ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface VoteEntry {
|
export interface VoteEntry {
|
||||||
usermapKey: string;
|
userKey: string;
|
||||||
displayName: string; // server nickname → global nickname → username
|
displayName: string; // server nickname → global nickname → username
|
||||||
characterName?: string; // active character name at time of vote
|
characterName?: string; // active character name at time of vote
|
||||||
characterClass?: ClassKey; // snapshotted
|
characterClass?: ClassKey; // snapshotted
|
||||||
|
|
@ -107,6 +107,7 @@ export interface VoteEntry {
|
||||||
publicMessageOverride?: string;// set by officer via /tg poll set-message
|
publicMessageOverride?: string;// set by officer via /tg poll set-message
|
||||||
ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral
|
ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral
|
||||||
borrowedFrom?: string // Borrowed character from who
|
borrowedFrom?: string // Borrowed character from who
|
||||||
|
discordId?: string; // real Discord ID of the voter (for notifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollState {
|
export interface PollState {
|
||||||
|
|
@ -123,11 +124,13 @@ export interface PollState {
|
||||||
// ─── Scores ──────────────────────────────────────────────────────────────────
|
// ─── Scores ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TGScore {
|
export interface TGScore {
|
||||||
usermapKey: string;
|
userKey: string;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
class: ClassKey;
|
class: ClassKey;
|
||||||
nation: Nation; // snapshotted at submission time
|
nation: Nation; // snapshotted at submission time
|
||||||
pts: number;
|
pts: number;
|
||||||
|
k?: number;
|
||||||
|
d?: number;
|
||||||
atk?: number;
|
atk?: number;
|
||||||
def?: number;
|
def?: number;
|
||||||
heal?: number;
|
heal?: number;
|
||||||
|
|
@ -135,6 +138,7 @@ export interface TGScore {
|
||||||
slot: number; // TG hour
|
slot: number; // TG hour
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
submittedByOfficer: boolean;
|
submittedByOfficer: boolean;
|
||||||
|
playedBy?: string; // userKey of who actually played (if borrowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── TG Result ───────────────────────────────────────────────────────────────
|
// ─── TG Result ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -160,7 +164,7 @@ export interface TGResult {
|
||||||
// ─── W.Rank ──────────────────────────────────────────────────────────────────
|
// ─── W.Rank ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface WRankEntry {
|
export interface WRankEntry {
|
||||||
usermapKey: string;
|
userKey: string;
|
||||||
characterName: string; // snapshotted
|
characterName: string; // snapshotted
|
||||||
class: ClassKey; // snapshotted
|
class: ClassKey; // snapshotted
|
||||||
nation: Nation; // snapshotted
|
nation: Nation; // snapshotted
|
||||||
|
|
@ -177,10 +181,10 @@ export interface WRankWeek {
|
||||||
procyon: WRankEntry[];
|
procyon: WRankEntry[];
|
||||||
};
|
};
|
||||||
scoreIndex: {
|
scoreIndex: {
|
||||||
[usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"]
|
[userKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"]
|
||||||
};
|
};
|
||||||
bringer: {
|
bringer: {
|
||||||
capella: string | null; // usermapKey of bringer, null if none qualified
|
capella: string | null; // userKey of bringer, null if none qualified
|
||||||
procyon: string | null;
|
procyon: string | null;
|
||||||
capellaOverride?: string; // manually set by officer
|
capellaOverride?: string; // manually set by officer
|
||||||
procyonOverride?: string;
|
procyonOverride?: string;
|
||||||
|
|
@ -195,7 +199,7 @@ export interface WRankData {
|
||||||
|
|
||||||
export interface BringerState {
|
export interface BringerState {
|
||||||
currentWeek: string; // "2026-W22"
|
currentWeek: string; // "2026-W22"
|
||||||
capella: string | null; // usermapKey
|
capella: string | null; // userKey
|
||||||
procyon: string | null;
|
procyon: string | null;
|
||||||
capellaOverride?: string;
|
capellaOverride?: string;
|
||||||
procyonOverride?: string;
|
procyonOverride?: string;
|
||||||
|
|
@ -231,6 +235,7 @@ export interface BotConfig {
|
||||||
showNationTotalsInHeader?: boolean;
|
showNationTotalsInHeader?: boolean;
|
||||||
showNoInNationField?: boolean;
|
showNoInNationField?: boolean;
|
||||||
borrowRequestExpiryMs?: number; // 0 = never expire (default)
|
borrowRequestExpiryMs?: number; // 0 = never expire (default)
|
||||||
|
conflictReclaimBehavior?: "revert" | "remove"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -266,7 +271,8 @@ export interface EmojiMap {
|
||||||
export interface ResolvedUser {
|
export interface ResolvedUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
discordUsername: string; // interaction.user.username
|
discordUsername: string; // interaction.user.username
|
||||||
usermapKey: string | null; // resolved from usermap
|
userKey: string | null; // resolved from usermap
|
||||||
|
lookupUsername: string | null;
|
||||||
displayName: string; // server nickname → global nickname → username
|
displayName: string; // server nickname → global nickname → username
|
||||||
serverNickname: string | null;
|
serverNickname: string | null;
|
||||||
globalNickname: string | null;
|
globalNickname: string | null;
|
||||||
|
|
|
||||||
60
src/utils.ts
|
|
@ -1,18 +1,56 @@
|
||||||
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||||
|
|
||||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
// Poll vote confirmation messages (Yes/No button responses)
|
||||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false";
|
||||||
|
// Command output messages (score, rank, status etc.) — always on by default
|
||||||
|
const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false";
|
||||||
|
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||||
|
|
||||||
export async function replyAndDelete(
|
// For poll button responses
|
||||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
export async function pollReplyAndDelete(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
content: string | null
|
content: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!content || !EPHEMERAL_ENABLED) {
|
if (!content || !POLL_EPHEMERAL_ENABLED) return;
|
||||||
if (interaction.isButton()) return void interaction.deferUpdate();
|
try {
|
||||||
return void interaction.deferReply({ ephemeral: true }).then(() => interaction.deleteReply()).catch(() => {});
|
const reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true });
|
||||||
}
|
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||||
const reply = await interaction.reply({ content, ephemeral: true, fetchReply: true });
|
} catch (err: any) {
|
||||||
if (EPHEMERAL_DELETE_MS > 0) {
|
console.error("[pollReplyAndDelete] error:", err.message);
|
||||||
setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For command responses — always sends regardless of POLL_EPHEMERAL_ENABLED
|
||||||
|
export async function replyAndDelete(
|
||||||
|
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||||
|
content: string | null,
|
||||||
|
forceShow = false // set true for meaningful output that should always show
|
||||||
|
): Promise<void> {
|
||||||
|
const enabled = forceShow || COMMAND_EPHEMERAL_ENABLED;
|
||||||
|
|
||||||
|
if (!content || !enabled) {
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
if (!interaction.deferred && !interaction.replied) await interaction.deferUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!interaction.deferred && !interaction.replied) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
await interaction.deleteReply().catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let reply: any;
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true });
|
||||||
|
} else {
|
||||||
|
reply = await interaction.reply({ content, ephemeral: true, fetchReply: true });
|
||||||
|
}
|
||||||
|
if (reply && EPHEMERAL_DELETE_MS > 0) {
|
||||||
|
setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[replyAndDelete] error:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,25 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@src/*": ["src/*"],
|
||||||
|
"@data/*": ["data/*"],
|
||||||
|
"@messages/*": ["messages/*"],
|
||||||
|
"@tgHistory/*": ["data/tg-history/*"],
|
||||||
|
"@scripts/*": ["scripts/*"],
|
||||||
|
"@systems/*": ["src/systems/*"],
|
||||||
|
"@commands/*": ["src/commands/*"],
|
||||||
|
"@subcommands/*": ["src/subcommands/*"],
|
||||||
|
"@handlers/*": ["src/handlers/*"],
|
||||||
|
"@utils": ["src/utils"],
|
||||||
|
"@types": ["src/types"],
|
||||||
|
"@format": ["src/systems/format"],
|
||||||
|
"@emojis": ["src/systems/emojis"],
|
||||||
|
"@characters": ["src/systems/characters"],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||