Add TwitchMonitor IRC chat logging module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 21s

This commit is contained in:
2026-04-19 17:52:41 +00:00
parent b6caf4b3b3
commit 2fcb7cec66
8 changed files with 561 additions and 13 deletions

View File

@@ -0,0 +1,141 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags, ChannelType } from 'discord.js';
import { Command } from '../../structures/Command.js';
import { TwitchMonitor } from '../../structures/TwitchMonitor.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('twitchmonitor')
.setDescription('Twitch IRC Chat-Monitoring konfigurieren')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Fügt einen Twitch-Kanal zum Monitoring hinzu')
.addStringOption(option =>
option.setName('twitch')
.setDescription('Twitch-Kanalname')
.setRequired(true))
.addChannelOption(option =>
option.setName('discord')
.setDescription('Discord-Kanal für Logs')
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Entfernt einen Twitch-Kanal vom Monitoring')
.addStringOption(option =>
option.setName('twitch')
.setDescription('Twitch-Kanalname')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Listet alle überwachten Twitch-Kanäle auf'))
.addSubcommand(subcommand =>
subcommand
.setName('help')
.setDescription('Zeigt Hilfe zu den TwitchMonitor-Befehlen')),
category: 'Admin',
async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB;
if (subcommand === 'help') {
const embed = new EmbedBuilder()
.setTitle('TwitchMonitor Hilfe')
.setColor(0x9146ff)
.setDescription('Überwacht Twitch IRC-Chat für Moderations-Events.')
.addFields(
{ name: '/twitchmonitor add <twitch> <discord>', value: 'Kanal hinzufügen (Admin)', inline: false },
{ name: '/twitchmonitor remove <twitch>', value: 'Kanal entfernen (Admin)', inline: false },
{ name: '/twitchmonitor list', value: 'Alle überwachten Kanäle', inline: false }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
return;
}
if (subcommand === 'list') {
const monitors: any[] = DB.all('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? ORDER BY twitch_channel ASC', guildId);
if (monitors.length === 0) {
await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', flags: [MessageFlags.Ephemeral] });
return;
}
const embed = new EmbedBuilder()
.setTitle('TwitchMonitor Kanäle')
.setColor(0x9146ff)
.setDescription(monitors.map((m: any) =>
`• **${m.twitch_channel}** → <#${m.discord_channel_id}>`
).join('\n'))
.setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
return;
}
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', flags: [MessageFlags.Ephemeral] });
return;
}
if (subcommand === 'add') {
const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, '');
const discordChannel = interaction.options.getChannel('discord');
const existing = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
if (existing) {
await interaction.reply({ content: `${twitchChannel} wird bereits überwacht.`, flags: [MessageFlags.Ephemeral] });
return;
}
const webhook = await discordChannel.createWebhook({
name: `${twitchChannel} | Mod-Log`,
avatar: undefined,
}).catch(async () => {
const existing_webhook = await discordChannel.fetchWebhooks().then((webhooks: any) =>
webhooks.find((w: any) => w.name?.includes(twitchChannel))
);
if (existing_webhook) return existing_webhook;
throw new Error('Could not create webhook');
});
DB.run(
'INSERT INTO twitch_monitor_channels (guild_id, twitch_channel, discord_channel_id, webhook_url) VALUES (?, ?, ?, ?)',
guildId,
twitchChannel,
discordChannel.id,
webhook.url
);
const monitor = TwitchMonitor.getInstance();
await monitor.addMonitor(guildId, twitchChannel, discordChannel.id, webhook.url);
await interaction.reply({ content: `${twitchChannel} wird jetzt überwacht.`, flags: [MessageFlags.Ephemeral] });
return;
}
if (subcommand === 'remove') {
const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, '');
const monitor: any = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
if (!monitor) {
await interaction.reply({ content: `${twitchChannel} wird nicht überwacht.`, flags: [MessageFlags.Ephemeral] });
return;
}
DB.run('DELETE FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
const twitchMonitor = TwitchMonitor.getInstance();
await twitchMonitor.removeMonitor(guildId, twitchChannel);
await interaction.reply({ content: `${twitchChannel} wurde entfernt.`, flags: [MessageFlags.Ephemeral] });
}
},
};
export default command;

View File

@@ -1,4 +1,5 @@
import { Events } from 'discord.js';
import { TwitchMonitor } from '../structures/TwitchMonitor.js';
async function validateRoles(client: any) {
const categories: any[] = client.DB.all('SELECT * FROM role_categories');
@@ -36,6 +37,26 @@ async function validateRoles(client: any) {
}
}
async function initTwitchMonitor(client: any) {
const monitors: any[] = client.DB.all('SELECT * FROM twitch_monitor_channels');
if (monitors.length === 0) {
console.log('[TWITCH] No channels to monitor.');
return;
}
const monitor = TwitchMonitor.getInstance();
for (const m of monitors) {
const webhookExists = m.webhook_url && m.webhook_url.length > 0;
if (webhookExists) {
await monitor.addMonitor(m.guild_id, m.twitch_channel, m.discord_channel_id, m.webhook_url);
}
}
await monitor.connect();
}
export default {
name: Events.ClientReady,
once: true,
@@ -44,5 +65,6 @@ export default {
console.log(`[READY] Serving ${client.guilds.cache.size} servers`);
await validateRoles(client);
await initTwitchMonitor(client);
},
};

View File

@@ -65,7 +65,7 @@ export class DB {
)
`).run();
// Twitch Monitors
// Twitch Monitors (Stream notifications)
db.prepare(`
CREATE TABLE IF NOT EXISTS twitch_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,6 +79,18 @@ export class DB {
)
`).run();
// Twitch Monitor Channels (IRC chat logging)
db.prepare(`
CREATE TABLE IF NOT EXISTS twitch_monitor_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
twitch_channel TEXT NOT NULL,
discord_channel_id TEXT NOT NULL,
webhook_url TEXT,
UNIQUE(guild_id, twitch_channel)
)
`).run();
// Auto Responses
db.prepare(`
CREATE TABLE IF NOT EXISTS auto_responses (

View File

@@ -0,0 +1,41 @@
export interface ChatMessage {
channel: string;
user: string;
text: string;
timestamp: number;
msgId: string;
}
export class TwitchCache {
private buffer: ChatMessage[] = [];
private maxSize: number;
constructor(maxSize: number = 1000) {
this.maxSize = maxSize;
}
push(msg: ChatMessage): void {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift();
}
this.buffer.push(msg);
}
getLastN(user: string, channel: string, n: number): ChatMessage[] {
return this.buffer
.filter(m => m.user.toLowerCase() === user.toLowerCase() && m.channel === channel)
.slice(-n);
}
getByMsgId(msgId: string): ChatMessage | undefined {
return this.buffer.find(m => m.msgId === msgId);
}
clear(): void {
this.buffer = [];
}
get size(): number {
return this.buffer.length;
}
}

View File

@@ -0,0 +1,208 @@
import tmi, { Client, ChatUserstate, DeleteUserstate, SubMethods, SubUserstate } from 'tmi.js';
import { EmbedBuilder, WebhookClient } from 'discord.js';
import { TwitchCache, ChatMessage } from './TwitchCache.js';
const MOD_ICON = 'https://static-cdn.jtvnw.net/jtv_user_pictures/a736384e-8a1d-40ed-8a7a-808a23a15476-profile_image-300x300.png';
interface MonitorChannel {
guildId: string;
twitchChannel: string;
discordChannelId: string;
webhookUrl: string;
}
export class TwitchMonitor {
private static instance: TwitchMonitor | null = null;
private client: Client | null = null;
private cache: TwitchCache;
private monitors: Map<string, MonitorChannel> = new Map();
private webhookClients: Map<string, WebhookClient> = new Map();
private constructor() {
this.cache = new TwitchCache(1000);
}
static getInstance(): TwitchMonitor {
if (!TwitchMonitor.instance) {
TwitchMonitor.instance = new TwitchMonitor();
}
return TwitchMonitor.instance;
}
async connect(): Promise<void> {
if (this.client) return;
const channels = Array.from(this.monitors.values()).map(m => m.twitchChannel);
this.client = new Client({
options: {
skipUpdatingEmotesets: true,
},
channels: channels,
});
this.client.on('message', (channel: string, userstate: ChatUserstate, text: string, self: boolean) => {
if (self) return;
this.cache.push({
channel,
user: userstate['display-name'] || userstate.username || 'unknown',
text,
timestamp: Date.now(),
msgId: userstate.id || '',
});
});
this.client.on('messagedeleted', (channel: string, username: string, deletedMessage: string, userstate: DeleteUserstate) => {
const msgId = userstate['target-msg-id'] || '';
const cached = this.cache.getByMsgId(msgId);
const monitor = this.findMonitor(channel);
if (!monitor) return;
this.sendWebhook(monitor.webhookUrl, {
title: '🗑️ Nachricht gelöscht',
description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`,
color: 0xff0000,
});
});
this.client.on('ban', (channel: string, username: string, reason: string) => {
const monitor = this.findMonitor(channel);
if (!monitor) return;
const lastMessages = this.cache.getLastN(username, channel, 5);
const context = lastMessages.length > 0
? `\n\n**Letzte Nachrichten:**\n${lastMessages.map(m => `• ${m.text}`).join('\n')}`
: '';
this.sendWebhook(monitor.webhookUrl, {
title: '🔨 User gebannt',
description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`,
color: 0x8b0000,
});
});
this.client.on('timeout', (channel: string, username: string, reason: string) => {
const monitor = this.findMonitor(channel);
if (!monitor) return;
this.sendWebhook(monitor.webhookUrl, {
title: '⏱️ User getimeouted',
description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`,
color: 0xffa500,
});
});
this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => {
const monitor = this.findMonitor(channel);
if (!monitor) return;
const amount = userstate.bits || 0;
this.sendWebhook(monitor.webhookUrl, {
title: '💰 Bit-Cheer',
description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`,
color: 0x9b59b6,
});
});
this.client.on('subscription', (channel: string, username: string, methods: SubMethods, message: string, userstate: SubUserstate) => {
const monitor = this.findMonitor(channel);
if (!monitor) return;
this.sendWebhook(monitor.webhookUrl, {
title: '🅿️ Subscription',
description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`,
color: 0x9146ff,
});
});
this.client.on('resub', (channel: string, username: string, months: number, message: string, userstate: SubUserstate, methods: SubMethods) => {
const monitor = this.findMonitor(channel);
if (!monitor) return;
this.sendWebhook(monitor.webhookUrl, {
title: '🅿️ Resubscription',
description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`,
color: 0x9146ff,
});
});
try {
await this.client.connect();
console.log('[TWITCH] Monitor connected to IRC');
} catch (error) {
console.error('[TWITCH] Failed to connect:', error);
}
}
private findMonitor(channel: string): MonitorChannel | undefined {
const normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`;
return Array.from(this.monitors.values()).find(
m => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() ||
m.twitchChannel.toLowerCase() === channel.toLowerCase()
);
}
async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise<void> {
const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`;
const key = `${guildId}:${normalizedChannel}`;
this.monitors.set(key, {
guildId,
twitchChannel: normalizedChannel,
discordChannelId,
webhookUrl,
});
this.webhookClients.set(key, new WebhookClient({ url: webhookUrl }));
if (this.client) {
await this.client.join(normalizedChannel);
}
}
async removeMonitor(guildId: string, twitchChannel: string): Promise<void> {
const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`;
const key = `${guildId}:${normalizedChannel}`;
const monitor = this.monitors.get(key);
if (monitor && this.client) {
await this.client.part(normalizedChannel);
}
this.monitors.delete(key);
this.webhookClients.delete(key);
}
getMonitors(guildId?: string): MonitorChannel[] {
if (guildId) {
return Array.from(this.monitors.values()).filter(m => m.guildId === guildId);
}
return Array.from(this.monitors.values());
}
private async sendWebhook(webhookUrl: string, data: { title: string; description: string; color: number }): Promise<void> {
try {
const webhook = new WebhookClient({ url: webhookUrl });
const embed = new EmbedBuilder()
.setTitle(data.title)
.setDescription(data.description)
.setColor(data.color)
.setThumbnail(MOD_ICON)
.setTimestamp();
await webhook.send({ embeds: [embed] });
} catch (error) {
console.error('[TWITCH] Webhook send error:', error);
}
}
disconnect(): void {
if (this.client) {
this.client.disconnect();
this.client = null;
}
this.monitors.clear();
this.webhookClients.clear();
}
}