Add bot to repo

This commit is contained in:
2026-06-26 16:02:46 -04:00
parent 2a817aa816
commit 932c40f095
3 changed files with 721 additions and 0 deletions

687
bot.js Normal file
View File

@@ -0,0 +1,687 @@
/**
* MapleCore Character Image Discord Bot
* Standalone bot - no full webserver needed.
*
* Usage: !char <characterName>
* /char name:<characterName>
*
* Dependencies:
* npm install discord.js mysql2 canvas axios dotenv
*/
require("dotenv").config();
const { Client, GatewayIntentBits, SlashCommandBuilder, REST, Routes, AttachmentBuilder, EmbedBuilder } = require("discord.js");
const mysql = require("mysql2/promise");
const { createCanvas, loadImage } = require("canvas");
const axios = require("axios");
// ─── Config ──────────────────────────────────────────────────────────────────
const CONFIG = {
token: process.env.DISCORD_TOKEN,
clientId: process.env.DISCORD_CLIENT_ID,
guildId: process.env.DISCORD_GUILD_ID,
prefix: process.env.BOT_PREFIX || "!",
rankingsChannelId: process.env.RANKINGS_CHANNEL_ID,
db: {
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "root",
database: process.env.DB_NAME || "cosmic",
},
};
// ─── Equipment slot IDs (Cosmic / HeavenMS v83 standard) ─────────────────────
// inventoryitems.position values for equipped items (inventorytype = 1)
const EQUIP_SLOTS = {
1: "Hat",
2: "Face",
3: "Eye Accessory",
4: "Earring",
5: "Top",
6: "Bottom",
7: "Shoes",
8: "Glove",
9: "Cape",
10: "Shield",
11: "Weapon",
12: "Ring 1",
13: "Ring 2",
15: "Ring 3",
16: "Ring 4",
17: "Pendant",
49: "Belt",
50: "Medal",
};
// maplestory.io render layers (order matters for compositing)
const RENDER_LAYERS = ["body", "arm", "head", "face", "hair"];
// ─── Database ─────────────────────────────────────────────────────────────────
let pool;
async function getDb() {
if (!pool) {
pool = mysql.createPool({
...CONFIG.db,
waitForConnections: true,
connectionLimit: 5,
});
}
return pool;
}
/**
* Fetch character row + equipped item IDs from the Cosmic DB.
* Returns null if character not found.
*/
async function fetchCharacterData(characterName) {
const db = await getDb();
// Get character base info
const [chars] = await db.query(
`SELECT id, name, level, job, gender, hair, face, skincolor, exp, meso
FROM characters
WHERE name = ? LIMIT 1`,
[characterName]
);
if (chars.length === 0) return null;
const char = chars[0];
// Get equipped items (inventorytype=1, position < 0 means equipped in some servers;
// position > 0 and inventorytype=1 in others — handle both)
// inventorytype = -1 is the equipped inventory in Cosmic/HeavenMS
// Regular equipped slots: -1 to -11
// Cash equipped slots: -101 to -111 (override regular)
const [items] = await db.query(
`SELECT itemid, position
FROM inventoryitems
WHERE characterid = ?
AND inventorytype = -1
ORDER BY position DESC`,
[char.id]
);
// Map negative position values to named slot keys (from db.ts mapEquipmentPosition)
// Regular slots: -1 to -11 | Cash slots: -101 to -111 (cash overrides regular)
const SLOT_MAP = {
'-1': 'cap', '-101': 'cap',
'-2': 'mask', '-102': 'mask',
'-3': 'eyes', '-103': 'eyes',
'-4': 'ears', '-104': 'ears',
'-5': 'coat', '-105': 'coat',
'-6': 'pants', '-106': 'pants',
'-7': 'shoes', '-107': 'shoes',
'-8': 'glove', '-108': 'glove',
'-9': 'cape', '-109': 'cape',
'-10': 'shield','-110': 'shield',
'-11': 'weapon','-111': 'weapon',
};
// First pass: regular items; second pass: cash items override
const equipped = {};
for (const item of items) {
const key = SLOT_MAP[String(item.position)];
if (key) {
// Only override if slot is empty OR this is a cash item (-101 and below)
if (!equipped[key] || item.position <= -101) {
equipped[key] = item.itemid;
}
}
}
return { ...char, equipped };
}
// ─── Image Generation ─────────────────────────────────────────────────────────
/**
* Build a maplestory.io character render URL.
* maplestory.io supports compositing equipped items into a single PNG.
*
* Docs: https://maplestory.io/api
*/
function buildRenderUrl(charData) {
const { hair, face, skincolor, equipped } = charData;
const VERSION = '241';
// Skin color mapping: DB value -> maplestory.io skin item ID
const SKIN_MAP = { 0: 2000, 1: 2001, 2: 2002, 3: 2003 };
const skinId = SKIN_MAP[skincolor] ?? 2000;
// Determine stance based on weapon type (2H weapons use stand2)
const weaponId = equipped.weapon;
let stance = 'stand1';
if (weaponId) {
const twoHanded = [
[1402000,1402999],[1412000,1412999],[1422000,1422999],[1432000,1432999],
[1452000,1459999],[1462000,1469999],[1382000,1389999],[1372000,1379999],
[1442000,1442999],[1700000,1799999],
];
if (twoHanded.some(([min,max]) => weaponId >= min && weaponId <= max)) {
stance = 'stand2';
}
}
// Build items in same order as maplestory-api.ts:
// hair, weapon, shoes, pants, coat, glove, cape, shield, cap, mask, eyes, ears, skin, 12000 (head), face
const itemList = [];
const push = (id) => { if (id) itemList.push({ itemId: id, version: VERSION }); };
push(hair || 30021);
push(equipped.weapon);
push(equipped.shoes);
push(equipped.pants);
push(equipped.coat);
push(equipped.glove);
push(equipped.cape);
push(equipped.shield);
push(equipped.cap);
push(equipped.mask);
push(equipped.eyes);
push(equipped.ears);
push(skinId);
push(12000); // head base
push(face || 20001);
// Format: comma-separated JSON objects (NOT a JSON array), URL-encoded
const itemsParam = encodeURIComponent(itemList.map(i => JSON.stringify(i)).join(','));
return `https://maplestory.io/api/character/${itemsParam}/${stance}/0?resize=2&renderMode=default&flipX=false`;
}
/**
* Generate a character preview image Buffer using maplestory.io.
* Falls back to a styled embed if the API is unreachable.
*/
async function generateCharacterImage(charData) {
const url = buildRenderUrl(charData);
console.log("Render URL:", decodeURIComponent(url));
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 10000,
headers: { "User-Agent": "MapleCore-DiscordBot/1.0" },
});
return Buffer.from(resp.data);
} catch (err) {
console.error("maplestory.io render failed:", err.message);
// Fallback: generate a simple placeholder with canvas
return generateFallbackImage(charData);
}
}
/** Simple canvas fallback when maplestory.io is unavailable */
async function generateFallbackImage(charData) {
const canvas = createCanvas(200, 300);
const ctx = canvas.getContext("2d");
// Background
ctx.fillStyle = "#1a1a2e";
ctx.fillRect(0, 0, 200, 300);
// Border
ctx.strokeStyle = "#e94560";
ctx.lineWidth = 3;
ctx.strokeRect(4, 4, 192, 292);
// Character name
ctx.fillStyle = "#ffffff";
ctx.font = "bold 18px Arial";
ctx.textAlign = "center";
ctx.fillText(charData.name, 100, 40);
// Job / level
ctx.fillStyle = "#a0a0c0";
ctx.font = "14px Arial";
ctx.fillText(`Lv.${charData.level}`, 100, 65);
// Silhouette placeholder
ctx.fillStyle = "#e94560";
ctx.font = "60px Arial";
ctx.textAlign = "center";
ctx.fillText("🍁", 100, 180);
ctx.fillStyle = "#606080";
ctx.font = "11px Arial";
ctx.fillText("(preview unavailable)", 100, 270);
return canvas.toBuffer("image/png");
}
// ─── Embed Builder ────────────────────────────────────────────────────────────
function getJobName(jobId) {
const jobs = {
0: "Beginner", 100: "Warrior", 110: "Fighter", 111: "Crusader", 112: "Hero",
120: "Page", 121: "White Knight", 122: "Paladin",
130: "Spearman", 131: "Dragon Knight", 132: "Dark Knight",
200: "Magician", 210: "F/P Wizard", 211: "F/P Mage", 212: "F/P Arch Mage",
220: "I/L Wizard", 221: "I/L Mage", 222: "I/L Arch Mage",
230: "Cleric", 231: "Priest", 232: "Bishop",
300: "Bowman", 310: "Hunter", 311: "Ranger", 312: "Bowmaster",
320: "Crossbowman", 321: "Sniper", 322: "Marksman",
400: "Thief", 410: "Assassin", 411: "Hermit", 412: "Night Lord",
420: "Bandit", 421: "Chief Bandit", 422: "Shadower",
500: "Pirate", 510: "Brawler", 511: "Marauder", 512: "Buccaneer",
520: "Gunslinger", 521: "Outlaw", 522: "Corsair",
900: "GM", 910: "Super GM",
};
return jobs[jobId] ?? `Job ${jobId}`;
}
// Real pre-Big Bang v83 EXP required to level up FROM each level
// Source: MapleLegends EXP table (https://legends.ml/lib/exptable)
const EXP_TABLE = [
15,15,34,57,92,135,372,560,840,1144,
1242,1573,2144,2800,3640,4700,5893,7360,9144,11120,
13477,16268,19320,22880,27008,31477,36600,42444,48720,55813,
63800,86784,98208,110932,124432,139372,155865,173280,192400,213345,
235372,259392,285532,312928,342624,374760,408336,445544,483532,524160,
567772,598886,631704,666321,702836,741351,781976,824828,870028,917625,
967995,1021041,1076994,1136013,1198266,1263930,1333194,1406252,1483314,1564600,
1650340,1740778,1836173,1936794,2042930,2154882,2272970,2397528,2528912,2667496,
2813674,2967863,3130502,3302053,3483005,3673873,3875201,4087562,4311559,4547832,
4797053,5059931,5337215,5629694,5938202,6263614,6606860,6968915,7350811,7753635,
8178534,8626718,9099462,9598112,10124088,10678888,11264090,11881362,12532461,13219239,
13943653,14707765,15513750,16363902,17260644,18206527,19204245,20256637,21366700,22537594,
23772654,25075395,26449526,27898960,29427822,31040466,32741483,34535716,36428273,38424542,
40530206,42751262,45094030,47565183,50171755,52921167,55821246,58880250,62106888,65510344,
69100311,72887008,76881216,81094306,85594273,90225770,95170142,100385466,105886589,111689174,
117809740,124265714,131075474,138258410,145834970,153826726,162256430,171148082,180526997,190419876,
200854885,211861732,223471711,223471711,248635353,262260570,276632449,291791906,307782102,324648562,
342439302,361204976,380999008,401877754,423900654,447130410,471633156,497478653,524740482,553496261,
583827855,615821622,649568646,685165008,722712050,762316670,804091623,848155844,894634784,943660770,
995373379,1049919840,1107455447,1168144006,1232158297,1299680571,1370903066,1446028554,1525246918,1608855764,
1697021059
];
function getExpForLevel(level) {
// Returns EXP required to go FROM this level TO the next
if (level < 1 || level > 200) return 0;
return EXP_TABLE[level];
}
// Render a text-based EXP progress bar
function buildExpBar(pct, width = 20) {
const filled = Math.round((pct / 100) * width);
const empty = width - filled;
return `${"█".repeat(filled)}${"░".repeat(empty)}`;
}
function buildEmbed(charData, imageFile) {
// Calculate EXP progress
// charData.exp is EXP accumulated within the current level (resets on levelup in Cosmic)
const expCurrent = Number(charData.exp) || 0;
const expNeeded = getExpForLevel(charData.level); // EXP required to level up from current level
const pct = expNeeded > 0 ? Math.min(100, Math.floor((expCurrent / expNeeded) * 100)) : 100;
const expBar = buildExpBar(pct);
const expField = [
`\`${expBar}\` **${pct}%**`,
`${expCurrent.toLocaleString()} / ${expNeeded.toLocaleString()} EXP`,
].join("\n");
const embed = new EmbedBuilder()
.setTitle(charData.name)
.setColor(0xe94560)
.addFields(
{ name: "Level", value: `${charData.level}`, inline: true },
{ name: "Job", value: getJobName(charData.job), inline: true },
{ name: "Meso", value: charData.meso?.toLocaleString() ?? "0", inline: true },
{ name: "EXP to Next Level", value: expField },
)
.setImage(`attachment://${imageFile}`)
.setFooter({ text: "Sweetbot" })
.setTimestamp();
return embed;
}
// ─── Command Handler ──────────────────────────────────────────────────────────
async function handleCharCommand(interaction, characterName) {
// Works for both slash commands (interaction = CommandInteraction)
// and prefix messages (interaction has .reply())
const isSlash = !!interaction.deferReply;
if (isSlash) await interaction.deferReply();
const name = characterName?.trim();
if (!name) {
const msg = "Please provide a character name. Usage: `/char name:YourCharacter`";
return isSlash ? interaction.editReply(msg) : interaction.reply(msg);
}
try {
const charData = await fetchCharacterData(name);
if (!charData) {
const msg = `❌ Character **${name}** not found.`;
return isSlash ? interaction.editReply(msg) : interaction.reply(msg);
}
const imageBuffer = await generateCharacterImage(charData);
const fileName = `${charData.name}.png`;
const attachment = new AttachmentBuilder(imageBuffer, { name: fileName });
const embed = buildEmbed(charData, fileName);
const payload = { embeds: [embed], files: [attachment] };
return isSlash ? interaction.editReply(payload) : interaction.reply(payload);
} catch (err) {
console.error("Error handling char command:", err);
const msg = "⚠️ An error occurred while fetching the character.";
return isSlash ? interaction.editReply(msg) : interaction.reply(msg);
}
}
// ─── Slash Command Registration ───────────────────────────────────────────────
async function registerSlashCommands() {
const commands = [
new SlashCommandBuilder()
.setName("char")
.setDescription("Show a Sweetgum character preview")
.addStringOption((opt) =>
opt.setName("name").setDescription("Character name").setRequired(true)
),
new SlashCommandBuilder()
.setName("rankings")
.setDescription("Post the current top 10 rankings now"),
].map((c) => c.toJSON());
const rest = new REST({ version: "10" }).setToken(CONFIG.token);
try {
if (CONFIG.guildId) {
// Guild-scoped (instant, good for testing)
await rest.put(Routes.applicationGuildCommands(CONFIG.clientId, CONFIG.guildId), { body: commands });
console.log("✅ Guild slash commands registered.");
} else {
// Global (takes ~1 hour to propagate)
await rest.put(Routes.applicationCommands(CONFIG.clientId), { body: commands });
console.log("✅ Global slash commands registered.");
}
} catch (err) {
console.error("Failed to register slash commands:", err);
}
}
// ─── Rankings ─────────────────────────────────────────────────────────────────
/** Fetch top 10 characters by level then EXP, excluding GMs (job 900/910) */
async function fetchTopCharacters() {
const db = await getDb();
const [rows] = await db.query(
`SELECT id, name, level, job, gender, hair, face, skincolor, exp, meso
FROM characters
WHERE job NOT IN (900, 910)
AND gm = 0
ORDER BY level DESC, exp DESC
LIMIT 10`
);
// Fetch equipped items for each character
const characters = [];
for (const char of rows) {
const [items] = await db.query(
`SELECT itemid, position
FROM inventoryitems
WHERE characterid = ?
AND inventorytype = -1
ORDER BY position DESC`,
[char.id]
);
const SLOT_MAP = {
'-1': 'cap', '-101': 'cap',
'-2': 'mask', '-102': 'mask',
'-3': 'eyes', '-103': 'eyes',
'-4': 'ears', '-104': 'ears',
'-5': 'coat', '-105': 'coat',
'-6': 'pants', '-106': 'pants',
'-7': 'shoes', '-107': 'shoes',
'-8': 'glove', '-108': 'glove',
'-9': 'cape', '-109': 'cape',
'-10': 'shield','-110': 'shield',
'-11': 'weapon','-111': 'weapon',
};
const equipped = {};
for (const item of items) {
const key = SLOT_MAP[String(item.position)];
if (key && (!equipped[key] || item.position <= -101)) {
equipped[key] = item.itemid;
}
}
characters.push({ ...char, equipped });
}
return characters;
}
const MEDAL_EMOJIS = ["🥇", "🥈", "🥉"];
const RANK_COLORS = [0xFFD700, 0xC0C0C0, 0xCD7F32]; // gold, silver, bronze
/**
* Build a composite rankings image:
* A column of character renders with their rank, name, level beside each one.
*/
async function buildRankingsImage(characters) {
const CARD_W = 520;
const CARD_H = 110;
const SPRITE_W = 80;
const PAD = 12;
const TOTAL_H = CARD_H * characters.length + PAD * 2;
const canvas = createCanvas(CARD_W, TOTAL_H);
const ctx = canvas.getContext("2d");
// Background
ctx.fillStyle = "#0f0f1a";
ctx.fillRect(0, 0, CARD_W, TOTAL_H);
for (let i = 0; i < characters.length; i++) {
const char = characters[i];
const y = PAD + i * CARD_H;
// Card background — highlight top 3
ctx.fillStyle = i === 0 ? "#1a1500" : i === 1 ? "#111118" : i === 2 ? "#120a00" : "#111118";
ctx.fillRect(PAD, y, CARD_W - PAD * 2, CARD_H - 4);
// Left accent bar (gold/silver/bronze/grey)
ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#333355";
ctx.fillRect(PAD, y, 4, CARD_H - 4);
// Rank number
ctx.font = "bold 22px Arial";
ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#666688";
ctx.textAlign = "center";
ctx.fillText(`#${i + 1}`, PAD + 28, y + CARD_H / 2 + 8);
// Character sprite
try {
const imgBuf = await generateCharacterImage(char);
const img = await loadImage(imgBuf);
// Draw sprite scaled to fit card height, preserving aspect ratio
const scale = (CARD_H - 8) / img.height;
const sw = img.width * scale;
ctx.drawImage(img, PAD + 56, y + 4, sw, CARD_H - 8);
} catch (e) {
// Fallback: coloured placeholder
ctx.fillStyle = "#333355";
ctx.fillRect(PAD + 56, y + 4, SPRITE_W, CARD_H - 8);
}
// Name
ctx.textAlign = "left";
ctx.font = "bold 18px Arial";
ctx.fillStyle = "#ffffff";
ctx.fillText(char.name, PAD + 56 + SPRITE_W + 8, y + 32);
// Job
ctx.font = "14px Arial";
ctx.fillStyle = "#9999bb";
ctx.fillText(getJobName(char.job), PAD + 56 + SPRITE_W + 8, y + 52);
// Level
ctx.font = "bold 20px Arial";
ctx.fillStyle = "#e94560";
ctx.textAlign = "right";
ctx.fillText(`Lv.${char.level}`, CARD_W - PAD * 2, y + 38);
// EXP bar at bottom of card
const expNeeded = getExpForLevel(char.level);
const expPct = expNeeded > 0 ? Math.min(1, Number(char.exp) / expNeeded) : 1;
const barX = PAD + 56 + SPRITE_W + 8;
const barW = CARD_W - PAD * 2 - 56 - SPRITE_W - 16;
const barY = y + CARD_H - 20;
const barH = 6;
ctx.fillStyle = "#222233";
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#e94560";
ctx.fillRect(barX, barY, Math.floor(barW * expPct), barH);
ctx.font = "11px Arial";
ctx.fillStyle = "#666688";
ctx.textAlign = "left";
ctx.fillText(`${Math.floor(expPct * 100)}% to next level`, barX, barY + barH + 11);
}
return canvas.toBuffer("image/png");
}
/** Post (or refresh) the daily rankings in the configured channel */
async function postRankings(client) {
if (!CONFIG.rankingsChannelId) {
console.warn("⚠️ RANKINGS_CHANNEL_ID not set — skipping rankings post.");
return;
}
const channel = await client.channels.fetch(CONFIG.rankingsChannelId).catch(() => null);
if (!channel) {
console.error("❌ Could not find rankings channel.");
return;
}
console.log("📊 Building daily rankings...");
try {
const characters = await fetchTopCharacters();
if (characters.length === 0) {
console.log("No characters found for rankings.");
return;
}
const imageBuffer = await buildRankingsImage(characters);
const attachment = new AttachmentBuilder(imageBuffer, { name: "rankings.png" });
// Build summary embed
const now = new Date();
const embed = new EmbedBuilder()
.setTitle("🏆 Daily Rankings")
.setDescription(
characters.map((c, i) => {
const medal = MEDAL_EMOJIS[i] ?? `**#${i + 1}**`;
return `${medal} **${c.name}** — Lv.${c.level} ${getJobName(c.job)}`;
}).join("\n")
)
.setImage("attachment://rankings.png")
.setColor(0xFFD700)
.setFooter({ text: "Sweetbot • Updates daily at 9:00 PM EST" })
.setTimestamp(now);
// Delete previous rankings message if one exists
try {
const messages = await channel.messages.fetch({ limit: 20 });
const old = messages.find(
(m) => m.author.id === client.user.id &&
m.embeds.length > 0 &&
m.embeds[0].title === "🏆 Daily Rankings"
);
if (old) await old.delete();
} catch (e) {
console.warn("Could not delete old rankings message:", e.message);
}
await channel.send({ embeds: [embed], files: [attachment] });
console.log("✅ Rankings posted.");
} catch (err) {
console.error("Error posting rankings:", err);
}
}
/**
* Schedule the daily rankings post at 9:00 PM EST (02:00 UTC next day).
* Uses a simple setTimeout loop that recalculates the delay each time.
*/
function scheduleRankings(client) {
function msUntilNext9pmEST() {
const now = new Date();
// EST = UTC-5; target hour in UTC = 21 + 5 = 02:00 next day
const target = new Date();
target.setUTCHours(2, 0, 0, 0);
if (target <= now) target.setUTCDate(target.getUTCDate() + 1);
return target - now;
}
function scheduleNext() {
const delay = msUntilNext9pmEST();
const hrsAway = (delay / 1000 / 60 / 60).toFixed(2);
console.log(`📅 Next rankings post in ${hrsAway} hours (9:00 PM EST).`);
setTimeout(async () => {
await postRankings(client);
scheduleNext(); // reschedule for next day
}, delay);
}
scheduleNext();
}
// ─── Bot Client ───────────────────────────────────────────────────────────────
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once("ready", async () => {
console.log(`✅ Logged in as ${client.user.tag}`);
await registerSlashCommands();
scheduleRankings(client);
});
// Slash command handler
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "char") {
await handleCharCommand(interaction, interaction.options.getString("name"));
}
if (interaction.commandName === "rankings") {
await interaction.deferReply({ ephemeral: true });
await postRankings(client);
await interaction.editReply("✅ Rankings posted!");
}
});
// Prefix command handler (!char <name>)
client.on("messageCreate", async (message) => {
if (message.author.bot) return;
if (!message.content.startsWith(CONFIG.prefix)) return;
const args = message.content.slice(CONFIG.prefix.length).trim().split(/\s+/);
const cmd = args.shift().toLowerCase();
if (cmd === "char") {
await handleCharCommand(message, args.join(" "));
}
if (cmd === "rankings") {
await postRankings(client);
}
});
client.login(CONFIG.token);

17
env.example Normal file
View File

@@ -0,0 +1,17 @@
# ─── Discord ──────────────────────────────────────────────────────────────────
DISCORD_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_application_client_id
DISCORD_GUILD_ID=your_guild_id_here # Remove or leave blank for global slash commands
# Prefix for !char and !rankings commands (default: !)
BOT_PREFIX=!
# Channel ID where daily rankings will be posted (right-click channel → Copy Channel ID)
RANKINGS_CHANNEL_ID=your_channel_id_here
# ─── Database (same as MapleCore .env.local) ──────────────────────────────────
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_NAME=cosmic

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "maplecore-discord-bot",
"version": "1.0.0",
"description": "Standalone MapleCore v83 character preview Discord bot",
"main": "bot.js",
"scripts": {
"start": "node bot.js",
"dev": "node --watch bot.js"
},
"dependencies": {
"axios": "^1.7.2",
"canvas": "^2.11.2",
"discord.js": "^14.15.3",
"dotenv": "^16.4.5",
"mysql2": "^3.10.1"
}
}