From ce578ab377cfa40b328a7fe43eab822022572b39 Mon Sep 17 00:00:00 2001 From: coding Date: Tue, 31 Mar 2026 09:28:28 +0000 Subject: [PATCH] Add Kick streaming monitoring module --- .env.example | 4 +- AGENTS-BOT.md | 59 +++++++++- package-lock.json | 16 ++- package.json | 10 +- src/commands/utility/kickstream.ts | 170 +++++++++++++++++++++++++++++ src/index.ts | 4 + src/structures/Database.ts | 14 +++ src/structures/KickManager.ts | 142 ++++++++++++++++++++++++ 8 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 src/commands/utility/kickstream.ts create mode 100644 src/structures/KickManager.ts diff --git a/.env.example b/.env.example index f7ab4c5..424abf5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ DISCORD_TOKEN=dein_bot_token CLIENT_ID=deine_client_id TWITCH_CLIENT_ID=deine_twitch_id -TWITCH_CLIENT_SECRET=dein_twitch_secret \ No newline at end of file +TWITCH_CLIENT_SECRET=dein_twitch_secret +KICK_CLIENT_ID=deine_kick_id +KICK_CLIENT_SECRET=dein_kick_secret \ No newline at end of file diff --git a/AGENTS-BOT.md b/AGENTS-BOT.md index 33b831e..a65a982 100644 --- a/AGENTS-BOT.md +++ b/AGENTS-BOT.md @@ -12,9 +12,10 @@ 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. +6. **Kick Monitoring:** Periodic API polling with `@nekiro/kick-api` library. +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. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. @@ -30,6 +31,7 @@ pixelpoebel/ │ │ ├── ping.ts │ │ ├── help.ts │ │ ├── twitch.ts +│ │ ├── kickstream.ts # Kick streaming │ │ ├── trigger.ts # Auto-responses │ │ ├── timer.ts # Reminders │ │ ├── welcome.ts # Welcome/Goodbye messages @@ -52,6 +54,7 @@ pixelpoebel/ │ ├── Command.ts │ ├── Database.ts │ ├── TwitchManager.ts +│ ├── KickManager.ts │ ├── ReminderManager.ts │ └── Deployer.ts ├── data/ # SQLite database (volume mounted) @@ -146,6 +149,18 @@ CREATE TABLE twitch_monitors ( UNIQUE(guild_id, channel_name) ); +-- kick_monitors +CREATE TABLE kick_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_name TEXT, + discord_channel_id TEXT, + custom_message TEXT, + last_status TEXT DEFAULT 'offline', + last_stream_id TEXT, + UNIQUE(guild_id, channel_name) +); + -- auto_responses CREATE TABLE auto_responses ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -329,7 +344,42 @@ if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { } ``` -### 9. Configurable Warn System +### 9. Kick Monitoring with @nekiro/kick-api + +```typescript +// Uses @nekiro/kick-api library for OAuth 2.1 authentication +import { client as KickClient } from '@nekiro/kick-api'; + +const kickClient = new KickClient({ + clientId: process.env.KICK_CLIENT_ID, + clientSecret: process.env.KICK_CLIENT_SECRET, +}); + +// Fetch channel and livestream data +const channels = await kickClient.channels.getChannels({ slug: [channelName] }); +const livestreams = await kickClient.livestreams.getLivestreams({ + broadcaster_user_id: [channel.user_id] +}); + +// Stream ID tracking prevents duplicate notifications +const streamId = livestream.session_id?.toString(); +const isNewStream = monitor.last_stream_id !== streamId; +const wasOffline = monitor.last_status === 'offline'; + +if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { + // Send notification to Discord channel + DB.run('UPDATE kick_monitors SET last_status = "online", last_stream_id = ? WHERE id = ?', + streamId, monitor.id); +} +``` + +**Required Environment Variables:** +- `KICK_CLIENT_ID` - Kick Developer App Client ID +- `KICK_CLIENT_SECRET` - Kick Developer App Client Secret + +**Kick Developer App Setup:** https://docs.kick.com/getting-started/kick-apps-setup + +### 10. Configurable Warn System **Database Schema:** - `warn_threshold`: Number of warns before action @@ -425,6 +475,7 @@ docker-compose up -d --build | `/ping` | – | Public | | `/help` | – | Public | | `/twitch` | online, add, remove, list, listall, help | Public/Admin | +| `/kickstream` | online, add, remove, list, help | Public/Admin | | `/trigger` | add, remove, list, help | Public/Admin | | `/timer` | add, remove, list, listall, help | Public/Admin | | `/welcome` | setchannel, add, remove, list, help | Admin | diff --git a/package-lock.json b/package-lock.json index 094d718..4cdfd07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,17 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@nekiro/kick-api": "^2.1.2", "better-sqlite3": "^12.8.0", "discord.js": "^14.18.0", - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "typescript": "^5.8.2" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.13.9", "nodemon": "^3.1.9", - "ts-node": "^10.9.2", - "typescript": "^5.8.2" + "ts-node": "^10.9.2" } }, "node_modules/@cspotcode/source-map-support": { @@ -192,6 +193,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@nekiro/kick-api": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@nekiro/kick-api/-/kick-api-2.1.2.tgz", + "integrity": "sha512-D2Yq+j3U7TBK4WNc48lM+fHYh3s+9b6+EGV66wVzh6QPTr0kcQ0ThgrXkyMbppkqv+nZwFYrIw+PxmECQ3BnfQ==", + "engines": { + "node": ">=20.0" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -1274,7 +1283,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 94b1acf..341fd87 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,16 @@ "dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/index.ts", "deploy": "node --loader ts-node/esm src/deploy-commands.ts" }, - "keywords": ["discord", "bot", "typescript", "modular"], + "keywords": [ + "discord", + "bot", + "typescript", + "modular" + ], "author": "sarah", "license": "MIT", "dependencies": { + "@nekiro/kick-api": "^2.1.2", "better-sqlite3": "^12.8.0", "discord.js": "^14.18.0", "dotenv": "^16.4.7", @@ -25,4 +31,4 @@ "nodemon": "^3.1.9", "ts-node": "^10.9.2" } -} \ No newline at end of file +} diff --git a/src/commands/utility/kickstream.ts b/src/commands/utility/kickstream.ts new file mode 100644 index 0000000..5ac6207 --- /dev/null +++ b/src/commands/utility/kickstream.ts @@ -0,0 +1,170 @@ +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags, ChannelType } from 'discord.js'; +import { Command } from '../../structures/Command.js'; +import { KickManager } from '../../structures/KickManager.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('kickstream') + .setDescription('Kick-Streamer Benachrichtigungen verwalten.') + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Fügt einen Kick-Streamer zum Monitoring hinzu.') + .addStringOption(option => + option.setName('channel') + .setDescription('Der Kick-Channel-Name (Slug)') + .setRequired(true)) + .addChannelOption(option => + option.setName('discord_channel') + .setDescription('Der Discord-Kanal für Benachrichtigungen') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true)) + .addStringOption(option => + option.setName('nachricht') + .setDescription('Optionale custom Nachricht'))) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Entfernt einen Kick-Streamer vom Monitoring.') + .addStringOption(option => + option.setName('channel') + .setDescription('Der Kick-Channel-Name') + .setRequired(true))) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('Listet alle überwachten Kick-Streamer auf.')) + .addSubcommand(subcommand => + subcommand + .setName('online') + .setDescription('Prüft ob ein Kick-Streamer online ist.') + .addStringOption(option => + option.setName('channel') + .setDescription('Der Kick-Channel-Name') + .setRequired(true))) + .addSubcommand(subcommand => + subcommand + .setName('help') + .setDescription('Zeigt Hilfe zu den Kick-Befehlen an.')), + 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('🟢 Kick Streaming Hilfe') + .setColor(0x53fc18) + .setDescription('Benachrichtigungen bei Kick-Streams.') + .addFields( + { name: '`/kickstream add [nachricht]`', value: 'Fügt einen Streamer hinzu (Mod).', inline: false }, + { name: '`/kickstream remove `', value: 'Entfernt einen Streamer (Mod).', inline: false }, + { name: '`/kickstream list`', value: 'Zeigt alle überwachten Streamer.', inline: false }, + { name: '`/kickstream online `', value: 'Prüft ob ein Streamer live ist.', inline: false }, + { name: 'Hinweis', value: 'Benötigt KICK_CLIENT_ID und KICK_CLIENT_SECRET in der .env', inline: false } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + return; + } + + if (subcommand === 'online') { + const channelName = interaction.options.getString('channel')!.toLowerCase().replace(/^@/, ''); + await interaction.deferReply(); + + const { channel, livestream } = await KickManager.fetchChannelData(channelName); + if (!channel) { + await interaction.editReply({ content: `❌ Channel **${channelName}** nicht gefunden.` }); + return; + } + + const isLive = livestream !== null; + const embed = new EmbedBuilder() + .setTitle(isLive ? `🟢 ${channel.user.username} ist LIVE` : `⚫ ${channel.user.username} ist offline`) + .setURL(`https://kick.com/${channel.slug}`) + .setColor(isLive ? 0x53fc18 : 0x5865F2); + + if (isLive && livestream) { + embed.addFields( + { name: 'Titel', value: livestream.stream_title || 'Kein Titel', inline: false }, + { name: 'Kategorie', value: livestream.category?.name || 'Unbekannt', inline: true }, + { name: 'Zuschauer', value: livestream.viewer_count.toString(), inline: true } + ); + if (livestream.thumbnail) { + embed.setImage(livestream.thumbnail); + } + } + + await interaction.editReply({ embeds: [embed] }); + return; + } + + if (subcommand === 'list') { + const monitors: any[] = DB.all('SELECT * FROM kick_monitors WHERE guild_id = ? ORDER BY channel_name ASC', guildId); + + if (monitors.length === 0) { + await interaction.reply({ content: 'Keine Kick-Streamer überwacht.', flags: [MessageFlags.Ephemeral] }); + return; + } + + const embed = new EmbedBuilder() + .setTitle('🟢 Kick-Monitoring') + .setColor(0x53fc18) + .setDescription(monitors.map((m: any) => + `• **${m.channel_name}** → <#${m.discord_channel_id}> ${m.last_status === 'online' ? '🔴' : '⚫'}` + ).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 channelName = interaction.options.getString('channel')!.toLowerCase().replace(/^@/, ''); + const discordChannel = interaction.options.getChannel('discord_channel'); + const customMessage = interaction.options.getString('nachricht'); + + const { channel } = await KickManager.fetchChannelData(channelName); + if (!channel) { + await interaction.reply({ content: `❌ Kick-Channel **${channelName}** nicht gefunden.`, flags: [MessageFlags.Ephemeral] }); + return; + } + + DB.run( + 'INSERT INTO kick_monitors (guild_id, channel_name, discord_channel_id, custom_message) VALUES (?, ?, ?, ?) ON CONFLICT(guild_id, channel_name) DO UPDATE SET discord_channel_id = ?, custom_message = ?', + guildId, + channelName, + discordChannel.id, + customMessage, + discordChannel.id, + customMessage + ); + + await interaction.reply({ content: `✅ **${channelName}** wird jetzt überwacht.`, flags: [MessageFlags.Ephemeral] }); + return; + } + + if (subcommand === 'remove') { + const channelName = interaction.options.getString('channel')!.toLowerCase().replace(/^@/, ''); + + const result = DB.run('DELETE FROM kick_monitors WHERE guild_id = ? AND channel_name = ?', guildId, channelName); + + if (result.changes === 0) { + await interaction.reply({ content: `❌ **${channelName}** nicht gefunden.`, flags: [MessageFlags.Ephemeral] }); + return; + } + + await interaction.reply({ content: `✅ **${channelName}** entfernt.`, flags: [MessageFlags.Ephemeral] }); + } + }, +}; + +export default command; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index cde96ae..bf85fdb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { Command } from './structures/Command.js'; import { DB } from './structures/Database.js'; import { TwitchManager } from './structures/TwitchManager.js'; +import { KickManager } from './structures/KickManager.js'; import { ReminderManager } from './structures/ReminderManager.js'; const __filename = fileURLToPath(import.meta.url); @@ -60,6 +61,9 @@ for (const file of eventFiles) { // Start Twitch Polling TwitchManager.startPolling(client); +// Start Kick Polling +KickManager.startPolling(client); + // Start Reminder Polling ReminderManager.startPolling(client); diff --git a/src/structures/Database.ts b/src/structures/Database.ts index 62c0e3e..ae39859 100644 --- a/src/structures/Database.ts +++ b/src/structures/Database.ts @@ -79,6 +79,20 @@ export class DB { ) `).run(); + // Kick Monitors + db.prepare(` + CREATE TABLE IF NOT EXISTS kick_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_name TEXT, + discord_channel_id TEXT, + custom_message TEXT, + last_status TEXT DEFAULT 'offline', + last_stream_id TEXT, + UNIQUE(guild_id, channel_name) + ) + `).run(); + // Auto Responses db.prepare(` CREATE TABLE IF NOT EXISTS auto_responses ( diff --git a/src/structures/KickManager.ts b/src/structures/KickManager.ts new file mode 100644 index 0000000..7188f36 --- /dev/null +++ b/src/structures/KickManager.ts @@ -0,0 +1,142 @@ +import { EmbedBuilder, TextChannel } from 'discord.js'; +import { client as KickClient } from '@nekiro/kick-api'; + +interface KickChannel { + id: number; + user_id: number; + slug: string; + user: { username: string }; + category?: { id: number; name: string }; + followers_count: number; +} + +interface KickLivestream { + broadcaster_user_id: number; + category: { id: number; name: string }; + channel_id: number; + slug: string; + started_at: string; + stream_title: string; + thumbnail: string; + viewer_count: number; + session_id?: string; +} + +export class KickManager { + private static kickClient: InstanceType | null = null; + private static channelLivestreamMap: Map = new Map(); + + private static initClient() { + if (this.kickClient) return this.kickClient; + + const clientId = process.env.KICK_CLIENT_ID; + const clientSecret = process.env.KICK_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + console.error('[KICK] Missing KICK_CLIENT_ID or KICK_CLIENT_SECRET in .env'); + return null; + } + + this.kickClient = new KickClient({ + clientId, + clientSecret, + }); + + return this.kickClient; + } + + static async fetchChannelData(channelName: string): Promise<{ channel: KickChannel | null; livestream: KickLivestream | null }> { + const client = this.initClient(); + if (!client) return { channel: null, livestream: null }; + + try { + const channels = await client.channels.getChannels({ slug: [channelName] }) as KickChannel[]; + const channel = channels?.[0] || null; + + if (!channel) return { channel: null, livestream: null }; + + const livestreams = await client.livestreams.getLivestreams({ + broadcaster_user_id: [channel.user_id] + }) as KickLivestream[]; + const livestream = livestreams?.[0] || null; + + return { channel, livestream }; + } catch (error) { + console.error(`[KICK] Error fetching data for ${channelName}:`, error); + return { channel: null, livestream: null }; + } + } + + static async checkStreams(client: any) { + if (!this.initClient()) return; + + const monitors: any[] = (client as any).DB?.all('SELECT * FROM kick_monitors') || []; + if (monitors.length === 0) return; + + for (const monitor of monitors) { + try { + const { channel, livestream } = await this.fetchChannelData(monitor.channel_name); + + if (channel && livestream) { + const streamId = livestream.session_id?.toString() || livestream.broadcaster_user_id.toString(); + const isNewStream = monitor.last_stream_id !== streamId; + const wasOffline = monitor.last_status === 'offline'; + + if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { + await this.sendNotification(client, monitor, channel, livestream); + (client as any).DB?.run( + "UPDATE kick_monitors SET last_status = 'online', last_stream_id = ? WHERE id = ?", + streamId, + monitor.id + ); + } + } else { + if (monitor.last_status === 'online') { + (client as any).DB?.run("UPDATE kick_monitors SET last_status = 'offline', last_stream_id = NULL WHERE id = ?", monitor.id); + } + } + } catch (error) { + console.error(`[KICK] Error checking stream ${monitor.channel_name}:`, error); + } + } + } + + private static async sendNotification(client: any, monitor: any, channel: KickChannel, livestream: KickLivestream) { + const guild = client.guilds.cache.get(monitor.guild_id); + if (!guild || !monitor.discord_channel_id) return; + + const discordChannel = guild.channels.cache.get(monitor.discord_channel_id) as TextChannel; + if (!discordChannel) return; + + const thumbnailUrl = livestream.thumbnail || `https://kick.com/img/channel-thumbnails/${channel.id}?t=${Date.now()}`; + + const embed = new EmbedBuilder() + .setTitle(`${channel.user.username} ist jetzt LIVE auf Kick!`) + .setURL(`https://kick.com/${channel.slug}`) + .setColor(0x53fc18) + .setImage(thumbnailUrl) + .addFields( + { name: 'Titel', value: livestream.stream_title || 'Kein Titel', inline: false }, + { name: 'Kategorie', value: livestream.category?.name || 'Unbekannt', inline: true }, + { name: 'Zuschauer', value: livestream.viewer_count.toString(), inline: true } + ) + .setTimestamp(); + + let content = `📢 **${channel.user.username}** ist jetzt auf Kick online!`; + if (monitor.custom_message) { + content += `\n\n${monitor.custom_message}`; + } + + await discordChannel.send({ content, embeds: [embed] }); + } + + static startPolling(client: any) { + if (!process.env.KICK_CLIENT_ID || !process.env.KICK_CLIENT_SECRET) { + console.log('[KICK] Skipping polling - missing credentials.'); + return; + } + + setInterval(() => this.checkStreams(client), 5 * 60 * 1000); + console.log('[KICK] Polling started (5m interval).'); + } +} \ No newline at end of file