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:
@@ -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<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:**
|
||||
```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 |
|
||||
|
||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
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