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

@@ -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. 3. **Interface-Driven Commands:** All commands implement `Command` interface.
4. **SQLite Database:** Persistent data storage with `better-sqlite3`. 4. **SQLite Database:** Persistent data storage with `better-sqlite3`.
5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking. 5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking.
6. **Reminder System:** Periodic reminder checking with target_time tracking. 6. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events.
7. **Auto-Response System:** Trigger word detection for automatic replies. 7. **Reminder System:** Periodic reminder checking with target_time tracking.
8. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. 8. **Auto-Response System:** Trigger word detection for automatic replies.
9. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). 9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages.
10. **Role Selection System:** Self-service role assignment via select menus. 10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.).
11. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. 11. **Role Selection System:** Self-service role assignment via select menus.
12. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands.
## 📁 Project Structure ## 📁 Project Structure
@@ -31,6 +32,7 @@ pixelpoebel/
│ │ ├── ping.ts │ │ ├── ping.ts
│ │ ├── help.ts │ │ ├── help.ts
│ │ ├── twitch.ts │ │ ├── twitch.ts
│ │ ├── twitchmonitor.ts # Twitch IRC chat monitoring
│ │ ├── rolesetup.ts # Self-service role selection │ │ ├── rolesetup.ts # Self-service role selection
│ │ ├── trigger.ts # Auto-responses │ │ ├── trigger.ts # Auto-responses
│ │ ├── timer.ts # Reminders │ │ ├── timer.ts # Reminders
@@ -54,6 +56,8 @@ pixelpoebel/
│ ├── Command.ts │ ├── Command.ts
│ ├── Database.ts │ ├── Database.ts
│ ├── TwitchManager.ts │ ├── TwitchManager.ts
│ ├── TwitchMonitor.ts
│ ├── TwitchCache.ts
│ ├── ReminderManager.ts │ ├── ReminderManager.ts
│ └── Deployer.ts │ └── Deployer.ts
├── data/ # SQLite database (volume mounted) ├── data/ # SQLite database (volume mounted)
@@ -136,7 +140,7 @@ CREATE TABLE dm_users (
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- twitch_monitors -- twitch_monitors (Stream notifications)
CREATE TABLE twitch_monitors ( CREATE TABLE twitch_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT, guild_id TEXT,
@@ -148,6 +152,16 @@ CREATE TABLE twitch_monitors (
UNIQUE(guild_id, channel_name) 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 -- auto_responses
CREATE TABLE auto_responses ( CREATE TABLE auto_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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:** **Overview:** Uses TMI.js to connect to Twitch IRC as a "lurker" (read-only) and logs moderation events to Discord via webhooks.
- `warn_threshold`: Number of warns before action
- `warn_action`: 'none', 'kick', 'mute', 'ban' **Architecture:**
- `warn_mute_duration`: Duration in seconds for mute ```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<string, MonitorChannel>;
}
```
**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 <twitch_channel> <discord_channel> // Admin
/twitchmonitor remove <twitch_channel> // Admin
/twitchmonitor list // Public
/twitchmonitor help // Public
```
### 10. Configurable Warn System
**Auto-Action Logic:** **Auto-Action Logic:**
```typescript ```typescript
@@ -487,6 +550,7 @@ docker-compose up -d --build
| `/ping` | | Public | | `/ping` | | Public |
| `/help` | | Public | | `/help` | | Public |
| `/twitch` | online, add, remove, list, listall, help | Public/Admin | | `/twitch` | online, add, remove, list, listall, help | Public/Admin |
| `/twitchmonitor` | add, remove, list, help | Admin |
| `/rolesetup` | create, add, edit, remove, delete, config, list | Admin | | `/rolesetup` | create, add, edit, remove, delete, config, list | Admin |
| `/trigger` | add, remove, list, help | Public/Admin | | `/trigger` | add, remove, list, help | Public/Admin |
| `/timer` | add, remove, list, listall, help | Public/Admin | | `/timer` | add, remove, list, listall, help | Public/Admin |

58
package-lock.json generated
View File

@@ -12,11 +12,13 @@
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"tmi.js": "^1.8.5",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@types/tmi.js": "^1.8.6",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
} }
@@ -272,6 +274,12 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -883,6 +891,25 @@
"node": ">=10" "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": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -1179,6 +1206,18 @@
"node": ">=6" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1202,6 +1241,11 @@
"nodetouch": "bin/nodetouch.js" "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": { "node_modules/ts-mixer": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -1318,6 +1362,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -22,11 +22,13 @@
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"tmi.js": "^1.8.5",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@types/tmi.js": "^1.8.6",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
} }

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 { Events } from 'discord.js';
import { TwitchMonitor } from '../structures/TwitchMonitor.js';
async function validateRoles(client: any) { async function validateRoles(client: any) {
const categories: any[] = client.DB.all('SELECT * FROM role_categories'); 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 { export default {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
@@ -44,5 +65,6 @@ export default {
console.log(`[READY] Serving ${client.guilds.cache.size} servers`); console.log(`[READY] Serving ${client.guilds.cache.size} servers`);
await validateRoles(client); await validateRoles(client);
await initTwitchMonitor(client);
}, },
}; };

View File

@@ -65,7 +65,7 @@ export class DB {
) )
`).run(); `).run();
// Twitch Monitors // Twitch Monitors (Stream notifications)
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS twitch_monitors ( CREATE TABLE IF NOT EXISTS twitch_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,6 +79,18 @@ export class DB {
) )
`).run(); `).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 // Auto Responses
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS auto_responses ( 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();
}
}