Add TwitchMonitor IRC chat logging module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 21s
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 21s
This commit is contained in:
141
src/commands/utility/twitchmonitor.ts
Normal file
141
src/commands/utility/twitchmonitor.ts
Normal 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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
41
src/structures/TwitchCache.ts
Normal file
41
src/structures/TwitchCache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
208
src/structures/TwitchMonitor.ts
Normal file
208
src/structures/TwitchMonitor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user