From 2fcb7cec66ff86fda9af8a9f4b2eca2ed54f0bf8 Mon Sep 17 00:00:00 2001 From: coding Date: Sun, 19 Apr 2026 17:52:41 +0000 Subject: [PATCH] Add TwitchMonitor IRC chat logging module --- AGENTS-BOT.md | 88 +++++++++-- package-lock.json | 58 +++++++ package.json | 2 + src/commands/utility/twitchmonitor.ts | 141 +++++++++++++++++ src/events/ready.ts | 22 +++ src/structures/Database.ts | 14 +- src/structures/TwitchCache.ts | 41 +++++ src/structures/TwitchMonitor.ts | 208 ++++++++++++++++++++++++++ 8 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 src/commands/utility/twitchmonitor.ts create mode 100644 src/structures/TwitchCache.ts create mode 100644 src/structures/TwitchMonitor.ts diff --git a/AGENTS-BOT.md b/AGENTS-BOT.md index cc00bd0..6ee343f 100644 --- a/AGENTS-BOT.md +++ b/AGENTS-BOT.md @@ -12,12 +12,13 @@ The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScrip 3. **Interface-Driven Commands:** All commands implement `Command` interface. 4. **SQLite Database:** Persistent data storage with `better-sqlite3`. 5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking. -6. **Reminder System:** Periodic reminder checking with target_time tracking. -7. **Auto-Response System:** Trigger word detection for automatic replies. -8. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. -9. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). -10. **Role Selection System:** Self-service role assignment via select menus. -11. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. +6. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events. +7. **Reminder System:** Periodic reminder checking with target_time tracking. +8. **Auto-Response System:** Trigger word detection for automatic replies. +9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. +10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). +11. **Role Selection System:** Self-service role assignment via select menus. +12. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. ## 📁 Project Structure @@ -31,6 +32,7 @@ pixelpoebel/ │ │ ├── ping.ts │ │ ├── help.ts │ │ ├── twitch.ts +│ │ ├── twitchmonitor.ts # Twitch IRC chat monitoring │ │ ├── rolesetup.ts # Self-service role selection │ │ ├── trigger.ts # Auto-responses │ │ ├── timer.ts # Reminders @@ -54,6 +56,8 @@ pixelpoebel/ │ ├── Command.ts │ ├── Database.ts │ ├── TwitchManager.ts +│ ├── TwitchMonitor.ts +│ ├── TwitchCache.ts │ ├── ReminderManager.ts │ └── Deployer.ts ├── data/ # SQLite database (volume mounted) @@ -136,7 +140,7 @@ CREATE TABLE dm_users ( last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ); --- twitch_monitors +-- twitch_monitors (Stream notifications) CREATE TABLE twitch_monitors ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT, @@ -148,6 +152,16 @@ CREATE TABLE twitch_monitors ( UNIQUE(guild_id, channel_name) ); +-- twitch_monitor_channels (IRC chat logging) +CREATE TABLE 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) +); + -- auto_responses CREATE TABLE auto_responses ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -355,12 +369,61 @@ if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { } ``` -### 9. Configurable Warn System +### 9. TwitchMonitor IRC Chat Logging -**Database Schema:** -- `warn_threshold`: Number of warns before action -- `warn_action`: 'none', 'kick', 'mute', 'ban' -- `warn_mute_duration`: Duration in seconds for mute +**Overview:** Uses TMI.js to connect to Twitch IRC as a "lurker" (read-only) and logs moderation events to Discord via webhooks. + +**Architecture:** +```typescript +// Singleton pattern for IRC connection +class TwitchMonitor { + private static instance: TwitchMonitor | null = null; + private client: tmi.Client; + private cache: TwitchCache; // In-memory FIFO ring buffer (default: 1000 messages) + private monitors: Map; +} +``` + +**TwitchCache (In-Memory Ring Buffer):** +```typescript +interface ChatMessage { + channel: string; // twitch channel + user: string; + text: string; + timestamp: number; + msgId: string; +} + +class TwitchCache { + private buffer: ChatMessage[] = []; + private maxSize: number = 1000; + + push(msg: ChatMessage): void { /* FIFO - shift oldest when full */ } + getLastN(user: string, channel: string, n: number): ChatMessage[] { /* for ban context */ } + getByMsgId(msgId: string): ChatMessage | undefined { /* for delete restore */ } +} +``` + +**Events Logged:** +| Event | Discord Embed | +|-------|--------------| +| `ban` | 🔨 Ban - mit letzten 5 Nachrichten als Kontext | +| `timeout` | ⏱️ Timeout | +| `messagedeleted` | 🗑️ Delete - ~~text~~ (aus Cache wiederhergestellt) | +| `cheer` | 💰 Bits | +| `subscription` | 🅿️ Sub | + +**Mod-Icon:** `https://static-cdn.jtvnw.net/jtv_user_pictures/a736384e-8a1d-40ed-8a7a-808a23a15476-profile_image-300x300.png` + +**Commands:** +```typescript +/twitchmonitor add // Admin +/twitchmonitor remove // Admin +/twitchmonitor list // Public +/twitchmonitor help // Public +``` + +### 10. Configurable Warn System **Auto-Action Logic:** ```typescript @@ -487,6 +550,7 @@ docker-compose up -d --build | `/ping` | – | Public | | `/help` | – | Public | | `/twitch` | online, add, remove, list, listall, help | Public/Admin | +| `/twitchmonitor` | add, remove, list, help | Admin | | `/rolesetup` | create, add, edit, remove, delete, config, list | Admin | | `/trigger` | add, remove, list, help | Public/Admin | | `/timer` | add, remove, list, listall, help | Public/Admin | diff --git a/package-lock.json b/package-lock.json index ea4896e..c42aad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "better-sqlite3": "^12.8.0", "discord.js": "^14.18.0", "dotenv": "^16.4.7", + "tmi.js": "^1.8.5", "typescript": "^5.8.2" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.13.9", + "@types/tmi.js": "^1.8.6", "nodemon": "^3.1.9", "ts-node": "^10.9.2" } @@ -272,6 +274,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/tmi.js": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@types/tmi.js/-/tmi.js-1.8.6.tgz", + "integrity": "sha512-LVzNK7AxTMyh9qHLanAQR1o0I9XzfbIcXk85cx85igmCJHHO1Sm71sdhQ8Mj1WmRGzynPIoCXx6mVaFynWbsQw==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -883,6 +891,25 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1179,6 +1206,18 @@ "node": ">=6" } }, + "node_modules/tmi.js": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/tmi.js/-/tmi.js-1.8.5.tgz", + "integrity": "sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==", + "dependencies": { + "node-fetch": "^2.6.1", + "ws": "^8.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1202,6 +1241,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -1318,6 +1362,20 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index bccca81..2db42ff 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "better-sqlite3": "^12.8.0", "discord.js": "^14.18.0", "dotenv": "^16.4.7", + "tmi.js": "^1.8.5", "typescript": "^5.8.2" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.13.9", + "@types/tmi.js": "^1.8.6", "nodemon": "^3.1.9", "ts-node": "^10.9.2" } diff --git a/src/commands/utility/twitchmonitor.ts b/src/commands/utility/twitchmonitor.ts new file mode 100644 index 0000000..ac12afc --- /dev/null +++ b/src/commands/utility/twitchmonitor.ts @@ -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 ', value: 'Kanal hinzufügen (Admin)', inline: false }, + { name: '/twitchmonitor remove ', 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; \ No newline at end of file diff --git a/src/events/ready.ts b/src/events/ready.ts index fb6c2a3..1d33ea9 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -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); }, }; \ No newline at end of file diff --git a/src/structures/Database.ts b/src/structures/Database.ts index 32ee747..0995146 100644 --- a/src/structures/Database.ts +++ b/src/structures/Database.ts @@ -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 ( diff --git a/src/structures/TwitchCache.ts b/src/structures/TwitchCache.ts new file mode 100644 index 0000000..436f3f3 --- /dev/null +++ b/src/structures/TwitchCache.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/structures/TwitchMonitor.ts b/src/structures/TwitchMonitor.ts new file mode 100644 index 0000000..e9b1396 --- /dev/null +++ b/src/structures/TwitchMonitor.ts @@ -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 = new Map(); + private webhookClients: Map = 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 { + 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 { + 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 { + 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 { + 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(); + } +} \ No newline at end of file