Files
autoscale/index.js
T
2026-09-30 23:06:22 -04:00

1479 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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 <username>` — Create a Headscale user',
'`discord <username>` — Link your Discord to a Headscale user',
'`link <nodekey>` or `link <username> <nodekey>` — 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 <username>` — 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 <username>` — Create your Headscale user',
'`discord <username>` — Link your Discord to your Headscale user',
'`link <nodekey>` or `link <username> <nodekey>` — 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 <username>` — Create a Headscale user\n`discord <username>` — Link Discord to Headscale user\n`link <nodekey>` or `link <username> <nodekey>` — Register a node\n`register` — Get Tailscale setup command\n`list` — Show all nodes and their IPs\n`rename <id> <name>` — 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 <username>` — Create a Headscale user\n`discord <username>` — Link Discord to Headscale user\n`link <nodekey>` or `link <username> <nodekey>` — Register a node\n`register` — Get Tailscale setup command\n`list` — Show all nodes and their IPs\n`rename <id> <name>` — 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 <username>` — Delete Headscale user (Admin only)\n`removeuser <username>` — Alias for deleteuser (Admin only)\n`removenode <id>` — Force remove node (Admin only)\n`deletenode <id>` — Alias for removenode (Admin only)\n`preauth <username>` — 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 <username>\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 <username>\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 <nodekey>\n\`\`\``,
inline: false,
},
{
name: '✅ Quick Commands Reference',
value: '`!hs create <username>` - Create user\n`!hs discord <username>` - Link Discord\n`!hs register` - Get setup command\n`!hs link <nodekey>` - 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 <username>` to link your account.');
}
const nodeId = args[0];
const newName = args[1];
if (!nodeId || !newName) {
return message.reply('Usage: `!hs rename <node_id> <new_name>`');
}
// 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 <username>`');
}
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 <node_id>`');
}
// 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 <username>`');
}
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 <user_id>`');
}
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 <user_id>`');
}
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 <username>`');
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 <username>`');
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 <nodekey>\` 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 <nodekey>` or `link <username> <nodekey>`');
}
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 <username>\` first, then \`link <nodekey>\`.`
);
}
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 <nodekey>\` 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);
}
})();