From fafaef850d8f09328dfe9b26d325539fc4b4e405 Mon Sep 17 00:00:00 2001 From: jkortis Date: Wed, 30 Sep 2026 23:06:22 -0400 Subject: [PATCH] Initial commit --- .gitignore | 6 + README.md | 59 ++ USAGE.md | 37 ++ ecosystem.config.js | 16 + index.js | 1478 +++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 299 +++++++++ package.json | 20 + tickets-state.json | 3 + tickets.json | 1 + updater.js | 60 ++ 10 files changed, 1979 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 USAGE.md create mode 100644 ecosystem.config.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tickets-state.json create mode 100644 tickets.json create mode 100644 updater.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7bd8f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.autoscale_bak/ +.DS_Store +admin-users.json +user-mappings.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf2cb12 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Autoscale Bot + +A self‑hosted Discord bot for managing a Headscale instance. + +## Features +- Create, delete and manage Headscale users +- Link Discord accounts to Headscale users +- Register and rename nodes +- Ticketing system for support +- **Auto‑Updater**: Checks the GitHub repository for new releases, notifies the bot owner, and offers an in‑Discord update prompt. + +## Installation +```bash +# Clone the repository +git clone https://github.com/jadonkortislolz-wq/autoscale.git +cd autoscale +npm install +``` + +Create a `.env` file (do **not** commit it) with the required variables: +``` +BOT_TOKEN=your_discord_bot_token +HEADSCALE_URL=https://your-headscale-url +HEADSCALE_CONTAINER=headscale +BOT_OWNER_ID=your_discord_user_id +# optional tickets config +TICKET_CATEGORY_ID=your_category_id +TICKET_SUPPORT_ROLE_ID=your_support_role_id +``` + +Start the bot (PM2 recommended): +```bash +npm start # or pm2 start ecosystem.config.js +``` + +## Usage +All commands are prefixed with `!hs` or `!headscale`. +- `!hs create ` – Create a Headscale user +- `!hs discord ` – Link your Discord account to a Headscale user +- `!hs link ` – Register a device +- `!hs register` – Get the Tailscale registration command +- `!hs list` – List all nodes and IPs +- `!hs rename ` – Rename your node +- `!hs info` – Bot information +- Ticket commands: `!hs ticket panel`, `!hs ticket close` +- Admin commands: `!hs restart`, `!hs addadmin @user`, etc. +- `!hs update` – Check for a new version and update the bot + +## Auto‑Updater +The bot automatically checks the GitHub repository for new releases every hour. +1. If a newer version is found, the bot owner receives a DM: + > "A new version **vX.Y.Z** is available. Run `!hs update` to apply it." +2. Running `!hs update` pulls the latest code, installs dependencies, and restarts the bot. + +## Contributing +Feel free to open issues or submit pull requests. Ensure you do not commit any secrets. + +## License +ISC diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..9af6e53 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,37 @@ +# Usage Guide for Autoscale Bot + +## Command Prefixes +The bot listens for the following prefixes: +- `!hs` +- `!headscale` + +## User Commands +| Command | Description | +| ------- | ----------- | +| `!hs create ` | Create a Headscale user | +| `!hs discord ` | Link your Discord account to a Headscale user | +| `!hs link ` | Register a device | +| `!hs register` | Get the Tailscale registration command | +| `!hs list` | List all nodes and their IPs | +| `!hs rename ` | Rename your node | +| `!hs info` | Show bot information | +| `!hs ticket panel` | Post the ticket panel (admin only) | +| `!hs ticket close` | Close a ticket (admin/support only) | +| `!hs restart` | Restart the bot (admin only) | +| `!hs update` | Check for a new version and update the bot | + +## Admin Commands +- `!hs addadmin @user` – Grant admin privileges (owner only) +- `!hs removeadmin @user` – Revoke admin privileges (owner only) +- `!hs listadmins` – List all admin users +- `!hs deleteuser ` – Delete a Headscale user +- `!hs removenode ` – Force‑remove a node +- `!hs preauth ` – Generate a pre‑auth key + +## Auto‑Updater +The `!hs update` command triggers the auto‑updater. If a newer release is found on GitHub, the bot will: +1. Pull the latest code. +2. Install any new dependencies. +3. Restart itself. + +Make sure the bot has write permissions to its directory. diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..d599f49 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,16 @@ +module.exports = { + apps : [{ + name: 'autoscale-bot', + script: 'index.js', + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '1G', + restart_delay: 5000, + exp_backoff_restart_delay: 100, + env: { + NODE_ENV: 'production', + } + }] +}; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..e301a13 --- /dev/null +++ b/index.js @@ -0,0 +1,1478 @@ +/// version 1.3.0 +/// 1.3.0 /// Added addadmin/removeadmin/listadmins commands for managing admin users +/// 1.2.8 /// Added admin command to generate pre-auth keys +/// 1.2.6 /// Headscale login server URL and container name configurable via .env +/// 1.2.5 /// Added removenode command to force remove nodes +/// 1.2.4 /// Added deleteuser command to delete Headscale users +/// 1.2.3 /// Added rename command to rename nodes by ID +/// 1.2.2 /// Added list command to show all nodes and IP addresses +/// 1.2.1 /// Added admin commands and improved user guidance +/// 1.2.0 /// Added a ticketing system (panel + modal form + private channels + close/transcripts) +/// 1.1.03 /// Impletmented a restart command. +/// 1.1.02 /// Impletmented a info command that shows verison +/// 1.1.0 /// Started using proud verisoning standard +/// 1.0.0 /// Created the basic functions for the bot. + +require('dotenv/config'); + +// Global error handling to prevent crashing and allow PM2 to manage restarts +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); +}); + +process.on('uncaughtException', (err) => { + console.error('Uncaught Exception thrown:', err); +}); + +const { exec, spawn } = require('child_process'); +const fs = require('fs').promises; +const { promisify } = require('util'); +const { + ActionRowBuilder, + ActivityType, + ButtonBuilder, + ButtonStyle, + ChannelType, + Client, + EmbedBuilder, + GatewayIntentBits, + PermissionsBitField, +} = require('discord.js'); + +const execAsync = promisify(exec); + +// simple delay helper +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ----------------------------------------------------------------------------- +// Config +// ----------------------------------------------------------------------------- + +function normalizeHeadscaleUrl(url) { + return String(url || '').trim().replace(/\/+$/, ''); +} + +function buildRegisterCommand(loginServerUrl) { + const base = normalizeHeadscaleUrl(loginServerUrl); + return `tailscale up --login-server=${base} --force-reauth --accept-dns --accept-routes`; +} + +const HEADSCALE_URL = normalizeHeadscaleUrl( + process.env.HEADSCALE_URL || 'https://headscale.thebigload.com' +); + +const CONFIG = { + // Headscale bot + prefixes: ['!hs', '!headscale'], + headscaleUrl: HEADSCALE_URL, + headscaleContainer: process.env.HEADSCALE_CONTAINER || 'headscale', + mappingsFile: './user-mappings.json', + registerCommand: buildRegisterCommand(HEADSCALE_URL), + adminRoleId: '1482914182927089916', + botOwnerId: process.env.BOT_OWNER_ID || '259880027053752321', + + // Ticketing + ticketsFile: './tickets.json', + ticketsStateFile: './tickets-state.json', + ticketCategoryId: process.env.TICKET_CATEGORY_ID || '1483053433370968218', // optional (recommended) + supportRoleId: process.env.TICKET_SUPPORT_ROLE_ID || '1482928996449521794', + ticketChannelPrefix: 'ticket', + ticketAutoDeleteMs: Number(process.env.TICKET_AUTO_DELETE_MS || 5000), + + adminUsersFile: './admin-users.json', + version: '1.3.0', // Update this AND /// version line at top when releasing +}; + +function CONFIG_FALLBACK(envName, fallback) { + return process.env[envName] ?? fallback; +} + +const USERNAME_REGEX = /^[a-zA-Z0-9._-]{3,32}$/; + +const HELP_TEXT = [ + 'Commands:', + '`create ` — Create a Headscale user', + '`discord ` — Link your Discord to a Headscale user', + '`link ` or `link ` — Register a node', + '`register` — Get the Tailscale setup command', + '`list` — Show all nodes and their IPs', + '`info` — Show bot information and commands', + '', + 'Tickets:', + '`ticket panel` — Post the ticket panel (Admin only)', + '`ticket close` — Close a ticket (inside the ticket channel; Admin/Support only)', + '', + 'Admin:', + '`restart` — Restart bot (Admin only)', + '`preauth ` — Generate pre-auth key (Admin only)', + '`addadmin @user` — Grant admin privileges (Owner only)', + '`removeadmin @user` — Revoke admin privileges (Owner only)', + '`listadmins` — List all admin users (Admin only)', +].join('\n'); + +// ----------------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------------- + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], +}); + +const userMappings = new Map(); +const adminUserIds = new Set(); + +// Ticket storage: +// ticketsByChannelId: { [channelId]: { ownerId, ownerTag, topic, createdAt, closedAt? } } +const ticketsByChannelId = new Map(); +let ticketCounter = 0; + +// ----------------------------------------------------------------------------- +// Headscale / Docker helpers +// ----------------------------------------------------------------------------- + +async function headscale(...args) { + const cmd = `docker exec ${CONFIG.headscaleContainer} headscale ${args.join(' ')}`; + const { stdout, stderr } = await execAsync(cmd).catch((e) => { + console.error(`[Docker Error] Command: ${cmd}`); + console.error(`[Docker Error] Message: ${e.message}`); + console.error(`[Docker Error] Stderr: ${e.stderr}`); + return { + stdout: '', + stderr: e.stderr || e.message, + }; + }); + + const filterUpdateWarning = (text) => + (text || '').replace(/.*\bWRN An updated version of Headscale has been found.*\n?/g, '').trim(); + + return { + stdout: filterUpdateWarning(stdout), + stderr: filterUpdateWarning(stderr) + }; +} + +/** Headscale CLI sometimes prints literal `null` when empty; always normalize to an array. */ +function parseHeadscaleJson(stdout, label = 'response') { + const raw = (stdout || '').trim(); + if (!raw) return []; + let data; + try { + data = JSON.parse(raw); + } catch { + throw new Error(`Invalid JSON from headscale (${label})`); + } + if (data == null) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data.nodes)) return data.nodes; + if (Array.isArray(data.users)) return data.users; + throw new Error(`Unexpected headscale ${label} format`); +} + +async function getHeadscaleUsers() { + const { stdout, stderr } = await headscale('users', 'list', '--output', 'json'); + if (stderr && !stdout) throw new Error(stderr); + return parseHeadscaleJson(stdout, 'users list'); +} + +// ----------------------------------------------------------------------------- +// Persistence +// ----------------------------------------------------------------------------- + +async function loadMappings() { + try { + const raw = await fs.readFile(CONFIG.mappingsFile, 'utf8'); + const data = JSON.parse(raw); + for (const [discordId, username] of Object.entries(data)) { + userMappings.set(discordId, username); + } + console.log(`Loaded ${userMappings.size} user mappings from ${CONFIG.mappingsFile}`); + } catch { + console.log('No existing mappings file found, starting fresh'); + } +} + +async function saveMappings() { + const data = Object.fromEntries(userMappings); + await fs.writeFile(CONFIG.mappingsFile, JSON.stringify(data, null, 2)); +} + +async function loadAdminUsers() { + try { + const raw = await fs.readFile(CONFIG.adminUsersFile, 'utf8'); + const data = JSON.parse(raw); + if (Array.isArray(data)) { + for (const id of data) adminUserIds.add(id); + } + console.log(`Loaded ${adminUserIds.size} admin user(s) from ${CONFIG.adminUsersFile}`); + } catch { + console.log('No existing admin-users file found, starting fresh'); + } +} + +async function saveAdminUsers() { + await fs.writeFile(CONFIG.adminUsersFile, JSON.stringify([...adminUserIds], null, 2)); +} + +async function loadTickets() { + try { + const raw = await fs.readFile(CONFIG.ticketsFile, 'utf8'); + const data = JSON.parse(raw); + for (const [channelId, ticket] of Object.entries(data || {})) { + ticketsByChannelId.set(channelId, ticket); + } + console.log(`Loaded ${ticketsByChannelId.size} tickets from ${CONFIG.ticketsFile}`); + } catch { + console.log('No existing tickets file found, starting fresh'); + } + + try { + const raw = await fs.readFile(CONFIG.ticketsStateFile, 'utf8'); + const state = JSON.parse(raw); + ticketCounter = Number(state?.ticketCounter || 0); + } catch { + ticketCounter = 0; + } +} + +async function saveTickets() { + const data = Object.fromEntries(ticketsByChannelId); + await fs.writeFile(CONFIG.ticketsFile, JSON.stringify(data, null, 2)); + await fs.writeFile(CONFIG.ticketsStateFile, JSON.stringify({ ticketCounter }, null, 2)); +} + +function updateBotStatus() { + if (!client.user) return; + const linked = userMappings.size; + client.user.setActivity(`${linked} linked users`, { + type: ActivityType.Watching, + }); +} + +// ----------------------------------------------------------------------------- +// Permission helpers +// ----------------------------------------------------------------------------- + +function isAdmin(member) { + if (member.roles?.cache?.has(CONFIG.adminRoleId)) return true; + if (adminUserIds.has(member.id || member.user?.id)) return true; + return false; +} + +function isSupport(member) { + if (isAdmin(member)) return true; + if (!CONFIG.supportRoleId) return false; + return member.roles?.cache?.has(CONFIG.supportRoleId); +} + +function isTicketChannel(channel) { + if (!channel) return false; + const ticket = ticketsByChannelId.get(channel.id); + if (ticket) return true; + return channel.name?.startsWith(`${CONFIG.ticketChannelPrefix}-`); +} + +// ----------------------------------------------------------------------------- +// Ticketing UI builders +// ----------------------------------------------------------------------------- + +const IDS = { + ticketCreateBtn: 'ticket:create', + ticketCloseBtn: 'ticket:close', +}; + +function buildTicketPanel() { + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('Headscale Registration Tickets') + .setDescription( + [ + 'Need a private place to get set up with Headscale?', + '', + '- Click **Create Ticket** to create a private channel just for you.', + '- Use it to link your Discord, create your Headscale user, and register your devices.', + '- Only you and staff can see the channel.', + ].join('\n') + ) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(IDS.ticketCreateBtn) + .setLabel('Create Ticket') + .setStyle(ButtonStyle.Primary) + ); + + return { embeds: [embed], components: [row] }; +} + +function buildTicketControlsRow() { + return new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(IDS.ticketCloseBtn).setLabel('Close Ticket').setStyle(ButtonStyle.Danger) + ); +} + +function findOpenTicketChannelIdByOwnerId(ownerId) { + for (const [channelId, ticket] of ticketsByChannelId.entries()) { + if (ticket?.ownerId === ownerId && !ticket?.closedAt) return channelId; + } + return null; +} + +async function createTicketChannel({ guild, requester, topic, details }) { + ticketCounter += 1; + const ticketNumber = String(ticketCounter).padStart(4, '0'); + const nameSafeUser = `${requester.user.username}`.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 20); + const channelName = `${CONFIG.ticketChannelPrefix}-${ticketNumber}-${nameSafeUser}`.replace(/-+/g, '-'); + + const everyoneId = guild.roles.everyone.id; + const overwrites = [ + { + id: everyoneId, + deny: [PermissionsBitField.Flags.ViewChannel], + }, + { + id: requester.id, + allow: [ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.ReadMessageHistory, + PermissionsBitField.Flags.AttachFiles, + PermissionsBitField.Flags.EmbedLinks, + ], + }, + ]; + + if (CONFIG.supportRoleId) { + overwrites.push({ + id: CONFIG.supportRoleId, + allow: [ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.ReadMessageHistory, + PermissionsBitField.Flags.ManageMessages, + PermissionsBitField.Flags.AttachFiles, + PermissionsBitField.Flags.EmbedLinks, + ], + }); + } + + // Always allow admins to view (via their role id) + if (CONFIG.adminRoleId) { + overwrites.push({ + id: CONFIG.adminRoleId, + allow: [ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.ReadMessageHistory, + PermissionsBitField.Flags.ManageChannels, + PermissionsBitField.Flags.ManageMessages, + ], + }); + } + + const channel = await guild.channels.create({ + name: channelName, + type: ChannelType.GuildText, + parent: CONFIG.ticketCategoryId || null, + permissionOverwrites: overwrites, + topic: `Ticket ${ticketNumber} • Owner ${requester.user.tag} (${requester.id}) • ${topic}`, + }); + + const ticketRecord = { + ownerId: requester.id, + ownerTag: requester.user.tag, + topic: topic || 'Support', + details: details || '', + createdAt: new Date().toISOString(), + ticketNumber, + }; + ticketsByChannelId.set(channel.id, ticketRecord); + await saveTickets(); + updateBotStatus(); + + const intro = new EmbedBuilder() + .setColor(0x57f287) + .setTitle(`Ticket #${ticketNumber}`) + .setDescription( + [ + `**Owner:** <@${requester.id}>`, + `**Topic:** ${ticketRecord.topic}`, + '', + '**Next steps:** Use this private channel to get registered with Headscale and link your devices.', + '', + '**User commands available here:**', + '`create ` — Create your Headscale user', + '`discord ` — Link your Discord to your Headscale user', + '`link ` or `link ` — Register a node', + '`register` — Get the Tailscale setup command', + '`ping` — Check if the bot is responsive', + '`info` — Show bot information and commands', + ].join('\n') + ) + .setTimestamp(); + + await channel.send({ + content: CONFIG.supportRoleId ? `<@&${CONFIG.supportRoleId}> new ticket created.` : 'New ticket created.', + embeds: [intro], + components: [buildTicketControlsRow()], + }); + + return { channel, ticketRecord }; +} + +async function closeTicketChannel({ channel, closedBy }) { + const record = ticketsByChannelId.get(channel.id); + if (!record) { + throw new Error('Ticket record not found.'); + } + if (record.closedAt) { + return { alreadyClosed: true, record }; + } + + record.closedAt = new Date().toISOString(); + record.closedById = closedBy.id; + record.closedByTag = closedBy.user.tag; + ticketsByChannelId.set(channel.id, record); + await saveTickets(); + updateBotStatus(); + + const embed = new EmbedBuilder() + .setColor(0xed4245) + .setTitle(`Ticket #${record.ticketNumber} closed`) + .setDescription(`Closed by <@${closedBy.id}>.`) + .setTimestamp(); + + await channel.send({ embeds: [embed] }); + + // Lock channel for owner (leave readable) + try { + await channel.permissionOverwrites.edit(record.ownerId, { + SendMessages: false, + }); + } catch { + // ignore + } + + // Auto-delete ticket channel after a short delay (so responses can complete) + const deleteAfterMs = Number.isFinite(CONFIG.ticketAutoDeleteMs) ? CONFIG.ticketAutoDeleteMs : 5000; + if (deleteAfterMs >= 0) { + setTimeout(async () => { + try { + // Delete the ticket from JSON and remove from memory + ticketsByChannelId.delete(channel.id); + await saveTickets(); + updateBotStatus(); + + try { + await channel.delete(`Ticket closed by ${closedBy.user.tag}`); + } catch (e) { + console.warn('Failed to auto-delete ticket channel:', e); + try { + if (CONFIG.botOwnerId) { + const owner = await client.users.fetch(CONFIG.botOwnerId); + await owner.send( + `❌ Failed to auto-delete ticket channel **#${record.ticketNumber}** (${channel.id}).\n` + + `Error: \`${e?.message || String(e)}\`\n` + + `Fix: ensure the bot has **Manage Channels** permission in the ticket category/channel.` + ); + } + } catch { + // ignore + } + } + } catch (e) { + console.warn('Failed to remove ticket record:', e); + } + }, deleteAfterMs); + } + + return { alreadyClosed: false, record }; +} + +// ----------------------------------------------------------------------------- +// Headscale command handlers +// ----------------------------------------------------------------------------- + +function buildInfoEmbed() { + const embed = new EmbedBuilder() + .setColor(0x00ae86) + .setTitle(`🛡️ Headscale Bot v${CONFIG.version}`) + .setDescription('Headscale user + node management') + .addFields( + { + name: '📋 Commands', + value: '`create ` — Create a Headscale user\n`discord ` — Link Discord to Headscale user\n`link ` or `link ` — Register a node\n`register` — Get Tailscale setup command\n`list` — Show all nodes and their IPs\n`rename ` — Rename your own node by ID\n`info` — Show bot information', + inline: false, + }, + { name: '📊 Stats', value: `${userMappings.size} linked`, inline: true }, + { name: '📦 Version', value: CONFIG.version, inline: true } + ) + .setThumbnail(client.user ? client.user.displayAvatarURL() : null) + .setFooter({ text: `Prefix: ${CONFIG.prefixes.join(', ')}` }) + .setTimestamp(); + + return embed; +} + +async function cmdInfo(message) { + const embed = buildInfoEmbed(); + return message.reply({ embeds: [embed] }); +} + +async function cmdAdmin(message) { + // Allow bot owner or any admin user + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + const isPromotedAdmin = adminUserIds.has(message.author.id); + + if (!isOwner && !isAdminMember && !isPromotedAdmin) { + return message.reply('❌ Admin only command.'); + } + + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('🔧 Admin Command List') + .setDescription('All available commands for the Headscale Bot') + .addFields( + { + name: '👤 User Commands', + value: '`create ` — Create a Headscale user\n`discord ` — Link Discord to Headscale user\n`link ` or `link ` — Register a node\n`register` — Get Tailscale setup command\n`list` — Show all nodes and their IPs\n`rename ` — Rename your own node by ID (requires linked account)\n`info` — Show bot information', + inline: false, + }, + { + name: '🎫 Ticket Commands (Admin only)', + value: '`ticket panel` — Post ticket panel (Admin only)\n`ticket close` — Close current ticket (Admin only)', + inline: false, + }, + { + name: '⚙️ Admin Commands', + value: '`restart` — Restart bot (Admin only)\n`clear` — Clear channel messages (Admin only)\n`deleteuser ` — Delete Headscale user (Admin only)\n`removeuser ` — Alias for deleteuser (Admin only)\n`removenode ` — Force remove node (Admin only)\n`deletenode ` — Alias for removenode (Admin only)\n`preauth ` — Generate pre-auth key (Admin only)\n`addadmin @user` — Grant admin privileges (Owner only)\n`removeadmin @user` — Revoke admin privileges (Owner only)\n`listadmins` — List all admin users (Admin only)\n`admin` — Show this command list (Bot owner only)', + inline: false, + }, + { + name: 'ℹ️ Info', + value: `**Prefixes:** ${CONFIG.prefixes.join(', ')}\n**Version:** ${CONFIG.version}\n**Bot Owner:** <@259880027053752321>`, + inline: false, + } + ) + .setThumbnail(client.user ? client.user.displayAvatarURL() : null) + .setFooter({ text: 'This command is restricted to the bot owner' }) + .setTimestamp(); + + return message.reply({ embeds: [embed] }); +} + +async function cmdInstructions(message) { + // Only allow admin role or bot owner to use this command + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + + // Delete the user's command message + try { + await message.delete(); + } catch (err) { + console.warn('Failed to delete message:', err); + } + + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('🚀 Headscale Getting Started Guide') + .setDescription('Complete guide to get connected with Headscale') + .addFields( + { + name: '🎫 Step 1: Create a Private Ticket', + value: 'Start by creating a private channel for personalized help:\n\n1. Find the ticket panel in the server\n2. Click "Create Ticket"\n3. Get your own private channel with staff support', + inline: false, + }, + { + name: '📝 Step 2: Create Your Headscale User', + value: '```\n!hs create \n```\n\nChoose a username with 3-32 characters (letters, numbers, dot, dash, underscore).', + inline: false, + }, + { + name: '🔗 Step 3: Link Your Discord Account', + value: '```\n!hs discord \n```\n\nThis links your Discord account to your Headscale user for authentication.', + inline: false, + }, + { + name: '💻 Step 4: Install Tailscale on Your Device', + value: 'Download Tailscale from: https://tailscale.com/download/\n\nAvailable for: Windows, Mac, Linux, iOS, Android, and more.', + inline: false, + }, + { + name: '🔑 Step 5: Get Your Auth Key', + value: '```\n!hs register\n```\n\nThis gives you the command to generate an authentication key for your device.', + inline: false, + }, + { + name: '🌐 Step 6: Connect Your Device', + value: `Run the command from Step 5 on your device:\n\`\`\`bash\n${CONFIG.registerCommand}\n\`\`\`\n\nThen use the generated node key to register:\n\`\`\`\n!hs link \n\`\`\``, + inline: false, + }, + { + name: '✅ Quick Commands Reference', + value: '`!hs create ` - Create user\n`!hs discord ` - Link Discord\n`!hs register` - Get setup command\n`!hs link ` - Register device\n`!hs info` - Show bot info', + inline: false, + }, + { + name: '❓ Troubleshooting', + value: '• If you get "already exists", try a different username\n• If registration fails, make sure your Headscale user exists\n• For device issues, check that Tailscale is running', + inline: false, + } + ) + .setThumbnail(client.user ? client.user.displayAvatarURL() : null) + .setTimestamp(); + + return message.channel.send({ embeds: [embed] }); +} + +async function cmdListNodes(message) { + try { + await message.reply('📡 Fetching node information...'); + + // Get all nodes from headscale + const { stdout, stderr } = await headscale('nodes', 'list', '--output', 'json'); + if (stderr && !stdout) throw new Error(stderr); + + const nodes = parseHeadscaleJson(stdout, 'nodes list'); + + if (nodes.length === 0) { + return message.reply('❌ No nodes found in the Headscale network.'); + } + + // Create embed with node information + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('🌐 Headscale Network Nodes') + .setDescription(`Found ${nodes.length} node(s) in the network`) + .setTimestamp(); + + // Group nodes by user for better organization + const nodesByUser = {}; + nodes.forEach(node => { + const user = node.user?.name || 'Unknown'; + if (!nodesByUser[user]) { + nodesByUser[user] = []; + } + nodesByUser[user].push(node); + }); + + // Add each user's nodes as a field, splitting if it exceeds Discord's 1024 char limit + Object.entries(nodesByUser).forEach(([user, userNodes]) => { + const nodeLines = userNodes.map(node => { + const ips = node.ip_addresses && Array.isArray(node.ip_addresses) ? node.ip_addresses.join(', ') : 'No IP'; + const name = node.given_name || node.name || 'Unnamed'; + const status = node.online ? '🟢 Online' : '🔴 Offline'; + const nodeId = node.id || 'Unknown'; + + let lastSeen = 'Never'; + if (node.last_seen && node.last_seen.seconds) { + lastSeen = new Date(node.last_seen.seconds * 1000).toLocaleString(); + } + + return `**ID: ${nodeId} | ${name}** - ${ips}\n${status} • Last seen: ${lastSeen}`; + }); + + let currentChunk = []; + let currentLength = 0; + let chunkIndex = 1; + + nodeLines.forEach((line) => { + const lineLength = line.length + 2; // +2 for double newline + if (currentLength + lineLength > 1000 && currentChunk.length > 0) { + embed.addFields({ + name: `👤 ${user} (Part ${chunkIndex++})`, + value: currentChunk.join('\n\n'), + inline: false + }); + currentChunk = []; + currentLength = 0; + } + currentChunk.push(line); + currentLength += lineLength; + }); + + if (currentChunk.length > 0) { + const name = chunkIndex === 1 ? `👤 ${user} (${userNodes.length} node${userNodes.length > 1 ? 's' : ''})` : `👤 ${user} (Part ${chunkIndex})`; + embed.addFields({ + name: name, + value: currentChunk.join('\n\n'), + inline: false + }); + } + }); + + embed.setFooter({ + text: `Total: ${nodes.length} nodes • ${nodes.filter(n => n.online).length} online` + }); + + return message.reply({ embeds: [embed] }); + + } catch (error) { + console.error('List nodes error:', error); + const detail = error?.message ? `\n\`\`\`\n${error.message.slice(0, 500)}\n\`\`\`` : ''; + return message.reply(`❌ Failed to fetch node information.${detail}`); + } +} + +async function cmdRenameNode(message, args) { + // Only allow users with linked Discord accounts + const linkedUsername = userMappings.get(message.author.id); + if (!linkedUsername) { + return message.reply('❌ You must link your Discord account first. Use `!hs discord ` to link your account.'); + } + + const nodeId = args[0]; + const newName = args[1]; + + if (!nodeId || !newName) { + return message.reply('Usage: `!hs rename `'); + } + + // Validate node ID is a number + if (isNaN(nodeId)) { + return message.reply('❌ Node ID must be a number.'); + } + + // Validate new name + if (!newName || newName.length < 1 || newName.length > 64) { + return message.reply('❌ New name must be between 1 and 64 characters.'); + } + + try { + // First, get all nodes to check ownership + const { stdout, stderr } = await headscale('nodes', 'list', '--output', 'json'); + if (stderr && !stdout) throw new Error(stderr); + + const nodes = parseHeadscaleJson(stdout, 'nodes list'); + const targetNode = nodes.find(node => node.id === parseInt(nodeId)); + + if (!targetNode) { + return message.reply(`❌ Node with ID ${nodeId} not found.`); + } + + // Check if the node belongs to the user's linked Headscale account + const nodeUsername = targetNode.user?.name; + if (nodeUsername !== linkedUsername) { + return message.reply(`❌ You can only rename your own nodes. Node ${nodeId} belongs to "${nodeUsername}" but your account is linked to "${linkedUsername}".`); + } + + await message.reply(`🔄 Renaming your node ${nodeId} to "${newName}"...`); + + // Use headscale to rename the node + const { stdout: renameStdout, stderr: renameStderr } = await headscale('nodes', 'rename', newName, '-i', nodeId); + + if (renameStderr && !renameStderr.includes('Node renamed successfully')) { + throw new Error(renameStderr); + } + + return message.reply(`✅ **Node renamed successfully!**\nNode ID: ${nodeId}\nNew name: "${newName}"`); + + } catch (error) { + console.error('Rename node error:', error); + const errorMsg = error.stderr || error.message || 'Unknown error occurred'; + + // Provide helpful error messages + if (errorMsg.includes('not found')) { + return message.reply(`❌ Node with ID ${nodeId} not found.`); + } else if (errorMsg.includes('already exists')) { + return message.reply(`❌ A node with name "${newName}" already exists.`); + } else if (errorMsg.includes('permission')) { + return message.reply('❌ Permission denied. Make sure the bot has proper access.'); + } else { + return message.reply(`❌ Failed to rename node: \`\`\`${errorMsg}\`\`\``); + } + } +} + +async function cmdDeleteUser(message, args) { + // Only allow admin role or bot owner to use this command + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + + const username = args[0]; + + if (!username) { + return message.reply('Usage: `!hs deleteuser `'); + } + + try { + await message.reply(`🗑️ Deleting Headscale user "${username}"...`); + + // First, check if user exists + const users = await getHeadscaleUsers(); + const user = users.find(u => u.name === username); + + if (!user) { + return message.reply(`❌ Headscale user "${username}" not found.`); + } + + // Delete the user + const { stdout, stderr } = await headscale('users', 'destroy', '-n', username, '--force'); + + if (stderr && !stderr.includes('deleted successfully')) { + throw new Error(stderr); + } + + // Remove Discord mappings for this user + const discordIdsToRemove = []; + for (const [discordId, mappedUsername] of userMappings.entries()) { + if (mappedUsername === username) { + discordIdsToRemove.push(discordId); + } + } + + for (const discordId of discordIdsToRemove) { + userMappings.delete(discordId); + } + + await saveMappings(); + updateBotStatus(); + + return message.reply(`✅ **User deleted successfully!**\nUsername: ${username}\nDiscord mappings removed: ${discordIdsToRemove.length}`); + + } catch (error) { + console.error('Delete user error:', error); + const errorMsg = error.stderr || error.message || 'Unknown error occurred'; + + // Provide helpful error messages + if (errorMsg.includes('not found')) { + return message.reply(`❌ Headscale user "${username}" not found.`); + } else if (errorMsg.includes('permission')) { + return message.reply('❌ Permission denied. Make sure bot has proper access.'); + } else if (errorMsg.includes('cannot be deleted')) { + return message.reply(`❌ Cannot delete user "${username}". User may still have active nodes.`); + } else { + return message.reply(`❌ Failed to delete user: \`\`\`${errorMsg}\`\`\``); + } + } +} + +async function cmdRemoveNode(message, args) { + // Only allow admin role or bot owner to use this command + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + + const nodeId = args[0]; + + if (!nodeId) { + return message.reply('Usage: `!hs removenode `'); + } + + // Validate node ID is a number + if (isNaN(nodeId)) { + return message.reply('❌ Node ID must be a number.'); + } + + try { + await message.reply(`🗑️ Force removing node ${nodeId}...`); + + // First, get node info to show what's being removed + const { stdout, stderr } = await headscale('nodes', 'list', '--output', 'json'); + if (stderr && !stdout) throw new Error(stderr); + + const nodes = parseHeadscaleJson(stdout, 'nodes list'); + const targetNode = nodes.find(node => node.id === parseInt(nodeId)); + + if (!targetNode) { + return message.reply(`❌ Node with ID ${nodeId} not found.`); + } + + const nodeName = targetNode.given_name || targetNode.name || 'Unknown'; + const nodeUser = targetNode.user?.name || 'Unknown'; + const nodeIps = targetNode.ip_addresses?.join(', ') || 'No IPs'; + + // Force remove the node + const { stdout: removeStdout, stderr: removeStderr } = await headscale('nodes', 'delete', '--force', '-i', nodeId); + + if (removeStderr && !removeStderr.includes('deleted successfully') && !removeStderr.includes('removed successfully')) { + throw new Error(removeStderr); + } + + return message.reply(`✅ **Node force removed successfully!**\n\n**Node ID:** ${nodeId}\n**Name:** ${nodeName}\n**User:** ${nodeUser}\n**IPs:** ${nodeIps}`); + + } catch (error) { + console.error('Remove node error:', error); + const errorMsg = error.stderr || error.message || 'Unknown error occurred'; + + // Provide helpful error messages + if (errorMsg.includes('not found')) { + return message.reply(`❌ Node with ID ${nodeId} not found.`); + } else if (errorMsg.includes('permission')) { + return message.reply('❌ Permission denied. Make sure bot has proper access.'); + } else { + return message.reply(`❌ Failed to remove node: \`\`\`${errorMsg}\`\`\``); + } + } +} + +async function cmdRemoveUser(message, args) { + // Alias for deleteuser command + return cmdDeleteUser(message, args); +} + +async function cmdPreAuth(message, args) { + // Only allow admin role or bot owner to use this command + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + + const username = args[0]; + if (!username) { + return message.reply('Usage: `!hs preauth `'); + } + + try { + await message.reply(`🔑 Generating pre-auth key for \`${username}\`...`); + + // Create pre-auth key for the specific user + const { stdout, stderr } = await headscale('preauthkeys', 'create', '--user', username); + + if (stderr && !stdout) { + throw new Error(stderr); + } + + // Headscale output for create is usually just the key itself + const key = stdout.trim(); + if (!key) { + throw new Error('No key was returned from Headscale.'); + } + + const fullCommand = `tailscale up --login-server=${CONFIG.headscaleUrl} --authkey=${key}`; + + return message.reply( + `✅ **Pre-auth key generated for \`${username}\`**\n\n` + + `**Key:** \`${key}\`\n\n` + + `**Full Command for User:**\n\`\`\`bash\n${fullCommand}\n\`\`\`` + ); + } catch (error) { + console.error('Pre-auth key error:', error); + const errorMsg = error.stderr || error.message || 'Unknown error occurred'; + return message.reply(`❌ Failed to generate pre-auth key: \`\`\`${errorMsg}\`\`\``); + } +} + +async function cmdAddAdmin(message, args) { + // Only the bot owner can add admins + if (message.author.id !== CONFIG.botOwnerId) { + return message.reply('❌ This command is restricted to the bot owner.'); + } + + // Support both @mention and raw user ID + const raw = args[0]; + if (!raw) { + return message.reply('Usage: `!hs addadmin <@user>` or `!hs addadmin `'); + } + + const userId = raw.replace(/[<@!>]/g, ''); + if (!/^\d{17,20}$/.test(userId)) { + return message.reply('❌ Invalid user. Mention a user or provide a valid Discord user ID.'); + } + + if (adminUserIds.has(userId)) { + return message.reply(`ℹ️ <@${userId}> is already an admin.`); + } + + adminUserIds.add(userId); + await saveAdminUsers(); + + return message.reply(`✅ <@${userId}> has been added as an admin. They can now run admin commands.`); +} + +async function cmdRemoveAdmin(message, args) { + // Only the bot owner can remove admins + if (message.author.id !== CONFIG.botOwnerId) { + return message.reply('❌ This command is restricted to the bot owner.'); + } + + const raw = args[0]; + if (!raw) { + return message.reply('Usage: `!hs removeadmin <@user>` or `!hs removeadmin `'); + } + + const userId = raw.replace(/[<@!>]/g, ''); + if (!/^\d{17,20}$/.test(userId)) { + return message.reply('❌ Invalid user. Mention a user or provide a valid Discord user ID.'); + } + + if (!adminUserIds.has(userId)) { + return message.reply(`ℹ️ <@${userId}> is not in the admin list.`); + } + + adminUserIds.delete(userId); + await saveAdminUsers(); + + return message.reply(`✅ <@${userId}> has been removed from admin. They can no longer run admin commands (unless they still have the admin role).`); +} + +async function cmdListAdmins(message) { + // Allow admins and bot owner to see the list + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + + if (adminUserIds.size === 0) { + return message.reply('ℹ️ No admin users have been added yet. The admin role and bot owner still have access.'); + } + + const list = [...adminUserIds].map(id => `• <@${id}> (\`${id}\`)`).join('\n'); + + const embed = new EmbedBuilder() + .setColor(0xff9900) + .setTitle('👑 Admin Users') + .setDescription(list) + .setFooter({ text: `${adminUserIds.size} admin user(s) • Users with the admin role also have access` }) + .setTimestamp(); + + return message.reply({ embeds: [embed] }); +} + +async function cmdDeleteNode(message, args) { + // Alias for removenode command + return cmdRemoveNode(message, args); +} + +function relaunchBotProcess() { + const script = require.main?.filename || __filename; + const child = spawn(process.execPath, [script], { + cwd: process.cwd(), + env: process.env, + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + +async function cmdRestart(message) { + const isOwner = message.author.id === CONFIG.botOwnerId; + const isAdminMember = message.member && isAdmin(message.member); + + if (!isOwner && !isAdminMember) { + return message.reply('❌ Admin only command.'); + } + await message.reply('🔄 Restarting bot safely...'); + console.log(`Restart initiated by ${message.author.tag} (${message.author.id})`); + await Promise.all([saveMappings(), saveTickets(), saveAdminUsers()]); + + const systemdService = process.env.BOT_SYSTEMD_SERVICE; + if (systemdService) { + try { + await execAsync(`systemctl restart ${systemdService}`); + return; + } catch (e) { + console.error('systemctl restart failed, falling back:', e.message); + } + } + + // systemd unit with Restart=always — exit and let the manager restart us. + if (process.env.INVOCATION_ID) { + process.exit(0); + return; + } + + // Manual `npm start` / `node index.js` — spawn replacement, then exit. + relaunchBotProcess(); + setTimeout(() => process.exit(0), 300).unref(); +} + +async function cmdCreate(message, args) { + const username = args[0]; + if (!username) return message.reply('Usage: `create `'); + if (!USERNAME_REGEX.test(username)) { + return message.reply('Invalid username. Use 3–32 letters, numbers, dot, dash, underscore.'); + } + await message.reply(`Creating Headscale user \`${username}\`...`); + try { + await headscale('users', 'create', username); + return message.reply(`✅ User \`${username}\` created successfully.`); + } catch (err) { + const stderr = err.stderr || err.message || ''; + if (stderr.includes('already exists')) { + return message.reply(`User \`${username}\` already exists.`); + } + console.error('Create user error:', stderr); + return message.reply('Failed to create Headscale user.'); + } +} + +async function cmdDiscord(message, args) { + const username = args[0]; + if (!username) return message.reply('Usage: `discord `'); + let users; + try { + users = await getHeadscaleUsers(); + } catch { + return message.reply('Failed to verify Headscale user.'); + } + const user = users.find((u) => u.name === username); + if (!user) { + return message.reply( + `❌ Headscale user \`${username}\` not found. Create it first with \`create ${username}\`` + ); + } + const existing = [...userMappings.entries()].find(([, u]) => u === username); + if (existing && existing[0] !== message.author.id) { + return message.reply(`❌ Headscale \`${username}\` is already linked to another Discord account.`); + } + userMappings.set(message.author.id, username); + await saveMappings(); + updateBotStatus(); + console.log(`Linked Discord ${message.author.tag} → Headscale ${username}`); + return message.reply( + `✅ **Linked!** Your Discord is now tied to Headscale user \`${username}\`.\n\n` + + `Run \`link \` to register nodes or \`register\` to get the setup command.` + ); +} + +async function cmdLink(message, args) { + const usernameOrKey = args[0]; + const explicitUsername = args[1]; + const nodeKey = args.length === 1 ? usernameOrKey : explicitUsername; + if (!nodeKey) { + return message.reply('Usage: `link ` or `link `'); + } + const discordId = message.author.id; + let username = userMappings.get(discordId); + if (explicitUsername && args.length === 2) username = usernameOrKey; + if (!username) { + return message.reply( + `❌ No Headscale account linked. Run \`discord \` first, then \`link \`.` + ); + } + await message.reply(`Registering node for \`${username}\`...`); + let users; + try { + users = await getHeadscaleUsers(); + } catch { + return message.reply('Failed to look up user.'); + } + const user = users.find((u) => u.name === username); + if (!user) return message.reply(`❌ Headscale user \`${username}\` not found.`); + try { + await headscale('nodes', 'register', '--key', nodeKey, '--user', username); + return message.reply(`✅ **Node registered!** Added to Headscale account \`${username}\`.`); + } catch (err) { + const stderr = (err.stderr || err.message || '').toString().trim(); + console.error('Register node error:', stderr); + return message.reply(`❌ Failed to register node:\n\`\`\`\n${stderr}\n\`\`\``); + } +} + +function cmdRegister(message) { + return message.reply( + `\`\`\`bash\n${CONFIG.registerCommand}\n\`\`\`\n\n**Run this on your device to generate a node key, then use \`link \` to register it.**` + ); +} + +// ----------------------------------------------------------------------------- +// Admin-only utility commands +// ----------------------------------------------------------------------------- + +async function cmdClear(message, args) { + // Clear command only works in guilds + if (!message.guild || !message.member) { + return message.reply('❌ This command is only available in servers.'); + } + + if (!isAdmin(message.member)) { + return message.reply('❌ Admin only command.'); + } + + const channel = message.channel; + const confirm = await message.reply('🧹 Clearing recent messages in this channel...'); + + let totalDeleted = 0; + try { + // Discord only allows bulk delete of messages newer than 14 days. + // We loop until we get fewer than 2 messages back. + // This will not remove very old messages (Discord limitation). + // Adjust the limit here if you want to be more/less aggressive. + // eslint-disable-next-line no-constant-condition + while (true) { + const deleted = await channel.bulkDelete(100, true).catch(() => null); + if (!deleted || deleted.size === 0) break; + totalDeleted += deleted.size; + if (deleted.size < 2) break; + await sleep(1000); + } + } catch (err) { + console.error('Clear channel error:', err); + return confirm.edit('❌ Failed to clear messages. Make sure I have **Manage Messages** permission.'); + } + + try { + await confirm.edit( + totalDeleted > 0 + ? `✅ Cleared **${totalDeleted}** recent messages (messages older than 14 days cannot be bulk deleted).` + : 'ℹ️ No recent messages could be deleted (messages older than 14 days cannot be bulk deleted).' + ); + } catch { + // ignore if message was deleted by bulkDelete pass + } +} + +// ----------------------------------------------------------------------------- +// Ticket commands (message-based) +// ----------------------------------------------------------------------------- + +async function cmdTicket(message, args) { + const sub = (args[0] || '').toLowerCase(); + if (!sub) return message.reply('Usage: `ticket panel` or `ticket close`'); + + // Ticket commands only work in guilds + if (!message.guild || !message.member) { + return message.reply('❌ Ticket commands are only available in servers.'); + } + + if (sub === 'panel') { + if (!isAdmin(message.member)) return message.reply('❌ Admin only command.'); + await message.channel.send(buildTicketPanel()); + return message.reply('✅ Ticket panel posted.'); + } + + if (sub === 'close') { + if (!isTicketChannel(message.channel)) { + return message.reply('❌ This is not a ticket channel.'); + } + if (!isSupport(message.member)) { + return message.reply('❌ Support/Admin only.'); + } + try { + const res = await closeTicketChannel({ channel: message.channel, closedBy: message.member }); + if (res.alreadyClosed) return message.reply('ℹ️ Ticket is already closed.'); + return message.reply('✅ Ticket closed.'); + } catch (e) { + return message.reply(`❌ Failed to close ticket: ${e.message}`); + } + } + + return message.reply('Unknown ticket subcommand.'); +} + +// ----------------------------------------------------------------------------- +// Command routing (message-based) +// ----------------------------------------------------------------------------- + +const COMMANDS = { + info: cmdInfo, + admin: cmdAdmin, + instructions: cmdInstructions, + restart: cmdRestart, + create: cmdCreate, + discord: cmdDiscord, + link: cmdLink, + register: cmdRegister, + list: cmdListNodes, + rename: cmdRenameNode, + deleteuser: cmdDeleteUser, + removeuser: cmdRemoveUser, + removenode: cmdRemoveNode, + deletenode: cmdDeleteNode, + preauth: cmdPreAuth, + addadmin: cmdAddAdmin, + removeadmin: cmdRemoveAdmin, + listadmins: cmdListAdmins, + ticket: cmdTicket, + clear: cmdClear, +}; + +async function handleMessage(message) { + if (message.author.bot) return; + + const isDM = !message.guild; + + // DM command support: only the configured bot owner can use commands in DMs, + // and the command text is the message content (no `!` prefix required). + if (isDM) { + if (message.author.id !== CONFIG.botOwnerId) return; + + const content = (message.content || '').trim(); + if (!content) return; + + const lower = content.toLowerCase(); + if (lower.includes('ticket')) { + return message.reply('Ticket commands are only available in servers.'); + } + + // DM parsing: allow `list`, `hs list`, `!hs list`, `headscale list`, `!headscale list`. + const tokens = content.split(/\s+/); + const first = (tokens[0] || '').toLowerCase(); + + let parts = tokens; + if (first === 'hs' || first === '!hs' || first === 'headscale' || first === '!headscale') { + parts = tokens.slice(1); + } + + const subcommand = (parts.shift() || '').toLowerCase(); + if (subcommand === 'clear') { + return message.reply('This command is only available in servers.'); + } + + if (subcommand === 'ping') { + return message.reply('Pong!'); + } + + const handler = COMMANDS[subcommand]; + if (handler) return handler(message, parts); + return message.reply(HELP_TEXT); + } + + // Server commands: require a valid prefix. + const prefix = CONFIG.prefixes.find((p) => message.content.startsWith(p)); + if (!prefix) return; + + const afterPrefix = message.content.slice(prefix.length).trim(); + const parts = afterPrefix.split(/\s+/); + const subcommand = (parts.shift() || '').toLowerCase(); + + if (subcommand === 'ping') { + return message.reply('Pong!'); + } + + const handler = COMMANDS[subcommand]; + if (handler) { + return handler(message, parts); + } + + return message.reply(HELP_TEXT); +} + +// ----------------------------------------------------------------------------- +// Interactions (ticket buttons + modal) +// ----------------------------------------------------------------------------- + +client.on('interactionCreate', async (interaction) => { + try { + if (interaction.isButton()) { + if (interaction.customId === IDS.ticketCreateBtn) { + if (!interaction.inGuild()) return interaction.reply({ content: 'Use this in a server.', ephemeral: true }); + const existing = findOpenTicketChannelIdByOwnerId(interaction.user.id); + if (existing) { + return interaction.reply({ content: `You already have an open ticket: <#${existing}>`, ephemeral: true }); + } + + await interaction.reply({ content: 'Creating your private ticket channel...', ephemeral: true }); + const member = await interaction.guild.members.fetch(interaction.user.id); + const { channel, ticketRecord } = await createTicketChannel({ + guild: interaction.guild, + requester: member, + topic: 'Support', + details: '', + }); + return interaction.editReply({ + content: `✅ Ticket created: <#${channel.id}> (Ticket #${ticketRecord.ticketNumber})`, + }); + } + + if (interaction.customId === IDS.ticketCloseBtn) { + if (!interaction.inGuild()) return interaction.reply({ content: 'Use this in a server.', ephemeral: true }); + if (!interaction.channel || !isTicketChannel(interaction.channel)) { + return interaction.reply({ content: 'This is not a ticket channel.', ephemeral: true }); + } + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!isSupport(member)) { + return interaction.reply({ content: 'Support/Admin only.', ephemeral: true }); + } + const res = await closeTicketChannel({ channel: interaction.channel, closedBy: member }); + if (res.alreadyClosed) { + return interaction.reply({ content: 'Ticket already closed.', ephemeral: true }); + } + return interaction.reply({ content: 'Ticket closed.', ephemeral: true }); + } + } + + if (interaction.isModalSubmit()) { + return interaction.reply({ content: 'This bot no longer uses forms for tickets.', ephemeral: true }); + } + } catch (e) { + console.error('Interaction error:', e); + if (interaction.isRepliable()) { + const msg = '❌ Something went wrong while handling that.'; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}); + } else { + await interaction.reply({ content: msg, ephemeral: true }).catch(() => {}); + } + } + } +}); + +// ----------------------------------------------------------------------------- +// Events & lifecycle +// ----------------------------------------------------------------------------- + +client.once('clientReady', async () => { + await Promise.all([loadMappings(), loadTickets(), loadAdminUsers()]); + console.log(`Logged in as ${client.user.tag}`); + console.log(`Prefixes: ${CONFIG.prefixes.join(', ')}`); + console.log(`Version: ${CONFIG.version}`); + console.log(`Headscale URL: ${CONFIG.headscaleUrl} (container: ${CONFIG.headscaleContainer})`); + updateBotStatus(); + + if (!CONFIG.supportRoleId) { + console.warn('TICKET_SUPPORT_ROLE_ID is not set. Tickets will be private to the user + admin role only.'); + } + + // DM bot owner on startup with admin command embed + if (CONFIG.botOwnerId) { + try { + const owner = await client.users.fetch(CONFIG.botOwnerId); + if (owner) { + // Create a mock message object for the admin command + const mockMessage = { + author: { id: CONFIG.botOwnerId }, + reply: async (content) => owner.send(content) + }; + await cmdAdmin(mockMessage); + } + } catch (e) { + console.warn('Failed to DM bot owner on startup:', e); + } + } +}); + +client.on('messageCreate', handleMessage); + +async function shutdown() { + await Promise.all([saveMappings(), saveTickets(), saveAdminUsers()]); + console.log('State saved, shutting down...'); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +(async () => { + try { + await client.login(process.env.DISCORD_TOKEN); + } catch (err) { + console.error('Failed to log in to Discord:', err); + process.exit(1); + } +})(); + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0577821 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,299 @@ +{ + "name": "autoscale", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autoscale", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "discord.js": "^14.25.1", + "dotenv": "^17.3.1" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.42", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", + "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==" + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==" + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4f6555a --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "autoscale", + "version": "1.0.0", + "main": "index.js", + "repository": "https://github.com/your-github-username/autoscale", + "scripts": { + "start": "node index.js", + "update": "node updater.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "node-fetch": "^3.3.2", + "discord.js": "^14.25.1", + "dotenv": "^17.3.1" + }, + "description": "" +} diff --git a/tickets-state.json b/tickets-state.json new file mode 100644 index 0000000..977e41b --- /dev/null +++ b/tickets-state.json @@ -0,0 +1,3 @@ +{ + "ticketCounter": 0 +} \ No newline at end of file diff --git a/tickets.json b/tickets.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tickets.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/updater.js b/updater.js new file mode 100644 index 0000000..7a856f6 --- /dev/null +++ b/updater.js @@ -0,0 +1,60 @@ +// updater.js - Auto-updater for Autoscale Bot +// Checks the GitHub repository for a newer release and updates the bot. +// Requires node-fetch (added to dependencies). + +const fetch = require('node-fetch'); +const { execSync } = require('child_process'); +const fs = require('fs'); + +// Configuration - replace with actual repo owner/name if different. +const REPO = 'your-github-username/autoscale'; +const GITHUB_API = `https://api.github.com/repos/${REPO}/releases/latest`; + +function getCurrentVersion() { + try { + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + return pkg.version; + } catch (e) { + console.error('Failed to read package.json:', e); + return null; + } +} + +async function getLatestRelease() { + try { + const resp = await fetch(GITHUB_API, { headers: { 'User-Agent': 'autoscale-updater' } }); + if (!resp.ok) throw new Error(`GitHub API error ${resp.status}`); + const data = await resp.json(); + return { version: data.tag_name.replace(/^v/, ''), tarball_url: data.tarball_url }; + } catch (e) { + console.error('Failed to fetch latest release:', e); + return null; + } +} + +async function runUpdate() { + const current = getCurrentVersion(); + const latest = await getLatestRelease(); + if (!current || !latest) return; + + if (current === latest.version) { + console.log(`✅ Already up‑to‑date (v${current}).`); + return; + } + + console.log(`🔄 Updating from v${current} to v${latest.version}…`); + try { + // Pull latest code from git (assumes the repo is a git clone) + execSync('git fetch --all', { stdio: 'inherit' }); + execSync(`git checkout v${latest.version}`, { stdio: 'inherit' }); + // Install any new dependencies + execSync('npm install', { stdio: 'inherit' }); + console.log('✅ Update complete. Restart the bot (e.g., pm2 restart autoscale).'); + } catch (e) { + console.error('Update failed:', e); + } +} + +if (require.main === module) { + runUpdate(); +}