Update backend, DB, Commands, Live Reloading
This commit is contained in:
@@ -163,4 +163,49 @@ async function _rawGetTwitchStreams(usersCsv) {
|
||||
try { return await res.json(); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser };
|
||||
// Kick users helpers
|
||||
async function getKickUsers(guildId) {
|
||||
const path = `/api/servers/${guildId}/kick-users`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function addKickUser(guildId, username) {
|
||||
const path = `/api/servers/${guildId}/kick-users`;
|
||||
try {
|
||||
const res = await tryFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKickUser(guildId, username) {
|
||||
const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`;
|
||||
try {
|
||||
const res = await tryFetch(path, { method: 'DELETE' });
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getWelcomeLeaveSettings(guildId) {
|
||||
const path = `/api/servers/${guildId}/welcome-leave-settings`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || { welcome: { enabled: false }, leave: { enabled: false } };
|
||||
}
|
||||
|
||||
async function getAutoroleSettings(guildId) {
|
||||
const path = `/api/servers/${guildId}/autorole-settings`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || { enabled: false, roleId: '' };
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings };
|
||||
|
||||
43
discord-bot/commands/add-kickuser.js
Normal file
43
discord-bot/commands/add-kickuser.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
module.exports = {
|
||||
name: 'add-kickuser',
|
||||
description: 'Admin: add a Kick username to watch for this server (DISABLED)',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('add-kickuser')
|
||||
.setDescription('Add a Kick username to watch for live notifications')
|
||||
.addStringOption(opt => opt.setName('username').setDescription('Kick username').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
|
||||
});
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Added ${username} to Kick watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend so watcher sees new user immediately
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error adding kick user:', e);
|
||||
await interaction.reply({ content: 'Internal error adding kick user.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,15 @@ module.exports = {
|
||||
});
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend so watcher sees new user immediately
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||
}
|
||||
|
||||
24
discord-bot/commands/list-kickusers.js
Normal file
24
discord-bot/commands/list-kickusers.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { SlashCommandBuilder } = require('discord.js');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'list-kickusers',
|
||||
description: 'List watched Kick usernames for this server (DISABLED).',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder().setName('list-kickusers').setDescription('List watched Kick usernames for this server'),
|
||||
async execute(interaction) {
|
||||
try {
|
||||
const users = await api.getKickUsers(interaction.guildId) || [];
|
||||
if (!users || users.length === 0) {
|
||||
await interaction.reply({ content: 'No Kick users are being watched for this server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const list = users.map(u => `• ${u}`).join('\n');
|
||||
await interaction.reply({ content: `Watched Kick users:\n${list}`, ephemeral: true });
|
||||
} catch (e) {
|
||||
console.error('Error listing kick users:', e);
|
||||
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -20,7 +20,7 @@ module.exports = {
|
||||
let toggles = existingSettings.commandToggles;
|
||||
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
|
||||
// `ping` are also listed. Filter for objects with a name for safety.
|
||||
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name);
|
||||
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev);
|
||||
|
||||
// Build button components (max 5 rows, 5 buttons per row)
|
||||
const actionRows = [];
|
||||
|
||||
41
discord-bot/commands/remove-kickuser.js
Normal file
41
discord-bot/commands/remove-kickuser.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
module.exports = {
|
||||
name: 'remove-kickuser',
|
||||
description: 'Admin: remove a Kick username from this server watch list',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('remove-kickuser')
|
||||
.setDescription('Remove a Kick username from the watch list')
|
||||
.addStringOption(opt => opt.setName('username').setDescription('Kick username to remove').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Removed ${username} from Kick watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error removing kick user:', e);
|
||||
await interaction.reply({ content: 'Internal error removing kick user.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,15 @@ module.exports = {
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
const { readDb, writeDb } = require('../../backend/db');
|
||||
|
||||
module.exports = {
|
||||
name: 'setup-live',
|
||||
description: 'Admin: configure Twitch live notifications for this server',
|
||||
description: 'Admin: enable or disable Twitch live notifications for this server',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('setup-live')
|
||||
.setDescription('Configure Twitch live notifications for this server')
|
||||
.addStringOption(opt => opt.setName('twitch_user').setDescription('Twitch username to watch').setRequired(true))
|
||||
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true))
|
||||
.setDescription('Enable or disable Twitch live notifications for this server')
|
||||
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
|
||||
|
||||
async execute(interaction) {
|
||||
@@ -20,24 +16,18 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
const twitchUser = interaction.options.getString('twitch_user');
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
|
||||
try {
|
||||
const api = require('../api');
|
||||
const existing = (await api.getServerSettings(interaction.guildId)) || {};
|
||||
existing.liveNotifications = { enabled: !!enabled, twitchUser, channelId: channel.id };
|
||||
const currentLn = existing.liveNotifications || {};
|
||||
existing.liveNotifications = { ...currentLn, enabled: !!enabled };
|
||||
await api.upsertServerSettings(interaction.guildId, existing);
|
||||
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 });
|
||||
} catch (e) {
|
||||
console.error('Error saving live notifications to backend, falling back to local:', e);
|
||||
// fallback to local db
|
||||
const db = readDb();
|
||||
if (!db[interaction.guildId]) db[interaction.guildId] = {};
|
||||
db[interaction.guildId].liveNotifications = { enabled, twitchUser, channelId: channel.id };
|
||||
writeDb(db);
|
||||
await interaction.reply({ content: `Saved locally: Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||
console.error('Error saving live notifications to backend:', e);
|
||||
await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
|
||||
for (const file of commandFiles) {
|
||||
const filePath = path.join(commandsPath, file);
|
||||
const command = require(filePath);
|
||||
if (command.enabled === false) continue;
|
||||
if (command.enabled === false || command.dev === true) continue;
|
||||
|
||||
if (command.builder) {
|
||||
commands.push(command.builder.toJSON());
|
||||
|
||||
@@ -1,72 +1,46 @@
|
||||
const { Events } = require('discord.js');
|
||||
const { readDb } = require('../../backend/db.js');
|
||||
|
||||
module.exports = {
|
||||
name: Events.GuildMemberAdd,
|
||||
async execute(member) {
|
||||
try {
|
||||
const api = require('../api');
|
||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||
try {
|
||||
const api = require('../api');
|
||||
// Get the welcome/leave settings from the API
|
||||
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } };
|
||||
const welcome = welcomeLeaveSettings.welcome;
|
||||
|
||||
const welcome = {
|
||||
enabled: settings.welcomeEnabled || false,
|
||||
channel: settings.welcomeChannel || '',
|
||||
message: settings.welcomeMessage || 'Welcome {user} to {server}!'
|
||||
};
|
||||
|
||||
if (welcome && welcome.enabled && welcome.channel) {
|
||||
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (welcome.message).replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
if (welcome && welcome.enabled && welcome.channel) {
|
||||
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const autorole = settings.autorole || {};
|
||||
if (autorole && autorole.enabled && autorole.roleId) {
|
||||
const role = member.guild.roles.cache.get(autorole.roleId);
|
||||
if (role) {
|
||||
try {
|
||||
// Re-check that role is assignable
|
||||
const botHighest = member.guild.members.me.roles.highest.position;
|
||||
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
|
||||
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
|
||||
return;
|
||||
}
|
||||
await member.roles.add(role);
|
||||
} catch (error) {
|
||||
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
|
||||
// fallback to local db
|
||||
try {
|
||||
const db = readDb();
|
||||
const settings = db[member.guild.id];
|
||||
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
|
||||
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
|
||||
const role = member.guild.roles.cache.get(settings.autorole.roleId);
|
||||
if (role) {
|
||||
try { await member.roles.add(role); } catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (inner) {
|
||||
// ignore fallback errors
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle autorole
|
||||
const autoroleSettings = await api.getAutoroleSettings(member.guild.id) || { enabled: false };
|
||||
if (autoroleSettings && autoroleSettings.enabled && autoroleSettings.roleId) {
|
||||
const role = member.guild.roles.cache.get(autoroleSettings.roleId);
|
||||
if (role) {
|
||||
try {
|
||||
// Re-check that role is assignable
|
||||
const botHighest = member.guild.members.me.roles.highest.position;
|
||||
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
|
||||
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
|
||||
return;
|
||||
}
|
||||
await member.roles.add(role);
|
||||
} catch (error) {
|
||||
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,19 @@
|
||||
const { Events } = require('discord.js');
|
||||
const { readDb } = require('../../backend/db.js');
|
||||
|
||||
module.exports = {
|
||||
name: Events.GuildMemberRemove,
|
||||
async execute(member) {
|
||||
try {
|
||||
const api = require('../api');
|
||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||
const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' };
|
||||
// Get the welcome/leave settings from the API
|
||||
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
|
||||
const leave = welcomeLeaveSettings.leave;
|
||||
|
||||
if (leave && leave.enabled && leave.channel) {
|
||||
const channel = member.guild.channels.cache.get(leave.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (leave.message).replace('{user}', member.user.toString());
|
||||
const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString());
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
|
||||
@@ -22,20 +22,6 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error);
|
||||
// fallback to local db
|
||||
try {
|
||||
const db = readDb();
|
||||
const settings = db[member.guild.id];
|
||||
if (settings && settings.leaveEnabled && settings.leaveChannel) {
|
||||
const channel = member.guild.channels.cache.get(settings.leaveChannel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', member.user.toString());
|
||||
await channel.send(message);
|
||||
} catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (inner) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ module.exports = (client) => {
|
||||
// Clear require cache to allow updates during development
|
||||
delete require.cache[require.resolve(filePath)];
|
||||
const command = require(filePath);
|
||||
if (command.name) {
|
||||
if (command.name && !command.dev) {
|
||||
client.commands.set(command.name, command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,19 +145,28 @@ async function announceLive(guildId, stream) {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) return { success: false, message: 'Channel not found' };
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||
.setURL(stream.url)
|
||||
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
|
||||
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription(stream.description || '')
|
||||
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||
await channel.send({ embeds: [embed] });
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||
.setURL(stream.url)
|
||||
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
|
||||
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription((stream.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🔴 ${stream.user_name} is now live!`;
|
||||
}
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('announceLive failed:', e && e.message ? e.message : e);
|
||||
@@ -170,8 +179,7 @@ module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, a
|
||||
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||
try {
|
||||
const watcher = require('./twitch-watcher');
|
||||
// discord.js renamed the ready event to clientReady; the event loader registers
|
||||
// handlers based on event.name so we listen for the same 'clientReady' here.
|
||||
// discord.js uses 'clientReady' event
|
||||
client.once('clientReady', () => {
|
||||
// start polling in background
|
||||
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
|
||||
@@ -182,6 +190,19 @@ try {
|
||||
// ignore if watcher not available
|
||||
}
|
||||
|
||||
try {
|
||||
const kickWatcher = require('./kick-watcher');
|
||||
client.once('clientReady', () => {
|
||||
// TEMPORARILY DISABLED: Kick watcher removed for now
|
||||
// kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err));
|
||||
console.log('Kick watcher: temporarily disabled');
|
||||
});
|
||||
// process.on('exit', () => { kickWatcher.stop(); });
|
||||
// process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); });
|
||||
} catch (e) {
|
||||
// ignore if kick watcher not available
|
||||
}
|
||||
|
||||
// --- Optional push receiver (so backend can notify a remote bot process) ---
|
||||
try {
|
||||
const express = require('express');
|
||||
|
||||
294
discord-bot/kick-watcher.js
Normal file
294
discord-bot/kick-watcher.js
Normal file
@@ -0,0 +1,294 @@
|
||||
const api = require('./api');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Kick API helpers (web scraping since no public API)
|
||||
let polling = false;
|
||||
const pollIntervalMs = Number(process.env.KICK_POLL_INTERVAL_MS || 15000); // 15s default (slower than Twitch)
|
||||
|
||||
// Keep track of which streams we've already announced per guild:user -> { started_at }
|
||||
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
|
||||
|
||||
// Simple web scraping to check if a Kick user is live
|
||||
async function checkKickUserLive(username) {
|
||||
try {
|
||||
// First try the API endpoint
|
||||
const apiUrl = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
|
||||
const apiController = new AbortController();
|
||||
const apiTimeoutId = setTimeout(() => apiController.abort(), 5000);
|
||||
|
||||
const apiResponse = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://kick.com/'
|
||||
},
|
||||
signal: apiController.signal
|
||||
});
|
||||
|
||||
clearTimeout(apiTimeoutId);
|
||||
|
||||
if (apiResponse.ok) {
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (data && data.livestream && data.livestream.is_live) {
|
||||
return {
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: data.user?.username || username,
|
||||
title: data.livestream.session_title || `${username} is live`,
|
||||
viewer_count: data.livestream.viewer_count || 0,
|
||||
started_at: data.livestream.start_time,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: data.livestream.thumbnail?.url || null,
|
||||
category: data.category?.name || 'Unknown',
|
||||
description: data.user?.bio || ''
|
||||
};
|
||||
}
|
||||
|
||||
return { is_live: false, user_login: username };
|
||||
}
|
||||
|
||||
// If API fails with 403, try web scraping as fallback
|
||||
if (apiResponse.status === 403) {
|
||||
console.log(`API blocked for ${username}, trying web scraping fallback...`);
|
||||
|
||||
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(() => pageController.abort(), 5000);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Cache-Control': 'max-age=0'
|
||||
},
|
||||
signal: pageController.signal
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (pageResponse.ok) {
|
||||
const html = await pageResponse.text();
|
||||
|
||||
// Check for live stream indicators in the HTML
|
||||
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
|
||||
html.includes('data-is-live="true"') || html.includes('isLive:true');
|
||||
|
||||
if (isLive) {
|
||||
// Try to extract stream info from HTML
|
||||
let title = `${username} is live`;
|
||||
let viewerCount = 0;
|
||||
let category = 'Unknown';
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(/\\"/g, '"');
|
||||
}
|
||||
|
||||
// Extract viewer count
|
||||
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
|
||||
if (viewerMatch) {
|
||||
viewerCount = parseInt(viewerMatch[1]);
|
||||
}
|
||||
|
||||
// Extract category
|
||||
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
|
||||
if (categoryMatch) {
|
||||
category = categoryMatch[1];
|
||||
}
|
||||
|
||||
return {
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: title,
|
||||
viewer_count: viewerCount,
|
||||
started_at: new Date().toISOString(),
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: category,
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { is_live: false, user_login: username };
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.error(`Timeout checking Kick user ${username}`);
|
||||
} else {
|
||||
console.error(`Failed to check Kick user ${username}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
return { is_live: false, user_login: username };
|
||||
}
|
||||
}
|
||||
|
||||
// Check all Kick users for a guild
|
||||
async function checkKickStreamsForGuild(guildId, usernames) {
|
||||
const results = [];
|
||||
for (const username of usernames) {
|
||||
try {
|
||||
const stream = await checkKickUserLive(username);
|
||||
if (stream.is_live) {
|
||||
results.push(stream);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error checking Kick user ${username}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
// Small delay between requests to be respectful
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function checkGuild(client, guild) {
|
||||
try {
|
||||
// Get settings for this guild
|
||||
const settings = await api.getServerSettings(guild.id) || {};
|
||||
const liveSettings = settings.liveNotifications || {};
|
||||
if (!liveSettings.enabled) return;
|
||||
|
||||
const channelId = liveSettings.channelId;
|
||||
const users = (liveSettings.kickUsers || []).map(u => u.toLowerCase()).filter(Boolean);
|
||||
if (!channelId || users.length === 0) return;
|
||||
|
||||
// Check which users are live
|
||||
const live = await checkKickStreamsForGuild(guild.id, users);
|
||||
if (!live || live.length === 0) {
|
||||
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
if (announced.has(key)) {
|
||||
announced.delete(key);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch channel using client to ensure we can reach it
|
||||
let channel = null;
|
||||
try {
|
||||
channel = await client.channels.fetch(channelId);
|
||||
if (channel.type !== 0) { // 0 is text channel
|
||||
console.error(`KickWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
|
||||
channel = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`KickWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||
channel = null;
|
||||
}
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a map of live logins for quick lookup
|
||||
const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase()));
|
||||
|
||||
// Clear announced entries for users that are no longer live
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
if (!liveLogins.has(u) && announced.has(key)) {
|
||||
announced.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Announce each live once per live session
|
||||
for (const s of live) {
|
||||
const login = (s.user_login || '').toLowerCase();
|
||||
const key = `${guild.id}:${login}`;
|
||||
if (announced.has(key)) continue; // already announced for this live session
|
||||
|
||||
// Mark announced for this session
|
||||
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||
|
||||
// Build and send embed (similar to Twitch but with Kick branding)
|
||||
try {
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x53FC18) // Kick green color
|
||||
.setTitle(s.title || `${s.user_name} is live`)
|
||||
.setURL(s.url)
|
||||
.setAuthor({ name: s.user_name, iconURL: s.thumbnail_url || undefined, url: s.url })
|
||||
.setThumbnail(s.thumbnail_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: s.category || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription((s.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||
|
||||
// Determine message text (custom overrides default)
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🟢 ${s.user_name} is now live on Kick!`;
|
||||
}
|
||||
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
console.log(`🔔 Announced Kick live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
} catch (e) {
|
||||
console.error(`KickWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||
// Fallback to simple message
|
||||
const msg = `🟢 ${s.user_name} is live on Kick: **${s.title}**\nWatch: ${s.url}`;
|
||||
try {
|
||||
await channel.send({ content: msg });
|
||||
console.log('KickWatcher: fallback message sent');
|
||||
} catch (err) {
|
||||
console.error('KickWatcher: fallback send failed:', err && err.message ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking guild for Kick live streams:', e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(client) {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
console.log(`🔁 KickWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
|
||||
|
||||
// Initial check on restart: send messages for currently live users
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error('KickWatcher: initial checkGuild error', err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during initial Kick check:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
while (polling) {
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error('KickWatcher: checkGuild error', err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during Kick poll loop:', e && e.message ? e.message : e);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
function stop() { polling = false; }
|
||||
|
||||
module.exports = { poll, stop };
|
||||
@@ -1,4 +1,62 @@
|
||||
const api = require('./api');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio.
|
||||
const twitchClientId = process.env.TWITCH_CLIENT_ID || null;
|
||||
const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null;
|
||||
let twitchAppToken = null; // cached app access token
|
||||
let twitchTokenExpires = 0;
|
||||
|
||||
// Cache of user login -> { description, profile_image_url, fetchedAt }
|
||||
const userInfoCache = new Map();
|
||||
|
||||
async function getAppToken() {
|
||||
if (!twitchClientId || !twitchClientSecret) return null;
|
||||
const now = Date.now();
|
||||
if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early
|
||||
return twitchAppToken;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' });
|
||||
const json = await res.json();
|
||||
twitchAppToken = json.access_token;
|
||||
twitchTokenExpires = now + (json.expires_in * 1000);
|
||||
return twitchAppToken;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo(login) {
|
||||
if (!login) return null;
|
||||
const lower = login.toLowerCase();
|
||||
const cached = userInfoCache.get(lower);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache
|
||||
return cached;
|
||||
}
|
||||
const token = await getAppToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, {
|
||||
headers: {
|
||||
'Client-ID': twitchClientId,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const json = await res.json();
|
||||
const data = (json.data && json.data[0]) || null;
|
||||
if (data) {
|
||||
const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now };
|
||||
userInfoCache.set(lower, info);
|
||||
return info;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let polling = false;
|
||||
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
|
||||
@@ -45,6 +103,10 @@ async function checkGuild(client, guild) {
|
||||
let channel = null;
|
||||
try {
|
||||
channel = await client.channels.fetch(channelId);
|
||||
if (channel.type !== 0) { // 0 is text channel
|
||||
console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
|
||||
channel = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||
channel = null;
|
||||
@@ -73,10 +135,16 @@ async function checkGuild(client, guild) {
|
||||
// mark announced for this session
|
||||
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||
|
||||
// Build and send embed
|
||||
// Build and send embed (standardized layout)
|
||||
try {
|
||||
// Announce without per-guild log spam
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
// Attempt to enrich with user bio (description) if available
|
||||
let bio = '';
|
||||
try {
|
||||
const info = await fetchUserInfo(login);
|
||||
if (info && info.description) bio = info.description.slice(0, 200);
|
||||
} catch (_) {}
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setTitle(s.title || `${s.user_name} is live`)
|
||||
@@ -87,10 +155,21 @@ async function checkGuild(client, guild) {
|
||||
{ name: 'Category', value: s.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription(s.description || '')
|
||||
.setDescription(bio || (s.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
// Determine message text (custom overrides default). Provide a plain text prefix if available.
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🔴 ${s.user_name} is now live!`;
|
||||
}
|
||||
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||
@@ -108,6 +187,15 @@ async function poll(client) {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
|
||||
// Initial check on restart: send messages for currently live users
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during initial twitch check:', e && e.message ? e.message : e);
|
||||
}
|
||||
while (polling) {
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
|
||||
Reference in New Issue
Block a user