/// 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); } })();