1479 lines
51 KiB
JavaScript
1479 lines
51 KiB
JavaScript
/// 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);
|
||
}
|
||
})();
|
||
|