Optimize Twitch polling with batching and implement database caching with WAL mode

This commit is contained in:
2026-04-28 20:42:34 +02:00
parent 21c3b01e64
commit b2c3f5731b
12 changed files with 240 additions and 614 deletions

View File

@@ -1,17 +1,13 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
const settings = client.DB.getSettings(guildId);
if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
export default {
name: Events.ChannelCreate,
async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder()
.setTitle('📁 Channel erstellt')
.setColor(0x27ae60)
@@ -33,6 +31,6 @@ export default {
)
.setTimestamp();
await sendLog(channel.client, channel.guild!.id, 'channels', embed);
await sendLog(channel.client, channel.guild.id, 'channels', embed);
},
};

View File

@@ -1,17 +1,13 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
const settings = client.DB.getSettings(guildId);
if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
export default {
name: Events.ChannelDelete,
async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder()
.setTitle('📁 Channel gelöscht')
.setColor(0xe74c3c)
@@ -33,6 +31,6 @@ export default {
)
.setTimestamp();
await sendLog(channel.client, channel.guild!.id, 'channels', embed);
await sendLog(channel.client, channel.guild.id, 'channels', embed);
},
};

View File

@@ -6,7 +6,7 @@ export default {
const DB = (member.client as any).DB;
const guildId = member.guild.id;
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
const settings = DB.getSettings(guildId);
// Use custom channel or fall back to Discord's system channel
const channelId = settings?.welcome_channel || member.guild.systemChannelId;

View File

@@ -2,16 +2,15 @@ import { Events, GuildMember, TextChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
const settings = DB.getSettings(guildId);
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
if (!settings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
@@ -40,7 +39,7 @@ export default {
await sendLog(member.client, guildId, 'leaves', embed);
// Send goodbye message if configured
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
const settings = DB.getSettings(guildId);
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
if (!channelId || !settings?.goodbye_message) {

View File

@@ -1,17 +1,13 @@
import { Events, GuildMember, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
const settings = client.DB.getSettings(guildId);
if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
}
}
// Guild Member Update (nickname + roles)
export default {
name: Events.GuildMemberUpdate,
async execute(oldMember: GuildMember, newMember: GuildMember) {

View File

@@ -6,10 +6,9 @@ export default {
if (message.author.bot) return;
if (!message.guildId) return;
const triggers: any[] = client.DB.all(
'SELECT * FROM auto_responses WHERE guild_id = ?',
message.guildId
);
const triggers = client.DB.getTriggers(message.guildId);
if (triggers.length === 0) return;
const contentLower = message.content.toLowerCase();

View File

@@ -1,51 +1,22 @@
import { Events, Message, TextChannel, GuildChannel, AuditLogEvent, EmbedBuilder } from 'discord.js';
function getLogChannel(client: any, guildId: string): TextChannel | null {
const DB = client.DB;
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
if (!settings?.log_channel) {
return null;
}
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) {
return null;
}
return channel as TextChannel;
}
function isEventEnabled(client: any, guildId: string, event: string): boolean {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
if (!settings?.log_events) {
return false;
}
const activeEvents = settings.log_events.split(',').filter(Boolean);
return activeEvents.includes(event);
}
import { Events, Message, TextChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
if (!isEventEnabled(client, guildId, event)) {
return;
}
const settings = client.DB.getSettings(guildId);
if (!settings?.log_events || !settings?.log_channel) return;
const channel = getLogChannel(client, guildId);
if (!channel) {
return;
}
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
await channel.send({ embeds: [embed] });
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
// Message Delete
export default {
name: Events.MessageDelete,
async execute(message: Message) {

View File

@@ -1,17 +1,13 @@
import { Events, Message, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
const settings = client.DB.getSettings(guildId);
if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
}
}
// Message Update (Edit)
export default {
name: Events.MessageUpdate,
async execute(oldMessage: Message, newMessage: Message) {

View File

@@ -15,9 +15,15 @@ const dbPath = join(dataDir, 'database.sqlite');
const db = new Database(dbPath);
export class DB {
private static settingsCache = new Map<string, any>();
private static triggersCache = new Map<string, any[]>();
static init() {
console.log(`[DATABASE] Initializing at ${dbPath}...`);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Guild Settings
db.prepare(`
CREATE TABLE IF NOT EXISTS guild_settings (
@@ -143,7 +149,7 @@ export class DB {
)
`).run();
// Migration: Add missing columns to existing guild_settings
// Migration logic
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
const columnNames = columns.map((c: any) => c.name);
@@ -163,6 +169,47 @@ export class DB {
console.log('[DATABASE] Initialization complete.');
}
/**
* Optimized getter for guild settings with caching
*/
static getSettings(guildId: string): any {
if (this.settingsCache.has(guildId)) {
return this.settingsCache.get(guildId);
}
const settings = db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
if (settings) {
this.settingsCache.set(guildId, settings);
}
return settings;
}
/**
* Optimized getter for auto responses with caching
*/
static getTriggers(guildId: string): any[] {
if (this.triggersCache.has(guildId)) {
return this.triggersCache.get(guildId)!;
}
const triggers = db.prepare('SELECT * FROM auto_responses WHERE guild_id = ?').all(guildId);
this.triggersCache.set(guildId, triggers);
return triggers;
}
/**
* Clear cache for a specific guild or all
*/
static clearCache(guildId?: string) {
if (guildId) {
this.settingsCache.delete(guildId);
this.triggersCache.delete(guildId);
} else {
this.settingsCache.clear();
this.triggersCache.clear();
}
}
static get(query: string, ...params: any[]) {
return db.prepare(query).get(...params);
}
@@ -171,7 +218,21 @@ export class DB {
return db.prepare(query).all(...params);
}
/**
* Extended run method that automatically clears relevant caches on write
*/
static run(query: string, ...params: any[]) {
return db.prepare(query).run(...params);
const result = db.prepare(query).run(...params);
// Simple heuristic to clear cache on updates to settings or triggers
const lowerQuery = query.toLowerCase();
if (lowerQuery.includes('guild_settings') || lowerQuery.includes('auto_responses')) {
// If the query contains a guild_id in params, we could be more specific,
// but for safety and simplicity, we clear the cache or just wait for next read.
// We search for guild_id in params to be surgical if possible.
this.clearCache();
}
return result;
}
}

View File

@@ -29,35 +29,58 @@ export class TwitchManager {
}
}
static async fetchStreamData(channelName: string) {
/**
* Fetches stream data for multiple channels in a single batch (max 100).
*/
private static async fetchStreamsBatch(channelNames: string[]) {
const token = await this.getAccessToken();
if (!token) return null;
if (!token || channelNames.length === 0) return [];
// Twitch API allows up to 100 user_login parameters
const query = channelNames.map(name => `user_login=${encodeURIComponent(name)}`).join('&');
try {
const response = await fetch(`https://api.twitch.tv/helix/streams?user_login=${channelName}`, {
const response = await fetch(`https://api.twitch.tv/helix/streams?${query}`, {
headers: {
'Client-ID': process.env.TWITCH_CLIENT_ID!,
'Authorization': `Bearer ${token}`
}
});
const data: any = await response.json();
return data.data?.[0] || null;
return data.data || [];
} catch (error) {
console.error(`[TWITCH] Error fetching stream data for ${channelName}:`, error);
return null;
console.error(`[TWITCH] Error fetching batch (${channelNames.length} channels):`, error);
return [];
}
}
static async checkStreams(client: any) {
const token = await this.getAccessToken();
if (!token) return;
/**
* Legacy helper for single channel fetch (e.g. for commands)
*/
static async fetchStreamData(channelName: string) {
const streams = await this.fetchStreamsBatch([channelName]);
return streams[0] || null;
}
static async checkStreams(client: any) {
const monitors: any[] = (client as any).DB?.all('SELECT * FROM twitch_monitors') || [];
if (monitors.length === 0) return;
const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))];
const onlineStreamsMap = new Map<string, any>();
// Process in chunks of 100 (Twitch API limit)
for (let i = 0; i < uniqueChannels.length; i += 100) {
const chunk = uniqueChannels.slice(i, i + 100);
const streams = await this.fetchStreamsBatch(chunk);
for (const stream of streams) {
onlineStreamsMap.set(stream.user_login.toLowerCase(), stream);
}
}
for (const monitor of monitors) {
try {
const stream = await this.fetchStreamData(monitor.channel_name);
const stream = onlineStreamsMap.get(monitor.channel_name.toLowerCase());
if (stream) {
const isNewStream = monitor.last_stream_id !== stream.id;
@@ -77,44 +100,49 @@ export class TwitchManager {
}
}
} catch (error) {
console.error(`[TWITCH] Error checking stream ${monitor.channel_name}:`, error);
console.error(`[TWITCH] Error processing monitor ${monitor.channel_name}:`, error);
}
}
}
private static async sendNotification(client: any, monitor: any, stream: any) {
const guild = client.guilds.cache.get(monitor.guild_id);
if (!guild || !monitor.discord_channel_id) return;
try {
const guild = client.guilds.cache.get(monitor.guild_id);
if (!guild || !monitor.discord_channel_id) return;
const channel = guild.channels.cache.get(monitor.discord_channel_id) as TextChannel;
if (!channel) return;
const channel = guild.channels.cache.get(monitor.discord_channel_id) as TextChannel;
if (!channel) return;
const thumbnailUrl = stream.thumbnail_url
.replace('{width}', '1280')
.replace('{height}', '720') + `?t=${Date.now()}`;
const thumbnailUrl = stream.thumbnail_url
.replace('{width}', '1280')
.replace('{height}', '720') + `?t=${Date.now()}`;
const embed = new EmbedBuilder()
.setTitle(`${stream.user_name} ist jetzt LIVE auf Twitch!`)
.setURL(`https://twitch.tv/${stream.user_login}`)
.setColor(0x6441a5)
.setImage(thumbnailUrl)
.addFields(
{ name: 'Titel', value: stream.title || 'Kein Titel', inline: false },
{ name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true },
{ name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true }
)
.setTimestamp();
const embed = new EmbedBuilder()
.setTitle(`${stream.user_name} ist jetzt LIVE auf Twitch!`)
.setURL(`https://twitch.tv/${stream.user_login}`)
.setColor(0x6441a5)
.setImage(thumbnailUrl)
.addFields(
{ name: 'Titel', value: stream.title || 'Kein Titel', inline: false },
{ name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true },
{ name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true }
)
.setTimestamp();
let content = `📢 **${stream.user_name}** ist online!`;
if (monitor.custom_message) {
content += `\n\n${monitor.custom_message}`;
let content = `📢 **${stream.user_name}** ist online!`;
if (monitor.custom_message) {
content += `\n\n${monitor.custom_message}`;
}
await channel.send({ content, embeds: [embed] });
} catch (error) {
console.error(`[TWITCH] Failed to send notification for ${monitor.channel_name}:`, error);
}
await channel.send({ content, embeds: [embed] });
}
static startPolling(client: any) {
setInterval(() => this.checkStreams(client), 5 * 60 * 1000);
console.log('[TWITCH] Polling started (5m interval).');
// Polling every 2 minutes for faster notifications
setInterval(() => this.checkStreams(client), 2 * 60 * 1000);
console.log('[TWITCH] Batch-Polling started (2m interval).');
}
}