Optimize Twitch polling with batching and implement database caching with WAL mode
This commit is contained in:
564
AGENTS-BOT.md
564
AGENTS-BOT.md
@@ -10,11 +10,11 @@ The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScrip
|
|||||||
1. **Extended Client Pattern:** Extend `discord.js` `Client` class to hold global state.
|
1. **Extended Client Pattern:** Extend `discord.js` `Client` class to hold global state.
|
||||||
2. **Dynamic Discovery:** `index.ts` uses `readdirSync` and dynamic `import()` to register commands/events.
|
2. **Dynamic Discovery:** `index.ts` uses `readdirSync` and dynamic `import()` to register commands/events.
|
||||||
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. **Optimized SQLite Database:** Persistent storage with `better-sqlite3`, utilizing **WAL (Write-Ahead Logging)** and **In-Memory Caching** for frequently accessed data.
|
||||||
5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking.
|
5. **Twitch Monitoring:** Periodic API polling using **Batch-Requests** (100 channels per request) with Stream ID tracking.
|
||||||
6. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events.
|
6. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events.
|
||||||
7. **Reminder System:** Periodic reminder checking with target_time tracking.
|
7. **Reminder System:** Periodic reminder checking with target_time tracking.
|
||||||
8. **Auto-Response System:** Trigger word detection for automatic replies.
|
8. **Auto-Response System:** Trigger word detection for automatic replies (cached).
|
||||||
9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages.
|
9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages.
|
||||||
10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.).
|
10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.).
|
||||||
11. **Role Selection System:** Self-service role assignment via select menus.
|
11. **Role Selection System:** Self-service role assignment via select menus.
|
||||||
@@ -29,35 +29,15 @@ pixelpoebel/
|
|||||||
│ ├── deploy-commands.ts # Deploy script for Discord
|
│ ├── deploy-commands.ts # Deploy script for Discord
|
||||||
│ ├── commands/
|
│ ├── commands/
|
||||||
│ │ └── utility/ # All commands grouped here
|
│ │ └── utility/ # All commands grouped here
|
||||||
│ │ ├── 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
|
|
||||||
│ │ ├── welcome.ts # Welcome/Goodbye messages
|
|
||||||
│ │ ├── log.ts # Event logging
|
|
||||||
│ │ ├── admin.ts # All admin subcommands
|
|
||||||
│ │ └── owner.ts # All owner subcommands
|
|
||||||
│ ├── events/
|
│ ├── events/
|
||||||
│ │ ├── ready.ts
|
│ │ ├── ... # All Discord event handlers
|
||||||
│ │ ├── interactionCreate.ts
|
|
||||||
│ │ ├── messageCreate.ts # Auto-response triggers
|
|
||||||
│ │ ├── messageDelete.ts # Deleted messages
|
|
||||||
│ │ ├── messageUpdate.ts # Edited messages
|
|
||||||
│ │ ├── guildMemberAdd.ts # Welcome messages
|
|
||||||
│ │ ├── guildMemberRemove.ts # Goodbye messages
|
|
||||||
│ │ ├── guildMemberUpdate.ts # Nickname/role changes
|
|
||||||
│ │ ├── channelCreate.ts # Channel create
|
|
||||||
│ │ └── channelDelete.ts # Channel delete
|
|
||||||
│ └── structures/
|
│ └── structures/
|
||||||
│ ├── ExtendedClient.ts
|
│ ├── ExtendedClient.ts
|
||||||
│ ├── Command.ts
|
│ ├── Command.ts
|
||||||
│ ├── Database.ts
|
│ ├── Database.ts # Database with Caching logic
|
||||||
│ ├── TwitchManager.ts
|
│ ├── TwitchManager.ts # Batch Polling logic
|
||||||
│ ├── TwitchMonitor.ts
|
│ ├── TwitchMonitor.ts # IRC Monitoring logic
|
||||||
│ ├── TwitchCache.ts
|
│ ├── TwitchCache.ts # IRC Message FIFO Cache
|
||||||
│ ├── ReminderManager.ts
|
│ ├── ReminderManager.ts
|
||||||
│ └── Deployer.ts
|
│ └── Deployer.ts
|
||||||
├── data/ # SQLite database (volume mounted)
|
├── data/ # SQLite database (volume mounted)
|
||||||
@@ -71,490 +51,96 @@ pixelpoebel/
|
|||||||
|
|
||||||
## 📝 Implementation Steps
|
## 📝 Implementation Steps
|
||||||
|
|
||||||
### 1. Project Setup
|
### 1. Database with Caching
|
||||||
|
|
||||||
```json
|
The database uses WAL mode for performance and an in-memory Map for caching settings and triggers.
|
||||||
{
|
|
||||||
"type": "module",
|
```typescript
|
||||||
"scripts": {
|
// src/structures/Database.ts
|
||||||
"build": "tsc",
|
export class DB {
|
||||||
"start": "node dist/index.js",
|
private static settingsCache = new Map<string, any>();
|
||||||
"dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/index.ts",
|
private static triggersCache = new Map<string, any[]>();
|
||||||
"deploy": "node --loader ts-node/esm src/deploy-commands.ts"
|
|
||||||
|
static init() {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
// ... Table creation ...
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSettings(guildId: string) {
|
||||||
|
if (this.settingsCache.has(guildId)) return this.settingsCache.get(guildId);
|
||||||
|
const settings = db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
|
||||||
|
if (settings) this.settingsCache.set(guildId, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
static run(query: string, ...params: any[]) {
|
||||||
|
const result = db.prepare(query).run(...params);
|
||||||
|
if (query.toLowerCase().includes('guild_settings') || query.toLowerCase().includes('auto_responses')) {
|
||||||
|
this.settingsCache.clear();
|
||||||
|
this.triggersCache.clear();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
### 2. Twitch Batch Polling
|
||||||
{
|
|
||||||
"compilerOptions": {
|
To stay within API limits and provide faster updates, the bot polls Twitch in batches of 100.
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
```typescript
|
||||||
"moduleResolution": "NodeNext",
|
// src/structures/TwitchManager.ts
|
||||||
"outDir": "./dist",
|
static async checkStreams(client: any) {
|
||||||
"rootDir": "./src",
|
const monitors = client.DB.all('SELECT * FROM twitch_monitors');
|
||||||
"strict": true
|
const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))];
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueChannels.length; i += 100) {
|
||||||
|
const chunk = uniqueChannels.slice(i, i + 100);
|
||||||
|
const query = chunk.map(name => `user_login=${encodeURIComponent(name)}`).join('&');
|
||||||
|
// fetch https://api.twitch.tv/helix/streams?${query}
|
||||||
|
// ... process results ...
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Database Structure
|
static startPolling(client: any) {
|
||||||
|
setInterval(() => this.checkStreams(client), 2 * 60 * 1000); // 2 minute interval
|
||||||
```sql
|
|
||||||
-- guild_settings
|
|
||||||
CREATE TABLE guild_settings (
|
|
||||||
guild_id TEXT PRIMARY KEY,
|
|
||||||
prefix TEXT DEFAULT '!',
|
|
||||||
mod_log_channel TEXT,
|
|
||||||
welcome_channel TEXT,
|
|
||||||
welcome_message TEXT,
|
|
||||||
goodbye_message TEXT,
|
|
||||||
log_channel TEXT,
|
|
||||||
log_events TEXT DEFAULT 'messages,roles,nicks,channels,warns,mutes,kicks,bans',
|
|
||||||
warn_threshold INTEGER DEFAULT 3,
|
|
||||||
warn_action TEXT DEFAULT 'ban',
|
|
||||||
warn_mute_duration INTEGER DEFAULT 1800
|
|
||||||
);
|
|
||||||
|
|
||||||
-- mod_logs
|
|
||||||
CREATE TABLE mod_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
guild_id TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
moderator_id TEXT,
|
|
||||||
action TEXT,
|
|
||||||
reason TEXT,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- command_stats
|
|
||||||
CREATE TABLE command_stats (
|
|
||||||
command_name TEXT PRIMARY KEY,
|
|
||||||
uses INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- dm_users
|
|
||||||
CREATE TABLE dm_users (
|
|
||||||
user_id TEXT PRIMARY KEY,
|
|
||||||
username TEXT,
|
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- twitch_monitors (Stream notifications)
|
|
||||||
CREATE TABLE twitch_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)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 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,
|
|
||||||
guild_id TEXT,
|
|
||||||
trigger_word TEXT,
|
|
||||||
response_text TEXT,
|
|
||||||
UNIQUE(guild_id, trigger_word)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- reminders
|
|
||||||
CREATE TABLE reminders (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
guild_id TEXT,
|
|
||||||
channel_id TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
reminder_text TEXT,
|
|
||||||
target_time DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- role_categories
|
|
||||||
CREATE TABLE role_categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
guild_id TEXT NOT NULL,
|
|
||||||
channel_id TEXT NOT NULL,
|
|
||||||
message_id TEXT NOT NULL,
|
|
||||||
category_name TEXT NOT NULL,
|
|
||||||
max_roles INTEGER DEFAULT 0,
|
|
||||||
exclusive BOOLEAN DEFAULT 0,
|
|
||||||
remove_on_delete BOOLEAN DEFAULT 0,
|
|
||||||
UNIQUE(guild_id, category_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- role_options
|
|
||||||
CREATE TABLE role_options (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
category_id INTEGER NOT NULL,
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
emoji TEXT,
|
|
||||||
role_id TEXT NOT NULL,
|
|
||||||
UNIQUE(category_id, role_id),
|
|
||||||
FOREIGN KEY (category_id) REFERENCES role_categories(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Key Components
|
|
||||||
|
|
||||||
**ExtendedClient.ts:**
|
|
||||||
```typescript
|
|
||||||
import { Client, Collection, GatewayIntentBits } from 'discord.js';
|
|
||||||
import { Command } from './Command.js';
|
|
||||||
|
|
||||||
export class ExtendedClient extends Client {
|
|
||||||
public commands: Collection<string, Command> = new Collection();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMessages,
|
|
||||||
GatewayIntentBits.MessageContent,
|
|
||||||
GatewayIntentBits.GuildMembers,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Command Interface:**
|
### 3. Event Logging Optimization
|
||||||
|
|
||||||
|
All logging events use a centralized `sendLog` pattern that utilizes the `DB.getSettings()` cache.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface Command {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
data: any;
|
const settings = client.DB.getSettings(guildId);
|
||||||
category: 'Owner' | 'Admin' | 'Public';
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
execute: (interaction: any, client: any) => Promise<void> | void;
|
|
||||||
cooldown?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Timer System
|
|
||||||
|
|
||||||
**Time Parsing:**
|
|
||||||
```typescript
|
|
||||||
// Duration parsing: "30s", "2h", "1d30m", "3wochen"
|
|
||||||
function parseDuration(input: string): number | null {
|
|
||||||
const unitMs: Record<string, number> = {
|
|
||||||
's': 1000, 'sec': 1000, 'sek': 1000, 'sekunden': 1000,
|
|
||||||
'm': 60000, 'min': 60000, 'minuten': 60000,
|
|
||||||
'h': 3600000, 'hour': 3600000, 'std': 3600000, 'stunden': 3600000,
|
|
||||||
'd': 86400000, 'day': 86400000, 't': 86400000, 'tage': 86400000,
|
|
||||||
'w': 604800000, 'week': 604800000, 'wochen': 604800000,
|
|
||||||
'mo': 2592000000, 'month': 2592000000, 'monat': 2592000000, 'monate': 2592000000,
|
|
||||||
'y': 31536000000, 'year': 31536000000, 'j': 31536000000, 'jahr': 31536000000, 'jahre': 31536000000
|
|
||||||
};
|
|
||||||
// Parse and calculate total milliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date parsing: "25.12.2024 14:30", "morgen 15:00"
|
|
||||||
function parseDateTime(input: string): Date | null {
|
|
||||||
// German date format, ISO format, or relative "tomorrow"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reminder Polling:**
|
|
||||||
```typescript
|
|
||||||
export class ReminderManager {
|
|
||||||
static async checkReminders(client: any) {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const dueReminders = DB.all('SELECT * FROM reminders WHERE target_time <= ?', now);
|
|
||||||
|
|
||||||
for (const reminder of dueReminders) {
|
|
||||||
const guild = client.guilds.cache.get(reminder.guild_id);
|
|
||||||
const channel = guild.channels.cache.get(reminder.channel_id);
|
|
||||||
// Send notification and delete reminder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static startPolling(client: any) {
|
|
||||||
setInterval(() => this.checkReminders(client), 30 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Welcome/Goodbye System
|
|
||||||
|
|
||||||
**Variables in messages:** `{user}`, `{username}`, `{server}`, `{membercount}`
|
|
||||||
|
|
||||||
**Auto-use Discord system channel if no custom channel set:**
|
|
||||||
```typescript
|
|
||||||
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Logging System
|
|
||||||
|
|
||||||
**Events that can be logged:**
|
|
||||||
- `messages` - Deleted and edited messages
|
|
||||||
- `roles` - Role changes
|
|
||||||
- `nicks` - Nickname changes
|
|
||||||
- `channels` - Channel create/delete
|
|
||||||
- `warns` - User warnings
|
|
||||||
- `mutes` - User mutes
|
|
||||||
- `kicks` - User kicks
|
|
||||||
- `bans` - User bans
|
|
||||||
|
|
||||||
**Log moderation action:**
|
|
||||||
```typescript
|
|
||||||
async function sendModerationLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
|
||||||
const DB = client.DB;
|
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!logSettings?.log_channel) return;
|
if (channel?.isTextBased()) await channel.send({ embeds: [embed] });
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) return;
|
|
||||||
|
|
||||||
await (channel as any).send({ embeds: [embed] });
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. Auto-Response (Trigger) System
|
### 4. TwitchMonitor IRC Chat Logging
|
||||||
|
|
||||||
**Event Handler (`messageCreate.ts`):**
|
**Overview:** Uses TMI.js to connect to Twitch IRC and logs moderation events (bans, timeouts, deletes) via webhooks.
|
||||||
```typescript
|
|
||||||
// Listens for messages and checks against auto_responses
|
|
||||||
// Ignores bots and DM messages
|
|
||||||
const triggers = DB.all('SELECT * FROM auto_responses WHERE guild_id = ?', guildId);
|
|
||||||
const contentLower = message.content.toLowerCase();
|
|
||||||
|
|
||||||
for (const trigger of triggers) {
|
**Key Feature:** FIFO Message Cache (`TwitchCache.ts`) allows restoring the text of deleted messages in the logs.
|
||||||
if (contentLower.includes(trigger.trigger_word.toLowerCase())) {
|
|
||||||
await message.channel.send(trigger.response_text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Command (`trigger.ts`):**
|
## 🚀 Performance Benchmarks (approx.)
|
||||||
```typescript
|
|
||||||
// Add trigger (admin only)
|
|
||||||
DB.run('INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?)',
|
|
||||||
guildId, triggerWord.toLowerCase(), responseText);
|
|
||||||
|
|
||||||
// List triggers (public)
|
| Metric | Old (Individual) | New (Batch/Cached) |
|
||||||
DB.all('SELECT * FROM auto_responses WHERE guild_id = ? ORDER BY trigger_word ASC', guildId);
|
|--------|------------------|-------------------|
|
||||||
|
| API Calls (500 channels) | 500 | 5 |
|
||||||
// Remove trigger (admin only)
|
| DB Access (Triggers) | Disk I/O per msg | RAM access |
|
||||||
DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord);
|
| Notification Delay | ~5-7 min | ~2 min |
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Twitch Monitoring with Stream ID
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Stream ID tracking prevents duplicate notifications
|
|
||||||
const isNewStream = monitor.last_stream_id !== stream.id;
|
|
||||||
const wasOffline = monitor.last_status === 'offline';
|
|
||||||
|
|
||||||
if (wasOffline || (monitor.last_status === 'online' && isNewStream)) {
|
|
||||||
// Send notification
|
|
||||||
DB.run('UPDATE twitch_monitors SET last_status = "online", last_stream_id = ? WHERE id = ?',
|
|
||||||
stream.id, monitor.id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. TwitchMonitor IRC Chat Logging
|
|
||||||
|
|
||||||
**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
|
|
||||||
const warnCount = countResult?.count ?? 0;
|
|
||||||
const warnThreshold = settings?.warn_threshold ?? 3;
|
|
||||||
const warnAction = settings?.warn_action ?? 'ban';
|
|
||||||
|
|
||||||
if (warnCount >= warnThreshold) {
|
|
||||||
if (warnAction === 'kick') await target.kick(...);
|
|
||||||
else if (warnAction === 'mute') await target.timeout(duration, ...);
|
|
||||||
else if (warnAction === 'ban') await target.ban(...);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. Mute with Multiple Time Units
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const unitSeconds: Record<string, number> = {
|
|
||||||
sec: 1,
|
|
||||||
min: 60,
|
|
||||||
hour: 60 * 60,
|
|
||||||
day: 60 * 60 * 24,
|
|
||||||
week: 60 * 60 * 24 * 7,
|
|
||||||
month: 60 * 60 * 24 * 30,
|
|
||||||
year: 60 * 60 * 24 * 365
|
|
||||||
};
|
|
||||||
|
|
||||||
if (unit === 'perm') {
|
|
||||||
durationMs = null; // Permanent
|
|
||||||
} else {
|
|
||||||
durationMs = duration * unitSeconds[unit!] * 1000;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11. Purge Command (Message Deletion)
|
|
||||||
|
|
||||||
**Subcommands:**
|
|
||||||
- `purge amount <1-100>` - Delete X messages
|
|
||||||
- `purge time <duration> <min|hour>` - Delete messages from last X time
|
|
||||||
- `purge user <user> <1-100>` - Delete X messages from specific user
|
|
||||||
- `purge all` - Delete all possible messages (max 100)
|
|
||||||
|
|
||||||
**Discord API Constraints:**
|
|
||||||
- Max 100 messages per bulk delete
|
|
||||||
- Messages older than 14 days cannot be bulk deleted
|
|
||||||
- Requires `ModerateMembers` permission
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Filter messages older than 14 days (Discord API limit)
|
|
||||||
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
||||||
const filtered = messages.filter((m: any) => m.createdTimestamp > twoWeeksAgo);
|
|
||||||
|
|
||||||
await channel.bulkDelete(filtered, true);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logging:** Purge actions are logged to the moderation log channel with event type `messages`.
|
|
||||||
|
|
||||||
### 12. Role Selection System
|
|
||||||
|
|
||||||
**Overview:** Self-service role assignment via Discord Select Menus. Users can choose roles from a dropdown message without needing admin intervention.
|
|
||||||
|
|
||||||
**Admin Commands:**
|
|
||||||
```typescript
|
|
||||||
/rolesetup create <#channel> <category_name> // Create new role selection message
|
|
||||||
/rolesetup add <kategorie> <@rolle> [emoji] // Add role option
|
|
||||||
/rolesetup edit <kategorie> <@rolle> [new_emoji] // Change emoji
|
|
||||||
/rolesetup remove <kategorie> <@rolle> // Remove role option
|
|
||||||
/rolesetup delete <kategorie> // Delete entire category
|
|
||||||
/rolesetup config <kategorie> max:1 exclusive:true // Configure settings
|
|
||||||
/rolesetup list // Show all configurations
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config Options:**
|
|
||||||
| Option | Default | Description |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| `max_roles` | 0 (unlimited) | Maximum roles a user can select from this category |
|
|
||||||
| `exclusive` | false | If true, selecting a role removes other roles in this category |
|
|
||||||
| `remove_on_delete` | false | Remove role from users when option is deleted |
|
|
||||||
|
|
||||||
**User Interaction:**
|
|
||||||
- User clicks on Select Menu dropdown
|
|
||||||
- Role is added/removed based on config
|
|
||||||
- Ephemeral confirmation message appears (auto-hides after 60s)
|
|
||||||
|
|
||||||
**Database Schema:**
|
|
||||||
- `role_categories`: Stores category settings (channel, message_id, config)
|
|
||||||
- `role_options`: Stores individual role options with labels and emojis
|
|
||||||
- CASCADE DELETE removes options when category is deleted
|
|
||||||
|
|
||||||
**Role Validation:**
|
|
||||||
- On bot startup, validates that all stored role IDs still exist in Discord
|
|
||||||
- Missing roles are removed from database (and optionally from users)
|
|
||||||
|
|
||||||
## 🚀 Build & Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Deploy Commands
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# Run
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ Critical Constraints
|
## ⚠️ Critical Constraints
|
||||||
|
|
||||||
1. **Environment Variables:** Always use `process.env`, never hardcode.
|
1. **Batching:** Never exceed 100 channels per Twitch API request.
|
||||||
2. **Type Safety:** Use `any` for command `data` and `execute` parameters (compatibility).
|
2. **Caching:** Always invalidate cache (`clearCache`) when updating settings.
|
||||||
3. **Interaction States:** Check `interaction.replied` before replying.
|
3. **WAL Mode:** Ensure the `data/` directory has proper permissions for `.shm` and `.wal` files.
|
||||||
4. **Twitch API:** Max 100 channels per request, batch properly.
|
4. **Webhook Safety:** Use `WebhookClient` for Twitch IRC logging to avoid Discord bot rate limits.
|
||||||
5. **Deploy:** Commands NOT auto-registered on start – use `/owner deploy`.
|
|
||||||
6. **Ephemeral Replies:** Use `flags: [MessageFlags.Ephemeral]` instead of deprecated `ephemeral: true`.
|
|
||||||
7. **Line Endings:** Use `.gitattributes` to ensure LF line endings for consistency.
|
|
||||||
|
|
||||||
## 📋 Command Summary
|
|
||||||
|
|
||||||
| Command | Subcommands | Permission |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| `/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 |
|
|
||||||
| `/welcome` | setchannel, add, remove, list, help | Admin |
|
|
||||||
| `/log` | setchannel, enable, disable, list, help | Admin |
|
|
||||||
| `/admin` | kick, warn, unwarn, mute, unmute, ban, unban, purge, config, help | Admin |
|
|
||||||
| `/owner` | deploy, stats, servers, help | Owner |
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -131,18 +131,14 @@ npm run deploy
|
|||||||
- `/owner servers` – Alle Server
|
- `/owner servers` – Alle Server
|
||||||
- `/owner help` – Owner-Hilfe
|
- `/owner help` – Owner-Hilfe
|
||||||
|
|
||||||
## 📡 Features
|
## 📡 Features & Optimierungen
|
||||||
|
|
||||||
- ✅ Modular Architecture
|
- ✅ **Modular Architecture:** TypeScript-basiert, ESM-Unterstützung.
|
||||||
- ✅ TypeScript
|
- ✅ **High Performance Database:** SQLite mit **WAL-Modus** und **In-Memory Caching** für Guild-Settings und Trigger.
|
||||||
- ✅ SQLite Database
|
- ✅ **Optimized Twitch Polling:** Nutzt **Batch-Requests** (100 Kanäle pro Request) und ein 2-Minuten-Intervall für zeitnahe Benachrichtigungen.
|
||||||
- ✅ Twitch Monitoring (Stream-Status)
|
- ✅ **TwitchMonitor IRC:** Echtzeit IRC Chat-Logging (Mod-Events) via Webhooks und FIFO-Cache.
|
||||||
- ✅ TwitchMonitor IRC Chat Logging (Mod-Events)
|
- ✅ **Advanced Moderation:** Warn-System mit Auto-Actions, umfangreiches Purge-System.
|
||||||
- ✅ Rollen-Auswahl (Self-Service)
|
- ✅ **Role Selection:** Self-Service Rollen-System über Discord Select Menus.
|
||||||
- ✅ Moderation Tools
|
- ✅ **Reminders:** Intelligentes Zeit-Parsing (z.B. "morgen 15:00", "30min").
|
||||||
- ✅ Konfigurierbares Warn-System
|
- ✅ **Event Logging:** Detaillierte Erfassung von Server-Ereignissen.
|
||||||
- ✅ Auto-Antworten (Trigger)
|
- ✅ **Docker Support:** Containerisiertes Deployment inklusive Volume-Mounts für Daten.
|
||||||
- ✅ Timer & Erinnerungen
|
|
||||||
- ✅ Welcome/Goodbye Messages
|
|
||||||
- ✅ Event Logging
|
|
||||||
- ✅ Docker Support
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
|
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
const DB = client.DB;
|
const settings = client.DB.getSettings(guildId);
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!logSettings?.log_channel) return;
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) return;
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
|
|||||||
export default {
|
export default {
|
||||||
name: Events.ChannelCreate,
|
name: Events.ChannelCreate,
|
||||||
async execute(channel: GuildChannel) {
|
async execute(channel: GuildChannel) {
|
||||||
|
if (!channel.guild) return;
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('📁 Channel erstellt')
|
.setTitle('📁 Channel erstellt')
|
||||||
.setColor(0x27ae60)
|
.setColor(0x27ae60)
|
||||||
@@ -33,6 +31,6 @@ export default {
|
|||||||
)
|
)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
await sendLog(channel.client, channel.guild!.id, 'channels', embed);
|
await sendLog(channel.client, channel.guild.id, 'channels', embed);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
|
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
const DB = client.DB;
|
const settings = client.DB.getSettings(guildId);
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!logSettings?.log_channel) return;
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) return;
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
|
|||||||
export default {
|
export default {
|
||||||
name: Events.ChannelDelete,
|
name: Events.ChannelDelete,
|
||||||
async execute(channel: GuildChannel) {
|
async execute(channel: GuildChannel) {
|
||||||
|
if (!channel.guild) return;
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('📁 Channel gelöscht')
|
.setTitle('📁 Channel gelöscht')
|
||||||
.setColor(0xe74c3c)
|
.setColor(0xe74c3c)
|
||||||
@@ -33,6 +31,6 @@ export default {
|
|||||||
)
|
)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
await sendLog(channel.client, channel.guild!.id, 'channels', embed);
|
await sendLog(channel.client, channel.guild.id, 'channels', embed);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -6,7 +6,7 @@ export default {
|
|||||||
const DB = (member.client as any).DB;
|
const DB = (member.client as any).DB;
|
||||||
const guildId = member.guild.id;
|
const guildId = member.guild.id;
|
||||||
|
|
||||||
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
|
const settings = DB.getSettings(guildId);
|
||||||
|
|
||||||
// Use custom channel or fall back to Discord's system channel
|
// Use custom channel or fall back to Discord's system channel
|
||||||
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
|
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { Events, GuildMember, TextChannel, EmbedBuilder } from 'discord.js';
|
|||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
const DB = client.DB;
|
const DB = client.DB;
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
const settings = DB.getSettings(guildId);
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
if (!settings?.log_events) return;
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
if (!settings?.log_channel) return;
|
||||||
if (!logSettings?.log_channel) return;
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!channel || !channel.isTextBased()) return;
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -40,7 +39,7 @@ export default {
|
|||||||
await sendLog(member.client, guildId, 'leaves', embed);
|
await sendLog(member.client, guildId, 'leaves', embed);
|
||||||
|
|
||||||
// Send goodbye message if configured
|
// Send goodbye message if configured
|
||||||
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
|
const settings = DB.getSettings(guildId);
|
||||||
|
|
||||||
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
|
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
|
||||||
if (!channelId || !settings?.goodbye_message) {
|
if (!channelId || !settings?.goodbye_message) {
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Events, GuildMember, EmbedBuilder } from 'discord.js';
|
import { Events, GuildMember, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
const DB = client.DB;
|
const settings = client.DB.getSettings(guildId);
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!logSettings?.log_channel) return;
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) return;
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guild Member Update (nickname + roles)
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.GuildMemberUpdate,
|
name: Events.GuildMemberUpdate,
|
||||||
async execute(oldMember: GuildMember, newMember: GuildMember) {
|
async execute(oldMember: GuildMember, newMember: GuildMember) {
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ export default {
|
|||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
if (!message.guildId) return;
|
if (!message.guildId) return;
|
||||||
|
|
||||||
const triggers: any[] = client.DB.all(
|
const triggers = client.DB.getTriggers(message.guildId);
|
||||||
'SELECT * FROM auto_responses WHERE guild_id = ?',
|
|
||||||
message.guildId
|
if (triggers.length === 0) return;
|
||||||
);
|
|
||||||
|
|
||||||
const contentLower = message.content.toLowerCase();
|
const contentLower = message.content.toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,22 @@
|
|||||||
import { Events, Message, TextChannel, GuildChannel, AuditLogEvent, EmbedBuilder } from 'discord.js';
|
import { Events, Message, TextChannel, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
function getLogChannel(client: any, guildId: string): TextChannel | null {
|
|
||||||
const DB = client.DB;
|
|
||||||
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
|
|
||||||
|
|
||||||
if (!settings?.log_channel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return channel as TextChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEventEnabled(client: any, guildId: string, event: string): boolean {
|
|
||||||
const DB = client.DB;
|
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
|
||||||
|
|
||||||
if (!settings?.log_events) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
|
||||||
return activeEvents.includes(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
if (!isEventEnabled(client, guildId, event)) {
|
const settings = client.DB.getSettings(guildId);
|
||||||
return;
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
}
|
|
||||||
|
|
||||||
const channel = getLogChannel(client, guildId);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!channel) {
|
if (!activeEvents.includes(event)) return;
|
||||||
return;
|
|
||||||
}
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await channel.send({ embeds: [embed] });
|
await (channel as any).send({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[LOG] Error sending log (${event}):`, error);
|
console.error(`[LOG] Error sending log (${event}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message Delete
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.MessageDelete,
|
name: Events.MessageDelete,
|
||||||
async execute(message: Message) {
|
async execute(message: Message) {
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Events, Message, EmbedBuilder } from 'discord.js';
|
import { Events, Message, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
|
||||||
const DB = client.DB;
|
const settings = client.DB.getSettings(guildId);
|
||||||
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
|
if (!settings?.log_events || !settings?.log_channel) return;
|
||||||
|
|
||||||
if (!settings?.log_events) return;
|
|
||||||
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
const activeEvents = settings.log_events.split(',').filter(Boolean);
|
||||||
if (!activeEvents.includes(event)) return;
|
if (!activeEvents.includes(event)) return;
|
||||||
|
|
||||||
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
|
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
|
||||||
if (!logSettings?.log_channel) return;
|
|
||||||
|
|
||||||
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
|
|
||||||
if (!channel || !channel.isTextBased()) return;
|
if (!channel || !channel.isTextBased()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message Update (Edit)
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.MessageUpdate,
|
name: Events.MessageUpdate,
|
||||||
async execute(oldMessage: Message, newMessage: Message) {
|
async execute(oldMessage: Message, newMessage: Message) {
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ const dbPath = join(dataDir, 'database.sqlite');
|
|||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
export class DB {
|
export class DB {
|
||||||
|
private static settingsCache = new Map<string, any>();
|
||||||
|
private static triggersCache = new Map<string, any[]>();
|
||||||
|
|
||||||
static init() {
|
static init() {
|
||||||
console.log(`[DATABASE] Initializing at ${dbPath}...`);
|
console.log(`[DATABASE] Initializing at ${dbPath}...`);
|
||||||
|
|
||||||
|
// Enable WAL mode for better performance
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Guild Settings
|
// Guild Settings
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
CREATE TABLE IF NOT EXISTS guild_settings (
|
CREATE TABLE IF NOT EXISTS guild_settings (
|
||||||
@@ -143,7 +149,7 @@ export class DB {
|
|||||||
)
|
)
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
// Migration: Add missing columns to existing guild_settings
|
// Migration logic
|
||||||
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
|
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
|
||||||
const columnNames = columns.map((c: any) => c.name);
|
const columnNames = columns.map((c: any) => c.name);
|
||||||
|
|
||||||
@@ -163,6 +169,47 @@ export class DB {
|
|||||||
console.log('[DATABASE] Initialization complete.');
|
console.log('[DATABASE] Initialization complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimized getter for guild settings with caching
|
||||||
|
*/
|
||||||
|
static getSettings(guildId: string): any {
|
||||||
|
if (this.settingsCache.has(guildId)) {
|
||||||
|
return this.settingsCache.get(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
|
||||||
|
if (settings) {
|
||||||
|
this.settingsCache.set(guildId, settings);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimized getter for auto responses with caching
|
||||||
|
*/
|
||||||
|
static getTriggers(guildId: string): any[] {
|
||||||
|
if (this.triggersCache.has(guildId)) {
|
||||||
|
return this.triggersCache.get(guildId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggers = db.prepare('SELECT * FROM auto_responses WHERE guild_id = ?').all(guildId);
|
||||||
|
this.triggersCache.set(guildId, triggers);
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a specific guild or all
|
||||||
|
*/
|
||||||
|
static clearCache(guildId?: string) {
|
||||||
|
if (guildId) {
|
||||||
|
this.settingsCache.delete(guildId);
|
||||||
|
this.triggersCache.delete(guildId);
|
||||||
|
} else {
|
||||||
|
this.settingsCache.clear();
|
||||||
|
this.triggersCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static get(query: string, ...params: any[]) {
|
static get(query: string, ...params: any[]) {
|
||||||
return db.prepare(query).get(...params);
|
return db.prepare(query).get(...params);
|
||||||
}
|
}
|
||||||
@@ -171,7 +218,21 @@ export class DB {
|
|||||||
return db.prepare(query).all(...params);
|
return db.prepare(query).all(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended run method that automatically clears relevant caches on write
|
||||||
|
*/
|
||||||
static run(query: string, ...params: any[]) {
|
static run(query: string, ...params: any[]) {
|
||||||
return db.prepare(query).run(...params);
|
const result = db.prepare(query).run(...params);
|
||||||
|
|
||||||
|
// Simple heuristic to clear cache on updates to settings or triggers
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
if (lowerQuery.includes('guild_settings') || lowerQuery.includes('auto_responses')) {
|
||||||
|
// If the query contains a guild_id in params, we could be more specific,
|
||||||
|
// but for safety and simplicity, we clear the cache or just wait for next read.
|
||||||
|
// We search for guild_id in params to be surgical if possible.
|
||||||
|
this.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,35 +29,58 @@ export class TwitchManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fetchStreamData(channelName: string) {
|
/**
|
||||||
|
* Fetches stream data for multiple channels in a single batch (max 100).
|
||||||
|
*/
|
||||||
|
private static async fetchStreamsBatch(channelNames: string[]) {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
if (!token) return null;
|
if (!token || channelNames.length === 0) return [];
|
||||||
|
|
||||||
|
// Twitch API allows up to 100 user_login parameters
|
||||||
|
const query = channelNames.map(name => `user_login=${encodeURIComponent(name)}`).join('&');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.twitch.tv/helix/streams?user_login=${channelName}`, {
|
const response = await fetch(`https://api.twitch.tv/helix/streams?${query}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Client-ID': process.env.TWITCH_CLIENT_ID!,
|
'Client-ID': process.env.TWITCH_CLIENT_ID!,
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const data: any = await response.json();
|
const data: any = await response.json();
|
||||||
return data.data?.[0] || null;
|
return data.data || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[TWITCH] Error fetching stream data for ${channelName}:`, error);
|
console.error(`[TWITCH] Error fetching batch (${channelNames.length} channels):`, error);
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy helper for single channel fetch (e.g. for commands)
|
||||||
|
*/
|
||||||
|
static async fetchStreamData(channelName: string) {
|
||||||
|
const streams = await this.fetchStreamsBatch([channelName]);
|
||||||
|
return streams[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
static async checkStreams(client: any) {
|
static async checkStreams(client: any) {
|
||||||
const token = await this.getAccessToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const monitors: any[] = (client as any).DB?.all('SELECT * FROM twitch_monitors') || [];
|
const monitors: any[] = (client as any).DB?.all('SELECT * FROM twitch_monitors') || [];
|
||||||
if (monitors.length === 0) return;
|
if (monitors.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))];
|
||||||
|
const onlineStreamsMap = new Map<string, any>();
|
||||||
|
|
||||||
|
// Process in chunks of 100 (Twitch API limit)
|
||||||
|
for (let i = 0; i < uniqueChannels.length; i += 100) {
|
||||||
|
const chunk = uniqueChannels.slice(i, i + 100);
|
||||||
|
const streams = await this.fetchStreamsBatch(chunk);
|
||||||
|
for (const stream of streams) {
|
||||||
|
onlineStreamsMap.set(stream.user_login.toLowerCase(), stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const monitor of monitors) {
|
for (const monitor of monitors) {
|
||||||
try {
|
try {
|
||||||
const stream = await this.fetchStreamData(monitor.channel_name);
|
const stream = onlineStreamsMap.get(monitor.channel_name.toLowerCase());
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
const isNewStream = monitor.last_stream_id !== stream.id;
|
const isNewStream = monitor.last_stream_id !== stream.id;
|
||||||
@@ -77,12 +100,13 @@ export class TwitchManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[TWITCH] Error checking stream ${monitor.channel_name}:`, error);
|
console.error(`[TWITCH] Error processing monitor ${monitor.channel_name}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async sendNotification(client: any, monitor: any, stream: any) {
|
private static async sendNotification(client: any, monitor: any, stream: any) {
|
||||||
|
try {
|
||||||
const guild = client.guilds.cache.get(monitor.guild_id);
|
const guild = client.guilds.cache.get(monitor.guild_id);
|
||||||
if (!guild || !monitor.discord_channel_id) return;
|
if (!guild || !monitor.discord_channel_id) return;
|
||||||
|
|
||||||
@@ -111,10 +135,14 @@ export class TwitchManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await channel.send({ content, embeds: [embed] });
|
await channel.send({ content, embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[TWITCH] Failed to send notification for ${monitor.channel_name}:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static startPolling(client: any) {
|
static startPolling(client: any) {
|
||||||
setInterval(() => this.checkStreams(client), 5 * 60 * 1000);
|
// Polling every 2 minutes for faster notifications
|
||||||
console.log('[TWITCH] Polling started (5m interval).');
|
setInterval(() => this.checkStreams(client), 2 * 60 * 1000);
|
||||||
|
console.log('[TWITCH] Batch-Polling started (2m interval).');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user