This commit is contained in:
Slipstream 2025-04-25 14:03:49 -06:00
commit 60d7880d3c
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
124 changed files with 26643 additions and 0 deletions

1
.clineignore Normal file
View File

@ -0,0 +1 @@
.env

32
.env.example Normal file
View File

@ -0,0 +1,32 @@
# Discord Bot Configuration
DISCORD_TOKEN=your_discord_bot_token
OWNER_USER_ID=your_discord_user_id
# API Configuration
API_URL=http://localhost:8000
# AI Configuration
AI_API_KEY=your_openai_api_key
AI_API_URL=https://api.openai.com/v1/chat/completions
AI_DEFAULT_MODEL=gpt-3.5-turbo
AI_DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
AI_MAX_TOKENS=1000
AI_TEMPERATURE=0.7
AI_TIMEOUT=60
AI_COMPATIBILITY_MODE=openai
# SSL Configuration (for API)
SSL_CERT_FILE=path/to/cert.pem
SSL_KEY_FILE=path/to/key.pem
# OAuth Configuration
DISCORD_CLIENT_ID=your_discord_client_id
# No client secret for public clients
OAUTH_HOST=localhost
OAUTH_PORT=8080
API_OAUTH_ENABLED=true
# If API_OAUTH_ENABLED is true, this will be ignored and API_URL/auth will be used instead
DISCORD_REDIRECT_URI=http://localhost:8080/oauth/callback
# GitHub Webhook Configuration
GITHUB_SECRET=your_github_webhook_secret

57
.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
venv/
*.json
certs/
data/
.env
# C extensions
*.so
# Distribution / packaging
bin/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Rope
.ropeproject
# Django stuff:
*.log
*.pot
# Sphinx documentation
docs/_build/
.env
*.env

132
ADDITIONAL_BOTS_README.md Normal file
View File

@ -0,0 +1,132 @@
# Additional AI Bots - Neru and Miku
This system allows you to run two additional Discord bots (Neru and Miku) that only have AI commands, each with their own system prompt and configuration. These bots are designed for roleplay interactions with specific character personas.
## Setup Instructions
1. **Bot Tokens**:
- The tokens are already configured in `data/multi_bot_config.json`
- If you need to change them, you can edit the configuration file or use the `!setbottoken` command
2. **API Key**:
- The OpenRouter API key is already configured in the configuration file
- If you need to change it, you can edit the configuration file or use the `!setapikey` command
3. **System Prompts**:
- Each bot has a specific roleplay character system prompt:
- Neru: Akita Neru roleplay character
- Miku: Hatsune Miku roleplay character
## Running the Bots
### Option 1: Run from the Main Bot
The main bot has commands to control the additional bots:
- `!startbot <bot_id>` - Start a specific bot (e.g., `!startbot bot1`)
- `!stopbot <bot_id>` - Stop a specific bot
- `!startallbots` - Start all configured bots
- `!stopallbots` - Stop all running bots
- `!listbots` - List all configured bots and their status
### Option 2: Run Independently
You can run the additional bots independently of the main bot:
```bash
python run_additional_bots.py
```
This will start all configured bots in separate threads.
## Configuration Commands
The main bot provides commands to modify the configuration:
- `!setbottoken <bot_id> <token>` - Set the token for a specific bot
- `!setbotprompt <bot_id> <system_prompt>` - Set the system prompt for a specific bot
- `!setbotprefix <bot_id> <prefix>` - Set the command prefix for a specific bot
- `!setbotstatus <bot_id> <status_type> <status_text>` - Set the status for a specific bot
- `!setallbotstatus <status_type> <status_text>` - Set the status for all bots
- `!setapikey <api_key>` - Set the API key for all bots
- `!setapiurl <api_url>` - Set the API URL for all bots
- `!addbot <bot_id> [prefix]` - Add a new bot configuration
- `!removebot <bot_id>` - Remove a bot configuration
Status types for the status commands:
- `playing` - "Playing {status_text}"
- `listening` - "Listening to {status_text}"
- `watching` - "Watching {status_text}"
- `streaming` - "Streaming {status_text}"
- `competing` - "Competing in {status_text}"
## Bot Commands
Each additional bot supports the following commands:
- Neru: `$ai <prompt>` - Get a response from Akita Neru
- Miku: `.ai <prompt>` - Get a response from Hatsune Miku
Additional commands for both bots:
- `aiclear` - Clear your conversation history
- `aisettings` - Show your current AI settings
- `aiset <setting> <value>` - Change an AI setting
- `aireset` - Reset your AI settings to defaults
- `ailast` - Retrieve your last AI response
- `aihelp` - Get help with AI command issues
Available settings for the `aiset` command:
- `model` - The AI model to use (must contain ":free")
- `system_prompt` - The system prompt to use
- `max_tokens` - Maximum tokens in response (100-2000)
- `temperature` - Temperature for response generation (0.0-2.0)
- `timeout` - Timeout for API requests in seconds (10-120)
Note that each bot uses its own prefix (`$` for Neru and `.` for Miku).
## Customization
You can customize each bot by editing the `data/multi_bot_config.json` file:
```json
{
"bots": [
{
"id": "neru",
"token": "YOUR_NERU_BOT_TOKEN_HERE",
"prefix": "$",
"system_prompt": "You are a creative and intelligent AI assistant engaged in an iterative storytelling experience using a roleplay chat format. Chat exclusively as Akita Neru...",
"model": "deepseek/deepseek-chat-v3-0324:free",
"max_tokens": 1000,
"temperature": 0.7,
"timeout": 60,
"status_type": "playing",
"status_text": "with my phone"
},
{
"id": "miku",
"token": "YOUR_MIKU_BOT_TOKEN_HERE",
"prefix": ".",
"system_prompt": "You are a creative and intelligent AI assistant engaged in an iterative storytelling experience using a roleplay chat format. Chat exclusively as Hatsune Miku...",
"model": "deepseek/deepseek-chat-v3-0324:free",
"max_tokens": 1000,
"temperature": 0.7,
"timeout": 60,
"status_type": "listening",
"status_text": "music"
}
],
"api_key": "YOUR_OPENROUTER_API_KEY_HERE",
"api_url": "https://openrouter.ai/api/v1/chat/completions",
"compatibility_mode": "openai"
}
```
## Troubleshooting
- If a bot fails to start, check that its token is correctly set in the configuration
- If AI responses fail, check that the API key is correctly set
- Each bot stores its conversation history and user settings in separate files to avoid conflicts

76
DISCORD_SYNC_README.md Normal file
View File

@ -0,0 +1,76 @@
# Discord Sync Integration
This document explains how to set up the Discord OAuth integration between your Flutter app and Discord bot.
## Overview
The integration allows users to:
1. Log in with their Discord account
2. Sync conversations between the Flutter app and Discord bot
3. Import conversations from Discord to the Flutter app
4. Export conversations from the Flutter app to Discord
## Setup Instructions
### 1. Discord Developer Portal Setup
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and give it a name (e.g., "OpenRouter GUI")
3. Go to the "OAuth2" section
4. Add a redirect URL: `openroutergui://auth`
5. Copy the "Client ID" - you'll need this for the Flutter app
### 2. Flutter App Setup
1. Open `lib/services/discord_oauth_service.dart`
2. Replace `YOUR_DISCORD_CLIENT_ID` with the Client ID from the Discord Developer Portal:
```dart
static const String clientId = 'YOUR_DISCORD_CLIENT_ID';
```
3. Open `lib/services/sync_service.dart`
4. Replace `YOUR_BOT_API_URL` with the production API URL:
```dart
static const String botApiUrl = 'https://slipstreamm.dev/discordapi';
```
### 3. SSL Certificates
1. SSL certificates are required for the production API
2. Place your SSL certificates in the following locations:
```
certs/cert.pem # SSL certificate file
certs/key.pem # SSL key file
```
3. The API will automatically use SSL if certificates are available
4. For development, the API will fall back to HTTP on port 8000 if certificates are not found
## Bot Commands
The following commands are available for managing synced conversations:
- `!aisync` - View your synced conversations status
- `!syncstatus` - Check the status of the Discord sync API
- `!synchelp` - Get help with setting up the Discord sync integration
- `!syncclear` - Clear your synced conversations
- `!synclist` - List your synced conversations
## Usage
1. In the Flutter app, go to Settings > Discord Integration
2. Click "Login with Discord" to authenticate
3. Use the "Sync Conversations" button to sync conversations
4. Use the "Import from Discord" button to import conversations from Discord
## Troubleshooting
- **Authentication Issues**: Make sure the Client ID is correct and the redirect URL is properly configured
- **Sync Issues**: Check that the bot API URL is accessible and the API is running
- **Import/Export Issues**: Verify that the Discord bot has saved conversations to sync
## Security Considerations
- The integration uses Discord OAuth for authentication, ensuring only authorized users can access their conversations
- All API requests require a valid Discord token
- The API verifies the token with Discord for each request
- Consider adding rate limiting and additional security measures for production use

302
EXAMPLE.py Normal file
View File

@ -0,0 +1,302 @@
import random
from PIL import Image, ImageDraw, ImageFont
import math
import wave
import struct
from pydub import AudioSegment
from gtts import gTTS
import os
import moviepy.video.io.ImageSequenceClip
import glob
import json
import numpy as np
import nltk
from nltk.corpus import words, wordnet
nltk.download('words')
nltk.download('wordnet')
class JSON:
def read(file):
with open(f"{file}.json", "r", encoding="utf8") as file:
data = json.load(file, strict=False)
return data
def dump(file, data):
with open(f"{file}.json", "w", encoding="utf8") as file:
json.dump(data, file, indent=4)
config_data = JSON.read("config")
# SETTINGS #
w = config_data["WIDTH"]
h = config_data["HEIGHT"]
maxW = config_data["MAX_WIDTH"]
maxH = config_data["MAX_HEIGHT"]
minW = config_data["MIN_WIDTH"]
minH = config_data["MIN_HEIGHT"]
LENGTH = config_data["SLIDES"]
AMOUNT = config_data["VIDEOS"]
min_shapes = config_data["MIN_SHAPES"]
max_shapes = config_data["MAX_SHAPES"]
sample_rate = config_data["SOUND_QUALITY"]
tts_enabled = config_data.get("TTS_ENABLED", True)
tts_text = config_data.get("TTS_TEXT", "This is a default text for TTS.")
audio_wave_type = config_data.get("AUDIO_WAVE_TYPE", "sawtooth") # Options: sawtooth, sine, square
slide_duration = config_data.get("SLIDE_DURATION", 1000) # Duration in milliseconds
deform_level = config_data.get("DEFORM_LEVEL", "none") # Options: none, low, medium, high
color_mode = config_data.get("COLOR_MODE", "random") # Options: random, scheme, solid
color_scheme = config_data.get("COLOR_SCHEME", "default") # Placeholder for color schemes
solid_color = config_data.get("SOLID_COLOR", "#FFFFFF") # Default solid color
allowed_shapes = config_data.get("ALLOWED_SHAPES", ["rectangle", "ellipse", "polygon", "triangle", "circle"])
wave_vibe = config_data.get("WAVE_VIBE", "calm") # New config option for wave vibe
top_left_text_enabled = config_data.get("TOP_LEFT_TEXT_ENABLED", True)
top_left_text_mode = config_data.get("TOP_LEFT_TEXT_MODE", "random") # Options: random, word
words_topic = config_data.get("WORDS_TOPIC", "random") # Options: random, introspective, action, nature, technology
# Vibe presets for wave sound
wave_vibes = {
"calm": {"frequency": 200, "amplitude": 0.3, "modulation": 0.1},
"eerie": {"frequency": 600, "amplitude": 0.5, "modulation": 0.7},
"random": {}, # Randomized values will be generated
"energetic": {"frequency": 800, "amplitude": 0.7, "modulation": 0.2},
"dreamy": {"frequency": 400, "amplitude": 0.4, "modulation": 0.5},
"chaotic": {"frequency": 1000, "amplitude": 1.0, "modulation": 1.0},
}
color_schemes = {
"pastel": [(255, 182, 193), (176, 224, 230), (240, 230, 140), (221, 160, 221), (152, 251, 152)],
"dark_gritty": [(47, 79, 79), (105, 105, 105), (0, 0, 0), (85, 107, 47), (139, 69, 19)],
"nature": [(34, 139, 34), (107, 142, 35), (46, 139, 87), (32, 178, 170), (154, 205, 50)],
"vibrant": [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)],
"ocean": [(0, 105, 148), (72, 209, 204), (70, 130, 180), (135, 206, 250), (176, 224, 230)]
}
# Font scaling based on video size
font_size = max(w, h) // 40 # Scales font size to make it smaller and more readable
fnt = ImageFont.truetype("./FONT/sys.ttf", font_size)
files = glob.glob('./IMG/*')
for f in files:
os.remove(f)
print("REMOVED OLD FILES")
def generate_string(length, charset="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"):
result = ""
for i in range(length):
result += random.choice(charset)
return result
# Predefined word lists for specific topics
introspective_words = ["reflection", "thought", "solitude", "ponder", "meditation", "introspection", "awareness", "contemplation", "silence", "stillness"]
action_words = ["run", "jump", "climb", "race", "fight", "explore", "build", "create", "overcome", "achieve"]
nature_words = ["tree", "mountain", "river", "ocean", "flower", "forest", "animal", "sky", "valley", "meadow"]
technology_words = ["computer", "robot", "network", "data", "algorithm", "innovation", "digital", "machine", "software", "hardware"]
def generate_word(theme="random"):
if theme == "introspective":
return random.choice(introspective_words)
elif theme == "action":
return random.choice(action_words)
elif theme == "nature":
return random.choice(nature_words)
elif theme == "technology":
return random.choice(technology_words)
elif theme == "random":
return random.choice(words.words())
else:
return "unknown_theme"
def append_wave(
freq=None,
duration_milliseconds=1000,
volume=1.0):
global audio
vibe_params = wave_vibes.get(wave_vibe, wave_vibes["calm"])
if wave_vibe == "random":
freq = random.uniform(100, 1000) if freq is None else freq
amplitude = random.uniform(0.1, 1.0)
modulation = random.uniform(0.1, 1.0)
else:
base_freq = vibe_params["frequency"]
freq = random.uniform(base_freq * 0.7, base_freq * 1.3) if freq is None else freq
amplitude = vibe_params["amplitude"] * random.uniform(0.7, 1.3)
modulation = vibe_params["modulation"] * random.uniform(0.6, 1.4)
num_samples = duration_milliseconds * (sample_rate / 1000.0)
for x in range(int(num_samples)):
wave_sample = amplitude * math.sin(2 * math.pi * freq * (x / sample_rate))
modulated_sample = wave_sample * (1 + modulation * math.sin(2 * math.pi * 0.5 * x / sample_rate))
audio.append(volume * modulated_sample)
return
def save_wav(file_name):
wav_file = wave.open(file_name, "w")
nchannels = 1
sampwidth = 2
nframes = len(audio)
comptype = "NONE"
compname = "not compressed"
wav_file.setparams((nchannels, sampwidth, sample_rate, nframes, comptype, compname))
for sample in audio:
wav_file.writeframes(struct.pack('h', int(sample * 32767.0)))
wav_file.close()
return
# Generate TTS audio using gTTS
def generate_tts_audio(text, output_file):
tts = gTTS(text=text, lang='en')
tts.save(output_file)
print(f"TTS audio saved to {output_file}")
if tts_enabled:
tts_audio_file = "./SOUND/tts_output.mp3"
generate_tts_audio(tts_text, tts_audio_file)
for xyz in range(AMOUNT):
video_name = generate_string(6) # Generate a consistent video name
for i in range(LENGTH):
img = Image.new("RGB", (w, h))
img1 = ImageDraw.Draw(img)
img1.rectangle([(0, 0), (w, h)], fill="white", outline="white")
num_shapes = random.randint(min_shapes, max_shapes)
for _ in range(num_shapes):
shape_type = random.choice(allowed_shapes)
x1, y1 = random.randint(0, w), random.randint(0, h)
if deform_level == "none":
x2, y2 = minW + (maxW - minW) // 2, minH + (maxH - minH) // 2
elif deform_level == "low":
x2 = random.randint(minW, minW + (maxW - minW) // 4)
y2 = random.randint(minH, minH + (maxH - minH) // 4)
elif deform_level == "medium":
x2 = random.randint(minW, minW + (maxW - minW) // 2)
y2 = random.randint(minH, minH + (maxH - minH) // 2)
elif deform_level == "high":
x2 = random.randint(minW, maxW)
y2 = random.randint(minH, maxH)
if color_mode == "random":
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
elif color_mode == "scheme":
scheme_colors = color_schemes.get(color_scheme, [(128, 128, 128)])
color = random.choice(scheme_colors)
elif color_mode == "solid":
color = tuple(int(solid_color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
if shape_type == "rectangle":
img1.rectangle([(x1, y1), (x1 + x2, y1 + y2)], fill=color, outline=color)
elif shape_type == "ellipse":
img1.ellipse([(x1, y1), (x1 + x2, y1 + y2)], fill=color, outline=color)
elif shape_type == "polygon":
num_points = random.randint(3, 6)
points = [(random.randint(0, w), random.randint(0, h)) for _ in range(num_points)]
img1.polygon(points, fill=color, outline=color)
elif shape_type == "triangle":
points = [
(x1, y1),
(x1 + random.randint(-x2, x2), y1 + y2),
(x1 + x2, y1 + random.randint(-y2, y2))
]
img1.polygon(points, fill=color, outline=color)
elif shape_type == "star":
points = []
for j in range(5):
outer_x = x1 + int(x2 * math.cos(j * 2 * math.pi / 5))
outer_y = y1 + int(y2 * math.sin(j * 2 * math.pi / 5))
points.append((outer_x, outer_y))
inner_x = x1 + int(x2 / 2 * math.cos((j + 0.5) * 2 * math.pi / 5))
inner_y = y1 + int(y2 / 2 * math.sin((j + 0.5) * 2 * math.pi / 5))
points.append((inner_x, inner_y))
img1.polygon(points, fill=color, outline=color)
elif shape_type == "circle":
radius = min(x2, y2) // 2
img1.ellipse([(x1 - radius, y1 - radius), (x1 + radius, y1 + radius)], fill=color, outline=color)
if top_left_text_enabled:
if top_left_text_mode == "random":
random_top_left_text = generate_string(30, charset="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:',.<>?/")
elif top_left_text_mode == "word":
random_top_left_text = generate_word(words_topic)
else:
random_top_left_text = ""
img1.text((10, 10), random_top_left_text, font=fnt, fill="black")
# Add video name to bottom-left corner
video_name_text = f"{video_name}.mp4"
video_name_width = img1.textlength(video_name_text, font=fnt)
video_name_height = font_size
img1.text((10, h - video_name_height - 10), video_name_text, font=fnt, fill="black")
# Move slide info text to the top right corner
slide_text = f"Slide {i}"
text_width = img1.textlength(slide_text, font=fnt)
text_height = font_size
img1.text((w - text_width - 10, 10), slide_text, font=fnt, fill="black")
img.save(f"./IMG/{str(i).zfill(4)}_{random.randint(1000, 9999)}.png")
print("IMAGE GENERATION DONE")
audio = []
for i in range(LENGTH):
append_wave(None, duration_milliseconds=slide_duration, volume=0.25)
save_wav("./SOUND/output.wav")
print("WAV GENERATED")
wav_audio = AudioSegment.from_file("./SOUND/output.wav", format="wav")
if tts_enabled:
tts_audio = AudioSegment.from_file(tts_audio_file, format="mp3")
combined_audio = wav_audio.overlay(tts_audio, position=0)
else:
combined_audio = wav_audio
combined_audio.export("./SOUND/output.m4a", format="adts")
print("MP3 GENERATED")
image_folder = './IMG'
fps = 1000 / slide_duration # Ensure fps is precise to handle timing discrepancies
image_files = sorted([f for f in glob.glob(f"{image_folder}/*.png")], key=lambda x: int(os.path.basename(x).split('_')[0]))
# Ensure all frames have the same dimensions
frames = []
first_frame = np.array(Image.open(image_files[0]))
for idx, file in enumerate(image_files):
frame = np.array(Image.open(file))
if frame.shape != first_frame.shape:
print(f"Frame {idx} has inconsistent dimensions: {frame.shape} vs {first_frame.shape}")
frame = np.resize(frame, first_frame.shape) # Resize if necessary
frames.append(frame)
print("Starting video compilation...")
clip = moviepy.video.io.ImageSequenceClip.ImageSequenceClip(
frames, fps=fps
)
clip.write_videofile(
f'./OUTPUT/{video_name}.mp4',
audio="./SOUND/output.m4a",
codec="libx264",
audio_codec="aac"
)
print("Video compilation finished successfully!")

BIN
FONT/sys.ttf Normal file

Binary file not shown.

View File

@ -0,0 +1,105 @@
# Multi-Conversation AI Feature
This document explains how to use the multi-conversation AI feature, which allows users to maintain multiple separate conversations with the AI, each with its own settings and history.
## Commands
### Basic Commands
- `!chat <message>` - Send a message to the AI using your active conversation
- `!convs` - Manage your conversations with a UI
- `!chatset` - View and update settings for your active conversation
- `!chatimport` - Import conversations from the sync API
### Slash Commands
- `/chat <message>` - Send a message to the AI using your active conversation
## Managing Conversations
Use the `!convs` command to see a list of your conversations. This will display a UI with the following options:
- **Dropdown Menu** - Select a conversation to switch to, or create a new one
- **Settings Button** - View and modify settings for the active conversation
- **Rename Button** - Change the title of the active conversation
- **Delete Button** - Delete the active conversation
## Conversation Settings
Each conversation has its own settings that can be viewed and modified using the `!chatset` command:
```
!chatset - Show current settings
!chatset temperature 0.8 - Set temperature to 0.8
!chatset max_tokens 2000 - Set maximum tokens
!chatset reasoning on - Enable reasoning
!chatset reasoning_effort medium - Set reasoning effort (low, medium, high)
!chatset web_search on - Enable web search
!chatset model gpt-4 - Set the model
!chatset system <message> - Set system message
!chatset title <title> - Set conversation title
!chatset character <name> - Set character name
!chatset character_info <info> - Set character information
!chatset character_breakdown on - Enable character breakdown
!chatset custom_instructions <text> - Set custom instructions
```
## Syncing with Flutter App
The multi-conversation feature is compatible with the Discord sync API, allowing users to access their conversations from a Flutter app.
To import conversations from the sync API, use the `!chatimport` command. This will show a confirmation message with the number of conversations that will be imported.
## Examples
### Starting a New Conversation
```
!chat Hello, how are you?
```
### Managing Conversations
```
!convs
```
### Viewing Settings
```
!chatset
```
### Changing Settings
```
!chatset temperature 0.8
!chatset reasoning on
!chatset system You are a helpful assistant that specializes in programming.
!chatset character Hatsune Miku
!chatset character_info Hatsune Miku is a virtual singer and the most famous VOCALOID character.
!chatset character_breakdown on
```
### Importing Conversations
```
!chatimport
```
## Technical Details
- Conversations are stored in `ai_multi_conversations.json`
- Active conversation IDs are stored in `ai_multi_user_settings.json`
- Conversations are synced with the Discord sync API if available
- Each conversation has its own history, settings, system message, and character settings
## Tips for Best Results
1. **Use descriptive titles** for your conversations to easily identify them
2. **Customize the system message** for each conversation to get more relevant responses
3. **Use character settings** for roleplay conversations
4. **Add custom instructions** for specific requirements or constraints
5. **Adjust temperature** based on the task - lower for factual responses, higher for creative ones
6. **Enable reasoning** for complex questions that require step-by-step thinking
7. **Use web search** for conversations that need up-to-date information

171
OAUTH_SETUP_GUIDE.md Normal file
View File

@ -0,0 +1,171 @@
# Discord OAuth2 Setup Guide
This guide explains how to set up Discord OAuth2 authentication for the Discord bot, allowing users to authenticate with their Discord accounts and authorize the bot to access the API on their behalf.
## Overview
The Discord bot now includes a proper OAuth2 implementation that allows users to:
1. Authenticate with their Discord account
2. Authorize the bot to access the API on their behalf
3. Securely store and manage tokens
4. Automatically refresh tokens when they expire
This implementation uses the OAuth2 Authorization Code flow with PKCE (Proof Key for Code Exchange) for enhanced security.
## Setup Instructions
### 1. Create a Discord Application
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and give it a name (e.g., "Your Bot Name")
3. Go to the "OAuth2" section
4. Add a redirect URL: `http://your-server-address:8080/oauth/callback`
- For local testing, you can use `http://localhost:8080/oauth/callback`
- For production, use your server's domain name or IP address
5. Copy the "Client ID" and "Client Secret" - you'll need these for the bot configuration
### 2. Configure Environment Variables
Create a `.env` file in the bot directory based on the provided `.env.example`:
```bash
cp .env.example .env
```
Edit the `.env` file and update the OAuth configuration:
```
# OAuth Configuration
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
OAUTH_HOST=0.0.0.0
OAUTH_PORT=8080
DISCORD_REDIRECT_URI=http://your-server-address:8080/oauth/callback
```
Replace `your_discord_client_id` and `your_discord_client_secret` with the values from the Discord Developer Portal.
### 3. Configure Port Forwarding (for Production)
If you're running the bot on a server, you'll need to configure port forwarding to allow external access to the OAuth callback server:
1. Forward port 8080 (or whatever port you specified in `OAUTH_PORT`) to your server
2. Make sure your firewall allows incoming connections on this port
3. If you're using a domain name, make sure it points to your server's IP address
### 4. Start the Bot
Start the bot as usual:
```bash
python main.py
```
The bot will automatically start the OAuth callback server on the specified host and port.
## Using OAuth Commands
The bot now includes several commands for managing OAuth authentication:
### `!auth`
This command starts the OAuth flow for a user. It will:
1. Generate a unique state parameter and code verifier for security
2. Create an authorization URL with the Discord OAuth2 endpoint
3. Send the URL to the user via DM (or in the channel if DMs are disabled)
4. Wait for the user to complete the authorization flow
5. Store the resulting token securely
Example:
```
!auth
```
### `!deauth`
This command revokes the bot's access to the user's Discord account by deleting their token.
Example:
```
!deauth
```
### `!authstatus`
This command checks the user's authentication status and displays information about their token.
Example:
```
!authstatus
```
### `!authhelp`
This command displays help information about the OAuth commands.
Example:
```
!authhelp
```
## Integration with AI Commands
The OAuth system is integrated with the existing AI commands:
- `!aiset` - Now uses the OAuth token for API authentication
- `!ai` - Now uses the OAuth token for API authentication
- `!aisyncsettings` - Now uses the OAuth token for API authentication
- `!aiapicheck` - Now uses the OAuth token for API authentication
- `!aitokencheck` - Now uses the OAuth token for API authentication
Users need to authenticate with `!auth` before they can use these commands with API integration.
## Technical Details
### Token Storage
Tokens are stored securely in JSON files in the `tokens` directory. Each file is named with the user's Discord ID and contains:
- Access token
- Refresh token (if available)
- Token expiration time
- Token type
- Scope
### Token Refresh
The system automatically refreshes tokens when they expire. If a token cannot be refreshed, the user will need to authenticate again using the `!auth` command.
### Security Considerations
- The implementation uses PKCE to prevent authorization code interception attacks
- State parameters are used to prevent CSRF attacks
- Tokens are stored securely and not exposed in logs or error messages
- The OAuth callback server only accepts connections from authorized sources
## Troubleshooting
### Common Issues
1. **"No Discord token available" error**
- The user needs to authenticate with `!auth` first
- Check if the token file exists in the `tokens` directory
2. **"Failed to exchange code" error**
- Check if the redirect URI in the Discord Developer Portal matches the one in your `.env` file
- Check if the client ID and client secret are correct
3. **"Invalid state parameter" error**
- The state parameter in the callback doesn't match the one sent in the authorization request
- This could indicate a CSRF attack or a timeout (the state parameter expires after 10 minutes)
4. **OAuth callback server not starting**
- Check if the port is already in use
- Check if the host is correctly configured
- Check if the bot has permission to bind to the specified port
### Logs
The OAuth system logs detailed information about the authentication process. Check the bot's console output for error messages and debugging information.

1
README.md Normal file
View File

@ -0,0 +1 @@
Code to some random bot I made. Do whatever you want with it.

106
SYNC_FEATURES_README.md Normal file
View File

@ -0,0 +1,106 @@
# Discord Bot Sync Features
This document explains the new synchronization features added to the Discord bot to ensure settings are properly synced between the Discord bot and Flutter app.
## Overview
The Discord bot now includes several new commands and features to help diagnose and fix synchronization issues between the Discord bot and Flutter app. These features allow you to:
1. Check the API connection status
2. Verify your Discord token
3. Force sync settings with the API
4. Save a Discord token for testing purposes
5. Get help with all AI commands
## New Commands
### `!aisyncsettings`
This command forces a sync of your settings with the API. It will:
- First try to fetch settings from the API
- If that fails, it will try to push your local settings to the API
- Display the result of the sync operation
Example:
```
!aisyncsettings
```
### `!aiapicheck`
This command checks if the API server is accessible. It will:
- Try to connect to the API server
- Display the status code and response
- Let you know if the connection was successful
Example:
```
!aiapicheck
```
### `!aitokencheck`
This command checks if you have a valid Discord token for API authentication. It will:
- Try to authenticate with the API using your token
- Display whether the token is valid
- Show a preview of your settings if authentication is successful
Example:
```
!aitokencheck
```
### `!aisavetoken` (Owner only)
This command allows the bot owner to save a Discord token for API authentication. This is primarily for testing purposes.
Example:
```
!aisavetoken your_discord_token_here
```
### `!aihelp`
This command displays help for all AI commands, including the new sync commands.
Example:
```
!aihelp
```
## Troubleshooting Sync Issues
If your settings aren't syncing properly between the Discord bot and Flutter app, follow these steps:
1. Use `!aiapicheck` to verify the API is accessible
2. Use `!aitokencheck` to verify your Discord token is valid
3. Use `!aisyncsettings` to force a sync with the API
4. Make sure you're logged in to the Flutter app with the same Discord account
## Technical Details
### Token Storage
For testing purposes, the bot can store Discord tokens in the following ways:
- Environment variables: `DISCORD_TOKEN_{user_id}` or `DISCORD_TEST_TOKEN`
- Token files: `tokens/{user_id}.token`
In a production environment, you would use a more secure method of storing tokens, such as a database with encryption.
### API Integration
The bot communicates with the API server using the following endpoints:
- `/settings` - Get or update user settings
- `/sync` - Sync conversations and settings
All API requests include the Discord token for authentication.
### Settings Synchronization
The bot now fetches settings from the API in the following situations:
- When the bot starts up (for all users)
- When a user uses the `!aiset` command
- When a user uses the `!ai` command
- When a user uses the `!aisyncsettings` command
This ensures that the bot always has the latest settings from the API.

24
UNLICENSE Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

156
api_integration.py Normal file
View File

@ -0,0 +1,156 @@
import os
import asyncio
import datetime
from typing import Dict, List, Optional, Any, Union
import sys
import json
# Add the api_service directory to the Python path
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'api_service'))
# Import the API client and models
from discord_client import ApiClient
from api_models import Conversation, UserSettings, Message
# API client instance
api_client = None
# Initialize the API client
def init_api_client(api_url: str):
"""Initialize the API client with the given URL"""
global api_client
api_client = ApiClient(api_url)
return api_client
# Set the Discord token for the API client
def set_token(token: str):
"""Set the Discord token for the API client"""
if api_client:
api_client.set_token(token)
else:
raise ValueError("API client not initialized")
# ============= Conversation Methods =============
async def get_user_conversations(user_id: str, token: str) -> List[Conversation]:
"""Get all conversations for a user"""
if not api_client:
raise ValueError("API client not initialized")
# Set the token for this request
api_client.set_token(token)
try:
return await api_client.get_conversations()
except Exception as e:
print(f"Error getting conversations for user {user_id}: {e}")
return []
async def save_discord_conversation(
user_id: str,
token: str,
messages: List[Dict[str, Any]],
model_id: str = "openai/gpt-3.5-turbo",
conversation_id: Optional[str] = None,
title: str = "Discord Conversation",
reasoning_enabled: bool = False,
reasoning_effort: str = "medium",
temperature: float = 0.7,
max_tokens: int = 1000,
web_search_enabled: bool = False,
system_message: Optional[str] = None
) -> Optional[Conversation]:
"""Save a conversation from Discord to the API"""
if not api_client:
raise ValueError("API client not initialized")
# Set the token for this request
api_client.set_token(token)
try:
return await api_client.save_discord_conversation(
messages=messages,
model_id=model_id,
conversation_id=conversation_id,
title=title,
reasoning_enabled=reasoning_enabled,
reasoning_effort=reasoning_effort,
temperature=temperature,
max_tokens=max_tokens,
web_search_enabled=web_search_enabled,
system_message=system_message
)
except Exception as e:
print(f"Error saving conversation for user {user_id}: {e}")
return None
# ============= Settings Methods =============
async def get_user_settings(user_id: str, token: str) -> Optional[UserSettings]:
"""Get settings for a user"""
if not api_client:
raise ValueError("API client not initialized")
# Set the token for this request
api_client.set_token(token)
try:
return await api_client.get_settings()
except Exception as e:
print(f"Error getting settings for user {user_id}: {e}")
return None
async def update_user_settings(
user_id: str,
token: str,
settings: UserSettings
) -> Optional[UserSettings]:
"""Update settings for a user"""
if not api_client:
raise ValueError("API client not initialized")
# Set the token for this request
api_client.set_token(token)
try:
return await api_client.update_settings(settings)
except Exception as e:
print(f"Error updating settings for user {user_id}: {e}")
return None
# ============= Helper Methods =============
def convert_discord_settings_to_api(settings: Dict[str, Any]) -> UserSettings:
"""Convert Discord bot settings to API UserSettings"""
return UserSettings(
model_id=settings.get("model", "openai/gpt-3.5-turbo"),
temperature=settings.get("temperature", 0.7),
max_tokens=settings.get("max_tokens", 1000),
reasoning_enabled=settings.get("show_reasoning", False),
reasoning_effort=settings.get("reasoning_effort", "medium"),
web_search_enabled=settings.get("web_search_enabled", False),
system_message=settings.get("system_prompt"),
character=settings.get("character"),
character_info=settings.get("character_info"),
character_breakdown=settings.get("character_breakdown", False),
custom_instructions=settings.get("custom_instructions"),
advanced_view_enabled=False, # Default value
streaming_enabled=True, # Default value
last_updated=datetime.datetime.now()
)
def convert_api_settings_to_discord(settings: UserSettings) -> Dict[str, Any]:
"""Convert API UserSettings to Discord bot settings"""
return {
"model": settings.model_id,
"temperature": settings.temperature,
"max_tokens": settings.max_tokens,
"show_reasoning": settings.reasoning_enabled,
"reasoning_effort": settings.reasoning_effort,
"web_search_enabled": settings.web_search_enabled,
"system_prompt": settings.system_message,
"character": settings.character,
"character_info": settings.character_info,
"character_breakdown": settings.character_breakdown,
"custom_instructions": settings.custom_instructions
}

View File

@ -0,0 +1,30 @@
import importlib.util
import subprocess
import sys
def check_and_install_dependencies():
"""Check if required dependencies are installed and install them if not."""
required_packages = ["fastapi", "uvicorn", "pydantic"]
missing_packages = []
for package in required_packages:
if importlib.util.find_spec(package) is None:
missing_packages.append(package)
if missing_packages:
print(f"Installing missing dependencies for Discord sync: {', '.join(missing_packages)}")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install"] + missing_packages)
print("Dependencies installed successfully.")
return True
except subprocess.CalledProcessError as e:
print(f"Error installing dependencies: {e}")
print("Please install the following packages manually:")
for package in missing_packages:
print(f" - {package}")
return False
return True
if __name__ == "__main__":
check_and_install_dependencies()

865
cogs/ai_cog.py Normal file
View File

@ -0,0 +1,865 @@
import discord
from discord.ext import commands
from discord import app_commands
import json
import os
import datetime
import asyncio
from typing import Dict, List, Optional, Any, Union
# Import the API integration
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api_integration import (
init_api_client,
set_token,
get_user_conversations,
save_discord_conversation,
get_user_settings,
update_user_settings,
convert_discord_settings_to_api,
convert_api_settings_to_discord
)
# Constants
HISTORY_FILE = "conversation_history.json"
USER_SETTINGS_FILE = "user_settings.json"
ACTIVE_CONVOS_FILE = "active_convos.json" # New file for active convo IDs
API_URL = os.getenv("API_URL", "https://slipstreamm.dev/api")
# Initialize the API client
api_client = init_api_client(API_URL)
class AICog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.conversation_history = {}
self.user_settings = {}
self.active_conversation_ids = {} # New dict to track active convo ID per user
# Load conversation history, user settings, and active convo IDs
self.load_conversation_history()
self.load_user_settings()
self.load_active_conversation_ids()
def load_conversation_history(self):
"""Load conversation history from JSON file"""
if os.path.exists(HISTORY_FILE):
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
self.conversation_history = {int(k): v for k, v in data.items()}
print(f"Loaded conversation history for {len(self.conversation_history)} users")
except Exception as e:
print(f"Error loading conversation history: {e}")
def save_conversation_history(self):
"""Save conversation history to JSON file"""
try:
# Convert int keys to strings for JSON serialization
serializable_history = {str(k): v for k, v in self.conversation_history.items()}
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_history, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving conversation history: {e}")
def load_user_settings(self):
"""Load user settings from JSON file"""
if os.path.exists(USER_SETTINGS_FILE):
try:
with open(USER_SETTINGS_FILE, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
self.user_settings = {int(k): v for k, v in data.items()}
print(f"Loaded settings for {len(self.user_settings)} users")
except Exception as e:
print(f"Error loading user settings: {e}")
def save_user_settings(self):
"""Save user settings to JSON file"""
try:
# Convert int keys to strings for JSON serialization
serializable_settings = {str(k): v for k, v in self.user_settings.items()}
with open(USER_SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_settings, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving user settings: {e}")
def load_active_conversation_ids(self):
"""Load active conversation IDs from JSON file"""
if os.path.exists(ACTIVE_CONVOS_FILE):
try:
with open(ACTIVE_CONVOS_FILE, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
self.active_conversation_ids = {int(k): v for k, v in data.items()}
print(f"Loaded active conversation IDs for {len(self.active_conversation_ids)} users")
except Exception as e:
print(f"Error loading active conversation IDs: {e}")
def save_active_conversation_ids(self):
"""Save active conversation IDs to JSON file"""
try:
# Convert int keys to strings for JSON serialization
serializable_ids = {str(k): v for k, v in self.active_conversation_ids.items()}
with open(ACTIVE_CONVOS_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_ids, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving active conversation IDs: {e}")
def get_user_settings(self, user_id: int) -> Dict[str, Any]:
"""Get settings for a user with defaults"""
if user_id not in self.user_settings:
self.user_settings[user_id] = {
"model": "openai/gpt-3.5-turbo",
"temperature": 0.7,
"max_tokens": 1000,
"show_reasoning": False,
"reasoning_effort": "medium",
"web_search_enabled": False,
"system_prompt": None,
"character": None,
"character_info": None,
"character_breakdown": False,
"custom_instructions": None
}
return self.user_settings[user_id]
async def sync_settings_with_api(self, user_id: int, token: str):
"""Sync user settings with the API"""
try:
# Get current settings
discord_settings = self.get_user_settings(user_id)
# Convert to API format
api_settings = convert_discord_settings_to_api(discord_settings)
# Update settings in the API
updated_settings = await update_user_settings(str(user_id), token, api_settings)
if updated_settings:
print(f"Successfully synced settings for user {user_id} with API")
return True
else:
print(f"Failed to sync settings for user {user_id} with API")
return False
except Exception as e:
print(f"Error syncing settings for user {user_id} with API: {e}")
return False
async def fetch_settings_from_api(self, user_id: int, token: str):
"""Fetch user settings from the API"""
try:
# Get settings from the API
api_settings = await get_user_settings(str(user_id), token)
if api_settings:
# Convert to Discord format
discord_settings = convert_api_settings_to_discord(api_settings)
# Update local settings
self.user_settings[user_id] = discord_settings
# Save to file
self.save_user_settings()
print(f"Successfully fetched settings for user {user_id} from API")
return True
else:
print(f"Failed to fetch settings for user {user_id} from API")
return False
except Exception as e:
print(f"Error fetching settings for user {user_id} from API: {e}")
return False
@commands.Cog.listener()
async def on_ready(self):
print(f"{self.__class__.__name__} Cog ready")
# Try to fetch settings from the API for all users
await self.fetch_all_settings_from_api()
# Helper method to fetch settings from the API for all users
async def fetch_all_settings_from_api(self):
"""Fetch settings from the API for all users"""
print("Attempting to fetch settings from API for all users...")
# Get all user IDs from the user_settings dictionary
user_ids = list(self.user_settings.keys())
if not user_ids:
print("No users found in local settings")
return
print(f"Found {len(user_ids)} users in local settings")
# Try to fetch settings for each user
for user_id in user_ids:
try:
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
if token:
# Try to fetch settings from the API
success = await self.fetch_settings_from_api(user_id, token)
if success:
print(f"Successfully fetched settings from API for user {user_id}")
else:
print(f"Failed to fetch settings from API for user {user_id}")
else:
print(f"No token available for user {user_id}")
except Exception as e:
print(f"Error fetching settings from API for user {user_id}: {e}")
# Helper method to get Discord token for API authentication
async def get_discord_token(self, user_id: int) -> Optional[str]:
"""Get the Discord token for a user"""
# Import the OAuth module
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import discord_oauth
# Try to get the token from the OAuth system
token = await discord_oauth.get_token(str(user_id))
if token:
print(f"Using OAuth token for user {user_id}")
return token
# For backward compatibility, check environment variables
user_token_var = f"DISCORD_TOKEN_{user_id}"
user_token = os.getenv(user_token_var)
if user_token:
print(f"Using user-specific token from environment for user {user_id}")
return user_token
# Then check if we have a general test token
test_token = os.getenv("DISCORD_TEST_TOKEN")
if test_token:
print(f"Using general test token for user {user_id}")
return test_token
# Try to load from a token file if it exists (legacy method)
token_file = os.path.join(os.path.dirname(__file__), "..", "tokens", f"{user_id}.token")
if os.path.exists(token_file):
try:
with open(token_file, "r", encoding="utf-8") as f:
token = f.read().strip()
if token:
print(f"Loaded token from legacy file for user {user_id}")
return token
except Exception as e:
print(f"Error loading token from legacy file for user {user_id}: {e}")
# No token found
print(f"No token found for user {user_id}")
return None
@commands.command(name="aiset")
async def set_ai_settings(self, ctx, setting: str = None, *, value: str = None):
"""Set AI settings for the user"""
user_id = ctx.author.id
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
# Try to fetch the latest settings from the API if we have a token
api_settings_fetched = False
if token:
try:
print(f"Fetching settings from API for user {user_id}")
api_settings_fetched = await self.fetch_settings_from_api(user_id, token)
if api_settings_fetched:
print(f"Successfully fetched settings from API for user {user_id}")
else:
print(f"Failed to fetch settings from API for user {user_id}, using local settings")
except Exception as e:
print(f"Error fetching settings from API: {e}")
# Get the settings (either from API or local storage)
settings = self.get_user_settings(user_id)
if setting is None:
# Display current settings
settings_str = "Current AI settings:\n"
settings_str += f"Model: `{settings.get('model', 'openai/gpt-3.5-turbo')}`\n"
settings_str += f"Temperature: `{settings.get('temperature', 0.7)}`\n"
settings_str += f"Max Tokens: `{settings.get('max_tokens', 1000)}`\n"
settings_str += f"Show Reasoning: `{settings.get('show_reasoning', False)}`\n"
settings_str += f"Reasoning Effort: `{settings.get('reasoning_effort', 'medium')}`\n"
settings_str += f"Web Search: `{settings.get('web_search_enabled', False)}`\n"
# Character settings
character = settings.get('character')
character_info = settings.get('character_info')
character_breakdown = settings.get('character_breakdown', False)
custom_instructions = settings.get('custom_instructions')
if character:
settings_str += f"Character: `{character}`\n"
if character_info:
settings_str += f"Character Info: `{character_info[:50]}...`\n"
settings_str += f"Character Breakdown: `{character_breakdown}`\n"
if custom_instructions:
settings_str += f"Custom Instructions: `{custom_instructions[:50]}...`\n"
# System prompt
system_prompt = settings.get('system_prompt')
if system_prompt:
settings_str += f"System Prompt: `{system_prompt[:50]}...`\n"
# Add information about API sync status
if api_settings_fetched:
settings_str += "\n*Settings were synced with the API*\n"
elif token:
settings_str += "\n*Warning: Failed to sync settings with the API*\n"
else:
settings_str += "\n*Warning: No Discord token available for API sync*\n"
await ctx.send(settings_str)
return
# Update the specified setting
setting = setting.lower()
if setting == "model":
if value:
settings["model"] = value
await ctx.send(f"Model set to `{value}`")
else:
await ctx.send(f"Current model: `{settings.get('model', 'openai/gpt-3.5-turbo')}`")
elif setting == "temperature":
if value:
try:
temp = float(value)
if 0 <= temp <= 2:
settings["temperature"] = temp
await ctx.send(f"Temperature set to `{temp}`")
else:
await ctx.send("Temperature must be between 0 and 2")
except ValueError:
await ctx.send("Temperature must be a number")
else:
await ctx.send(f"Current temperature: `{settings.get('temperature', 0.7)}`")
elif setting == "max_tokens" or setting == "maxtokens":
if value:
try:
tokens = int(value)
if tokens > 0:
settings["max_tokens"] = tokens
await ctx.send(f"Max tokens set to `{tokens}`")
else:
await ctx.send("Max tokens must be greater than 0")
except ValueError:
await ctx.send("Max tokens must be a number")
else:
await ctx.send(f"Current max tokens: `{settings.get('max_tokens', 1000)}`")
elif setting == "reasoning" or setting == "show_reasoning":
if value and value.lower() in ("true", "yes", "on", "1"):
settings["show_reasoning"] = True
await ctx.send("Reasoning enabled")
elif value and value.lower() in ("false", "no", "off", "0"):
settings["show_reasoning"] = False
await ctx.send("Reasoning disabled")
else:
await ctx.send(f"Current reasoning setting: `{settings.get('show_reasoning', False)}`")
elif setting == "reasoning_effort":
if value and value.lower() in ("low", "medium", "high"):
settings["reasoning_effort"] = value.lower()
await ctx.send(f"Reasoning effort set to `{value.lower()}`")
else:
await ctx.send(f"Current reasoning effort: `{settings.get('reasoning_effort', 'medium')}`")
elif setting == "websearch" or setting == "web_search":
if value and value.lower() in ("true", "yes", "on", "1"):
settings["web_search_enabled"] = True
await ctx.send("Web search enabled")
elif value and value.lower() in ("false", "no", "off", "0"):
settings["web_search_enabled"] = False
await ctx.send("Web search disabled")
else:
await ctx.send(f"Current web search setting: `{settings.get('web_search_enabled', False)}`")
elif setting == "system" or setting == "system_prompt":
if value:
settings["system_prompt"] = value
await ctx.send(f"System prompt set to: `{value[:50]}...`")
else:
system_prompt = settings.get('system_prompt')
if system_prompt:
await ctx.send(f"Current system prompt: `{system_prompt[:50]}...`")
else:
await ctx.send("No system prompt set")
elif setting == "character":
if value:
settings["character"] = value
await ctx.send(f"Character set to: `{value}`")
else:
character = settings.get('character')
if character:
await ctx.send(f"Current character: `{character}`")
else:
await ctx.send("No character set")
elif setting == "character_info":
if value:
settings["character_info"] = value
await ctx.send(f"Character info set to: `{value[:50]}...`")
else:
character_info = settings.get('character_info')
if character_info:
await ctx.send(f"Current character info: `{character_info[:50]}...`")
else:
await ctx.send("No character info set")
elif setting == "character_breakdown":
if value and value.lower() in ("true", "yes", "on", "1"):
settings["character_breakdown"] = True
await ctx.send("Character breakdown enabled")
elif value and value.lower() in ("false", "no", "off", "0"):
settings["character_breakdown"] = False
await ctx.send("Character breakdown disabled")
else:
await ctx.send(f"Current character breakdown setting: `{settings.get('character_breakdown', False)}`")
elif setting == "custom_instructions":
if value:
settings["custom_instructions"] = value
await ctx.send(f"Custom instructions set to: `{value[:50]}...`")
else:
custom_instructions = settings.get('custom_instructions')
if custom_instructions:
await ctx.send(f"Current custom instructions: `{custom_instructions[:50]}...`")
else:
await ctx.send("No custom instructions set")
else:
await ctx.send(f"Unknown setting: {setting}")
return
# Save the updated settings
self.save_user_settings()
# Sync settings with the API if the user has a token
token = await self.get_discord_token(user_id)
if token:
try:
# Convert to API format
api_settings = convert_discord_settings_to_api(settings)
# Update settings in the API
updated_settings = await update_user_settings(str(user_id), token, api_settings)
if updated_settings:
print(f"Successfully synced updated settings for user {user_id} with API")
await ctx.send("*Settings updated and synced with the API*")
else:
print(f"Failed to sync updated settings for user {user_id} with API")
await ctx.send("*Settings updated locally but failed to sync with the API*")
except Exception as e:
print(f"Error syncing updated settings for user {user_id} with API: {e}")
await ctx.send("*Settings updated locally but an error occurred during API sync*")
else:
print(f"Settings updated for user {user_id}, but no token available for API sync")
await ctx.send("*Settings updated locally. No Discord token available for API sync*")
@commands.command(name="ai")
async def ai_command(self, ctx, *, prompt: str = None):
"""Interact with the AI"""
user_id = ctx.author.id
# Initialize conversation history for this user if it doesn't exist
if user_id not in self.conversation_history:
self.conversation_history[user_id] = []
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
# Try to fetch the latest settings from the API if we have a token
if token:
try:
await self.fetch_settings_from_api(user_id, token)
except Exception as e:
print(f"Error fetching settings from API before AI command: {e}")
# Get user settings
settings = self.get_user_settings(user_id)
if prompt is None:
await ctx.send("Please provide a prompt for the AI")
return
# Add user message to conversation history
self.conversation_history[user_id].append({
"role": "user",
"content": prompt,
"timestamp": datetime.datetime.now().isoformat()
})
# In a real implementation, you would call your AI service here
# For this example, we'll just echo the prompt
response = f"{prompt}"
# Add AI response to conversation history
self.conversation_history[user_id].append({
"role": "assistant",
"content": response,
"timestamp": datetime.datetime.now().isoformat()
})
# Save conversation history
self.save_conversation_history()
# Send the response
await ctx.send(response)
# Sync conversation with the API if the user has a token
token = await self.get_discord_token(user_id)
if token:
try:
# Convert messages to API format
messages = self.conversation_history[user_id]
# Get settings for the conversation
settings = self.get_user_settings(user_id)
# Get the current active conversation ID for this user
current_conversation_id = self.active_conversation_ids.get(user_id)
# Save the conversation to the API, passing the current ID
saved_conversation = await save_discord_conversation( # Assign return value
user_id=str(user_id),
token=token,
conversation_id=current_conversation_id, # Pass the current ID
messages=messages,
model_id=settings.get("model", "openai/gpt-3.5-turbo"),
temperature=settings.get("temperature", 0.7),
max_tokens=settings.get("max_tokens", 1000),
reasoning_enabled=settings.get("show_reasoning", False),
reasoning_effort=settings.get("reasoning_effort", "medium"),
web_search_enabled=settings.get("web_search_enabled", False),
system_message=settings.get("system_prompt")
)
# Check the result of the API call
if saved_conversation:
# Use the ID from the returned object if available
conv_id = getattr(saved_conversation, 'id', None)
print(f"Successfully synced conversation {conv_id} for user {user_id} with API")
# Update the active conversation ID if we got one back
if conv_id:
self.active_conversation_ids[user_id] = conv_id
self.save_active_conversation_ids() # Save the updated ID
else:
# Error message is already printed within save_discord_conversation
print(f"Failed to sync conversation for user {user_id} with API.")
# Optionally send a message to the user/channel?
# await ctx.send("⚠️ Failed to sync this conversation with the central server.")
except Exception as e:
print(f"Error during conversation sync process for user {user_id}: {e}")
else:
print(f"Conversation updated locally for user {user_id}, but no token available for API sync")
@commands.command(name="aiclear")
async def clear_history(self, ctx):
"""Clear conversation history for the user"""
user_id = ctx.author.id
if user_id in self.conversation_history or user_id in self.active_conversation_ids:
# Clear local history
if user_id in self.conversation_history:
self.conversation_history[user_id] = []
self.save_conversation_history()
# Clear active conversation ID
if user_id in self.active_conversation_ids:
removed_id = self.active_conversation_ids.pop(user_id, None)
self.save_active_conversation_ids()
print(f"Cleared active conversation ID {removed_id} for user {user_id}")
await ctx.send("Conversation history and active session cleared")
# TODO: Optionally call API to delete conversation by ID if needed
else:
await ctx.send("No conversation history or active session to clear")
@commands.command(name="aisyncsettings")
async def sync_settings_command(self, ctx):
"""Force sync settings with the API"""
user_id = ctx.author.id
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
if not token:
await ctx.send("❌ No Discord token available for API sync. Please log in to the Flutter app first or use !aisavetoken.")
return
# Send a message to indicate we're syncing
message = await ctx.send("⏳ Syncing settings with the API...")
try:
# First try to fetch settings from the API
api_settings_fetched = await self.fetch_settings_from_api(user_id, token)
if api_settings_fetched:
await message.edit(content="✅ Successfully fetched settings from the API")
# Display the current settings
settings = self.get_user_settings(user_id)
settings_str = "Current AI settings after sync:\n"
settings_str += f"Model: `{settings.get('model', 'openai/gpt-3.5-turbo')}`\n"
settings_str += f"Temperature: `{settings.get('temperature', 0.7)}`\n"
settings_str += f"Max Tokens: `{settings.get('max_tokens', 1000)}`\n"
# Character settings
character = settings.get('character')
if character:
settings_str += f"Character: `{character}`\n"
await ctx.send(settings_str)
else:
# If fetching failed, try pushing local settings to the API
await message.edit(content="⚠️ Failed to fetch settings from the API. Trying to push local settings...")
# Get current settings
settings = self.get_user_settings(user_id)
# Convert to API format
api_settings = convert_discord_settings_to_api(settings)
# Update settings in the API
updated_settings = await update_user_settings(str(user_id), token, api_settings)
if updated_settings:
await message.edit(content="✅ Successfully pushed local settings to the API")
else:
await message.edit(content="❌ Failed to sync settings with the API")
except Exception as e:
await message.edit(content=f"❌ Error syncing settings with the API: {str(e)}")
print(f"Error syncing settings for user {user_id} with API: {e}")
@commands.command(name="aisavetoken")
async def save_token_command(self, ctx, token: str = None):
"""Save a Discord token for API authentication (for testing only)"""
# This command should only be used by the bot owner or for testing
if ctx.author.id != self.bot.owner_id and not await self.bot.is_owner(ctx.author):
await ctx.send("❌ This command can only be used by the bot owner.")
return
# Delete the user's message to prevent token leakage
try:
await ctx.message.delete()
except:
pass
if not token:
await ctx.send("Please provide a token to save. Usage: `!aisavetoken <token>`")
return
user_id = ctx.author.id
# Import the OAuth module
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import discord_oauth
try:
# Validate the token
is_valid, discord_user_id = await discord_oauth.validate_token(token)
if not is_valid:
await ctx.send("❌ The token is invalid. Please provide a valid Discord token.")
return
# Create a mock token data structure
token_data = {
"access_token": token,
"token_type": "Bearer",
"expires_in": 604800, # 1 week
"refresh_token": None,
"scope": "identify"
}
# Save the token using the OAuth system
discord_oauth.save_token(str(user_id), token_data)
await ctx.send("✅ Token saved successfully. You can now use !aisyncsettings to sync with the API.")
except Exception as e:
await ctx.send(f"❌ Error saving token: {str(e)}")
print(f"Error saving token for user {user_id}: {e}")
@commands.command(name="aiapicheck")
async def api_check_command(self, ctx):
"""Check the API connection status"""
user_id = ctx.author.id
# Send a message to indicate we're checking
message = await ctx.send("⏳ Checking API connection...")
# Check if the API client is initialized
if not api_client:
await message.edit(content="❌ API client not initialized. Please check your API_URL environment variable.")
return
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
if not token:
await message.edit(content="⚠️ No Discord token available. Will check API without authentication.")
try:
# Try to make a simple request to the API
import aiohttp
async with aiohttp.ClientSession() as session:
api_url = os.getenv("API_URL", "https://slipstreamm.dev/api")
async with session.get(f"{api_url}/") as response:
if response.status == 200:
await message.edit(content=f"✅ API connection successful! Status: {response.status}")
else:
await message.edit(content=f"⚠️ API responded with status code: {response.status}")
# Try to get the response body
try:
response_json = await response.json()
await ctx.send(f"API response: ```json\n{response_json}\n```")
except:
response_text = await response.text()
await ctx.send(f"API response: ```\n{response_text[:1000]}\n```")
except Exception as e:
await message.edit(content=f"❌ Error connecting to API: {str(e)}")
print(f"Error checking API connection: {e}")
@commands.command(name="aitokencheck")
async def token_check_command(self, ctx):
"""Check if you have a valid Discord token for API authentication"""
user_id = ctx.author.id
# Try to get the user's Discord token for API authentication
token = await self.get_discord_token(user_id)
if not token:
await ctx.send("❌ No Discord token available. Please log in to the Flutter app first or use !aisavetoken.")
return
# Send a message to indicate we're checking
message = await ctx.send("⏳ Checking token validity...")
try:
# Try to make an authenticated request to the API
import aiohttp
async with aiohttp.ClientSession() as session:
api_url = os.getenv("API_URL", "https://slipstreamm.dev/api")
headers = {"Authorization": f"Bearer {token}"}
# Try to get user settings (requires authentication)
async with session.get(f"{api_url}/settings", headers=headers) as response:
if response.status == 200:
await message.edit(content=f"✅ Token is valid! Successfully authenticated with the API.")
# Try to get the response body to show some settings
try:
response_json = await response.json()
# Extract some basic settings to display
settings = response_json.get("settings", {})
if settings:
model = settings.get("model_id", "Unknown")
temp = settings.get("temperature", "Unknown")
await ctx.send(f"API settings preview: Model: `{model}`, Temperature: `{temp}`")
except Exception as e:
print(f"Error parsing settings response: {e}")
elif response.status == 401:
await message.edit(content=f"❌ Token is invalid or expired. Please log in to the Flutter app again or use !aisavetoken with a new token.")
else:
await message.edit(content=f"⚠️ API responded with status code: {response.status}")
response_text = await response.text()
await ctx.send(f"API response: ```\n{response_text[:500]}\n```")
except Exception as e:
await message.edit(content=f"❌ Error checking token: {str(e)}")
print(f"Error checking token: {e}")
@commands.command(name="aihelp")
async def ai_help_command(self, ctx):
"""Show help for AI commands"""
help_embed = discord.Embed(
title="AI Commands Help",
description="Here are all the available AI commands and their descriptions.",
color=discord.Color.blue()
)
# Basic commands
help_embed.add_field(
name="Basic Commands",
value=(
"`!ai <prompt>` - Chat with the AI\n"
"`!aiclear` - Clear your conversation history\n"
"`!aihelp` - Show this help message"
),
inline=False
)
# Settings commands
help_embed.add_field(
name="Settings Commands",
value=(
"`!aiset` - View current AI settings\n"
"`!aiset model <model_id>` - Set the AI model\n"
"`!aiset temperature <value>` - Set the temperature (0.0-2.0)\n"
"`!aiset max_tokens <value>` - Set the maximum tokens\n"
"`!aiset reasoning <true/false>` - Enable/disable reasoning\n"
"`!aiset reasoning_effort <low/medium/high>` - Set reasoning effort\n"
"`!aiset websearch <true/false>` - Enable/disable web search\n"
"`!aiset system <prompt>` - Set the system prompt\n"
"`!aiset character <name>` - Set the character name\n"
"`!aiset character_info <info>` - Set character information\n"
"`!aiset character_breakdown <true/false>` - Enable/disable character breakdown\n"
"`!aiset custom_instructions <instructions>` - Set custom instructions"
),
inline=False
)
# Sync commands
help_embed.add_field(
name="Sync Commands",
value=(
"`!aisyncsettings` - Force sync settings with the API\n"
"`!aiapicheck` - Check the API connection status\n"
"`!aitokencheck` - Check if you have a valid Discord token for API\n"
"`!aisavetoken <token>` - Save a Discord token for API authentication (owner only)"
),
inline=False
)
# Authentication commands
help_embed.add_field(
name="Authentication Commands",
value=(
"`!auth` - Authenticate with Discord to allow the bot to access the API\n"
"`!deauth` - Revoke the bot's access to your Discord account\n"
"`!authstatus` - Check your authentication status\n"
"`!authhelp` - Get help with authentication commands"
),
inline=False
)
# Troubleshooting
help_embed.add_field(
name="Troubleshooting",
value=(
"If your settings aren't syncing properly between the Discord bot and Flutter app:\n"
"1. Use `!auth` to authenticate with Discord\n"
"2. Use `!authstatus` to verify your authentication status\n"
"3. Use `!aiapicheck` to verify the API is accessible\n"
"4. Use `!aisyncsettings` to force a sync with the API\n"
"5. Make sure you're logged in to the Flutter app with the same Discord account"
),
inline=False
)
await ctx.send(embed=help_embed)
async def setup(bot):
await bot.add_cog(AICog(bot))

840
cogs/audio_cog.py Normal file
View File

@ -0,0 +1,840 @@
import discord
from discord import ui # Added for views/buttons
from discord.ext import commands, tasks
import asyncio
import yt_dlp as youtube_dl
import logging
from collections import deque
import math # For pagination calculation
# Configure logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
# Suppress noisy yt-dlp logs unless debugging
youtube_dl.utils.bug_reports_message = lambda: ''
# --- yt-dlp Options ---
YDL_OPTS_BASE = {
'format': 'bestaudio/best',
'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s',
'restrictfilenames': True,
'noplaylist': False, # Allow playlists by default, override per call if needed
'nocheckcertificate': True,
'ignoreerrors': False,
'logtostderr': False,
'quiet': True,
'no_warnings': True,
'default_search': 'ytsearch', # Default to YouTube search
'source_address': '0.0.0.0', # Bind to all IPs for better connectivity
'cookiefile': 'cookies.txt'
}
FFMPEG_OPTIONS = {
'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
'options': '-vn' # No video
}
class Song:
"""Represents a song to be played."""
def __init__(self, source_url, title, webpage_url, duration, requested_by):
self.source_url = source_url
self.title = title
self.webpage_url = webpage_url
self.duration = duration
self.requested_by = requested_by # User who requested the song
def __str__(self):
return f"**{self.title}** ({self.format_duration()})"
def format_duration(self):
"""Formats duration in seconds to MM:SS or HH:MM:SS."""
if not self.duration:
return "N/A"
minutes, seconds = divmod(self.duration, 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes:02d}:{seconds:02d}"
class AudioCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.queues = {} # Dictionary to hold queues per guild {guild_id: deque()}
self.current_song = {} # Dictionary for current song per guild {guild_id: Song}
self.voice_clients = {} # Dictionary for voice clients per guild {guild_id: discord.VoiceClient}
self.play_next_song.start() # Start the background task
def get_queue(self, guild_id):
"""Gets the queue for a guild, creating it if it doesn't exist."""
return self.queues.setdefault(guild_id, deque())
def get_current_song(self, guild_id):
"""Gets the current song for a guild."""
return self.current_song.get(guild_id)
def cleanup(self, guild_id):
"""Cleans up resources for a guild."""
if guild_id in self.queues:
del self.queues[guild_id]
if guild_id in self.current_song:
del self.current_song[guild_id]
if guild_id in self.voice_clients:
vc = self.voice_clients.pop(guild_id)
if vc and vc.is_connected():
# Use asyncio.create_task for fire-and-forget disconnect
asyncio.create_task(vc.disconnect(force=True))
log.info(f"Cleaned up resources for guild {guild_id}")
async def cog_unload(self):
"""Cog unload cleanup."""
self.play_next_song.cancel()
for guild_id in list(self.voice_clients.keys()): # Iterate over keys copy
self.cleanup(guild_id)
@tasks.loop(seconds=1.0)
async def play_next_song(self):
"""Background task to play the next song in the queue for each guild."""
for guild_id, vc in list(self.voice_clients.items()): # Iterate over copy
if not vc or not vc.is_connected():
# If VC disconnected unexpectedly, clean up
log.warning(f"VC for guild {guild_id} disconnected unexpectedly. Cleaning up.")
self.cleanup(guild_id)
continue
queue = self.get_queue(guild_id)
if not vc.is_playing() and not vc.is_paused() and queue:
next_song = queue.popleft()
self.current_song[guild_id] = next_song
try:
log.info(f"Playing next song in guild {guild_id}: {next_song.title}")
source = discord.FFmpegPCMAudio(next_song.source_url, **FFMPEG_OPTIONS)
vc.play(source, after=lambda e: self.handle_after_play(e, guild_id))
# Optionally send a "Now Playing" message to the channel
# This requires storing the context or channel ID somewhere
except Exception as e:
log.error(f"Error playing song {next_song.title} in guild {guild_id}: {e}")
self.current_song[guild_id] = None # Clear current song on error
# Try to play the next one if available
if queue:
log.info(f"Trying next song in queue for guild {guild_id}")
# Let the loop handle the next iteration naturally
else:
log.info(f"Queue empty for guild {guild_id} after error.")
# Consider leaving VC after inactivity?
elif not vc.is_playing() and not vc.is_paused() and not queue:
# If nothing is playing and queue is empty, clear current song
if self.current_song.get(guild_id):
self.current_song[guild_id] = None
log.info(f"Queue empty and playback finished for guild {guild_id}. Current song cleared.")
# Add inactivity disconnect logic here if desired
def handle_after_play(self, error, guild_id):
"""Callback function after a song finishes playing."""
if error:
log.error(f'Player error in guild {guild_id}: {error}')
else:
log.info(f"Song finished playing in guild {guild_id}.")
# The loop will handle playing the next song
@play_next_song.before_loop
async def before_play_next_song(self):
await self.bot.wait_until_ready()
log.info("AudioCog background task started.")
async def _extract_info(self, query):
"""Extracts info using yt-dlp. Handles URLs and search queries."""
ydl_opts = YDL_OPTS_BASE.copy()
is_search = not (query.startswith('http://') or query.startswith('https://'))
if is_search:
# For search, limit to 1 result and treat as single item
ydl_opts['default_search'] = 'ytsearch1'
ydl_opts['noplaylist'] = True # Explicitly search for single video
log.info(f"Performing YouTube search for: {query}")
else:
# For URLs, let yt-dlp determine if it's a playlist or single video
# Do not use extract_flat, get full info
ydl_opts['noplaylist'] = False # Allow playlists
log.info(f"Processing URL: {query}")
try:
# Extract full information
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(query, download=False)
# Determine if it's a playlist *after* extraction
is_playlist = info.get('_type') == 'playlist'
if is_search and 'entries' in info and info['entries']:
# If search returned results, take the first one
info = info['entries'][0]
is_playlist = False # Search result is treated as single item
elif is_search and ('entries' not in info or not info['entries']):
# Handle case where search yields no results directly
return None, False, True # Indicate no info found
return info, is_playlist, is_search
except youtube_dl.utils.DownloadError as e:
# Handle specific errors if possible (e.g., video unavailable)
error_msg = str(e)
if 'video unavailable' in error_msg.lower():
raise commands.CommandError(f"The video '{query}' is unavailable.")
elif 'playlist does not exist' in error_msg.lower():
raise commands.CommandError(f"The playlist '{query}' does not exist or is private.")
log.error(f"yt-dlp download error for '{query}': {error_msg}")
raise commands.CommandError(f"Could not process '{query}'. Is it a valid URL or search term?")
except Exception as e:
log.error(f"Unexpected yt-dlp error for '{query}': {e}")
raise commands.CommandError("An unexpected error occurred while fetching video info.")
async def _search_youtube(self, query: str, max_results: int = 15): # Increased max_results for pagination
"""Performs a YouTube search and returns multiple results."""
# Clamp max_results to avoid excessively long searches if abused
max_results = min(max(1, max_results), 25) # Limit between 1 and 25
ydl_opts = YDL_OPTS_BASE.copy()
# Use ytsearchN: query to get N results
ydl_opts['default_search'] = f'ytsearch{max_results}'
ydl_opts['noplaylist'] = True # Ensure only videos are searched
log.info(f"Performing YouTube search for '{query}' (max {max_results} results)")
try:
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
# Extract info without downloading
info = ydl.extract_info(query, download=False)
# Check if 'entries' exist and contain results
if 'entries' in info and info['entries']:
return info['entries'] # Return the list of video dictionaries
else:
log.info(f"No search results found for '{query}'")
return [] # Return empty list if no results
except youtube_dl.utils.DownloadError as e:
log.error(f"yt-dlp search download error for '{query}': {e}")
# Don't raise here, let the command handle empty results
return []
except Exception as e:
log.error(f"Unexpected yt-dlp error during search for '{query}': {e}")
# Don't raise here, let the command handle empty results
return []
async def _ensure_voice_connection(self, ctx_or_interaction):
"""Ensures the bot is connected to the user's voice channel. Accepts Context or Interaction."""
is_interaction = isinstance(ctx_or_interaction, discord.Interaction)
if is_interaction:
guild = ctx_or_interaction.guild
author = ctx_or_interaction.user
if not guild: raise commands.CommandError("Interaction must be in a guild.")
else: # Is Context
guild = ctx_or_interaction.guild
author = ctx_or_interaction.author
if not guild: raise commands.CommandError("Command must be used in a guild.")
if not isinstance(author, discord.Member) or not author.voice or not author.voice.channel:
raise commands.CommandError("You are not connected to a voice channel.")
vc = self.voice_clients.get(guild.id)
target_channel = author.voice.channel
if not vc or not vc.is_connected():
try:
log.info(f"Connecting to voice channel {target_channel.name} in guild {guild.id}")
vc = await target_channel.connect()
self.voice_clients[guild.id] = vc
except asyncio.TimeoutError:
raise commands.CommandError(f"Connecting to {target_channel.name} timed out.")
except discord.errors.ClientException as e:
raise commands.CommandError(f"Already connected to a voice channel? Error: {e}")
except Exception as e:
log.error(f"Failed to connect to {target_channel.name}: {e}")
raise commands.CommandError(f"Failed to connect to the voice channel: {e}")
elif vc.channel != target_channel:
try:
log.info(f"Moving to voice channel {target_channel.name} in guild {guild.id}")
await vc.move_to(target_channel)
self.voice_clients[guild.id] = vc # Ensure the instance is updated if move_to returns a new one
except Exception as e:
log.error(f"Failed to move to {target_channel.name}: {e}")
raise commands.CommandError(f"Failed to move to your voice channel: {e}")
return vc
# --- Commands ---
@commands.command(name="join", aliases=['connect'])
async def join(self, ctx: commands.Context):
"""Connects the bot to your current voice channel."""
try:
await self._ensure_voice_connection(ctx)
await ctx.reply(f"Connected to **{ctx.author.voice.channel.name}**.")
except commands.CommandError as e:
await ctx.reply(str(e))
except Exception as e:
log.error(f"Error in join command: {e}")
await ctx.reply("An unexpected error occurred while trying to join.")
@commands.command(name="leave", aliases=['disconnect', 'dc'])
async def leave(self, ctx: commands.Context):
"""Disconnects the bot from the voice channel."""
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_connected():
await ctx.reply("I am not connected to a voice channel.")
return
log.info(f"Disconnecting from voice channel in guild {ctx.guild.id}")
await ctx.reply(f"Disconnecting from **{vc.channel.name}**.")
self.cleanup(ctx.guild.id) # This handles the disconnect and queue clearing
@commands.command(name="play", aliases=['p'])
async def play(self, ctx: commands.Context, *, query: str):
"""Plays a song or adds it/playlist to the queue. Accepts URL or search query."""
try:
vc = await self._ensure_voice_connection(ctx)
except commands.CommandError as e:
await ctx.reply(str(e))
return
except Exception as e:
log.error(f"Error ensuring voice connection in play command: {e}")
await ctx.reply("An unexpected error occurred before playing.")
return
queue = self.get_queue(ctx.guild.id)
songs_added = 0
playlist_title = None
song_to_announce = None # Store the single song if added
async with ctx.typing(): # Indicate processing
try:
# info now contains full data for playlist or single video
info, is_playlist, is_search = await self._extract_info(query)
if not info:
await ctx.reply("Could not find anything matching your query.")
return
if is_playlist:
playlist_title = info.get('title', 'Unnamed Playlist')
log.info(f"Adding playlist '{playlist_title}' to queue for guild {ctx.guild.id}")
entries = info.get('entries', []) # Should contain full entry info now
if not entries:
await ctx.reply(f"Playlist '{playlist_title}' seems to be empty or could not be loaded.")
return
for entry in entries:
if not entry: continue
# Extract stream URL directly from the entry info
stream_url = entry.get('url') # yt-dlp often provides the best stream URL here
if not stream_url: # Fallback to formats if needed
formats = entry.get('formats', [])
for f in formats:
# Prioritize opus or known good audio codecs
if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'):
stream_url = f['url']
break
# Last resort fallback if still no URL
if not stream_url and formats:
for f in formats:
if f.get('url') and f.get('acodec') != 'none':
stream_url = f['url']
break
if not stream_url:
log.warning(f"Could not find playable stream URL for playlist entry: {entry.get('title', entry.get('id'))}")
await ctx.send(f"⚠️ Could not get audio for '{entry.get('title', 'an item')}' from playlist.", delete_after=15)
continue
try:
song = Song(
source_url=stream_url,
title=entry.get('title', 'Unknown Title'),
webpage_url=entry.get('webpage_url', entry.get('original_url')), # Use original_url as fallback
duration=entry.get('duration'),
requested_by=ctx.author
)
queue.append(song)
songs_added += 1
except Exception as song_e:
log.error(f"Error creating Song object for entry {entry.get('title', entry.get('id'))}: {song_e}")
await ctx.send(f"⚠️ Error processing metadata for '{entry.get('title', 'an item')}' from playlist.", delete_after=15)
else: # Single video or search result
# 'info' should be the dictionary for the single video here
stream_url = info.get('url')
if not stream_url: # Fallback if 'url' isn't top-level
formats = info.get('formats', [])
for f in formats:
# Prioritize opus or known good audio codecs
if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'):
stream_url = f['url']
break
# Last resort fallback if still no URL
if not stream_url and formats:
for f in formats:
if f.get('url') and f.get('acodec') != 'none':
stream_url = f['url']
break
if not stream_url:
await ctx.reply("Could not extract a playable audio stream for the video.")
return
song = Song(
source_url=stream_url,
title=info.get('title', 'Unknown Title'),
webpage_url=info.get('webpage_url'),
duration=info.get('duration'),
requested_by=ctx.author
)
queue.append(song)
songs_added = 1
song_to_announce = song # Store for announcement
log.info(f"Added song '{song.title}' to queue for guild {ctx.guild.id}")
except commands.CommandError as e:
await ctx.reply(str(e))
return
except Exception as e:
log.exception(f"Error during song processing in play command: {e}") # Log full traceback
await ctx.reply("An unexpected error occurred while processing your request.")
return
# --- Send confirmation message ---
if songs_added > 0:
if is_playlist:
await ctx.reply(f"✅ Added **{songs_added}** songs from playlist **'{playlist_title}'** to the queue.")
elif song_to_announce: # Check if a single song was added
# For single adds, show position if queue was not empty before adding
queue_pos = len(queue) # Position is the current length (after adding)
if vc.is_playing() or vc.is_paused() or queue_pos > 1: # If something playing or queue had items before this add
await ctx.reply(f"✅ Added **{song_to_announce.title}** to the queue (position #{queue_pos}).")
else:
# If nothing was playing and queue was empty, this song will play next
# The loop will handle the "Now Playing" implicitly, so just confirm add
await ctx.reply(f"✅ Added **{song_to_announce.title}** to the queue.")
# No need to explicitly start playback here, the loop handles it.
else:
# This case might happen if playlist extraction failed for all entries or search failed
if not is_playlist and is_search:
# If it was a search and nothing was added, the earlier message handles it
pass # Already sent "Could not find anything..."
else:
await ctx.reply("Could not add any songs from the provided source.")
@commands.command(name="pause")
async def pause(self, ctx: commands.Context):
"""Pauses the current playback."""
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_playing():
await ctx.reply("I am not playing anything right now.")
return
if vc.is_paused():
await ctx.reply("Playback is already paused.")
return
vc.pause()
await ctx.reply("⏸️ Playback paused.")
log.info(f"Playback paused in guild {ctx.guild.id}")
@commands.command(name="resume")
async def resume(self, ctx: commands.Context):
"""Resumes paused playback."""
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_connected():
await ctx.reply("I am not connected to a voice channel.")
return
if not vc.is_paused():
await ctx.reply("Playback is not paused.")
return
vc.resume()
await ctx.reply("▶️ Playback resumed.")
log.info(f"Playback resumed in guild {ctx.guild.id}")
@commands.command(name="skip", aliases=['s'])
async def skip(self, ctx: commands.Context):
"""Skips the current song."""
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_playing():
await ctx.reply("I am not playing anything to skip.")
return
current = self.get_current_song(ctx.guild.id)
await ctx.reply(f"⏭️ Skipping **{current.title if current else 'the current song'}**...")
vc.stop() # Triggers the 'after' callback, which lets the loop play the next song
log.info(f"Song skipped in guild {ctx.guild.id} by {ctx.author}")
# The loop will handle playing the next song
@commands.command(name="stop")
async def stop(self, ctx: commands.Context):
"""Stops playback and clears the queue."""
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_connected():
await ctx.reply("I am not connected to a voice channel.")
return
queue = self.get_queue(ctx.guild.id)
queue.clear()
self.current_song[ctx.guild.id] = None # Clear current song immediately
if vc.is_playing() or vc.is_paused():
vc.stop() # Stop playback
await ctx.reply("⏹️ Playback stopped and queue cleared.")
log.info(f"Playback stopped and queue cleared in guild {ctx.guild.id} by {ctx.author}")
else:
await ctx.reply("⏹️ Queue cleared.") # If nothing was playing, just confirm queue clear
log.info(f"Queue cleared in guild {ctx.guild.id} by {ctx.author} (nothing was playing).")
@commands.command(name="queue", aliases=['q'])
async def queue(self, ctx: commands.Context):
"""Displays the current song queue."""
queue = self.get_queue(ctx.guild.id)
current = self.get_current_song(ctx.guild.id)
if not queue and not current:
await ctx.reply("The queue is empty and nothing is playing.")
return
embed = discord.Embed(title="Music Queue", color=discord.Color.blue())
if current:
embed.add_field(name="Now Playing", value=f"[{current.title}]({current.webpage_url}) | `{current.format_duration()}` | Requested by {current.requested_by.mention}", inline=False)
else:
embed.add_field(name="Now Playing", value="Nothing currently playing.", inline=False)
if queue:
queue_list = []
max_display = 10 # Limit display to avoid huge embeds
for i, song in enumerate(list(queue)[:max_display]):
queue_list.append(f"`{i+1}.` [{song.title}]({song.webpage_url}) | `{song.format_duration()}` | Req by {song.requested_by.mention}")
if queue_list:
embed.add_field(name="Up Next", value="\n".join(queue_list), inline=False)
if len(queue) > max_display:
embed.set_footer(text=f"... and {len(queue) - max_display} more songs.")
else:
embed.add_field(name="Up Next", value="The queue is empty.", inline=False)
await ctx.reply(embed=embed)
@commands.command(name="nowplaying", aliases=['np', 'current'])
async def nowplaying(self, ctx: commands.Context):
"""Shows the currently playing song."""
current = self.get_current_song(ctx.guild.id)
vc = self.voice_clients.get(ctx.guild.id)
if not vc or not vc.is_connected():
await ctx.reply("I'm not connected to a voice channel.")
return
if not current or not (vc.is_playing() or vc.is_paused()):
await ctx.reply("Nothing is currently playing.")
return
embed = discord.Embed(title="Now Playing", description=f"[{current.title}]({current.webpage_url})", color=discord.Color.green())
embed.add_field(name="Duration", value=f"`{current.format_duration()}`", inline=True)
embed.add_field(name="Requested by", value=current.requested_by.mention, inline=True)
# Add progress bar if possible (requires tracking start time)
# progress = ...
# embed.add_field(name="Progress", value=progress, inline=False)
if hasattr(current, 'thumbnail') and current.thumbnail: # Check if thumbnail exists
embed.set_thumbnail(url=current.thumbnail)
await ctx.reply(embed=embed)
# --- Search Command and View ---
@commands.command(name="search")
async def search(self, ctx: commands.Context, *, query: str):
"""Searches YouTube and displays results with selection buttons."""
if not ctx.guild:
await ctx.reply("This command can only be used in a server.")
return
# Store the initial message to edit later
message = await ctx.reply(f"Searching for '{query}'...")
async with ctx.typing(): # Keep typing indicator while searching
try:
# Fetch more results for pagination
results = await self._search_youtube(query, max_results=15)
except Exception as e:
log.error(f"Error during YouTube search: {e}")
await message.edit(content="An error occurred while searching.", view=None)
return
if not results:
await message.edit(content=f"No results found for '{query}'.", view=None)
return
# Prepare data for the view
search_results_data = []
for entry in results:
# Store necessary info for adding to queue later
search_results_data.append({
'title': entry.get('title', 'Unknown Title'),
'webpage_url': entry.get('webpage_url'),
'duration': entry.get('duration'),
'id': entry.get('id'), # Need ID to re-fetch stream URL later
'uploader': entry.get('uploader', 'Unknown Uploader')
})
# Create the view with pagination
view = PaginatedSearchResultView(ctx.author, search_results_data, self, query) # Pass query for title
view.interaction_message = message # Store message reference in the view
# Initial update of the message with the first page
await view.update_message(interaction=None) # Use interaction=None for initial send/edit
@staticmethod
def format_duration_static(duration):
"""Static version of format_duration for use outside Song objects."""
if not duration:
return "N/A"
minutes, seconds = divmod(duration, 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes:02d}:{seconds:02d}"
# Error Handling for Audio Cog specifically
@commands.Cog.listener()
async def on_command_error(self, ctx, error):
# Handle errors specific to this cog, prevents double messages if global handler exists
if isinstance(error, commands.CommandError) and ctx.cog == self:
# Check if the error is specific to commands in this cog
# Avoid handling errors already handled locally in commands if possible
# This basic check just prevents duplicate generic messages
log.warning(f"Command error in AudioCog: {error}")
# await ctx.reply(f"An error occurred: {error}") # Optional: Send specific cog errors
# Return True or pass to prevent global handler if needed
# pass # Let global handler take care of it unless specific handling is needed
async def setup(bot):
await bot.add_cog(AudioCog(bot))
log.info("AudioCog loaded successfully.")
# --- Paginated Search Result View ---
class PaginatedSearchResultView(ui.View):
RESULTS_PER_PAGE = 5
def __init__(self, author: discord.Member, results: list, cog: AudioCog, query: str, timeout=180.0):
super().__init__(timeout=timeout)
self.author = author
self.results = results
self.cog = cog
self.query = query # Store original query for embed title
self.current_page = 0
self.total_pages = math.ceil(len(self.results) / self.RESULTS_PER_PAGE)
self.interaction_message: discord.Message = None # To disable view later
self.update_buttons() # Initial button setup
def update_buttons(self):
"""Clears and adds buttons based on the current page and total results."""
self.clear_items()
start_index = self.current_page * self.RESULTS_PER_PAGE
end_index = min(start_index + self.RESULTS_PER_PAGE, len(self.results))
# Add result selection buttons for the current page
for i in range(start_index, end_index):
result = self.results[i]
button = ui.Button(
label=f"{i+1}", # Overall result number
style=discord.ButtonStyle.secondary,
custom_id=f"search_select_{i}",
row= (i - start_index) // 5 # Arrange buttons neatly if more than 5 per page (though we limit to 5)
)
# Use lambda to capture the correct index 'i'
button.callback = lambda interaction, index=i: self.select_button_callback(interaction, index)
self.add_item(button)
# Add navigation buttons (Previous/Next) - ensure they are on the last row
nav_row = math.ceil(self.RESULTS_PER_PAGE / 5) # Calculate row for nav buttons
if self.total_pages > 1:
prev_button = ui.Button(label="◀ Previous", style=discord.ButtonStyle.primary, custom_id="search_prev", disabled=self.current_page == 0, row=nav_row)
prev_button.callback = self.prev_button_callback
self.add_item(prev_button)
next_button = ui.Button(label="Next ▶", style=discord.ButtonStyle.primary, custom_id="search_next", disabled=self.current_page == self.total_pages - 1, row=nav_row)
next_button.callback = self.next_button_callback
self.add_item(next_button)
def create_embed(self) -> discord.Embed:
"""Creates the embed for the current page."""
embed = discord.Embed(
title=f"Search Results for '{self.query}' (Page {self.current_page + 1}/{self.total_pages})",
description="Click a button below to add the song to the queue.",
color=discord.Color.purple()
)
start_index = self.current_page * self.RESULTS_PER_PAGE
end_index = min(start_index + self.RESULTS_PER_PAGE, len(self.results))
if start_index >= len(self.results): # Should not happen with proper page clamping
embed.description = "No results on this page."
return embed
for i in range(start_index, end_index):
entry = self.results[i]
title = entry.get('title', 'Unknown Title')
url = entry.get('webpage_url')
duration_sec = entry.get('duration')
duration_fmt = self.cog.format_duration_static(duration_sec) if duration_sec else "N/A" # Use cog's static method
uploader = entry.get('uploader', 'Unknown Uploader')
embed.add_field(
name=f"{i+1}. {title}", # Use overall index + 1 for label
value=f"[{uploader}]({url}) | `{duration_fmt}`",
inline=False
)
embed.set_footer(text=f"Showing results {start_index + 1}-{end_index} of {len(self.results)}")
return embed
async def update_message(self, interaction: discord.Interaction = None):
"""Updates the message with the current page's embed and buttons."""
self.update_buttons()
embed = self.create_embed()
if interaction:
await interaction.response.edit_message(embed=embed, view=self)
elif self.interaction_message: # For initial send/edit
await self.interaction_message.edit(content=None, embed=embed, view=self) # Remove "Searching..." text
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Only allow the original command author to interact
if interaction.user != self.author:
await interaction.response.send_message("Only the person who started the search can interact with this.", ephemeral=True)
return False
return True
async def select_button_callback(self, interaction: discord.Interaction, index: int):
"""Callback when a result selection button is pressed."""
if not interaction.guild: return
selected_result = self.results[index]
log.info(f"Search result {index+1} ('{selected_result['title']}') selected by {interaction.user} in guild {interaction.guild.id}")
# Defer the interaction
await interaction.response.defer()
# Disable all buttons in the view after selection
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
# Update the original message to show disabled buttons and confirmation
final_embed = self.create_embed() # Get current embed state
final_embed.description = f"Selected: **{selected_result['title']}**. Adding to queue..."
final_embed.color = discord.Color.green()
await interaction.edit_original_response(embed=final_embed, view=self)
self.stop() # Stop the view from listening further
# --- Add the selected song to the queue ---
try:
# Ensure bot is connected to voice (use interaction here)
vc = await self.cog._ensure_voice_connection(interaction)
if not vc:
log.error("Failed to ensure voice connection in search callback.")
await interaction.followup.send("Could not connect to voice channel.", ephemeral=True)
return
queue = self.cog.get_queue(interaction.guild.id)
# Re-fetch the stream URL
try:
query_for_stream = selected_result.get('webpage_url') or selected_result.get('id')
if not query_for_stream:
raise commands.CommandError("Missing video identifier for selected result.")
info, _, _ = await self.cog._extract_info(query_for_stream)
if not info:
raise commands.CommandError("Could not retrieve details for the selected video.")
stream_url = info.get('url')
if not stream_url:
formats = info.get('formats', [])
for f in formats:
if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'):
stream_url = f['url']
break
if not stream_url and formats:
for f in formats:
if f.get('url') and f.get('acodec') != 'none':
stream_url = f['url']
break
if not stream_url:
raise commands.CommandError("Could not extract a playable audio stream.")
song = Song(
source_url=stream_url,
title=info.get('title', selected_result.get('title', 'Unknown Title')),
webpage_url=info.get('webpage_url', selected_result.get('webpage_url')),
duration=info.get('duration', selected_result.get('duration')),
requested_by=interaction.user
)
queue.append(song)
log.info(f"Added search result '{song.title}' to queue for guild {interaction.guild.id}")
# Send confirmation followup
queue_pos = len(queue)
if vc.is_playing() or vc.is_paused() or queue_pos > 1:
await interaction.followup.send(f"✅ Added **{song.title}** to the queue (position #{queue_pos}).")
else:
await interaction.followup.send(f"✅ Added **{song.title}** to the queue.")
except commands.CommandError as e:
log.error(f"Error adding search result to queue: {e}")
await interaction.followup.send(f"Error adding song: {e}", ephemeral=True)
except Exception as e:
log.exception(f"Unexpected error adding search result to queue: {e}")
await interaction.followup.send("An unexpected error occurred while adding the song.", ephemeral=True)
except commands.CommandError as e:
await interaction.followup.send(str(e), ephemeral=True)
except Exception as e:
log.exception(f"Unexpected error in search select callback: {e}")
await interaction.followup.send("An unexpected error occurred.", ephemeral=True)
async def prev_button_callback(self, interaction: discord.Interaction):
"""Callback for the previous page button."""
if self.current_page > 0:
self.current_page -= 1
await self.update_message(interaction)
async def next_button_callback(self, interaction: discord.Interaction):
"""Callback for the next page button."""
if self.current_page < self.total_pages - 1:
self.current_page += 1
await self.update_message(interaction)
async def on_timeout(self):
# Disable buttons on timeout
log.info(f"Paginated search view timed out for user {self.author.id}")
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
# Try to edit the original message
if self.interaction_message:
try:
# Keep the last viewed embed but indicate timeout
timeout_embed = self.create_embed()
timeout_embed.description = "Search selection timed out."
timeout_embed.color = discord.Color.default() # Reset color
await self.interaction_message.edit(embed=timeout_embed, view=self)
except discord.NotFound:
log.warning("Original search message not found on timeout.")
except discord.Forbidden:
log.warning("Missing permissions to edit search message on timeout.")
except Exception as e:
log.error(f"Error editing search message on timeout: {e}")
# Override on_error if specific error handling for the view is needed
# async def on_error(self, interaction: discord.Interaction, error: Exception, item: ui.Item) -> None:
# log.error(f"Error in PaginatedSearchResultView interaction: {error}")
# await interaction.response.send_message("An error occurred with this interaction.", ephemeral=True)

101
cogs/command_debug_cog.py Normal file
View File

@ -0,0 +1,101 @@
import discord
from discord.ext import commands
from discord import app_commands
import inspect
import json
class CommandDebugCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("CommandDebugCog initialized!")
@commands.command(name="checkcommand")
@commands.is_owner()
async def check_command(self, ctx, command_name: str = "webdrivertorso"):
"""Check details of a specific slash command"""
await ctx.send(f"Checking details for slash command: {command_name}")
# Find the command in the command tree
command = None
for cmd in self.bot.tree.get_commands():
if cmd.name == command_name:
command = cmd
break
if not command:
await ctx.send(f"Command '{command_name}' not found in the command tree.")
return
# Get basic command info
await ctx.send(f"Command found: {command.name}")
await ctx.send(f"Description: {command.description}")
await ctx.send(f"Parameter count: {len(command.parameters)}")
# Get parameter details
for i, param in enumerate(command.parameters):
param_info = f"Parameter {i+1}: {param.name}"
param_info += f"\n Type: {type(param.type).__name__}"
param_info += f"\n Required: {param.required}"
# Check for choices
if hasattr(param, 'choices') and param.choices:
choices = [f"{c.name} ({c.value})" for c in param.choices]
param_info += f"\n Choices: {', '.join(choices)}"
# Check for tts_provider specifically
if param.name == "tts_provider":
param_info += "\n THIS IS THE TTS PROVIDER PARAMETER WE'RE LOOKING FOR!"
# Get the actual implementation
cog_instance = None
for cog in self.bot.cogs.values():
for command_obj in cog.get_app_commands():
if command_obj.name == command_name:
cog_instance = cog
break
if cog_instance:
break
if cog_instance:
param_info += f"\n Found in cog: {cog_instance.__class__.__name__}"
# Try to get the actual method
method = None
for name, method_obj in inspect.getmembers(cog_instance, predicate=inspect.ismethod):
if hasattr(method_obj, "callback") and getattr(method_obj, "callback", None) == command:
method = method_obj
break
elif hasattr(method_obj, "__name__") and method_obj.__name__ == f"{command_name}_slash":
method = method_obj
break
if method:
param_info += f"\n Method: {method.__name__}"
param_info += f"\n Signature: {str(inspect.signature(method))}"
await ctx.send(param_info)
# Check for the actual implementation in the cogs
await ctx.send("Checking implementation in cogs...")
for cog_name, cog in self.bot.cogs.items():
for cmd in cog.get_app_commands():
if cmd.name == command_name:
await ctx.send(f"Command implemented in cog: {cog_name}")
# Try to get the method
for name, method in inspect.getmembers(cog, predicate=inspect.ismethod):
if name.startswith(command_name) or name.endswith("_slash"):
await ctx.send(f"Possible implementing method: {name}")
sig = inspect.signature(method)
await ctx.send(f"Method signature: {sig}")
# Check if tts_provider is in the parameters
if "tts_provider" in [p for p in sig.parameters]:
await ctx.send("✅ tts_provider parameter found in method signature!")
else:
await ctx.send("❌ tts_provider parameter NOT found in method signature!")
async def setup(bot: commands.Bot):
print("Loading CommandDebugCog...")
await bot.add_cog(CommandDebugCog(bot))
print("CommandDebugCog loaded successfully!")

90
cogs/command_fix_cog.py Normal file
View File

@ -0,0 +1,90 @@
import discord
from discord.ext import commands
from discord import app_commands
import inspect
class CommandFixCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("CommandFixCog initialized!")
@commands.command(name="fixcommand")
@commands.is_owner()
async def fix_command(self, ctx):
"""Attempt to fix the webdrivertorso command at runtime"""
await ctx.send("Attempting to fix the webdrivertorso command...")
# Find the WebdriverTorsoCog
webdriver_cog = None
for cog_name, cog in self.bot.cogs.items():
if cog_name == "WebdriverTorsoCog":
webdriver_cog = cog
break
if not webdriver_cog:
await ctx.send("❌ WebdriverTorsoCog not found!")
return
await ctx.send("✅ Found WebdriverTorsoCog")
# Find the slash command
slash_command = None
for cmd in self.bot.tree.get_commands():
if cmd.name == "webdrivertorso":
slash_command = cmd
break
if not slash_command:
await ctx.send("❌ webdrivertorso slash command not found!")
return
await ctx.send(f"✅ Found webdrivertorso slash command with {len(slash_command.parameters)} parameters")
# Check if tts_provider is in the parameters
tts_provider_param = None
for param in slash_command.parameters:
if param.name == "tts_provider":
tts_provider_param = param
break
if tts_provider_param:
await ctx.send(f"✅ tts_provider parameter already exists in the command")
# Check if it has choices
if hasattr(tts_provider_param, 'choices') and tts_provider_param.choices:
choices = [f"{c.name} ({c.value})" for c in tts_provider_param.choices]
await ctx.send(f"✅ tts_provider has choices: {', '.join(choices)}")
else:
await ctx.send("❌ tts_provider parameter has no choices!")
else:
await ctx.send("❌ tts_provider parameter not found in the command!")
# Try to force a sync
await ctx.send("Forcing a command sync...")
try:
synced = await self.bot.tree.sync()
await ctx.send(f"✅ Synced {len(synced)} command(s)")
except Exception as e:
await ctx.send(f"❌ Failed to sync commands: {str(e)}")
# Create a new command as a workaround
await ctx.send("Creating a new ttsprovider command as a workaround...")
# Check if TTSProviderCog is loaded
tts_provider_cog = None
for cog_name, cog in self.bot.cogs.items():
if cog_name == "TTSProviderCog":
tts_provider_cog = cog
break
if tts_provider_cog:
await ctx.send("✅ TTSProviderCog is already loaded")
else:
await ctx.send("❌ TTSProviderCog not loaded. Please load it with !load tts_provider_cog")
await ctx.send("Fix attempt completed. Please check if the ttsprovider command is available.")
async def setup(bot: commands.Bot):
print("Loading CommandFixCog...")
await bot.add_cog(CommandFixCog(bot))
print("CommandFixCog loaded successfully!")

215
cogs/discord_sync_cog.py Normal file
View File

@ -0,0 +1,215 @@
import discord
from discord.ext import commands
from discord import app_commands
import datetime
import os
from typing import Optional, List, Dict, Any
# Try to import the Discord sync API
try:
from discord_bot_sync_api import (
user_conversations, save_discord_conversation,
load_conversations, SyncedConversation, SyncedMessage
)
SYNC_API_AVAILABLE = True
except ImportError:
print("Discord sync API not available in sync cog. Sync features will be disabled.")
SYNC_API_AVAILABLE = False
class DiscordSyncCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("DiscordSyncCog initialized!")
# Load conversations if API is available
if SYNC_API_AVAILABLE:
load_conversations()
@commands.command(name="syncstatus")
async def sync_status(self, ctx: commands.Context):
"""Check the status of the Discord sync API"""
if not SYNC_API_AVAILABLE:
await ctx.reply("❌ Discord sync API is not available. Please make sure the required dependencies are installed.")
return
# Count total synced conversations
total_conversations = sum(len(convs) for convs in user_conversations.values())
total_users = len(user_conversations)
# Check if the user has any synced conversations
user_id = str(ctx.author.id)
user_conv_count = len(user_conversations.get(user_id, []))
embed = discord.Embed(
title="Discord Sync Status",
description="Status of the Discord sync API for Flutter app integration",
color=discord.Color.green()
)
embed.add_field(
name="API Status",
value="✅ Running",
inline=False
)
embed.add_field(
name="Total Synced Conversations",
value=f"{total_conversations} conversations from {total_users} users",
inline=False
)
embed.add_field(
name="Your Synced Conversations",
value=f"{user_conv_count} conversations",
inline=False
)
embed.add_field(
name="API Endpoint",
value="https://slipstreamm.dev/discordapi",
inline=False
)
embed.add_field(
name="Setup Instructions",
value="Use `!synchelp` for setup instructions",
inline=False
)
embed.set_footer(text=f"Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
await ctx.reply(embed=embed)
@commands.command(name="synchelp")
async def sync_help(self, ctx: commands.Context):
"""Get help with setting up the Discord sync integration"""
embed = discord.Embed(
title="Discord Sync Integration Help",
description="How to set up the Discord sync integration with the Flutter app",
color=discord.Color.blue()
)
embed.add_field(
name="1. Discord Developer Portal Setup",
value=(
"1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)\n"
"2. Click 'New Application' and give it a name\n"
"3. Go to the 'OAuth2' section\n"
"4. Add a redirect URL: `openroutergui://auth`\n"
"5. Copy the 'Client ID' for the Flutter app"
),
inline=False
)
embed.add_field(
name="2. Flutter App Setup",
value=(
"1. Open the Flutter app settings\n"
"2. Go to 'Discord Integration'\n"
"3. Enter the Client ID from the Discord Developer Portal\n"
"4. Enter the Bot API URL: `https://slipstreamm.dev/discordapi`\n"
"5. Click 'Save'"
),
inline=False
)
embed.add_field(
name="3. Usage",
value=(
"1. Click 'Login with Discord' in the Flutter app\n"
"2. Authorize the app to access your Discord account\n"
"3. Use the 'Sync Conversations' button to sync conversations\n"
"4. Use the 'Import from Discord' button to import conversations"
),
inline=False
)
embed.add_field(
name="4. Troubleshooting",
value=(
"• Make sure the bot is running and accessible from the internet\n"
"• Check that the Client ID is correct\n"
"• Verify that the redirect URL is properly configured\n"
"• Use `!syncstatus` to check the API status"
),
inline=False
)
await ctx.reply(embed=embed)
@commands.command(name="syncclear")
async def sync_clear(self, ctx: commands.Context):
"""Clear your synced conversations"""
if not SYNC_API_AVAILABLE:
await ctx.reply("❌ Discord sync API is not available. Please make sure the required dependencies are installed.")
return
user_id = str(ctx.author.id)
if user_id not in user_conversations or not user_conversations[user_id]:
await ctx.reply("You don't have any synced conversations to clear.")
return
# Count conversations before clearing
conv_count = len(user_conversations[user_id])
# Clear the user's conversations
user_conversations[user_id] = []
# Save the updated conversations
from discord_bot_sync_api import save_conversations
save_conversations()
await ctx.reply(f"✅ Cleared {conv_count} synced conversations.")
@commands.command(name="synclist")
async def sync_list(self, ctx: commands.Context):
"""List your synced conversations"""
if not SYNC_API_AVAILABLE:
await ctx.reply("❌ Discord sync API is not available. Please make sure the required dependencies are installed.")
return
user_id = str(ctx.author.id)
if user_id not in user_conversations or not user_conversations[user_id]:
await ctx.reply("You don't have any synced conversations.")
return
# Create an embed to display the conversations
embed = discord.Embed(
title="Your Synced Conversations",
description=f"You have {len(user_conversations[user_id])} synced conversations",
color=discord.Color.blue()
)
# Add each conversation to the embed
for i, conv in enumerate(user_conversations[user_id], 1):
# Get the first few messages for context
preview = ""
for msg in conv.messages[:3]: # Show first 3 messages
if len(preview) < 100: # Keep preview short
preview += f"{msg.role}: {msg.content[:30]}...\n"
# Add field for this conversation
embed.add_field(
name=f"{i}. {conv.title} ({conv.model_id})",
value=(
f"ID: {conv.id}\n"
f"Created: {conv.created_at.strftime('%Y-%m-%d')}\n"
f"Messages: {len(conv.messages)}\n"
f"Preview: {preview[:100]}..."
),
inline=False
)
# Discord embeds have a limit of 25 fields
if i >= 10:
embed.add_field(
name="Note",
value=f"Showing 10/{len(user_conversations[user_id])} conversations. Use the Flutter app to view all.",
inline=False
)
break
await ctx.reply(embed=embed)
async def setup(bot):
await bot.add_cog(DiscordSyncCog(bot))

1
cogs/games/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file makes the games directory a Python package

114
cogs/games/basic_games.py Normal file
View File

@ -0,0 +1,114 @@
import discord
import random
import asyncio
from typing import List
# Simple utility functions for basic games
def roll_dice() -> int:
"""Roll a dice and return a number between 1 and 6."""
return random.randint(1, 6)
def flip_coin() -> str:
"""Flip a coin and return 'Heads' or 'Tails'."""
return random.choice(["Heads", "Tails"])
def magic8ball_response() -> str:
"""Return a random Magic 8 Ball response."""
responses = [
"It is certain.", "It is decidedly so.", "Without a doubt.", "Yes definitely.", "You may rely on it.",
"As I see it, yes.", "Most likely.", "Outlook good.", "Yes.", "Signs point to yes.",
"Reply hazy, try again.", "Ask again later.", "Better not tell you now.", "Cannot predict now.", "Concentrate and ask again.",
"Don't count on it.", "My reply is no.", "My sources say no.", "Outlook not so good.", "Very doubtful."
]
return random.choice(responses)
async def play_hangman(bot, channel, user, words_file_path: str = "words.txt") -> None:
"""
Play a game of Hangman in the specified channel.
Args:
bot: The Discord bot instance
channel: The channel to play in
user: The user who initiated the game
words_file_path: Path to the file containing words for the game
"""
try:
with open(words_file_path, "r") as file:
words = [line.strip().lower() for line in file if line.strip() and len(line.strip()) > 3]
if not words:
await channel.send("Word list is empty or not found.")
return
word = random.choice(words)
except FileNotFoundError:
await channel.send(f"`{words_file_path}` not found. Cannot start Hangman.")
return
guessed = ["_"] * len(word)
attempts = 6
guessed_letters = set()
def format_hangman_message(attempts_left, current_guessed, letters_tried):
stages = [ # Hangman stages (simple text version)
"```\n +---+\n | |\n O |\n/|\\ |\n/ \\ |\n |\n=======\n```", # 0 attempts left
"```\n +---+\n | |\n O |\n/|\\ |\n/ |\n |\n=======\n```", # 1 attempt left
"```\n +---+\n | |\n O |\n/|\\ |\n |\n |\n=======\n```", # 2 attempts left
"```\n +---+\n | |\n O |\n/| |\n |\n |\n=======\n```", # 3 attempts left
"```\n +---+\n | |\n O |\n | |\n |\n |\n=======\n```", # 4 attempts left
"```\n +---+\n | |\n O |\n |\n |\n |\n=======\n```", # 5 attempts left
"```\n +---+\n | |\n |\n |\n |\n |\n=======\n```" # 6 attempts left
]
stage_index = max(0, min(attempts_left, 6)) # Clamp index
guessed_str = ' '.join(current_guessed)
tried_str = ', '.join(sorted(list(letters_tried))) if letters_tried else "None"
return f"{stages[stage_index]}\nWord: `{guessed_str}`\nAttempts left: {attempts_left}\nGuessed letters: {tried_str}\n\nGuess a letter!"
initial_msg_content = format_hangman_message(attempts, guessed, guessed_letters)
game_message = await channel.send(initial_msg_content)
def check(m):
# Check if message is from the original user, in the same channel, and is a single letter
return m.author == user and m.channel == channel and len(m.content) == 1 and m.content.isalpha()
while attempts > 0 and "_" in guessed:
try:
msg = await bot.wait_for("message", check=check, timeout=120.0) # 2 min timeout per guess
guess = msg.content.lower()
# Delete the user's guess message for cleaner chat
try:
await msg.delete()
except (discord.Forbidden, discord.NotFound):
pass # Ignore if delete fails
if guess in guessed_letters:
feedback = "You already guessed that letter!"
else:
guessed_letters.add(guess)
if guess in word:
feedback = "✅ Correct!"
for i, letter in enumerate(word):
if letter == guess:
guessed[i] = guess
else:
attempts -= 1
feedback = f"❌ Wrong!"
# Check for win/loss after processing guess
if "_" not in guessed:
final_message = f"🎉 You guessed the word: **{word}**!"
await game_message.edit(content=final_message)
return # End game on win
elif attempts == 0:
final_message = f"💀 You ran out of attempts! The word was **{word}**."
await game_message.edit(content=format_hangman_message(0, guessed, guessed_letters) + "\n" + final_message)
return # End game on loss
# Update the game message with new state and feedback
updated_content = format_hangman_message(attempts, guessed, guessed_letters) + f"\n({feedback})"
await game_message.edit(content=updated_content)
except asyncio.TimeoutError:
timeout_message = f"⏰ Time's up! The word was **{word}**."
await game_message.edit(content=format_hangman_message(attempts, guessed, guessed_letters) + "\n" + timeout_message)
return # End game on timeout

1778
cogs/games/chess_game.py Normal file

File diff suppressed because it is too large Load Diff

153
cogs/games/coinflip_game.py Normal file
View File

@ -0,0 +1,153 @@
import discord
from discord import ui
from typing import Optional
class CoinFlipView(ui.View):
def __init__(self, initiator: discord.Member, opponent: discord.Member):
super().__init__(timeout=180.0) # 3-minute timeout
self.initiator = initiator
self.opponent = opponent
self.initiator_choice: Optional[str] = None
self.opponent_choice: Optional[str] = None
self.result: Optional[str] = None
self.winner: Optional[discord.Member] = None
self.message: Optional[discord.Message] = None # To store the message for editing
# Initial state: Initiator chooses side
self.add_item(self.HeadsButton())
self.add_item(self.TailsButton())
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check who is interacting at which stage."""
# Stage 1: Initiator chooses Heads/Tails
if self.initiator_choice is None:
if interaction.user.id != self.initiator.id:
await interaction.response.send_message("Only the initiator can choose their side.", ephemeral=True)
return False
return True
# Stage 2: Opponent Accepts/Declines
else:
if interaction.user.id != self.opponent.id:
await interaction.response.send_message("Only the opponent can accept or decline the game.", ephemeral=True)
return False
return True
async def update_view_state(self, interaction: discord.Interaction):
"""Updates the view items based on the current state."""
self.clear_items()
if self.initiator_choice is None: # Should not happen if called correctly, but for safety
self.add_item(self.HeadsButton())
self.add_item(self.TailsButton())
elif self.result is None: # Opponent needs to accept/decline
self.add_item(self.AcceptButton())
self.add_item(self.DeclineButton())
else: # Game finished, disable all (handled by disabling in callbacks)
pass # No items needed, or keep disabled ones
# Edit the original message
if self.message:
try:
# Use interaction response to edit if available, otherwise use message.edit
# This handles the case where the interaction is the one causing the edit
if interaction and interaction.message and interaction.message.id == self.message.id:
await interaction.response.edit_message(view=self)
else:
await self.message.edit(view=self)
except discord.NotFound:
print("CoinFlipView: Failed to edit message, likely deleted.")
except discord.Forbidden:
print("CoinFlipView: Missing permissions to edit message.")
except discord.InteractionResponded:
# If interaction already responded (e.g. initial choice), use followup or webhook
try:
await interaction.edit_original_response(view=self)
except discord.HTTPException:
print("CoinFlipView: Failed to edit original response after InteractionResponded.")
async def disable_all_buttons(self):
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
if self.message:
try:
await self.message.edit(view=self)
except discord.NotFound: pass # Ignore if message is gone
except discord.Forbidden: pass # Ignore if permissions lost
async def on_timeout(self):
if self.message and not self.is_finished(): # Check if not already stopped
await self.disable_all_buttons()
timeout_msg = f"Coin flip game between {self.initiator.mention} and {self.opponent.mention} timed out."
try:
await self.message.edit(content=timeout_msg, view=self)
except discord.NotFound: pass
except discord.Forbidden: pass
self.stop()
# --- Button Definitions ---
class HeadsButton(ui.Button):
def __init__(self):
super().__init__(label="Heads", style=discord.ButtonStyle.primary, custom_id="cf_heads")
async def callback(self, interaction: discord.Interaction):
view: 'CoinFlipView' = self.view
view.initiator_choice = "Heads"
view.opponent_choice = "Tails"
# Update message and view for opponent
await view.update_view_state(interaction) # Switches to Accept/Decline
await interaction.edit_original_response( # Edit the message content *after* updating state
content=f"{view.opponent.mention}, {view.initiator.mention} has chosen **Heads**! You get **Tails**. Do you accept?"
)
class TailsButton(ui.Button):
def __init__(self):
super().__init__(label="Tails", style=discord.ButtonStyle.primary, custom_id="cf_tails")
async def callback(self, interaction: discord.Interaction):
view: 'CoinFlipView' = self.view
view.initiator_choice = "Tails"
view.opponent_choice = "Heads"
# Update message and view for opponent
await view.update_view_state(interaction) # Switches to Accept/Decline
await interaction.edit_original_response( # Edit the message content *after* updating state
content=f"{view.opponent.mention}, {view.initiator.mention} has chosen **Tails**! You get **Heads**. Do you accept?"
)
class AcceptButton(ui.Button):
def __init__(self):
super().__init__(label="Accept", style=discord.ButtonStyle.success, custom_id="cf_accept")
async def callback(self, interaction: discord.Interaction):
view: 'CoinFlipView' = self.view
# Perform the coin flip
import random
view.result = random.choice(["Heads", "Tails"])
# Determine winner
if view.result == view.initiator_choice:
view.winner = view.initiator
else:
view.winner = view.opponent
# Construct result message
result_message = (
f"Coin flip game between {view.initiator.mention} ({view.initiator_choice}) and {view.opponent.mention} ({view.opponent_choice}).\n\n"
f"Flipping the coin... **{view.result}**!\n\n"
f"🎉 **{view.winner.mention} wins!** 🎉"
)
await view.disable_all_buttons()
await interaction.response.edit_message(content=result_message, view=view)
view.stop()
class DeclineButton(ui.Button):
def __init__(self):
super().__init__(label="Decline", style=discord.ButtonStyle.danger, custom_id="cf_decline")
async def callback(self, interaction: discord.Interaction):
view: 'CoinFlipView' = self.view
decline_message = f"{view.opponent.mention} has declined the coin flip game from {view.initiator.mention}."
await view.disable_all_buttons()
await interaction.response.edit_message(content=decline_message, view=view)
view.stop()

97
cogs/games/rps_game.py Normal file
View File

@ -0,0 +1,97 @@
import discord
from discord import ui
from typing import Optional
class RockPaperScissorsView(ui.View):
def __init__(self, initiator: discord.Member, opponent: discord.Member):
super().__init__(timeout=180.0) # 3-minute timeout
self.initiator = initiator
self.opponent = opponent
self.initiator_choice: Optional[str] = None
self.opponent_choice: Optional[str] = None
self.message: Optional[discord.Message] = None
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if the person interacting is part of the game."""
if interaction.user.id not in [self.initiator.id, self.opponent.id]:
await interaction.response.send_message("This is not your game!", ephemeral=True)
return False
return True
async def disable_all_buttons(self):
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
if self.message:
try:
await self.message.edit(view=self)
except discord.NotFound: pass
except discord.Forbidden: pass
async def on_timeout(self):
if self.message:
await self.disable_all_buttons()
timeout_msg = f"Rock Paper Scissors game between {self.initiator.mention} and {self.opponent.mention} timed out."
try:
await self.message.edit(content=timeout_msg, view=self)
except discord.NotFound: pass
except discord.Forbidden: pass
self.stop()
# Determine winner between two choices
def get_winner(self, choice1: str, choice2: str) -> Optional[str]:
if choice1 == choice2:
return None # Tie
if (choice1 == "Rock" and choice2 == "Scissors") or \
(choice1 == "Paper" and choice2 == "Rock") or \
(choice1 == "Scissors" and choice2 == "Paper"):
return "player1"
else:
return "player2"
@ui.button(label="Rock", style=discord.ButtonStyle.primary)
async def rock_button(self, interaction: discord.Interaction, button: ui.Button):
await self.make_choice(interaction, "Rock")
@ui.button(label="Paper", style=discord.ButtonStyle.success)
async def paper_button(self, interaction: discord.Interaction, button: ui.Button):
await self.make_choice(interaction, "Paper")
@ui.button(label="Scissors", style=discord.ButtonStyle.danger)
async def scissors_button(self, interaction: discord.Interaction, button: ui.Button):
await self.make_choice(interaction, "Scissors")
async def make_choice(self, interaction: discord.Interaction, choice: str):
player = interaction.user
# Record the choice for the appropriate player
if player.id == self.initiator.id:
self.initiator_choice = choice
await interaction.response.send_message(f"You chose **{choice}**!", ephemeral=True)
else: # opponent
self.opponent_choice = choice
await interaction.response.send_message(f"You chose **{choice}**!", ephemeral=True)
# Check if both players have made their choices
if self.initiator_choice and self.opponent_choice:
# Determine the winner
winner_id = self.get_winner(self.initiator_choice, self.opponent_choice)
if winner_id is None:
result = "It's a tie! 🤝"
elif winner_id == "player1":
result = f"**{self.initiator.mention}** wins! 🎉"
else:
result = f"**{self.opponent.mention}** wins! 🎉"
# Update the message with the results
result_message = (
f"**Rock Paper Scissors Results**\n"
f"{self.initiator.mention} chose **{self.initiator_choice}**\n"
f"{self.opponent.mention} chose **{self.opponent_choice}**\n\n"
f"{result}"
)
await self.disable_all_buttons()
await self.message.edit(content=result_message, view=self)
self.stop()

View File

@ -0,0 +1,246 @@
import discord
from discord import ui
from typing import Optional, List
# --- Tic Tac Toe (Player vs Player) ---
class TicTacToeButton(ui.Button['TicTacToeView']):
def __init__(self, x: int, y: int):
# Use a blank character for the initial label to avoid large buttons
super().__init__(style=discord.ButtonStyle.secondary, label='', row=y)
self.x = x
self.y = y
async def callback(self, interaction: discord.Interaction):
assert self.view is not None
view: TicTacToeView = self.view
# Check if it's the correct player's turn
if interaction.user != view.current_player:
await interaction.response.send_message("It's not your turn!", ephemeral=True)
return
# Check if the spot is already taken
if view.board[self.y][self.x] is not None:
await interaction.response.send_message("This spot is already taken!", ephemeral=True)
return
# Update board state and button appearance
view.board[self.y][self.x] = view.current_symbol
self.label = view.current_symbol
self.style = discord.ButtonStyle.success if view.current_symbol == 'X' else discord.ButtonStyle.danger
self.disabled = True
# Check for win/draw
if view.check_win():
view.winner = view.current_player
await view.end_game(interaction, f"🎉 {view.winner.mention} ({view.current_symbol}) wins! 🎉")
return
elif view.check_draw():
await view.end_game(interaction, "🤝 It's a draw! 🤝")
return
# Switch turns
view.switch_player()
await view.update_board_message(interaction)
class TicTacToeView(ui.View):
def __init__(self, initiator: discord.Member, opponent: discord.Member):
super().__init__(timeout=300.0) # 5 minute timeout
self.initiator = initiator
self.opponent = opponent
self.current_player = initiator # Initiator starts as X
self.current_symbol = 'X'
self.board: List[List[Optional[str]]] = [[None for _ in range(3)] for _ in range(3)]
self.winner: Optional[discord.Member] = None
self.message: Optional[discord.Message] = None
# Add buttons to the view
for y in range(3):
for x in range(3):
self.add_item(TicTacToeButton(x, y))
def switch_player(self):
if self.current_player == self.initiator:
self.current_player = self.opponent
self.current_symbol = 'O'
else:
self.current_player = self.initiator
self.current_symbol = 'X'
def check_win(self) -> bool:
s = self.current_symbol
b = self.board
# Rows
for row in b:
if all(cell == s for cell in row):
return True
# Columns
for col in range(3):
if all(b[row][col] == s for row in range(3)):
return True
# Diagonals
if all(b[i][i] == s for i in range(3)):
return True
if all(b[i][2 - i] == s for i in range(3)):
return True
return False
def check_draw(self) -> bool:
return all(cell is not None for row in self.board for cell in row)
async def disable_all_buttons(self):
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
async def update_board_message(self, interaction: discord.Interaction):
content = f"Tic Tac Toe: {self.initiator.mention} (X) vs {self.opponent.mention} (O)\n\nTurn: **{self.current_player.mention} ({self.current_symbol})**"
# Use response.edit_message for button interactions
await interaction.response.edit_message(content=content, view=self)
async def end_game(self, interaction: discord.Interaction, message_content: str):
await self.disable_all_buttons()
# Use response.edit_message as this follows a button click
await interaction.response.edit_message(content=message_content, view=self)
self.stop()
async def on_timeout(self):
if self.message and not self.is_finished():
await self.disable_all_buttons()
timeout_msg = f"Tic Tac Toe game between {self.initiator.mention} and {self.opponent.mention} timed out."
try:
await self.message.edit(content=timeout_msg, view=self)
except discord.NotFound: pass
except discord.Forbidden: pass
self.stop()
# --- Tic Tac Toe Bot Game ---
class BotTicTacToeButton(ui.Button['BotTicTacToeView']):
def __init__(self, x: int, y: int):
super().__init__(style=discord.ButtonStyle.secondary, label='', row=y)
self.x = x
self.y = y
self.position = y * 3 + x # Convert to position index (0-8) for the TicTacToe engine
async def callback(self, interaction: discord.Interaction):
assert self.view is not None
view: BotTicTacToeView = self.view
# Check if it's the player's turn
if interaction.user != view.player:
await interaction.response.send_message("This is not your game!", ephemeral=True)
return
# Try to make the move in the game engine
try:
view.game.play_turn(self.position)
self.label = 'X' # Player is always X
self.style = discord.ButtonStyle.success
self.disabled = True
# Check if game is over after player's move
if view.game.is_game_over():
await view.end_game(interaction)
return
# Now it's the bot's turn - defer without thinking message
await interaction.response.defer()
import asyncio
await asyncio.sleep(1) # Brief pause to simulate bot "thinking"
# Bot makes its move
bot_move = view.game.play_turn() # AI will automatically choose its move
# Update the button for the bot's move
bot_y, bot_x = divmod(bot_move, 3)
for child in view.children:
if isinstance(child, BotTicTacToeButton) and child.x == bot_x and child.y == bot_y:
child.label = 'O' # Bot is always O
child.style = discord.ButtonStyle.danger
child.disabled = True
break
# Check if game is over after bot's move
if view.game.is_game_over():
await view.end_game(interaction)
return
# Update the game board for the next player's turn
await interaction.followup.edit_message(
message_id=view.message.id,
content=f"Tic Tac Toe: {view.player.mention} (X) vs Bot (O) - Difficulty: {view.game.ai_difficulty.capitalize()}\n\nYour turn!",
view=view
)
except ValueError as e:
await interaction.response.send_message(f"Error: {str(e)}", ephemeral=True)
class BotTicTacToeView(ui.View):
def __init__(self, game, player: discord.Member):
super().__init__(timeout=300.0) # 5 minute timeout
self.game = game # Instance of the TicTacToe engine
self.player = player
self.message = None
# Add buttons to the view (3x3 grid)
for y in range(3):
for x in range(3):
self.add_item(BotTicTacToeButton(x, y))
async def disable_all_buttons(self):
for item in self.children:
if isinstance(item, ui.Button):
item.disabled = True
def format_board(self) -> str:
"""Format the game board into a string representation."""
board = self.game.get_board()
rows = []
for i in range(0, 9, 3):
row = board[i:i+3]
# Replace spaces with emoji equivalents for better visualization
row = [cell if cell != ' ' else '' for cell in row]
row = [cell.replace('X', '').replace('O', '') for cell in row]
rows.append(' '.join(row))
return '\n'.join(rows)
async def end_game(self, interaction: discord.Interaction):
await self.disable_all_buttons()
winner = self.game.get_winner()
if winner:
if winner == 'X': # Player wins
content = f"🎉 {self.player.mention} wins! 🎉"
else: # Bot wins
content = f"The bot ({self.game.ai_difficulty.capitalize()}) wins! Better luck next time."
else:
content = "It's a tie! 🤝"
# Convert the board to a visually appealing format
board_display = self.format_board()
# Update the message
try:
await interaction.followup.edit_message(
message_id=self.message.id,
content=f"{content}\n\n{board_display}",
view=self
)
except (discord.NotFound, discord.HTTPException):
# Fallback for interaction timeouts
if self.message:
try:
await self.message.edit(content=f"{content}\n\n{board_display}", view=self)
except: pass
self.stop()
async def on_timeout(self):
if self.message:
await self.disable_all_buttons()
try:
await self.message.edit(
content=f"Tic Tac Toe game for {self.player.mention} timed out.",
view=self
)
except discord.NotFound: pass
except discord.Forbidden: pass
self.stop()

688
cogs/games_cog.py Normal file
View File

@ -0,0 +1,688 @@
import discord
from discord.ext import commands
from discord import app_commands, ui
import random
import asyncio
from typing import Optional, List, Union
import chess
import chess.engine
import chess.pgn
import platform
import os
import io
import ast
# Import game implementations from separate files
from .games.chess_game import (
generate_board_image, MoveInputModal, ChessView, ChessBotView,
get_stockfish_path
)
from .games.coinflip_game import CoinFlipView
from .games.tictactoe_game import TicTacToeView, BotTicTacToeView
from .games.rps_game import RockPaperScissorsView
from .games.basic_games import roll_dice, flip_coin, magic8ball_response, play_hangman
class GamesCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# Store active bot game views to manage engine resources
self.active_chess_bot_views = {} # Store by message ID
self.ttt_games = {} # Store TicTacToe game instances by user ID
def _array_to_fen(self, board_array: List[List[str]], turn: chess.Color) -> str:
"""Converts an 8x8 array representation to a basic FEN string."""
fen_rows = []
for rank_idx in range(8): # Iterate ranks 0-7 (corresponds to 8-1 in FEN)
rank_data = board_array[rank_idx]
fen_row = ""
empty_count = 0
for piece in rank_data: # Iterate files a-h
if piece == ".":
empty_count += 1
else:
if empty_count > 0:
fen_row += str(empty_count)
empty_count = 0
# Validate piece character if needed, assume valid for now
fen_row += piece
if empty_count > 0:
fen_row += str(empty_count)
fen_rows.append(fen_row)
piece_placement = "/".join(fen_rows)
turn_char = 'w' if turn == chess.WHITE else 'b'
# Default castling, no en passant, 0 halfmove, 1 fullmove for simplicity from array
fen = f"{piece_placement} {turn_char} - - 0 1"
return fen
async def cog_unload(self):
"""Clean up resources when the cog is unloaded."""
print("Unloading GamesCog, closing active chess engines...")
# Create a copy of the dictionary items to avoid runtime errors during iteration
views_to_stop = list(self.active_chess_bot_views.values())
for view in views_to_stop:
await view.stop_engine()
view.stop() # Stop the view itself
self.active_chess_bot_views.clear()
print("GamesCog unloaded.")
@app_commands.command(name="coinflipbet", description="Challenge another user to a coin flip game.")
@app_commands.describe(
opponent="The user you want to challenge."
)
async def coinflipbet(self, interaction: discord.Interaction, opponent: discord.Member):
"""Initiates a coin flip game against another user."""
initiator = interaction.user
# --- Input Validation ---
if opponent.bot:
await interaction.response.send_message("You cannot challenge a bot!", ephemeral=True)
return
# --- Start the Game ---
view = CoinFlipView(initiator, opponent)
initial_message = f"{initiator.mention} has challenged {opponent.mention} to a coin flip game! {initiator.mention}, choose your side:"
# Send the initial message and store it in the view
await interaction.response.send_message(initial_message, view=view)
message = await interaction.original_response()
view.message = message
@app_commands.command(name="coinflip", description="Flip a coin and get Heads or Tails.")
async def coinflip(self, interaction: discord.Interaction):
"""Flips a coin and returns Heads or Tails."""
result = flip_coin()
await interaction.response.send_message(f"The coin landed on **{result}**! 🪙")
@app_commands.command(name="roll", description="Roll a dice and get a number between 1 and 6.")
async def roll(self, interaction: discord.Interaction):
"""Rolls a dice and returns a number between 1 and 6."""
result = roll_dice()
await interaction.response.send_message(f"You rolled a **{result}**! 🎲")
@app_commands.command(name="magic8ball", description="Ask the magic 8 ball a question.")
@app_commands.describe(
question="The question you want to ask the magic 8 ball."
)
async def magic8ball(self, interaction: discord.Interaction, question: str):
"""Provides a random response to a yes/no question."""
response = magic8ball_response()
await interaction.response.send_message(f"🎱 {response}")
@app_commands.command(name="rps", description="Play Rock-Paper-Scissors against the bot.")
@app_commands.describe(choice="Your choice: Rock, Paper, or Scissors.")
@app_commands.choices(choice=[
app_commands.Choice(name="Rock 🪨", value="Rock"),
app_commands.Choice(name="Paper 📄", value="Paper"),
app_commands.Choice(name="Scissors ✂️", value="Scissors")
])
async def rps(self, interaction: discord.Interaction, choice: app_commands.Choice[str]):
"""Play Rock-Paper-Scissors against the bot."""
choices = ["Rock", "Paper", "Scissors"]
bot_choice = random.choice(choices)
user_choice = choice.value # Get value from choice
if user_choice == bot_choice:
result = "It's a tie!"
elif (user_choice == "Rock" and bot_choice == "Scissors") or \
(user_choice == "Paper" and bot_choice == "Rock") or \
(user_choice == "Scissors" and bot_choice == "Paper"):
result = "You win! 🎉"
else:
result = "You lose! 😢"
emojis = {
"Rock": "🪨",
"Paper": "📄",
"Scissors": "✂️"
}
await interaction.response.send_message(
f"You chose **{user_choice}** {emojis[user_choice]}\n"
f"I chose **{bot_choice}** {emojis[bot_choice]}\n\n"
f"{result}"
)
@app_commands.command(name="rpschallenge", description="Challenge another user to a game of Rock-Paper-Scissors.")
@app_commands.describe(opponent="The user you want to challenge.")
async def rpschallenge(self, interaction: discord.Interaction, opponent: discord.Member):
"""Starts a Rock-Paper-Scissors game with another user."""
initiator = interaction.user
if opponent == initiator:
await interaction.response.send_message("You cannot challenge yourself!", ephemeral=True)
return
if opponent.bot:
await interaction.response.send_message("You cannot challenge a bot!", ephemeral=True)
return
view = RockPaperScissorsView(initiator, opponent)
initial_message = f"Rock Paper Scissors: {initiator.mention} vs {opponent.mention}\n\nChoose your move!"
await interaction.response.send_message(initial_message, view=view)
message = await interaction.original_response()
view.message = message
@app_commands.command(name="guess", description="Guess the number I'm thinking of (1-100).")
@app_commands.describe(guess="Your guess (1-100).")
async def guess(self, interaction: discord.Interaction, guess: int):
"""Guess the number the bot is thinking of."""
# Simple implementation: generate number per guess (no state needed)
number_to_guess = random.randint(1, 100)
if guess < 1 or guess > 100:
await interaction.response.send_message("Please guess a number between 1 and 100.", ephemeral=True)
return
if guess == number_to_guess:
await interaction.response.send_message(f"🎉 Correct! The number was **{number_to_guess}**.")
elif guess < number_to_guess:
await interaction.response.send_message(f"Too low! The number was {number_to_guess}.")
else:
await interaction.response.send_message(f"Too high! The number was {number_to_guess}.")
@app_commands.command(name="hangman", description="Play a game of Hangman.")
async def hangman(self, interaction: discord.Interaction):
"""Play a game of Hangman."""
await play_hangman(self.bot, interaction.channel, interaction.user)
@app_commands.command(name="tictactoe", description="Challenge another user to a game of Tic-Tac-Toe.")
@app_commands.describe(opponent="The user you want to challenge.")
async def tictactoe(self, interaction: discord.Interaction, opponent: discord.Member):
"""Starts a Tic-Tac-Toe game with another user."""
initiator = interaction.user
if opponent == initiator:
await interaction.response.send_message("You cannot challenge yourself!", ephemeral=True)
return
if opponent.bot:
await interaction.response.send_message("You cannot challenge a bot! Use `/tictactoebot` instead.", ephemeral=True)
return
view = TicTacToeView(initiator, opponent)
initial_message = f"Tic Tac Toe: {initiator.mention} (X) vs {opponent.mention} (O)\n\nTurn: **{initiator.mention} (X)**"
await interaction.response.send_message(initial_message, view=view)
message = await interaction.original_response()
view.message = message # Store message for timeout handling
@app_commands.command(name="tictactoebot", description="Play a game of Tic-Tac-Toe against the bot.")
@app_commands.describe(difficulty="Bot difficulty: random, rule, or minimax (default: minimax)")
@app_commands.choices(difficulty=[
app_commands.Choice(name="Random (Easy)", value="random"),
app_commands.Choice(name="Rule-based (Medium)", value="rule"),
app_commands.Choice(name="Minimax (Hard)", value="minimax")
])
async def tictactoebot(self, interaction: discord.Interaction, difficulty: app_commands.Choice[str] = None):
"""Play a game of Tic-Tac-Toe against the bot."""
# Use default if no choice is made (discord.py handles default value assignment)
difficulty_value = difficulty.value if difficulty else "minimax"
# Ensure tictactoe module is importable
try:
import sys
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.append(parent_dir)
from tictactoe import TicTacToe # Assuming tictactoe.py is in the parent directory
except ImportError:
await interaction.response.send_message("Error: TicTacToe game engine module not found.", ephemeral=True)
return
except Exception as e:
await interaction.response.send_message(f"Error importing TicTacToe module: {e}", ephemeral=True)
return
# Create a new game instance
try:
game = TicTacToe(ai_player='O', ai_difficulty=difficulty_value)
except Exception as e:
await interaction.response.send_message(f"Error initializing TicTacToe game: {e}", ephemeral=True)
return
# Create a view for the user interface
view = BotTicTacToeView(game, interaction.user)
await interaction.response.send_message(
f"Tic Tac Toe: {interaction.user.mention} (X) vs Bot (O) - Difficulty: {difficulty_value.capitalize()}\n\nYour turn!",
view=view
)
view.message = await interaction.original_response()
@app_commands.command(name="chess", description="Challenge another user to a game of chess.")
@app_commands.describe(opponent="The user you want to challenge.")
async def chess(self, interaction: discord.Interaction, opponent: discord.Member):
"""Start a game of chess with another user."""
initiator = interaction.user
if opponent == initiator:
await interaction.response.send_message("You cannot challenge yourself!", ephemeral=True)
return
if opponent.bot:
await interaction.response.send_message("You cannot challenge a bot! Use `/chessbot` instead.", ephemeral=True)
return
# Initiator is white, opponent is black
view = ChessView(initiator, opponent)
initial_status = f"Turn: **{initiator.mention}** (White)"
initial_message = f"Chess: {initiator.mention} (White) vs {opponent.mention} (Black)\n\n{initial_status}"
board_image = generate_board_image(view.board) # Generate initial board image
await interaction.response.send_message(initial_message, file=board_image, view=view)
message = await interaction.original_response()
view.message = message
# Send initial DMs
asyncio.create_task(view._send_or_update_dm(view.white_player))
asyncio.create_task(view._send_or_update_dm(view.black_player))
@app_commands.command(name="chessbot", description="Play chess against the bot.")
@app_commands.describe(
color="Choose your color (default: White).",
variant="Choose the chess variant (default: Standard).",
skill_level="Bot skill level (0=Easy - 20=Hard, default: 10).",
think_time="Bot thinking time per move in seconds (0.1 - 5.0, default: 1.0)."
)
@app_commands.choices(
color=[
app_commands.Choice(name="White", value="white"),
app_commands.Choice(name="Black", value="black"),
],
variant=[
app_commands.Choice(name="Standard", value="standard"),
app_commands.Choice(name="Chess960 (Fischer Random)", value="chess960"),
# Add more variants here as supported
]
)
async def chessbot(self, interaction: discord.Interaction, color: app_commands.Choice[str] = None, variant: app_commands.Choice[str] = None, skill_level: int = 10, think_time: float = 1.0):
"""Starts a chess game against the Stockfish engine."""
player = interaction.user
player_color_str = color.value if color else "white"
variant_str = variant.value if variant else "standard"
player_color = chess.WHITE if player_color_str == "white" else chess.BLACK
# Validate inputs
skill_level = max(0, min(20, skill_level))
think_time = max(0.1, min(5.0, think_time))
# Check if variant is supported (currently standard and chess960)
supported_variants = ["standard", "chess960"]
if variant_str not in supported_variants:
await interaction.response.send_message(f"Sorry, the variant '{variant_str}' is not currently supported. Choose from: {', '.join(supported_variants)}", ephemeral=True)
return
# Defer response as engine start might take a moment
await interaction.response.defer()
view = ChessBotView(player, player_color, variant_str, skill_level, think_time)
# Start the engine asynchronously
# Store interaction temporarily for potential error reporting during init
view._interaction = interaction
await view.start_engine()
del view._interaction # Remove temporary attribute
if view.engine is None or view.is_finished(): # Check if engine failed or view stopped during init
# Error message should have been sent by start_engine or view stopped itself
# Ensure we don't try to send another response if already handled
# No need to send another message here, start_engine handles it.
print("ChessBotView: Engine failed to start, stopping command execution.")
return # Stop if engine failed
# Determine initial message based on who moves first
initial_status_prefix = "Your turn." if player_color == chess.WHITE else "Bot is thinking..."
initial_message_content = view.get_board_message(initial_status_prefix)
board_image = generate_board_image(view.board, perspective_white=(player_color == chess.WHITE))
# Send the initial game state using followup
message = await interaction.followup.send(initial_message_content, file=board_image, view=view, wait=True)
view.message = message
self.active_chess_bot_views[message.id] = view # Track the view
# Send initial DM to player
asyncio.create_task(view._send_or_update_dm())
# If bot moves first (player chose black), trigger its move
if player_color == chess.BLACK:
# Don't await this, let it run in the background
asyncio.create_task(view.make_bot_move())
@app_commands.command(name="loadchess", description="Load a chess game from FEN, PGN, or array representation.")
@app_commands.describe(
state="FEN string, PGN string, or board array (e.g., [['r',...],...]).",
turn="Whose turn? ('white' or 'black'). Required only for array state.",
opponent="Challenge a user (optional, defaults to playing the bot).",
color="Your color vs bot (White/Black). Required if playing vs bot.",
skill_level="Bot skill level (0-20, default: 10).",
think_time="Bot think time (0.1-5.0, default: 1.0)."
)
@app_commands.choices(
turn=[app_commands.Choice(name="White", value="white"), app_commands.Choice(name="Black", value="black")],
color=[app_commands.Choice(name="White", value="white"), app_commands.Choice(name="Black", value="black")]
)
async def loadchess(self, interaction: discord.Interaction,
state: str,
turn: Optional[app_commands.Choice[str]] = None,
opponent: Optional[discord.Member] = None,
color: Optional[app_commands.Choice[str]] = None, # Now required for bot games
skill_level: int = 10,
think_time: float = 1.0):
"""Loads a chess game state (FEN, PGN, Array) and starts a view."""
await interaction.response.defer()
initiator = interaction.user
board = None
load_error = None
loaded_pgn_game = None # To store the loaded PGN game object if parsed
# --- Input Validation ---
if not opponent and not color:
await interaction.followup.send("The 'color' parameter is required when playing against the bot.", ephemeral=True)
return
# --- Parsing Logic ---
state_trimmed = state.strip()
# 1. Try parsing as PGN
if state_trimmed.startswith("[Event") or ('.' in state_trimmed and ('O-O' in state_trimmed or 'x' in state_trimmed or state_trimmed[0].isdigit())):
try:
pgn_io = io.StringIO(state_trimmed)
loaded_pgn_game = chess.pgn.read_game(pgn_io)
if loaded_pgn_game is None:
raise ValueError("Could not parse PGN data.")
# Get the board state from the end of the main line
board = loaded_pgn_game.end().board()
print("[Debug] Parsed as PGN.")
except Exception as e:
load_error = f"Could not parse as PGN: {e}. Trying other formats."
print(f"[Debug] PGN parsing failed: {e}")
loaded_pgn_game = None # Reset if PGN parsing failed
# 2. Try parsing as FEN (if not already parsed as PGN)
if board is None and '/' in state_trimmed and (' w ' in state_trimmed or ' b ' in state_trimmed):
try:
board = chess.Board(fen=state_trimmed)
print(f"[Debug] Parsed as FEN: {state_trimmed}")
except ValueError as e:
load_error = f"Invalid FEN string: {e}. Trying array format."
print(f"[Error] FEN parsing failed: {e}")
except Exception as e:
load_error = f"Unexpected FEN parsing error: {e}. Trying array format."
print(f"[Error] Unexpected FEN parsing error: {e}")
# 3. Try parsing as Array (if not parsed as PGN or FEN)
if board is None:
try:
# Check if it looks like a list before eval
if not state_trimmed.startswith('[') or not state_trimmed.endswith(']'):
raise ValueError("Input does not look like a list array.")
board_array = ast.literal_eval(state_trimmed)
print("[Debug] Attempting to parse as array...")
if not isinstance(board_array, list) or len(board_array) != 8 or \
not all(isinstance(row, list) and len(row) == 8 for row in board_array):
raise ValueError("Invalid array structure. Must be 8x8 list.")
if not turn:
load_error = "The 'turn' parameter is required when providing a board array."
else:
turn_color = chess.WHITE if turn.value == "white" else chess.BLACK
fen = self._array_to_fen(board_array, turn_color)
print(f"[Debug] Converted array to FEN: {fen}")
board = chess.Board(fen=fen)
except (ValueError, SyntaxError, TypeError) as e:
# If PGN/FEN failed, this is the final error message
load_error = f"Invalid state format. Could not parse as PGN, FEN, or Python list array. Error: {e}"
print(f"[Error] Array parsing failed: {e}")
except Exception as e:
load_error = f"Error parsing array state: {e}"
print(f"[Error] Unexpected array parsing error: {e}")
# --- Final Check and Error Handling ---
if board is None:
final_error = load_error or "Failed to load board state from the provided input."
await interaction.followup.send(final_error, ephemeral=True)
return
# --- Game Setup ---
if opponent:
# Player vs Player
if opponent == initiator:
await interaction.followup.send("You cannot challenge yourself!", ephemeral=True)
return
if opponent.bot:
await interaction.followup.send("You cannot challenge a bot! Use `/chessbot` or load without opponent.", ephemeral=True)
return
white_player = initiator if board.turn == chess.WHITE else opponent
black_player = opponent if board.turn == chess.WHITE else initiator
view = ChessView(white_player, black_player, board=board) # Pass loaded board
# If loaded from PGN, set the game object in the view
if loaded_pgn_game:
view.game_pgn = loaded_pgn_game
view.pgn_node = loaded_pgn_game.end() # Start from the end node
current_player_mention = white_player.mention if board.turn == chess.WHITE else black_player.mention
turn_color_name = "White" if board.turn == chess.WHITE else "Black"
initial_status = f"Turn: **{current_player_mention}** ({turn_color_name})"
if board.is_check(): initial_status += " **Check!**"
initial_message = f"Loaded Chess Game: {white_player.mention} (White) vs {black_player.mention} (Black)\n\n{initial_status}"
perspective_white = (board.turn == chess.WHITE)
board_image = generate_board_image(view.board, perspective_white=perspective_white)
message = await interaction.followup.send(initial_message, file=board_image, view=view, wait=True)
view.message = message
# Send initial DMs
asyncio.create_task(view._send_or_update_dm(view.white_player))
asyncio.create_task(view._send_or_update_dm(view.black_player))
else:
# Player vs Bot
player = initiator
# Color is now required, checked at the start
player_color = chess.WHITE if color.value == "white" else chess.BLACK
skill_level = max(0, min(20, skill_level))
think_time = max(0.1, min(5.0, think_time))
variant_str = "chess960" if board.chess960 else "standard"
view = ChessBotView(player, player_color, variant_str, skill_level, think_time, board=board) # Pass loaded board
# If loaded from PGN, set the game object in the view
if loaded_pgn_game:
view.game_pgn = loaded_pgn_game
view.pgn_node = loaded_pgn_game.end() # Start from the end node
view._interaction = interaction # For error reporting during start
await view.start_engine()
if hasattr(view, '_interaction'): del view._interaction
if view.engine is None or view.is_finished():
print("ChessBotView (Load): Engine failed to start, stopping command execution.")
return
status_prefix = "Your turn." if board.turn == player_color else "Bot is thinking..."
initial_message_content = view.get_board_message(status_prefix)
board_image = generate_board_image(view.board, perspective_white=(player_color == chess.WHITE))
message = await interaction.followup.send(initial_message_content, file=board_image, view=view, wait=True)
view.message = message
self.active_chess_bot_views[message.id] = view
# Send initial DM to player
asyncio.create_task(view._send_or_update_dm())
if board.turn != player_color:
asyncio.create_task(view.make_bot_move())
# --- Prefix Commands (Legacy Support) ---
@commands.command(name="coinflipbet")
async def coinflipbet_prefix(self, ctx: commands.Context, opponent: discord.Member):
"""(Prefix) Challenge another user to a coin flip game."""
initiator = ctx.author
if opponent.bot:
await ctx.send("You cannot challenge a bot!")
return
view = CoinFlipView(initiator, opponent)
initial_message = f"{initiator.mention} has challenged {opponent.mention} to a coin flip game! {initiator.mention}, choose your side:"
message = await ctx.send(initial_message, view=view)
view.message = message
@commands.command(name="coinflip")
async def coinflip_prefix(self, ctx: commands.Context):
"""(Prefix) Flip a coin."""
result = flip_coin()
await ctx.send(f"The coin landed on **{result}**! 🪙")
@commands.command(name="roll")
async def roll_prefix(self, ctx: commands.Context):
"""(Prefix) Roll a dice."""
result = roll_dice()
await ctx.send(f"You rolled a **{result}**! 🎲")
@commands.command(name="magic8ball")
async def magic8ball_prefix(self, ctx: commands.Context, *, question: str):
"""(Prefix) Ask the magic 8 ball."""
response = magic8ball_response()
await ctx.send(f"🎱 {response}")
@commands.command(name="tictactoe")
async def tictactoe_prefix(self, ctx: commands.Context, opponent: discord.Member):
"""(Prefix) Challenge another user to Tic-Tac-Toe."""
initiator = ctx.author
if opponent.bot:
await ctx.send("You cannot challenge a bot! Use `!tictactoebot` instead.")
return
view = TicTacToeView(initiator, opponent)
initial_message = f"Tic Tac Toe: {initiator.mention} (X) vs {opponent.mention} (O)\n\nTurn: **{initiator.mention} (X)**"
message = await ctx.send(initial_message, view=view)
view.message = message
@commands.command(name="tictactoebot")
async def tictactoebot_prefix(self, ctx: commands.Context, difficulty: str = "minimax"):
"""(Prefix) Play Tic-Tac-Toe against the bot."""
difficulty_value = difficulty.lower()
valid_difficulties = ["random", "rule", "minimax"]
if difficulty_value not in valid_difficulties:
await ctx.send(f"Invalid difficulty! Choose from: {', '.join(valid_difficulties)}")
return
try:
import sys
import os
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.append(parent_dir)
from tictactoe import TicTacToe
except ImportError:
await ctx.send("Error: TicTacToe game engine module not found.")
return
except Exception as e:
await ctx.send(f"Error importing TicTacToe module: {e}")
return
try:
game = TicTacToe(ai_player='O', ai_difficulty=difficulty_value)
except Exception as e:
await ctx.send(f"Error initializing TicTacToe game: {e}")
return
view = BotTicTacToeView(game, ctx.author)
message = await ctx.send(
f"Tic Tac Toe: {ctx.author.mention} (X) vs Bot (O) - Difficulty: {difficulty_value.capitalize()}\n\nYour turn!",
view=view
)
view.message = message
@commands.command(name="rpschallenge")
async def rpschallenge_prefix(self, ctx: commands.Context, opponent: discord.Member):
"""(Prefix) Challenge another user to Rock-Paper-Scissors."""
initiator = ctx.author
if opponent.bot:
await ctx.send("You cannot challenge a bot!")
return
view = RockPaperScissorsView(initiator, opponent)
initial_message = f"Rock Paper Scissors: {initiator.mention} vs {opponent.mention}\n\nChoose your move!"
message = await ctx.send(initial_message, view=view)
view.message = message
@commands.command(name="rps")
async def rps_prefix(self, ctx: commands.Context, choice: str):
"""(Prefix) Play Rock-Paper-Scissors against the bot."""
choices = ["Rock", "Paper", "Scissors"]
bot_choice = random.choice(choices)
user_choice = choice.capitalize()
if user_choice not in choices:
await ctx.send("Invalid choice! Please choose Rock, Paper, or Scissors.")
return
# Identical logic to slash command, just using ctx.send
if user_choice == bot_choice:
result = "It's a tie!"
elif (user_choice == "Rock" and bot_choice == "Scissors") or \
(user_choice == "Paper" and bot_choice == "Rock") or \
(user_choice == "Scissors" and bot_choice == "Paper"):
result = "You win! 🎉"
else:
result = "You lose! 😢"
emojis = { "Rock": "🪨", "Paper": "📄", "Scissors": "✂️" }
await ctx.send(
f"You chose **{user_choice}** {emojis[user_choice]}\n"
f"I chose **{bot_choice}** {emojis[bot_choice]}\n\n"
f"{result}"
)
@commands.command(name="chess")
async def chess_prefix(self, ctx: commands.Context, opponent: discord.Member):
"""(Prefix) Start a game of chess with another user."""
initiator = ctx.author
if opponent.bot:
await ctx.send("You cannot challenge a bot! Use `!chessbot` instead.")
return
view = ChessView(initiator, opponent)
initial_status = f"Turn: **{initiator.mention}** (White)"
initial_message = f"Chess: {initiator.mention} (White) vs {opponent.mention} (Black)\n\n{initial_status}"
board_image = generate_board_image(view.board)
message = await ctx.send(initial_message, file=board_image, view=view)
view.message = message
# Send initial DMs
asyncio.create_task(view._send_or_update_dm(view.white_player))
asyncio.create_task(view._send_or_update_dm(view.black_player))
@commands.command(name="hangman")
async def hangman_prefix(self, ctx: commands.Context):
"""(Prefix) Play a game of Hangman."""
await play_hangman(self.bot, ctx.channel, ctx.author)
@commands.command(name="guess")
async def guess_prefix(self, ctx: commands.Context, guess: int):
"""(Prefix) Guess a number between 1 and 100."""
number_to_guess = random.randint(1, 100)
if guess < 1 or guess > 100:
await ctx.send("Please guess a number between 1 and 100.")
return
if guess == number_to_guess:
await ctx.send(f"🎉 Correct! The number was **{number_to_guess}**.")
elif guess < number_to_guess:
await ctx.send(f"Too low! The number was {number_to_guess}.")
else:
await ctx.send(f"Too high! The number was {number_to_guess}.")
async def setup(bot: commands.Bot):
await bot.add_cog(GamesCog(bot))

3989
cogs/gurt_cog.py Normal file

File diff suppressed because it is too large Load Diff

456
cogs/help_cog.py Normal file
View File

@ -0,0 +1,456 @@
import discord
from discord.ext import commands
from discord import app_commands
import asyncio
# Define friendly names for cogs
COG_DISPLAY_NAMES = {
"AICog": "🤖 AI Chat",
"AudioCog": "🎵 Audio Player",
"GamesCog": "🎮 Games",
"HelpCog": "❓ Help",
"MultiConversationCog": "🤖 Multi-Conversation AI Chat",
"MessageCog": "💬 Messages",
"LevelingCog": "⭐ Leveling System",
"MarriageCog": "💍 Marriage System",
"ModerationCog": "🛡️ Moderation",
"PingCog": "🏓 Ping",
"RandomCog": "🎲 Random Image (NSFW)",
"RoleCreatorCog": "✨ Role Management (Owner Only)",
"RoleSelectorCog": "🎭 Role Selection (Owner Only)",
"RoleplayCog": "💋 Roleplay",
"Rule34Cog": "🔞 Rule34 Search (NSFW)",
"ShellCommandCog": "🖥️ Shell Command",
"SystemCheckCog": "📊 System Status",
"WebdriverTorsoCog": "🌐 Webdriver Torso",
"CommandDebugCog": "🐛 Command Debug (Owner Only)",
"CommandFixCog": "🐛 Command Fix (Owner Only)",
"TTSProviderCog": "🗣️ TTS Provider",
"RandomTimeoutCog": "⏰ Random Timeout",
"SyncCog": "🔄 Command Sync (Owner Only)",
# Add other cogs here as needed
}
class HelpSelect(discord.ui.Select):
def __init__(self, view: 'HelpView', start_index=0, max_options=24):
self.help_view = view
# Always include General Overview option
options = [discord.SelectOption(label="General Overview", description="Go back to the main help page.", value="-1")] # Value -1 for overview page
# Calculate end index, ensuring we don't go past the end of the cogs list
end_index = min(start_index + max_options, len(view.cogs))
# Add cog options for this page of the select menu
for i in range(start_index, end_index):
cog = view.cogs[i]
display_name = COG_DISPLAY_NAMES.get(cog.qualified_name, cog.qualified_name)
# Truncate description if too long for Discord API limit (100 chars)
# Use a relative index (i - start_index) as the value to avoid confusion
# when navigating between pages
relative_index = i - start_index
options.append(discord.SelectOption(label=display_name, value=str(relative_index)))
# Store the range of cogs this select menu covers
self.start_index = start_index
self.end_index = end_index
super().__init__(placeholder="Select a category...", min_values=1, max_values=1, options=options)
async def callback(self, interaction: discord.Interaction):
selected_value = int(self.values[0])
if selected_value == -1: # General Overview selected
self.help_view.current_page = 0
else:
# The value is a relative index (0-based) within the current page of options
# We need to convert it to an absolute index in the cogs list
actual_cog_index = selected_value + self.start_index
# Debug information
print(f"Selected value: {selected_value}, start_index: {self.start_index}, actual_cog_index: {actual_cog_index}")
# Make sure the index is valid
if 0 <= actual_cog_index < len(self.help_view.cogs):
self.help_view.current_page = actual_cog_index + 1 # +1 because page 0 is overview
else:
# If the index is invalid, go to the overview page
self.help_view.current_page = 0
await interaction.response.send_message(f"That category is no longer available. Showing overview. (Debug: value={selected_value}, start={self.start_index}, actual={actual_cog_index}, max={len(self.help_view.cogs)})", ephemeral=True)
# Ensure current_page is within valid range
if self.help_view.current_page >= len(self.help_view.pages):
self.help_view.current_page = 0
self.help_view._update_buttons()
self.help_view._update_select_menu()
# Update the placeholder to show the current selection
if self.help_view.current_page == 0:
current_option_label = "General Overview"
else:
cog_index = self.help_view.current_page - 1
if 0 <= cog_index < len(self.help_view.cogs):
current_option_label = COG_DISPLAY_NAMES.get(self.help_view.cogs[cog_index].qualified_name, self.help_view.cogs[cog_index].qualified_name)
else:
current_option_label = "Select a category..."
self.placeholder = current_option_label
try:
await interaction.response.edit_message(embed=self.help_view.pages[self.help_view.current_page], view=self.help_view)
except Exception as e:
# If we can't edit the message, try to defer or send a new message
try:
await interaction.response.defer()
print(f"Error in help command: {e}")
except:
pass
class HelpView(discord.ui.View):
def __init__(self, bot: commands.Bot, timeout=180):
super().__init__(timeout=timeout)
self.bot = bot
self.current_page = 0 # Current page in the embed pages
self.current_select_page = 0 # Current page of the select menu
self.max_select_options = 24 # Maximum number of cog options per select menu (25 total with General Overview)
# Filter cogs and sort them using the display name mapping
self.cogs = sorted(
[cog for _, cog in bot.cogs.items() if cog.get_commands()],
key=lambda cog: COG_DISPLAY_NAMES.get(cog.qualified_name, cog.qualified_name) # Sort alphabetically by display name
)
# Calculate total number of select menu pages needed
self.total_select_pages = (len(self.cogs) + self.max_select_options - 1) // self.max_select_options
# Create pages after total_select_pages is defined
self.pages = self._create_pages()
# Add components in order: Select, Previous/Next Page, Previous/Next Category
self._update_select_menu() # Initialize the select menu with the first page of options
# Buttons are added via decorators later
self._update_buttons() # Initial button state
def _create_overview_page(self):
# Create the overview page (page 0)
embed = discord.Embed(
title="Help Command",
description=f"Use the buttons below to navigate through command categories.\nTotal Categories: {len(self.cogs)}\nUse the Categories buttons to navigate between pages of categories.",
color=discord.Color.blue()
)
# Calculate how many cogs are shown in the current select page
start_index = self.current_select_page * self.max_select_options
end_index = min(start_index + self.max_select_options, len(self.cogs))
current_range = f"{start_index + 1}-{end_index}" if len(self.cogs) > self.max_select_options else f"1-{len(self.cogs)}"
# Add information about which cogs are currently visible
if len(self.cogs) > self.max_select_options:
embed.add_field(
name="Currently Showing",
value=f"Categories {current_range} of {len(self.cogs)}",
inline=False
)
embed.set_footer(text="Page 0 / {} | Category Page {} / {}".format(len(self.cogs), self.current_select_page + 1, self.total_select_pages))
return embed
def _create_pages(self):
pages = []
# Page 0: General overview
pages.append(self._create_overview_page())
# Subsequent pages: One per cog
for i, cog in enumerate(self.cogs):
try:
cog_name = cog.qualified_name
# Get the friendly display name, falling back to the original name
display_name = COG_DISPLAY_NAMES.get(cog_name, cog_name)
cog_commands = cog.get_commands()
embed = discord.Embed(
title=f"{display_name} Commands", # Use the display name here
description=f"Commands available in the {display_name} category:",
color=discord.Color.green() # Or assign colors dynamically
)
for command in cog_commands:
# Skip subcommands for now, just show top-level commands in the cog
if isinstance(command, commands.Group):
# If it's a group, list its subcommands or just the group name
sub_cmds = ", ".join([f"`{sub.name}`" for sub in command.commands])
if sub_cmds:
embed.add_field(name=f"`{command.name}` (Group)", value=f"Subcommands: {sub_cmds}\n{command.short_doc or 'No description'}", inline=False)
else:
embed.add_field(name=f"`{command.name}` (Group)", value=f"{command.short_doc or 'No description'}", inline=False)
elif command.parent is None: # Only show top-level commands
signature = f"{command.name} {command.signature}"
embed.add_field(
name=f"`{signature.strip()}`",
value=command.short_doc or "No description provided.",
inline=False
)
embed.set_footer(text=f"Page {i + 1} / {len(self.cogs)} | Category Page {self.current_select_page + 1} / {self.total_select_pages}")
pages.append(embed)
except Exception as e:
# If there's an error creating a page for a cog, log it and continue
print(f"Error creating help page for cog {i}: {e}")
# Create a simple error page for this cog
error_embed = discord.Embed(
title=f"Error displaying commands",
description=f"There was an error displaying commands for this category.\nPlease try again or contact the bot owner if the issue persists.",
color=discord.Color.red()
)
error_embed.set_footer(text=f"Page {i + 1} / {len(self.cogs)} | Category Page {self.current_select_page + 1} / {self.total_select_pages}")
pages.append(error_embed)
return pages
def _update_select_menu(self):
# Remove existing select menu if it exists
for item in self.children.copy():
if isinstance(item, HelpSelect):
self.remove_item(item)
# Calculate the starting index for this page of the select menu
start_index = self.current_select_page * self.max_select_options
# Check if the currently selected cog is in the current select page range
current_cog_in_view = False
if self.current_page > 0: # If a cog is selected (not overview)
cog_index = self.current_page - 1 # Convert page to cog index
# Check if this cog is in the current select page range
if start_index <= cog_index < start_index + self.max_select_options:
current_cog_in_view = True
# If the current cog is not in view and we're not on the overview page,
# adjust the select page to include the current cog
if not current_cog_in_view and self.current_page > 0:
cog_index = self.current_page - 1
self.current_select_page = cog_index // self.max_select_options
# Recalculate start_index
start_index = self.current_select_page * self.max_select_options
# Create and add the new select menu
self.select_menu = HelpSelect(self, start_index, self.max_select_options)
self.add_item(self.select_menu)
# Update the placeholder to show the current selection
if self.current_page == 0:
current_option_label = "General Overview"
else:
cog_index = self.current_page - 1
if 0 <= cog_index < len(self.cogs):
current_option_label = COG_DISPLAY_NAMES.get(self.cogs[cog_index].qualified_name, self.cogs[cog_index].qualified_name)
else:
current_option_label = "Select a category..."
self.select_menu.placeholder = current_option_label
def _update_buttons(self):
# Find the buttons by their custom_id
prev_page_button = None
next_page_button = None
prev_category_button = None
next_category_button = None
# First check if buttons have been added yet
if len(self.children) <= 1: # Only select menu exists
return # Buttons will be added by decorators later
for item in self.children:
if hasattr(item, 'custom_id'):
if item.custom_id == 'prev_page':
prev_page_button = item
elif item.custom_id == 'next_page':
next_page_button = item
elif item.custom_id == 'prev_category':
prev_category_button = item
elif item.custom_id == 'next_category':
next_category_button = item
# Update page navigation buttons
if prev_page_button:
prev_page_button.disabled = self.current_page == 0
if next_page_button:
next_page_button.disabled = self.current_page == len(self.pages) - 1
# Update category navigation buttons
if prev_category_button:
prev_category_button.disabled = self.current_select_page == 0
if next_category_button:
next_category_button.disabled = self.current_select_page == self.total_select_pages - 1
@discord.ui.button(label="Previous", style=discord.ButtonStyle.grey, row=1, custom_id="prev_page")
async def previous_button(self, interaction: discord.Interaction, _: discord.ui.Button):
if self.current_page > 0:
self.current_page -= 1
self._update_buttons()
self._update_select_menu()
# Ensure current_page is within valid range
if self.current_page >= len(self.pages):
self.current_page = 0
try:
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
except Exception as e:
try:
await interaction.response.defer()
print(f"Error in help command previous button: {e}")
except:
pass
else:
await interaction.response.defer()
@discord.ui.button(label="Next", style=discord.ButtonStyle.grey, row=1, custom_id="next_page")
async def next_button(self, interaction: discord.Interaction, _: discord.ui.Button):
if self.current_page < len(self.pages) - 1:
self.current_page += 1
self._update_buttons()
self._update_select_menu()
# Ensure current_page is within valid range
if self.current_page >= len(self.pages):
self.current_page = 0
try:
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
except Exception as e:
try:
await interaction.response.defer()
print(f"Error in help command next button: {e}")
except:
pass
else:
await interaction.response.defer()
@discord.ui.button(label="◀ Categories", style=discord.ButtonStyle.primary, row=2, custom_id="prev_category")
async def prev_category_button(self, interaction: discord.Interaction, _: discord.ui.Button):
if self.current_select_page > 0:
# Store the current page before updating
old_page = self.current_page
# Update the select page
self.current_select_page -= 1
# If we're on a cog page, check if we need to adjust the current page
if old_page > 0:
cog_index = old_page - 1
start_index = self.current_select_page * self.max_select_options
end_index = min(start_index + self.max_select_options, len(self.cogs))
# If the current cog is no longer in the visible range, go to the overview page
if cog_index < start_index or cog_index >= end_index:
self.current_page = 0
# Update UI elements
self._update_buttons()
self._update_select_menu()
# If on the overview page, recreate it to update the category information
if self.current_page == 0:
# Recreate the overview page with updated category info
self.pages[0] = self._create_overview_page()
# Ensure current_page is within valid range
if self.current_page >= len(self.pages):
self.current_page = 0
try:
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
except Exception as e:
try:
await interaction.response.defer()
print(f"Error in help command prev category button: {e}")
except:
pass
else:
await interaction.response.defer()
@discord.ui.button(label="Categories ▶", style=discord.ButtonStyle.primary, row=2, custom_id="next_category")
async def next_category_button(self, interaction: discord.Interaction, _: discord.ui.Button):
if self.current_select_page < self.total_select_pages - 1:
# Store the current page before updating
old_page = self.current_page
# Update the select page
self.current_select_page += 1
# If we're on a cog page, check if we need to adjust the current page
if old_page > 0:
cog_index = old_page - 1
start_index = self.current_select_page * self.max_select_options
end_index = min(start_index + self.max_select_options, len(self.cogs))
# If the current cog is no longer in the visible range, go to the overview page
if cog_index < start_index or cog_index >= end_index:
self.current_page = 0
# Update UI elements
self._update_buttons()
self._update_select_menu()
# If on the overview page, recreate it to update the category information
if self.current_page == 0:
# Recreate the overview page with updated category info
self.pages[0] = self._create_overview_page()
# Ensure current_page is within valid range
if self.current_page >= len(self.pages):
self.current_page = 0
try:
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
except Exception as e:
try:
await interaction.response.defer()
print(f"Error in help command next category button: {e}")
except:
pass
else:
await interaction.response.defer()
class HelpCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# Remove the default help command before adding the custom one
original_help_command = bot.get_command('help')
if original_help_command:
bot.remove_command(original_help_command.name)
@commands.hybrid_command(name="help", description="Shows this help message.")
async def help_command(self, ctx: commands.Context, command_name: str = None):
"""Displays an interactive help message with command categories or details about a specific command."""
try:
if command_name:
command = self.bot.get_command(command_name)
if command:
embed = discord.Embed(
title=f"Help for `{command.name}`",
description=command.help or "No detailed description provided.",
color=discord.Color.blue()
)
embed.add_field(name="Usage", value=f"`{command.name} {command.signature}`", inline=False)
if isinstance(command, commands.Group):
subcommands = "\n".join([f"`{sub.name}`: {sub.short_doc or 'No description'}" for sub in command.commands])
embed.add_field(name="Subcommands", value=subcommands or "None", inline=False)
await ctx.send(embed=embed, ephemeral=True)
else:
await ctx.send(f"Command `{command_name}` not found.", ephemeral=True)
else:
view = HelpView(self.bot)
await ctx.send(embed=view.pages[0], view=view, ephemeral=True) # Send ephemeral so only user sees it
except Exception as e:
# If there's an error, send a simple error message
print(f"Error in help command: {e}")
await ctx.send(f"An error occurred while displaying the help command. Please try again or contact the bot owner if the issue persists.", ephemeral=True)
@commands.Cog.listener()
async def on_ready(self):
print(f'{self.__class__.__name__} cog has been loaded.')
async def setup(bot: commands.Bot):
# Ensure the cog is added only after the bot is ready enough to have cogs attribute
# Or handle potential race conditions if setup is called very early
await bot.add_cog(HelpCog(bot))

876
cogs/leveling_cog.py Normal file
View File

@ -0,0 +1,876 @@
import discord
from discord.ext import commands
import json
import os
import asyncio
import random
import math
from typing import Dict, List, Optional, Union, Set
# File paths for JSON data
LEVELS_FILE = "levels_data.json"
LEVEL_ROLES_FILE = "level_roles.json"
RESTRICTED_CHANNELS_FILE = "level_restricted_channels.json"
LEVEL_CONFIG_FILE = "level_config.json"
# Default XP settings
DEFAULT_XP_PER_MESSAGE = 15
DEFAULT_XP_PER_REACTION = 5
DEFAULT_XP_COOLDOWN = 30 # seconds
DEFAULT_REACTION_COOLDOWN = 30 # seconds
DEFAULT_LEVEL_MULTIPLIER = 35 # XP needed per level = level * multiplier
class LevelingCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.user_data = {} # {user_id: {"xp": int, "level": int, "last_message_time": float}}
self.level_roles = {} # {guild_id: {level: role_id}}
self.restricted_channels = set() # Set of channel IDs where XP gain is disabled
self.xp_cooldowns = {} # {user_id: last_xp_time}
self.reaction_cooldowns = {} # {user_id: last_reaction_time}
# Configuration settings
self.config = {
"xp_per_message": DEFAULT_XP_PER_MESSAGE,
"xp_per_reaction": DEFAULT_XP_PER_REACTION,
"message_cooldown": DEFAULT_XP_COOLDOWN,
"reaction_cooldown": DEFAULT_REACTION_COOLDOWN,
"reaction_xp_enabled": True
}
# Load existing data
self.load_user_data()
self.load_level_roles()
self.load_restricted_channels()
self.load_config()
def load_user_data(self):
"""Load user XP and level data from JSON file"""
if os.path.exists(LEVELS_FILE):
try:
with open(LEVELS_FILE, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
self.user_data = {int(k): v for k, v in data.items()}
print(f"Loaded level data for {len(self.user_data)} users")
except Exception as e:
print(f"Error loading level data: {e}")
def save_user_data(self):
"""Save user XP and level data to JSON file"""
try:
# Convert int keys to strings for JSON serialization
serializable_data = {str(k): v for k, v in self.user_data.items()}
with open(LEVELS_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving level data: {e}")
def load_level_roles(self):
"""Load level role configuration from JSON file"""
if os.path.exists(LEVEL_ROLES_FILE):
try:
with open(LEVEL_ROLES_FILE, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
# Convert nested dictionaries with string keys to integers
self.level_roles = {}
for guild_id_str, roles_dict in data.items():
guild_id = int(guild_id_str)
self.level_roles[guild_id] = {}
# Process each level's role data
for level_str, role_data in roles_dict.items():
level = int(level_str)
# Check if this is a gendered role entry
if isinstance(role_data, dict):
# Handle gendered roles
self.level_roles[guild_id][level] = {}
for gender, role_id_str in role_data.items():
self.level_roles[guild_id][level][gender] = int(role_id_str)
else:
# Handle regular roles
self.level_roles[guild_id][level] = int(role_data)
print(f"Loaded level roles for {len(self.level_roles)} guilds")
except Exception as e:
print(f"Error loading level roles: {e}")
def save_level_roles(self):
"""Save level role configuration to JSON file"""
try:
# Convert int keys to strings for JSON serialization (for both guild_id and level)
serializable_data = {}
for guild_id, roles_dict in self.level_roles.items():
serializable_data[str(guild_id)] = {}
# Handle both regular and gendered roles
for level, role_data in roles_dict.items():
if isinstance(role_data, dict):
# Handle gendered roles
serializable_data[str(guild_id)][str(level)] = {
gender: str(role_id) for gender, role_id in role_data.items()
}
else:
# Handle regular roles
serializable_data[str(guild_id)][str(level)] = str(role_data)
with open(LEVEL_ROLES_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving level roles: {e}")
def load_restricted_channels(self):
"""Load restricted channels from JSON file"""
if os.path.exists(RESTRICTED_CHANNELS_FILE):
try:
with open(RESTRICTED_CHANNELS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Convert list to set of integers
self.restricted_channels = set(int(channel_id) for channel_id in data)
print(f"Loaded {len(self.restricted_channels)} restricted channels")
except Exception as e:
print(f"Error loading restricted channels: {e}")
def save_restricted_channels(self):
"""Save restricted channels to JSON file"""
try:
# Convert set to list of strings for JSON serialization
serializable_data = [str(channel_id) for channel_id in self.restricted_channels]
with open(RESTRICTED_CHANNELS_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving restricted channels: {e}")
def load_config(self):
"""Load leveling configuration from JSON file"""
if os.path.exists(LEVEL_CONFIG_FILE):
try:
with open(LEVEL_CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Update config with saved values, keeping defaults for missing keys
for key, value in data.items():
if key in self.config:
self.config[key] = value
print(f"Loaded leveling configuration")
except Exception as e:
print(f"Error loading leveling configuration: {e}")
def save_config(self):
"""Save leveling configuration to JSON file"""
try:
with open(LEVEL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(self.config, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving leveling configuration: {e}")
def calculate_level(self, xp: int) -> int:
"""Calculate level based on XP"""
# Level formula: level = sqrt(xp / multiplier)
return int(math.sqrt(xp / DEFAULT_LEVEL_MULTIPLIER))
def calculate_xp_for_level(self, level: int) -> int:
"""Calculate XP required for a specific level"""
return level * level * DEFAULT_LEVEL_MULTIPLIER
def get_user_data(self, user_id: int) -> Dict:
"""Get user data with defaults if not set"""
if user_id not in self.user_data:
self.user_data[user_id] = {"xp": 0, "level": 0, "last_message_time": 0}
return self.user_data[user_id]
async def add_xp(self, user_id: int, guild_id: int, xp_amount: int = DEFAULT_XP_PER_MESSAGE) -> Optional[int]:
"""
Add XP to a user and return new level if leveled up, otherwise None
"""
user_data = self.get_user_data(user_id)
current_level = user_data["level"]
# Add XP
user_data["xp"] += xp_amount
# Calculate new level
new_level = self.calculate_level(user_data["xp"])
user_data["level"] = new_level
# Save changes
self.save_user_data()
# Return new level if leveled up, otherwise None
if new_level > current_level:
# Check if there's a role to assign for this level in this guild
await self.assign_level_role(user_id, guild_id, new_level)
return new_level
return None
async def assign_level_role(self, user_id: int, guild_id: int, level: int) -> bool:
"""
Assign role based on user level
Returns True if role was assigned, False otherwise
"""
# Check if guild has level roles configured
if guild_id not in self.level_roles:
return False
# Get the guild object
guild = self.bot.get_guild(guild_id)
if not guild:
return False
# Get the member object
member = guild.get_member(user_id)
if not member:
return False
# Find the highest role that matches the user's level
highest_matching_level = 0
highest_role_id = None
# Check if we need to handle gendered roles
gender = None
# Check if the user has pronoun roles
for role in member.roles:
role_name_lower = role.name.lower()
if "he/him" in role_name_lower:
gender = "male"
break
elif "she/her" in role_name_lower:
gender = "female"
break
# Process level roles
for role_level, role_data in self.level_roles[guild_id].items():
if role_level <= level and role_level > highest_matching_level:
highest_matching_level = role_level
# Handle gendered roles if available
if isinstance(role_data, dict) and gender in role_data:
highest_role_id = role_data[gender]
elif isinstance(role_data, dict) and "male" in role_data and "female" in role_data:
# If we have gendered roles but no gender preference, use male as default
highest_role_id = role_data["male"]
else:
# Regular role ID
highest_role_id = role_data
if highest_role_id:
# Get the role object
role = guild.get_role(highest_role_id)
if role and role not in member.roles:
try:
# Remove any other level roles
roles_to_remove = []
for role_level, role_data in self.level_roles[guild_id].items():
# Handle both regular and gendered roles
if isinstance(role_data, dict):
# For gendered roles, check all gender variants
for gender_role_id in role_data.values():
if gender_role_id != highest_role_id:
other_role = guild.get_role(gender_role_id)
if other_role and other_role in member.roles:
roles_to_remove.append(other_role)
elif role_data != highest_role_id:
other_role = guild.get_role(role_data)
if other_role and other_role in member.roles:
roles_to_remove.append(other_role)
if roles_to_remove:
await member.remove_roles(*roles_to_remove, reason="Level role update")
# Add the new role
await member.add_roles(role, reason=f"Reached level {level}")
return True
except discord.Forbidden:
print(f"Missing permissions to assign roles in guild {guild_id}")
except Exception as e:
print(f"Error assigning level role: {e}")
return False
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Event listener for messages to award XP"""
# Ignore bot messages
if message.author.bot:
return
# Ignore messages in restricted channels
if message.channel.id in self.restricted_channels:
return
# Check cooldown
user_id = message.author.id
current_time = message.created_at.timestamp()
if user_id in self.xp_cooldowns:
time_diff = current_time - self.xp_cooldowns[user_id]
if time_diff < self.config["message_cooldown"]:
return # Still on cooldown
# Update cooldown
self.xp_cooldowns[user_id] = current_time
# Add XP with random variation (base ±5 XP)
base_xp = self.config["xp_per_message"]
xp_amount = random.randint(max(1, base_xp - 5), base_xp + 5)
new_level = await self.add_xp(user_id, message.guild.id, xp_amount)
# If user leveled up, send a message
if new_level:
try:
await message.channel.send(
f"🎉 Congratulations {message.author.mention}! You've reached level **{new_level}**!",
delete_after=10 # Delete after 10 seconds
)
except discord.Forbidden:
pass # Ignore if we can't send messages
@commands.hybrid_command(name="level", description="Check your current level and XP")
async def level_command(self, ctx: commands.Context, member: discord.Member = None):
"""Check your current level and XP or another member's"""
target = member or ctx.author
user_data = self.get_user_data(target.id)
level = user_data["level"]
xp = user_data["xp"]
# Calculate XP needed for next level
next_level = level + 1
xp_needed = self.calculate_xp_for_level(next_level)
xp_current = xp - self.calculate_xp_for_level(level)
xp_required = xp_needed - self.calculate_xp_for_level(level)
# Create progress bar (20 characters wide)
progress = xp_current / xp_required
progress_bar_length = 20
filled_length = int(progress_bar_length * progress)
bar = '' * filled_length + '' * (progress_bar_length - filled_length)
embed = discord.Embed(
title=f"{target.display_name}'s Level",
description=f"**Level:** {level}\n**XP:** {xp} / {xp_needed}\n\n**Progress to Level {next_level}:**\n[{bar}] {int(progress * 100)}%",
color=discord.Color.blue()
)
embed.set_thumbnail(url=target.display_avatar.url)
await ctx.send(embed=embed)
@commands.hybrid_command(name="leaderboard", description="Show the server's level leaderboard")
async def leaderboard_command(self, ctx: commands.Context):
"""Show the server's level leaderboard"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
# Get all members in the guild
guild_members = {member.id: member for member in ctx.guild.members}
# Filter user_data to only include members in this guild
guild_data = {}
for user_id, data in self.user_data.items():
if user_id in guild_members:
guild_data[user_id] = data
# Sort by XP (descending)
sorted_data = sorted(guild_data.items(), key=lambda x: x[1]["xp"], reverse=True)
# Create embed
embed = discord.Embed(
title=f"{ctx.guild.name} Level Leaderboard",
color=discord.Color.gold()
)
# Add top 10 users to embed
for i, (user_id, data) in enumerate(sorted_data[:10], 1):
member = guild_members[user_id]
embed.add_field(
name=f"{i}. {member.display_name}",
value=f"Level: {data['level']} | XP: {data['xp']}",
inline=False
)
await ctx.send(embed=embed)
@commands.hybrid_command(name="register_level_role", description="Register a role for a specific level")
@commands.has_permissions(manage_roles=True)
async def register_level_role(self, ctx: commands.Context, level: int, role: discord.Role):
"""Register a role to be assigned at a specific level"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
if level < 1:
await ctx.send("Level must be at least 1.")
return
# Initialize guild in level_roles if not exists
if ctx.guild.id not in self.level_roles:
self.level_roles[ctx.guild.id] = {}
# Register the role
self.level_roles[ctx.guild.id][level] = role.id
self.save_level_roles()
await ctx.send(f"✅ Role {role.mention} will now be assigned at level {level}.")
@commands.hybrid_command(name="remove_level_role", description="Remove a level role registration")
@commands.has_permissions(manage_roles=True)
async def remove_level_role(self, ctx: commands.Context, level: int):
"""Remove a level role registration"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
if ctx.guild.id not in self.level_roles or level not in self.level_roles[ctx.guild.id]:
await ctx.send("No role is registered for this level.")
return
# Remove the role registration
del self.level_roles[ctx.guild.id][level]
self.save_level_roles()
await ctx.send(f"✅ Level {level} role registration has been removed.")
@commands.hybrid_command(name="list_level_roles", description="List all registered level roles")
async def list_level_roles(self, ctx: commands.Context):
"""List all registered level roles for this server"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
if ctx.guild.id not in self.level_roles or not self.level_roles[ctx.guild.id]:
await ctx.send("No level roles are registered for this server.")
return
embed = discord.Embed(
title=f"Level Roles for {ctx.guild.name}",
color=discord.Color.blue()
)
# Sort by level
sorted_roles = sorted(self.level_roles[ctx.guild.id].items())
for level, role_id in sorted_roles:
role = ctx.guild.get_role(role_id)
role_name = role.mention if role else f"Unknown Role (ID: {role_id})"
embed.add_field(
name=f"Level {level}",
value=role_name,
inline=False
)
await ctx.send(embed=embed)
@commands.hybrid_command(name="restrict_channel", description="Restrict a channel from giving XP")
@commands.has_permissions(manage_channels=True)
async def restrict_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Restrict a channel from giving XP"""
target_channel = channel or ctx.channel
if target_channel.id in self.restricted_channels:
await ctx.send(f"{target_channel.mention} is already restricted from giving XP.")
return
self.restricted_channels.add(target_channel.id)
self.save_restricted_channels()
await ctx.send(f"{target_channel.mention} will no longer give XP for messages.")
@commands.hybrid_command(name="unrestrict_channel", description="Allow a channel to give XP again")
@commands.has_permissions(manage_channels=True)
async def unrestrict_channel(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Allow a channel to give XP again"""
target_channel = channel or ctx.channel
if target_channel.id not in self.restricted_channels:
await ctx.send(f"{target_channel.mention} is not restricted from giving XP.")
return
self.restricted_channels.remove(target_channel.id)
self.save_restricted_channels()
await ctx.send(f"{target_channel.mention} will now give XP for messages.")
@commands.hybrid_command(name="process_existing_messages", description="Process existing messages to award XP")
@commands.is_owner()
async def process_existing_messages(self, ctx: commands.Context, limit: int = 10000):
"""Process existing messages to award XP (Owner only)"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
status_message = await ctx.send(f"Processing existing messages (up to {limit} per channel)...")
total_processed = 0
total_channels = 0
# Get all text channels in the guild
text_channels = [channel for channel in ctx.guild.channels if isinstance(channel, discord.TextChannel)]
for channel in text_channels:
# Skip restricted channels
if channel.id in self.restricted_channels:
continue
try:
processed_in_channel = 0
# Update status message
await status_message.edit(content=f"Processing channel {channel.mention}... ({total_processed} messages processed so far)")
async for message in channel.history(limit=limit):
# Skip bot messages
if message.author.bot:
continue
# Add XP (without cooldown)
user_id = message.author.id
xp_amount = random.randint(10, 20)
await self.add_xp(user_id, ctx.guild.id, xp_amount)
processed_in_channel += 1
total_processed += 1
# Update status every 1000 messages
if total_processed % 1000 == 0:
await status_message.edit(content=f"Processing channel {channel.mention}... ({total_processed} messages processed so far)")
total_channels += 1
except discord.Forbidden:
await ctx.send(f"Missing permissions to read message history in {channel.mention}")
except Exception as e:
await ctx.send(f"Error processing messages in {channel.mention}: {e}")
# Final update
await status_message.edit(content=f"✅ Finished processing {total_processed} messages across {total_channels} channels.")
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload):
"""Event listener for reactions to award XP"""
# Check if reaction XP is enabled
if not self.config["reaction_xp_enabled"]:
return
# Ignore bot reactions
if payload.member and payload.member.bot:
return
# Get the channel
channel = self.bot.get_channel(payload.channel_id)
if not channel:
return
# Ignore reactions in restricted channels
if channel.id in self.restricted_channels:
return
# Check cooldown
user_id = payload.user_id
current_time = discord.utils.utcnow().timestamp()
if user_id in self.reaction_cooldowns:
time_diff = current_time - self.reaction_cooldowns[user_id]
if time_diff < self.config["reaction_cooldown"]:
return # Still on cooldown
# Update cooldown
self.reaction_cooldowns[user_id] = current_time
# Add XP with small random variation (base ±2 XP)
base_xp = self.config["xp_per_reaction"]
xp_amount = random.randint(max(1, base_xp - 2), base_xp + 2)
new_level = await self.add_xp(user_id, payload.guild_id, xp_amount)
# If user leveled up, send a DM to avoid channel spam
if new_level:
try:
member = channel.guild.get_member(user_id)
if member:
await member.send(f"🎉 Congratulations! You've reached level **{new_level}**!")
except discord.Forbidden:
pass # Ignore if we can't send DMs
@commands.Cog.listener()
async def on_ready(self):
print(f'{self.__class__.__name__} cog has been loaded.')
async def cog_unload(self):
"""Save all data when cog is unloaded"""
self.save_user_data()
self.save_level_roles()
self.save_restricted_channels()
self.save_config()
print(f'{self.__class__.__name__} cog has been unloaded and data saved.')
@commands.hybrid_command(name="xp_config", description="Configure XP settings")
@commands.has_permissions(administrator=True)
async def xp_config(self, ctx: commands.Context, setting: str = None, value: str = None):
"""Configure XP settings for the leveling system"""
if not setting:
# Display current settings
embed = discord.Embed(
title="XP Configuration Settings",
description="Current XP settings for the leveling system:",
color=discord.Color.blue()
)
embed.add_field(name="XP Per Message", value=str(self.config["xp_per_message"]), inline=True)
embed.add_field(name="XP Per Reaction", value=str(self.config["xp_per_reaction"]), inline=True)
embed.add_field(name="Message Cooldown", value=f"{self.config['message_cooldown']} seconds", inline=True)
embed.add_field(name="Reaction Cooldown", value=f"{self.config['reaction_cooldown']} seconds", inline=True)
embed.add_field(name="Reaction XP Enabled", value="Yes" if self.config["reaction_xp_enabled"] else "No", inline=True)
embed.set_footer(text="Use !xp_config <setting> <value> to change a setting")
await ctx.send(embed=embed)
return
if not value:
await ctx.send("Please provide a value for the setting.")
return
setting = setting.lower()
if setting == "xp_per_message":
try:
xp = int(value)
if xp < 1 or xp > 100:
await ctx.send("XP per message must be between 1 and 100.")
return
self.config["xp_per_message"] = xp
await ctx.send(f"✅ XP per message set to {xp}.")
except ValueError:
await ctx.send("Value must be a number.")
elif setting == "xp_per_reaction":
try:
xp = int(value)
if xp < 1 or xp > 50:
await ctx.send("XP per reaction must be between 1 and 50.")
return
self.config["xp_per_reaction"] = xp
await ctx.send(f"✅ XP per reaction set to {xp}.")
except ValueError:
await ctx.send("Value must be a number.")
elif setting == "message_cooldown":
try:
cooldown = int(value)
if cooldown < 0 or cooldown > 3600:
await ctx.send("Message cooldown must be between 0 and 3600 seconds.")
return
self.config["message_cooldown"] = cooldown
await ctx.send(f"✅ Message cooldown set to {cooldown} seconds.")
except ValueError:
await ctx.send("Value must be a number.")
elif setting == "reaction_cooldown":
try:
cooldown = int(value)
if cooldown < 0 or cooldown > 3600:
await ctx.send("Reaction cooldown must be between 0 and 3600 seconds.")
return
self.config["reaction_cooldown"] = cooldown
await ctx.send(f"✅ Reaction cooldown set to {cooldown} seconds.")
except ValueError:
await ctx.send("Value must be a number.")
elif setting == "reaction_xp_enabled":
value = value.lower()
if value in ["true", "yes", "on", "1", "enable", "enabled"]:
self.config["reaction_xp_enabled"] = True
await ctx.send("✅ Reaction XP has been enabled.")
elif value in ["false", "no", "off", "0", "disable", "disabled"]:
self.config["reaction_xp_enabled"] = False
await ctx.send("✅ Reaction XP has been disabled.")
else:
await ctx.send("Value must be 'true' or 'false'.")
else:
await ctx.send(f"Unknown setting: {setting}. Available settings: xp_per_message, xp_per_reaction, message_cooldown, reaction_cooldown, reaction_xp_enabled")
return
# Save the updated configuration
self.save_config()
@commands.hybrid_command(name="setup_medieval_roles", description="Set up medieval-themed level roles")
@commands.has_permissions(manage_roles=True)
async def setup_medieval_roles(self, ctx: commands.Context):
"""Automatically set up medieval-themed level roles with gender customization"""
if not ctx.guild:
await ctx.send("This command can only be used in a server.")
return
# Define the medieval role structure with levels and titles
medieval_roles = {
1: {"default": "Peasant", "male": "Peasant", "female": "Peasant"},
5: {"default": "Squire", "male": "Squire", "female": "Squire"},
10: {"default": "Knight", "male": "Knight", "female": "Dame"},
20: {"default": "Baron/Baroness", "male": "Baron", "female": "Baroness"},
30: {"default": "Count/Countess", "male": "Count", "female": "Countess"},
50: {"default": "Duke/Duchess", "male": "Duke", "female": "Duchess"},
75: {"default": "Prince/Princess", "male": "Prince", "female": "Princess"},
100: {"default": "King/Queen", "male": "King", "female": "Queen"}
}
# Colors for the roles (gradient from gray to gold)
colors = {
1: discord.Color.from_rgb(128, 128, 128), # Gray
5: discord.Color.from_rgb(153, 153, 153), # Light Gray
10: discord.Color.from_rgb(170, 170, 170), # Silver
20: discord.Color.from_rgb(218, 165, 32), # Goldenrod
30: discord.Color.from_rgb(255, 215, 0), # Gold
50: discord.Color.from_rgb(255, 223, 0), # Bright Gold
75: discord.Color.from_rgb(255, 235, 0), # Royal Gold
100: discord.Color.from_rgb(255, 255, 0) # Yellow/Gold
}
# Initialize guild in level_roles if not exists
if ctx.guild.id not in self.level_roles:
self.level_roles[ctx.guild.id] = {}
status_message = await ctx.send("Creating medieval-themed level roles...")
created_roles = []
updated_roles = []
# Check if the server has pronoun roles
pronoun_roles = {}
for role in ctx.guild.roles:
role_name_lower = role.name.lower()
if "he/him" in role_name_lower:
pronoun_roles["male"] = role
elif "she/her" in role_name_lower:
pronoun_roles["female"] = role
has_pronoun_roles = len(pronoun_roles) > 0
# Create or update roles for each level
for level, titles in medieval_roles.items():
# For servers without pronoun roles, use the default title
if not has_pronoun_roles:
role_name = f"Level {level} - {titles['default']}"
# Check if role already exists
existing_role = discord.utils.get(ctx.guild.roles, name=role_name)
if existing_role:
# Update existing role
try:
await existing_role.edit(color=colors[level], reason="Updating medieval level role")
updated_roles.append(role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to edit role: {role_name}")
except Exception as e:
await ctx.send(f"Error updating role {role_name}: {e}")
else:
# Create new role
try:
role = await ctx.guild.create_role(
name=role_name,
color=colors[level],
reason="Creating medieval level role"
)
created_roles.append(role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to create role: {role_name}")
except Exception as e:
await ctx.send(f"Error creating role {role_name}: {e}")
continue
# Register the role for this level
role_id = existing_role.id if existing_role else role.id
self.level_roles[ctx.guild.id][level] = role_id
# For servers with pronoun roles, create separate male and female roles
else:
# Create male role
male_role_name = f"Level {level} - {titles['male']}"
male_role = discord.utils.get(ctx.guild.roles, name=male_role_name)
if male_role:
try:
await male_role.edit(color=colors[level], reason="Updating medieval level role")
updated_roles.append(male_role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to edit role: {male_role_name}")
except Exception as e:
await ctx.send(f"Error updating role {male_role_name}: {e}")
else:
try:
male_role = await ctx.guild.create_role(
name=male_role_name,
color=colors[level],
reason="Creating medieval level role"
)
created_roles.append(male_role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to create role: {male_role_name}")
except Exception as e:
await ctx.send(f"Error creating role {male_role_name}: {e}")
male_role = None
# Create female role
female_role_name = f"Level {level} - {titles['female']}"
female_role = discord.utils.get(ctx.guild.roles, name=female_role_name)
if female_role:
try:
await female_role.edit(color=colors[level], reason="Updating medieval level role")
updated_roles.append(female_role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to edit role: {female_role_name}")
except Exception as e:
await ctx.send(f"Error updating role {female_role_name}: {e}")
else:
try:
female_role = await ctx.guild.create_role(
name=female_role_name,
color=colors[level],
reason="Creating medieval level role"
)
created_roles.append(female_role_name)
except discord.Forbidden:
await ctx.send(f"Missing permissions to create role: {female_role_name}")
except Exception as e:
await ctx.send(f"Error creating role {female_role_name}: {e}")
female_role = None
# Create a special entry for gendered roles
if level not in self.level_roles[ctx.guild.id]:
self.level_roles[ctx.guild.id][level] = {}
# Store the role IDs with gender information
if male_role:
self.level_roles[ctx.guild.id][level]["male"] = male_role.id
if female_role:
self.level_roles[ctx.guild.id][level]["female"] = female_role.id
# Save the updated level roles
self.save_level_roles()
# Update status message
created_str = "\n".join(created_roles) if created_roles else "None"
updated_str = "\n".join(updated_roles) if updated_roles else "None"
embed = discord.Embed(
title="Medieval Level Roles Setup",
description="The following roles have been set up for the medieval leveling system:",
color=discord.Color.gold()
)
if created_roles:
embed.add_field(name="Created Roles", value=created_str, inline=False)
if updated_roles:
embed.add_field(name="Updated Roles", value=updated_str, inline=False)
embed.add_field(
name="Gender Detection",
value="Gender-specific roles will be assigned based on pronoun roles." if has_pronoun_roles else "No pronoun roles detected. Using default titles.",
inline=False
)
await status_message.edit(content=None, embed=embed)
async def setup(bot: commands.Bot):
await bot.add_cog(LevelingCog(bot))

331
cogs/marriage_cog.py Normal file
View File

@ -0,0 +1,331 @@
import discord
from discord.ext import commands
from discord import app_commands, ui
import json
import os
import datetime
from typing import Dict, List, Optional, Tuple
# File to store marriage data
MARRIAGES_FILE = "data/marriages.json"
# Ensure the data directory exists
os.makedirs(os.path.dirname(MARRIAGES_FILE), exist_ok=True)
class MarriageView(ui.View):
"""View for marriage proposal buttons"""
def __init__(self, cog: 'MarriageCog', proposer: discord.Member, proposed_to: discord.Member):
super().__init__(timeout=300.0) # 5-minute timeout
self.cog = cog
self.proposer = proposer
self.proposed_to = proposed_to
self.message: Optional[discord.Message] = None
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Only allow the proposed person to interact with the buttons"""
if interaction.user.id != self.proposed_to.id:
await interaction.response.send_message("This proposal isn't for you to answer!", ephemeral=True)
return False
return True
async def on_timeout(self):
"""Handle timeout - edit the message to show the proposal expired"""
if self.message:
# Disable all buttons
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
# Update the message
await self.message.edit(
content=f"💔 {self.proposed_to.mention} didn't respond to {self.proposer.mention}'s proposal in time.",
view=self
)
@discord.ui.button(label="Accept", style=discord.ButtonStyle.success, emoji="💍")
async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Accept the marriage proposal"""
# Create the marriage
success, message = await self.cog.create_marriage(self.proposer, self.proposed_to)
# Disable all buttons
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
if success:
await interaction.response.edit_message(
content=f"💖 {self.proposed_to.mention} has accepted {self.proposer.mention}'s proposal! Congratulations on your marriage!",
view=self
)
else:
await interaction.response.edit_message(
content=f"{message}",
view=self
)
@discord.ui.button(label="Decline", style=discord.ButtonStyle.danger, emoji="💔")
async def decline_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Decline the marriage proposal"""
# Disable all buttons
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
await interaction.response.edit_message(
content=f"💔 {self.proposed_to.mention} has declined {self.proposer.mention}'s proposal.",
view=self
)
class MarriageCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.marriages = {}
self.load_marriages()
def load_marriages(self):
"""Load marriages from JSON file"""
if os.path.exists(MARRIAGES_FILE):
try:
with open(MARRIAGES_FILE, "r") as f:
data = json.load(f)
# Convert string keys back to integers
self.marriages = {int(k): v for k, v in data.items()}
print(f"Loaded {len(self.marriages)} marriages")
except Exception as e:
print(f"Error loading marriages: {e}")
self.marriages = {}
def save_marriages(self):
"""Save marriages to JSON file"""
try:
# Convert int keys to strings for JSON serialization
serializable_data = {str(k): v for k, v in self.marriages.items()}
with open(MARRIAGES_FILE, "w") as f:
json.dump(serializable_data, f, indent=4)
except Exception as e:
print(f"Error saving marriages: {e}")
async def create_marriage(self, user1: discord.Member, user2: discord.Member) -> Tuple[bool, str]:
"""Create a new marriage between two users"""
# Check if either user is already married
user1_id = user1.id
user2_id = user2.id
# Check if user1 is already married
if user1_id in self.marriages and self.marriages[user1_id]["status"] == "married":
return False, f"{user1.display_name} is already married!"
# Check if user2 is already married
if user2_id in self.marriages and self.marriages[user2_id]["status"] == "married":
return False, f"{user2.display_name} is already married!"
# Create marriage data
marriage_date = datetime.datetime.now().isoformat()
# Store marriage data for both users
marriage_data = {
"partner_id": user2_id,
"marriage_date": marriage_date,
"status": "married"
}
self.marriages[user1_id] = marriage_data
marriage_data = {
"partner_id": user1_id,
"marriage_date": marriage_date,
"status": "married"
}
self.marriages[user2_id] = marriage_data
# Save to file
self.save_marriages()
return True, "Marriage created successfully!"
async def divorce(self, user_id: int) -> Tuple[bool, str]:
"""End a marriage"""
if user_id not in self.marriages or self.marriages[user_id]["status"] != "married":
return False, "You are not currently married!"
# Get partner's ID
partner_id = self.marriages[user_id]["partner_id"]
# Update status for both users
if user_id in self.marriages:
self.marriages[user_id]["status"] = "divorced"
if partner_id in self.marriages:
self.marriages[partner_id]["status"] = "divorced"
# Save to file
self.save_marriages()
return True, "Divorce completed."
def get_marriage_days(self, user_id: int) -> int:
"""Get the number of days a marriage has lasted"""
if user_id not in self.marriages:
return 0
marriage_data = self.marriages[user_id]
marriage_date = datetime.datetime.fromisoformat(marriage_data["marriage_date"])
current_date = datetime.datetime.now()
# Calculate days
delta = current_date - marriage_date
return delta.days
def get_all_marriages(self) -> List[Tuple[int, int, int]]:
"""Get all active marriages sorted by duration"""
active_marriages = []
processed_pairs = set()
for user_id, marriage_data in self.marriages.items():
if marriage_data["status"] == "married":
partner_id = marriage_data["partner_id"]
# Avoid duplicates (each marriage appears twice in self.marriages)
pair = tuple(sorted([user_id, partner_id]))
if pair in processed_pairs:
continue
processed_pairs.add(pair)
# Calculate days
marriage_date = datetime.datetime.fromisoformat(marriage_data["marriage_date"])
current_date = datetime.datetime.now()
delta = current_date - marriage_date
days = delta.days
active_marriages.append((user_id, partner_id, days))
# Sort by days (descending)
return sorted(active_marriages, key=lambda x: x[2], reverse=True)
@app_commands.command(name="propose", description="Propose marriage to another user")
@app_commands.describe(user="The user you want to propose to")
async def propose_command(self, interaction: discord.Interaction, user: discord.Member):
"""Propose marriage to another user"""
proposer = interaction.user
# Check if proposing to self
if user.id == proposer.id:
await interaction.response.send_message("You can't propose to yourself!", ephemeral=True)
return
# Check if proposer is already married
if proposer.id in self.marriages and self.marriages[proposer.id]["status"] == "married":
partner_id = self.marriages[proposer.id]["partner_id"]
partner = interaction.guild.get_member(partner_id)
partner_name = partner.display_name if partner else "someone"
await interaction.response.send_message(f"You're already married to {partner_name}!", ephemeral=True)
return
# Check if proposed person is already married
if user.id in self.marriages and self.marriages[user.id]["status"] == "married":
partner_id = self.marriages[user.id]["partner_id"]
partner = interaction.guild.get_member(partner_id)
partner_name = partner.display_name if partner else "someone"
await interaction.response.send_message(f"{user.display_name} is already married to {partner_name}!", ephemeral=True)
return
# Create the proposal view
view = MarriageView(self, proposer, user)
# Send the proposal
await interaction.response.send_message(
f"💍 {proposer.mention} has proposed to {user.mention}! Will they accept?",
view=view
)
# Store the message for timeout handling
view.message = await interaction.original_response()
@app_commands.command(name="marriage", description="View your current marriage status")
async def marriage_command(self, interaction: discord.Interaction):
"""View your current marriage status"""
user_id = interaction.user.id
if user_id not in self.marriages or self.marriages[user_id]["status"] != "married":
await interaction.response.send_message("You are not currently married.", ephemeral=False)
return
# Get marriage info
marriage_data = self.marriages[user_id]
partner_id = marriage_data["partner_id"]
partner = interaction.guild.get_member(partner_id)
partner_name = partner.display_name if partner else f"Unknown User ({partner_id})"
# Calculate days
days = self.get_marriage_days(user_id)
# Create embed
embed = discord.Embed(
title="💖 Marriage Status",
color=discord.Color.pink()
)
embed.add_field(name="Married To", value=partner.mention if partner else partner_name, inline=False)
embed.add_field(name="Marriage Date", value=marriage_data["marriage_date"].split("T")[0], inline=True)
embed.add_field(name="Days Married", value=str(days), inline=True)
await interaction.response.send_message(embed=embed, ephemeral=False)
@app_commands.command(name="divorce", description="End your current marriage")
async def divorce_command(self, interaction: discord.Interaction):
"""End your current marriage"""
user_id = interaction.user.id
# Check if user is married
if user_id not in self.marriages or self.marriages[user_id]["status"] != "married":
await interaction.response.send_message("You are not currently married.", ephemeral=True)
return
# Get partner info
partner_id = self.marriages[user_id]["partner_id"]
partner = interaction.guild.get_member(partner_id)
partner_name = partner.mention if partner else f"Unknown User ({partner_id})"
# Process divorce
success, message = await self.divorce(user_id)
if success:
await interaction.response.send_message(f"💔 {interaction.user.mention} has divorced {partner_name}. The marriage has ended.", ephemeral=False)
else:
await interaction.response.send_message(message, ephemeral=True)
@app_commands.command(name="marriages", description="View the marriage leaderboard")
async def marriages_command(self, interaction: discord.Interaction):
"""View the marriage leaderboard"""
marriages = self.get_all_marriages()
if not marriages:
await interaction.response.send_message("There are no active marriages.", ephemeral=False)
return
# Create embed
embed = discord.Embed(
title="💖 Marriage Leaderboard",
description="Marriages ranked by duration",
color=discord.Color.pink()
)
# Add top 10 marriages
for i, (user1_id, user2_id, days) in enumerate(marriages[:10], 1):
user1 = interaction.guild.get_member(user1_id)
user2 = interaction.guild.get_member(user2_id)
user1_name = user1.display_name if user1 else f"Unknown User ({user1_id})"
user2_name = user2.display_name if user2 else f"Unknown User ({user2_id})"
embed.add_field(
name=f"{i}. {user1_name} & {user2_name}",
value=f"{days} days",
inline=False
)
await interaction.response.send_message(embed=embed, ephemeral=False)
async def setup(bot: commands.Bot):
await bot.add_cog(MarriageCog(bot))

59
cogs/message_cog.py Normal file
View File

@ -0,0 +1,59 @@
import discord
from discord.ext import commands
from discord import app_commands
class MessageCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
# Hardcoded message with {target} placeholder
self.message_template = """
{target} - Your legs are pulled apart from behind, the sudden movement causing you to stumble forward. As your balance falters, a hand shoots out to grab your hips, holding you in place.
With your body restrained, a finger begins to dance along the waistband of your pants, teasing and taunting until it finally hooks into the elasticized seam. The fabric is slowly peeled back, exposing your bare skin to the cool night air.
As the hand continues its downward journey, your breath catches in your throat. You try to move, but the grip on your hips is too tight, holding you firmly in place.
Your pants are slowly and deliberately removed, leaving you feeling exposed and vulnerable. The sensation is both thrilling and terrifying as a presence looms over you, the only sound being the faint rustling of fabric as your clothes are discarded.
"""
# Helper method for the message logic
async def _message_logic(self, target):
"""Core logic for the message command."""
# Replace {target} with the mentioned user
return self.message_template.format(target=target)
@commands.command(name="molest")
async def molest(self, ctx: commands.Context, member: discord.Member):
"""Send a hardcoded message to the mentioned user."""
response = await self._message_logic(member.mention)
await ctx.reply(response)
@app_commands.command(name="molest", description="Send a hardcoded message to the mentioned user")
@app_commands.describe(
member="The user to send the message to"
)
async def molest_slash(self, interaction: discord.Interaction, member: discord.Member):
"""Slash command version of message."""
response = await self._message_logic(member.mention)
await interaction.response.send_message(response)
@commands.command(name="seals", help="What the fuck did you just fucking say about me, you little bitch?")
@commands.is_owner()
async def seals(self, ctx):
await ctx.send("What the fuck did you just fucking say about me, you little bitch? I'll have you know I graduated top of my class in the Navy Seals, and I've been involved in numerous secret raids on Al-Quaeda, and I have over 300 confirmed kills. I am trained in gorilla warfare and I'm the top sniper in the entire US armed forces. You are nothing to me but just another target. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am contacting my secret network of spies across the USA and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can kill you in over seven hundred ways, and that's just with my bare hands. Not only am I extensively trained in unarmed combat, but I have access to the entire arsenal of the United States Marine Corps and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little \"clever\" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit fury all over you and you will drown in it. You're fucking dead, kiddo.")
@app_commands.command(name="seals", description="What the fuck did you just fucking say about me, you little bitch?")
async def seals_slash(self, interaction: discord.Interaction):
await interaction.response.send_message("What the fuck did you just fucking say about me, you little bitch? I'll have you know I graduated top of my class in the Navy Seals, and I've been involved in numerous secret raids on Al-Quaeda, and I have over 300 confirmed kills. I am trained in gorilla warfare and I'm the top sniper in the entire US armed forces. You are nothing to me but just another target. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am contacting my secret network of spies across the USA and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can kill you in over seven hundred ways, and that's just with my bare hands. Not only am I extensively trained in unarmed combat, but I have access to the entire arsenal of the United States Marine Corps and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little \"clever\" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit fury all over you and you will drown in it. You're fucking dead, kiddo.")
@commands.command(name="notlikeus", help="Honestly i think They Not Like Us is the only mumble rap song that is good")
@commands.is_owner()
async def notlikeus(self, ctx):
await ctx.send("Honestly i think They Not Like Us is the only mumble rap song that is good, because it calls out Drake for being a Diddy blud")
@app_commands.command(name="notlikeus", description="Honestly i think They Not Like Us is the only mumble rap song that is good")
async def notlikeus_slash(self, interaction: discord.Interaction):
await interaction.response.send_message("Honestly i think They Not Like Us is the only mumble rap song that is good, because it calls out Drake for being a Diddy blud")
async def setup(bot: commands.Bot):
await bot.add_cog(MessageCog(bot))

211
cogs/moderation_cog.py Normal file
View File

@ -0,0 +1,211 @@
import discord
from discord.ext import commands
from discord import app_commands
import random
class ModerationCog(commands.Cog):
"""Fake moderation commands that don't actually perform any actions."""
def __init__(self, bot):
self.bot = bot
# Helper method for generating responses
async def _fake_moderation_response(self, action, target, reason=None, duration=None):
"""Generate a fake moderation response."""
responses = {
"ban": [
f"🔨 **Banned {target}**{f' for {duration}' if duration else ''}! Reason: {reason or 'No reason provided'}",
f"👋 {target} has been banned from the server{f' for {duration}' if duration else ''}. Reason: {reason or 'No reason provided'}",
f"🚫 {target} is now banned{f' for {duration}' if duration else ''}. Reason: {reason or 'No reason provided'}"
],
"kick": [
f"👢 **Kicked {target}**! Reason: {reason or 'No reason provided'}",
f"👋 {target} has been kicked from the server. Reason: {reason or 'No reason provided'}",
f"🚪 {target} has been shown the door. Reason: {reason or 'No reason provided'}"
],
"mute": [
f"🔇 **Muted {target}**{f' for {duration}' if duration else ''}! Reason: {reason or 'No reason provided'}",
f"🤐 {target} has been muted{f' for {duration}' if duration else ''}. Reason: {reason or 'No reason provided'}",
f"📵 {target} can no longer speak{f' for {duration}' if duration else ''}. Reason: {reason or 'No reason provided'}"
],
"timeout": [
f"⏰ **Timed out {target}** for {duration or 'some time'}! Reason: {reason or 'No reason provided'}",
f"{target} has been put in timeout for {duration or 'some time'}. Reason: {reason or 'No reason provided'}",
f"🕒 {target} is now in timeout for {duration or 'some time'}. Reason: {reason or 'No reason provided'}"
],
"warn": [
f"⚠️ **Warned {target}**! Reason: {reason or 'No reason provided'}",
f"📝 {target} has been warned. Reason: {reason or 'No reason provided'}",
f"🚨 Warning issued to {target}. Reason: {reason or 'No reason provided'}"
],
"unban": [
f"🔓 **Unbanned {target}**! Reason: {reason or 'No reason provided'}",
f"🎊 {target} has been unbanned. Reason: {reason or 'No reason provided'}",
f"🔄 {target} is now allowed back in the server. Reason: {reason or 'No reason provided'}"
],
"unmute": [
f"🔊 **Unmuted {target}**! Reason: {reason or 'No reason provided'}",
f"🗣️ {target} can speak again. Reason: {reason or 'No reason provided'}",
f"📢 {target} has been unmuted. Reason: {reason or 'No reason provided'}"
]
}
return random.choice(responses.get(action, [f"Action performed on {target}"]))
# --- Ban Commands ---
@commands.command(name="ban")
async def ban(self, ctx: commands.Context, member: discord.Member = None, duration: str = None, *, reason: str = None):
"""Pretends to ban a member from the server."""
if not member:
await ctx.reply("Please specify a member to ban.")
return
response = await self._fake_moderation_response("ban", member.mention, reason, duration)
await ctx.reply(response)
@app_commands.command(name="ban", description="Pretends to ban a member from the server")
@app_commands.describe(
member="The member to ban",
duration="The duration of the ban (e.g., '1d', '7d')",
reason="The reason for the ban"
)
async def ban_slash(self, interaction: discord.Interaction, member: discord.Member, duration: str = None, reason: str = None):
"""Slash command version of ban."""
response = await self._fake_moderation_response("ban", member.mention, reason, duration)
await interaction.response.send_message(response)
# --- Kick Commands ---
@commands.command(name="kick")
async def kick(self, ctx: commands.Context, member: discord.Member = None, *, reason: str = None):
"""Pretends to kick a member from the server."""
if not member:
await ctx.reply("Please specify a member to kick.")
return
response = await self._fake_moderation_response("kick", member.mention, reason)
await ctx.reply(response)
@app_commands.command(name="kick", description="Pretends to kick a member from the server")
@app_commands.describe(
member="The member to kick",
reason="The reason for the kick"
)
async def kick_slash(self, interaction: discord.Interaction, member: discord.Member, reason: str = None):
"""Slash command version of kick."""
response = await self._fake_moderation_response("kick", member.mention, reason)
await interaction.response.send_message(response)
# --- Mute Commands ---
@commands.command(name="mute")
async def mute(self, ctx: commands.Context, member: discord.Member = None, duration: str = None, *, reason: str = None):
"""Pretends to mute a member in the server."""
if not member:
await ctx.reply("Please specify a member to mute.")
return
response = await self._fake_moderation_response("mute", member.mention, reason, duration)
await ctx.reply(response)
@app_commands.command(name="mute", description="Pretends to mute a member in the server")
@app_commands.describe(
member="The member to mute",
duration="The duration of the mute (e.g., '1h', '30m')",
reason="The reason for the mute"
)
async def mute_slash(self, interaction: discord.Interaction, member: discord.Member, duration: str = None, reason: str = None):
"""Slash command version of mute."""
response = await self._fake_moderation_response("mute", member.mention, reason, duration)
await interaction.response.send_message(response)
# --- Timeout Commands ---
@commands.command(name="timeout")
async def timeout(self, ctx: commands.Context, member: discord.Member = None, duration: str = None, *, reason: str = None):
"""Pretends to timeout a member in the server."""
if not member:
await ctx.reply("Please specify a member to timeout.")
return
response = await self._fake_moderation_response("timeout", member.mention, reason, duration)
await ctx.reply(response)
@app_commands.command(name="timeout", description="Pretends to timeout a member in the server")
@app_commands.describe(
member="The member to timeout",
duration="The duration of the timeout (e.g., '1h', '30m')",
reason="The reason for the timeout"
)
async def timeout_slash(self, interaction: discord.Interaction, member: discord.Member, duration: str = None, reason: str = None):
"""Slash command version of timeout."""
response = await self._fake_moderation_response("timeout", member.mention, reason, duration)
await interaction.response.send_message(response)
# --- Warn Commands ---
@commands.command(name="warn")
async def warn(self, ctx: commands.Context, member: discord.Member = None, *, reason: str = None):
"""Pretends to warn a member in the server."""
if not member:
await ctx.reply("Please specify a member to warn.")
return
response = await self._fake_moderation_response("warn", member.mention, reason)
await ctx.reply(response)
@app_commands.command(name="warn", description="Pretends to warn a member in the server")
@app_commands.describe(
member="The member to warn",
reason="The reason for the warning"
)
async def warn_slash(self, interaction: discord.Interaction, member: discord.Member, reason: str = None):
"""Slash command version of warn."""
response = await self._fake_moderation_response("warn", member.mention, reason)
await interaction.response.send_message(response)
# --- Unban Commands ---
@commands.command(name="unban")
async def unban(self, ctx: commands.Context, user: str = None, *, reason: str = None):
"""Pretends to unban a user from the server."""
if not user:
await ctx.reply("Please specify a user to unban.")
return
# Since we can't mention unbanned users, we'll just use the name
response = await self._fake_moderation_response("unban", user, reason)
await ctx.reply(response)
@app_commands.command(name="unban", description="Pretends to unban a user from the server")
@app_commands.describe(
user="The user to unban (username or ID)",
reason="The reason for the unban"
)
async def unban_slash(self, interaction: discord.Interaction, user: str, reason: str = None):
"""Slash command version of unban."""
response = await self._fake_moderation_response("unban", user, reason)
await interaction.response.send_message(response)
# --- Unmute Commands ---
@commands.command(name="unmute")
async def unmute(self, ctx: commands.Context, member: discord.Member = None, *, reason: str = None):
"""Pretends to unmute a member in the server."""
if not member:
await ctx.reply("Please specify a member to unmute.")
return
response = await self._fake_moderation_response("unmute", member.mention, reason)
await ctx.reply(response)
@app_commands.command(name="unmute", description="Pretends to unmute a member in the server")
@app_commands.describe(
member="The member to unmute",
reason="The reason for the unmute"
)
async def unmute_slash(self, interaction: discord.Interaction, member: discord.Member, reason: str = None):
"""Slash command version of unmute."""
response = await self._fake_moderation_response("unmute", member.mention, reason)
await interaction.response.send_message(response)
@commands.Cog.listener()
async def on_ready(self):
print(f'{self.__class__.__name__} cog has been loaded.')
async def setup(bot: commands.Bot):
await bot.add_cog(ModerationCog(bot))

706
cogs/multi_bot_cog.py Normal file
View File

@ -0,0 +1,706 @@
import discord
from discord.ext import commands
import os
import json
import subprocess
import sys
import threading
import asyncio
import psutil
from typing import Dict, List, Optional
# Import the multi_bot module
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import multi_bot
# Configuration file path
CONFIG_FILE = "data/multi_bot_config.json"
# Bot IDs
NERU_BOT_ID = "neru"
MIKU_BOT_ID = "miku"
class MultiBotCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.bot_processes = {} # Store subprocess objects
self.bot_threads = {} # Store thread objects
def cog_unload(self):
"""Stop all bots when the cog is unloaded"""
self.stop_all_bots()
@commands.command(name="startbot")
@commands.is_owner()
async def start_bot(self, ctx, bot_id: str):
"""Start a specific bot (Owner only)"""
# Check if the bot is already running
if bot_id in self.bot_processes and self.bot_processes[bot_id].poll() is None:
await ctx.send(f"Bot {bot_id} is already running.")
return
if bot_id in self.bot_threads and self.bot_threads[bot_id].is_alive():
await ctx.send(f"Bot {bot_id} is already running in a thread.")
return
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Find the bot configuration
bot_config = None
for bc in config.get("bots", []):
if bc.get("id") == bot_id:
bot_config = bc
break
if not bot_config:
await ctx.send(f"Bot {bot_id} not found in configuration.")
return
# Check if the token is set
if not bot_config.get("token"):
await ctx.send(f"Token for bot {bot_id} is not set in the configuration.")
return
# Start the bot in a separate thread
thread = multi_bot.run_bot_in_thread(bot_id)
self.bot_threads[bot_id] = thread
await ctx.send(f"Bot {bot_id} started successfully.")
except Exception as e:
await ctx.send(f"Error starting bot {bot_id}: {e}")
@commands.command(name="stopbot")
@commands.is_owner()
async def stop_bot(self, ctx, bot_id: str):
"""Stop a specific bot (Owner only)"""
# Check if the bot is running as a process
if bot_id in self.bot_processes:
process = self.bot_processes[bot_id]
if process.poll() is None: # Process is still running
try:
# Try to terminate gracefully
process.terminate()
# Wait a bit for it to terminate
await asyncio.sleep(2)
# If still running, kill it
if process.poll() is None:
process.kill()
await ctx.send(f"Bot {bot_id} stopped.")
del self.bot_processes[bot_id]
return
except Exception as e:
await ctx.send(f"Error stopping bot {bot_id}: {e}")
return
# Check if the bot is running in a thread
if bot_id in self.bot_threads:
# We can't directly stop a thread in Python, but we can try to find and kill the process
thread = self.bot_threads[bot_id]
if thread.is_alive():
try:
# Find and kill the process by looking for Python processes with multi_bot.py
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.info['cmdline']
if cmdline and 'python' in cmdline[0].lower() and any('multi_bot.py' in arg for arg in cmdline if arg):
# This is likely our bot process
proc.terminate()
await ctx.send(f"Bot {bot_id} process terminated.")
break
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
# Remove from our tracking
del self.bot_threads[bot_id]
# Note: The thread itself might still be alive but will eventually notice the process is gone
await ctx.send(f"Bot {bot_id} stopped.")
return
except Exception as e:
await ctx.send(f"Error stopping bot {bot_id}: {e}")
return
await ctx.send(f"Bot {bot_id} is not running.")
@commands.command(name="startallbots")
@commands.is_owner()
async def start_all_bots(self, ctx):
"""Start all configured bots (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
started_count = 0
failed_count = 0
already_running = 0
for bot_config in config.get("bots", []):
bot_id = bot_config.get("id")
if not bot_id:
continue
# Check if already running
if (bot_id in self.bot_processes and self.bot_processes[bot_id].poll() is None) or \
(bot_id in self.bot_threads and self.bot_threads[bot_id].is_alive()):
already_running += 1
continue
# Check if token is set
if not bot_config.get("token"):
await ctx.send(f"Token for bot {bot_id} is not set in the configuration.")
failed_count += 1
continue
try:
# Start the bot in a separate thread
thread = multi_bot.run_bot_in_thread(bot_id)
self.bot_threads[bot_id] = thread
started_count += 1
except Exception as e:
await ctx.send(f"Error starting bot {bot_id}: {e}")
failed_count += 1
status_message = f"Started {started_count} bots."
if already_running > 0:
status_message += f" {already_running} bots were already running."
if failed_count > 0:
status_message += f" Failed to start {failed_count} bots."
await ctx.send(status_message)
except Exception as e:
await ctx.send(f"Error starting bots: {e}")
@commands.command(name="stopallbots")
@commands.is_owner()
async def stop_all_bots(self, ctx):
"""Stop all running bots (Owner only)"""
stopped_count = 0
failed_count = 0
# Stop process-based bots
for bot_id, process in list(self.bot_processes.items()):
if process.poll() is None: # Process is still running
try:
# Try to terminate gracefully
process.terminate()
# Wait a bit for it to terminate
await asyncio.sleep(1)
# If still running, kill it
if process.poll() is None:
process.kill()
del self.bot_processes[bot_id]
stopped_count += 1
except Exception as e:
await ctx.send(f"Error stopping bot {bot_id}: {e}")
failed_count += 1
# Stop thread-based bots
for bot_id, thread in list(self.bot_threads.items()):
if thread.is_alive():
try:
# Find and kill the process
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.info['cmdline']
if cmdline and 'python' in cmdline[0].lower() and any('multi_bot.py' in arg for arg in cmdline if arg):
# This is likely our bot process
proc.terminate()
break
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
del self.bot_threads[bot_id]
stopped_count += 1
except Exception as e:
await ctx.send(f"Error stopping bot {bot_id}: {e}")
failed_count += 1
status_message = f"Stopped {stopped_count} bots."
if failed_count > 0:
status_message += f" Failed to stop {failed_count} bots."
await ctx.send(status_message)
def stop_all_bots(self):
"""Stop all running bots (internal method)"""
# Stop process-based bots
for bot_id, process in list(self.bot_processes.items()):
if process.poll() is None: # Process is still running
try:
# Try to terminate gracefully
process.terminate()
# Wait a bit for it to terminate
import time
time.sleep(1)
# If still running, kill it
if process.poll() is None:
process.kill()
except Exception as e:
print(f"Error stopping bot {bot_id}: {e}")
# Stop thread-based bots
for bot_id, thread in list(self.bot_threads.items()):
if thread.is_alive():
try:
# Find and kill the process
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.info['cmdline']
if cmdline and 'python' in cmdline[0].lower() and any('multi_bot.py' in arg for arg in cmdline if arg):
# This is likely our bot process
proc.terminate()
break
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
except Exception as e:
print(f"Error stopping bot {bot_id}: {e}")
@commands.command(name="listbots")
@commands.is_owner()
async def list_bots(self, ctx):
"""List all configured bots and their status (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
bot_list = []
for bot_config in config.get("bots", []):
bot_id = bot_config.get("id")
if not bot_id:
continue
# Check if running
is_running = False
run_type = ""
if bot_id in self.bot_processes and self.bot_processes[bot_id].poll() is None:
is_running = True
run_type = "process"
elif bot_id in self.bot_threads and self.bot_threads[bot_id].is_alive():
is_running = True
run_type = "thread"
# Get other info
prefix = bot_config.get("prefix", "!")
system_prompt = bot_config.get("system_prompt", "Default system prompt")
if len(system_prompt) > 50:
system_prompt = system_prompt[:47] + "..."
# Get status settings
status_type = bot_config.get("status_type", "listening")
status_text = bot_config.get("status_text", f"{prefix}ai")
run_status = f"Running ({run_type})" if is_running else "Stopped"
bot_list.append(f"**Bot ID**: {bot_id}\n**Status**: {run_status}\n**Prefix**: {prefix}\n**Activity**: {status_type.capitalize()} {status_text}\n**System Prompt**: {system_prompt}\n")
if not bot_list:
await ctx.send("No bots configured.")
return
# Create an embed to display the bot list
embed = discord.Embed(
title="Configured Bots",
description="List of all configured bots and their status",
color=discord.Color.blue()
)
for i, bot_info in enumerate(bot_list):
embed.add_field(
name=f"Bot {i+1}",
value=bot_info,
inline=False
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"Error listing bots: {e}")
@commands.command(name="setbottoken")
@commands.is_owner()
async def set_bot_token(self, ctx, bot_id: str, *, token: str):
"""Set the token for a bot (Owner only)"""
# Delete the message to protect the token
try:
await ctx.message.delete()
except:
pass
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Find the bot configuration
found = False
for bot_config in config.get("bots", []):
if bot_config.get("id") == bot_id:
bot_config["token"] = token
found = True
break
if not found:
await ctx.send(f"Bot {bot_id} not found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
await ctx.send(f"Token for bot {bot_id} has been updated. The message with your token has been deleted for security.")
except Exception as e:
await ctx.send(f"Error setting bot token: {e}")
@commands.command(name="setbotprompt")
@commands.is_owner()
async def set_bot_prompt(self, ctx, bot_id: str, *, system_prompt: str):
"""Set the system prompt for a bot (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Find the bot configuration
found = False
for bot_config in config.get("bots", []):
if bot_config.get("id") == bot_id:
bot_config["system_prompt"] = system_prompt
found = True
break
if not found:
await ctx.send(f"Bot {bot_id} not found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
await ctx.send(f"System prompt for bot {bot_id} has been updated.")
except Exception as e:
await ctx.send(f"Error setting bot system prompt: {e}")
@commands.command(name="setbotprefix")
@commands.is_owner()
async def set_bot_prefix(self, ctx, bot_id: str, prefix: str):
"""Set the command prefix for a bot (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Find the bot configuration
found = False
for bot_config in config.get("bots", []):
if bot_config.get("id") == bot_id:
bot_config["prefix"] = prefix
found = True
break
if not found:
await ctx.send(f"Bot {bot_id} not found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
await ctx.send(f"Command prefix for bot {bot_id} has been updated to '{prefix}'.")
# Notify that the bot needs to be restarted for the change to take effect
await ctx.send("Note: You need to restart the bot for this change to take effect.")
except Exception as e:
await ctx.send(f"Error setting bot prefix: {e}")
@commands.command(name="setapikey")
@commands.is_owner()
async def set_api_key(self, ctx, *, api_key: str):
"""Set the API key for all bots (Owner only)"""
# Delete the message to protect the API key
try:
await ctx.message.delete()
except:
pass
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Update the API key
config["api_key"] = api_key
# Save the updated configuration
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
await ctx.send("API key has been updated. The message with your API key has been deleted for security.")
except Exception as e:
await ctx.send(f"Error setting API key: {e}")
@commands.command(name="setapiurl")
@commands.is_owner()
async def set_api_url(self, ctx, *, api_url: str):
"""Set the API URL for all bots (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Update the API URL
config["api_url"] = api_url
# Save the updated configuration
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
await ctx.send(f"API URL has been updated to '{api_url}'.")
except Exception as e:
await ctx.send(f"Error setting API URL: {e}")
@commands.command(name="setbotstatus")
@commands.is_owner()
async def set_bot_status(self, ctx, bot_id: str, status_type: str, *, status_text: str):
"""Set the status for a bot (Owner only)
Status types:
- playing: "Playing {status_text}"
- listening: "Listening to {status_text}"
- watching: "Watching {status_text}"
- streaming: "Streaming {status_text}"
- competing: "Competing in {status_text}"
"""
# Validate status type
valid_status_types = ["playing", "listening", "watching", "streaming", "competing"]
status_type = status_type.lower()
if status_type not in valid_status_types:
await ctx.send(f"Invalid status type: '{status_type}'. Valid types are: {', '.join(valid_status_types)}")
return
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
# Find the bot configuration
found = False
for bot_config in config.get("bots", []):
if bot_config.get("id") == bot_id:
bot_config["status_type"] = status_type
bot_config["status_text"] = status_text
found = True
break
if not found:
await ctx.send(f"Bot {bot_id} not found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
await ctx.send(f"Status for bot {bot_id} has been updated to '{status_type.capitalize()} {status_text}'.")
# Check if the bot is running and update its status
if bot_id in self.bot_threads and self.bot_threads[bot_id].is_alive():
# We can't directly update the status of a bot running in a thread
await ctx.send("Note: You need to restart the bot for this change to take effect.")
except Exception as e:
await ctx.send(f"Error setting bot status: {e}")
@commands.command(name="setallbotstatus")
@commands.is_owner()
async def set_all_bots_status(self, ctx, status_type: str, *, status_text: str):
"""Set the status for all bots (Owner only)
Status types:
- playing: "Playing {status_text}"
- listening: "Listening to {status_text}"
- watching: "Watching {status_text}"
- streaming: "Streaming {status_text}"
- competing: "Competing in {status_text}"
"""
# Validate status type
valid_status_types = ["playing", "listening", "watching", "streaming", "competing"]
status_type = status_type.lower()
if status_type not in valid_status_types:
await ctx.send(f"Invalid status type: '{status_type}'. Valid types are: {', '.join(valid_status_types)}")
return
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
# Update all bot configurations
updated_count = 0
for bot_config in config.get("bots", []):
bot_config["status_type"] = status_type
bot_config["status_text"] = status_text
updated_count += 1
if updated_count == 0:
await ctx.send("No bots found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
await ctx.send(f"Status for all {updated_count} bots has been updated to '{status_type.capitalize()} {status_text}'.")
# Check if any bots are running
running_bots = [bot_id for bot_id, thread in self.bot_threads.items() if thread.is_alive()]
if running_bots:
await ctx.send(f"Note: You need to restart the following bots for this change to take effect: {', '.join(running_bots)}")
except Exception as e:
await ctx.send(f"Error setting all bot statuses: {e}")
@commands.command(name="addbot")
@commands.is_owner()
async def add_bot(self, ctx, bot_id: str, prefix: str = "!"):
"""Add a new bot configuration (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
# Check if bot_id already exists
for bot_config in config.get("bots", []):
if bot_config.get("id") == bot_id:
await ctx.send(f"Bot with ID '{bot_id}' already exists.")
return
# Create new bot configuration
new_bot = {
"id": bot_id,
"token": "",
"prefix": prefix,
"system_prompt": "You are a helpful assistant.",
"model": "deepseek/deepseek-chat-v3-0324:free",
"max_tokens": 1000,
"temperature": 0.7,
"timeout": 60,
"status_type": "listening",
"status_text": f"{prefix}ai"
}
# Add to the configuration
if "bots" not in config:
config["bots"] = []
config["bots"].append(new_bot)
# Save the updated configuration
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
await ctx.send(f"Bot '{bot_id}' added to configuration with prefix '{prefix}'.")
await ctx.send("Note: You need to set a token for this bot using the `!setbottoken` command before starting it.")
except Exception as e:
await ctx.send(f"Error adding bot: {e}")
@commands.command(name="removebot")
@commands.is_owner()
async def remove_bot(self, ctx, bot_id: str):
"""Remove a bot configuration (Owner only)"""
# Load the configuration
if not os.path.exists(CONFIG_FILE):
await ctx.send(f"Configuration file not found: {CONFIG_FILE}")
return
try:
with open(CONFIG_FILE, "r") as f:
config = json.load(f)
# Check if the bot is running and stop it
if (bot_id in self.bot_processes and self.bot_processes[bot_id].poll() is None) or \
(bot_id in self.bot_threads and self.bot_threads[bot_id].is_alive()):
await self.stop_bot(ctx, bot_id)
# Find and remove the bot configuration
found = False
if "bots" in config:
config["bots"] = [bc for bc in config["bots"] if bc.get("id") != bot_id]
found = True
if not found:
await ctx.send(f"Bot '{bot_id}' not found in configuration.")
return
# Save the updated configuration
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=4)
await ctx.send(f"Bot '{bot_id}' removed from configuration.")
except Exception as e:
await ctx.send(f"Error removing bot: {e}")
async def setup(bot):
await bot.add_cog(MultiBotCog(bot))

File diff suppressed because it is too large Load Diff

369
cogs/oauth_cog.py Normal file
View File

@ -0,0 +1,369 @@
"""
OAuth cog for the Discord bot.
This cog provides commands for authenticating with Discord OAuth2,
managing tokens, and checking authentication status.
"""
import os
import secrets
import discord
from discord.ext import commands
import asyncio
import aiohttp
from typing import Dict, Optional, Any
# Import the OAuth modules
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import discord_oauth
import oauth_server
class OAuthCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.pending_auth = {}
# Start the OAuth server
asyncio.create_task(self.start_oauth_server())
async def start_oauth_server(self):
"""Start the OAuth callback server if API OAuth is not enabled."""
# Check if API OAuth is enabled
api_oauth_enabled = os.getenv("API_OAUTH_ENABLED", "true").lower() in ("true", "1", "yes")
if api_oauth_enabled:
# If API OAuth is enabled, we don't need to start the local OAuth server
api_url = os.getenv("API_URL", "https://slipstreamm.dev/api")
redirect_uri = os.getenv("DISCORD_REDIRECT_URI", f"{api_url}/auth")
print(f"Using API OAuth endpoint at {redirect_uri}")
return
# Otherwise, start the local OAuth server
host = os.getenv("OAUTH_HOST", "localhost")
port = int(os.getenv("OAUTH_PORT", "8080"))
await oauth_server.start_server(host, port)
print(f"OAuth callback server running at http://{host}:{port}")
async def check_token_availability(self, user_id: str, channel_id: int, max_attempts: int = 15, delay: int = 3):
"""Check if a token is available for the user after API OAuth flow."""
# Import the OAuth module
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import discord_oauth
# Get the channel
channel = self.bot.get_channel(channel_id)
if not channel:
print(f"Could not find channel with ID {channel_id}")
return
# Wait for the token to become available
for attempt in range(max_attempts):
# Wait for a bit
await asyncio.sleep(delay)
print(f"Checking token availability for user {user_id}, attempt {attempt+1}/{max_attempts}")
# Try to get the token
try:
# First try to get the token from the local storage
token = await discord_oauth.get_token(user_id)
if token:
# Token is available locally, send a success message
await channel.send(f"<@{user_id}> ✅ Authentication successful! You can now use the API.")
return
# If not available locally, try to get it from the API service
if discord_oauth.API_OAUTH_ENABLED:
print(f"Token not found locally, checking API service for user {user_id}")
try:
# Make a direct API call to check if the token exists in the API service
async with aiohttp.ClientSession() as session:
url = f"{discord_oauth.API_URL}/check_auth/{user_id}"
print(f"Checking auth status at: {url}")
async with session.get(url) as resp:
if resp.status == 200:
# User is authenticated in the API service
data = await resp.json()
print(f"API service auth check response: {data}")
if data.get("authenticated", False):
# Try to retrieve the token from the API service
token_url = f"{discord_oauth.API_URL}/token/{user_id}"
async with session.get(token_url) as token_resp:
if token_resp.status == 200:
token_data = await token_resp.json()
# Save the token locally
discord_oauth.save_token(user_id, token_data)
await channel.send(f"<@{user_id}> ✅ Authentication successful! You can now use the API.")
return
except Exception as e:
print(f"Error checking auth status with API service: {e}")
except Exception as e:
print(f"Error checking token availability: {e}")
# If we get here, the token is not available after max_attempts
await channel.send(f"<@{user_id}> ⚠️ Authentication may have failed. Please try again or check with the bot owner.")
async def auth_callback(self, user_id: str, user_info: Dict[str, Any]):
"""Callback for successful authentication."""
# Find the user in Discord
discord_user = self.bot.get_user(int(user_id))
if not discord_user:
print(f"Could not find Discord user with ID {user_id}")
return
# Send a DM to the user
try:
await discord_user.send(
f"✅ Authentication successful! You are now logged in as {user_info.get('username')}#{user_info.get('discriminator')}.\n"
f"Your Discord bot is now authorized to access the API on your behalf."
)
except discord.errors.Forbidden:
# If we can't send a DM, try to find the channel where the auth command was used
channel_id = self.pending_auth.get(user_id)
if channel_id:
channel = self.bot.get_channel(channel_id)
if channel:
await channel.send(f"<@{user_id}> ✅ Authentication successful!")
# Remove the pending auth entry
self.pending_auth.pop(user_id, None)
@commands.command(name="auth")
async def auth_command(self, ctx):
"""Authenticate with Discord to allow the bot to access the API on your behalf."""
user_id = str(ctx.author.id)
# Check if the user is already authenticated
token = await discord_oauth.get_token(user_id)
if token:
# Validate the token
is_valid, _ = await discord_oauth.validate_token(token)
if is_valid:
await ctx.send(
f"You are already authenticated. Use `!deauth` to revoke access or `!authstatus` to check your status."
)
return
# Generate a state parameter for security
state = secrets.token_urlsafe(32)
# Generate a code verifier for PKCE
code_verifier = discord_oauth.generate_code_verifier()
# Get the authorization URL
auth_url = discord_oauth.get_auth_url(state, code_verifier)
# Check if API OAuth is enabled
api_oauth_enabled = os.getenv("API_OAUTH_ENABLED", "true").lower() in ("true", "1", "yes")
if not api_oauth_enabled:
# If using local OAuth server, register the state and callback
oauth_server.register_auth_state(state, self.auth_callback)
else:
# If using API OAuth, we'll need to handle the callback differently
# Store the channel ID for the callback
# We'll check for token availability periodically
asyncio.create_task(self.check_token_availability(user_id, ctx.channel.id))
# Store the channel ID for the callback
self.pending_auth[user_id] = ctx.channel.id
# Create an embed with the auth instructions
embed = discord.Embed(
title="Discord Authentication",
description="Please click the link below to authenticate with Discord.",
color=discord.Color.blue()
)
embed.add_field(
name="Instructions",
value=(
"1. Click the link below to open Discord's authorization page\n"
"2. Authorize the application to access your Discord account\n"
"3. You will be redirected to a confirmation page\n"
"4. Return to Discord after seeing the confirmation"
),
inline=False
)
embed.add_field(
name="Authentication Link",
value=f"[Click here to authenticate]({auth_url})",
inline=False
)
# Add information about the redirect URI
if api_oauth_enabled:
api_url = os.getenv("API_URL", "https://slipstreamm.dev/api")
embed.add_field(
name="Note",
value=f"You will be redirected to the API service at {api_url}/auth",
inline=False
)
embed.set_footer(text="This link will expire in 10 minutes")
# Send the embed as a DM to the user
try:
await ctx.author.send(embed=embed)
await ctx.send("📬 I've sent you a DM with authentication instructions!")
except discord.errors.Forbidden:
# If we can't send a DM, send the auth link in the channel
await ctx.send(
f"I couldn't send you a DM. Please click this link to authenticate: {auth_url}\n"
f"This link will expire in 10 minutes."
)
@commands.command(name="deauth")
async def deauth_command(self, ctx):
"""Revoke the bot's access to your Discord account."""
user_id = str(ctx.author.id)
# Delete the local token
local_success = discord_oauth.delete_token(user_id)
# If API OAuth is enabled, also delete the token from the API service
api_success = False
if discord_oauth.API_OAUTH_ENABLED:
try:
async with aiohttp.ClientSession() as session:
url = f"{discord_oauth.API_URL}/token/{user_id}"
async with session.delete(url) as resp:
if resp.status == 200:
api_success = True
data = await resp.json()
print(f"API service token deletion response: {data}")
except Exception as e:
print(f"Error deleting token from API service: {e}")
if local_success or api_success:
if local_success and api_success:
await ctx.send("✅ Authentication revoked from both local storage and API service.")
elif local_success:
await ctx.send("✅ Authentication revoked from local storage.")
else:
await ctx.send("✅ Authentication revoked from API service.")
else:
await ctx.send("❌ You are not currently authenticated.")
@commands.command(name="authstatus")
async def auth_status_command(self, ctx):
"""Check your authentication status."""
user_id = str(ctx.author.id)
# First check if the user has a token locally
token = await discord_oauth.get_token(user_id)
if token:
# Validate the token
is_valid, _ = await discord_oauth.validate_token(token)
if is_valid:
# Get user info
try:
user_info = await discord_oauth.get_user_info(token)
username = user_info.get("username")
discriminator = user_info.get("discriminator")
await ctx.send(
f"✅ You are authenticated as {username}#{discriminator}.\n"
f"The bot can access the API on your behalf."
)
return
except discord_oauth.OAuthError:
await ctx.send(
"⚠️ Your authentication is valid, but there was an error retrieving your user information."
)
return
else:
# Token is invalid, but we'll check the API service before giving up
await ctx.send(
"⚠️ Your local token has expired. Checking with the API service..."
)
# If we get here, either there's no local token or it's invalid
# Check with the API service if enabled
if discord_oauth.API_OAUTH_ENABLED:
try:
async with aiohttp.ClientSession() as session:
url = f"{discord_oauth.API_URL}/check_auth/{user_id}"
print(f"Checking auth status at: {url}")
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
print(f"API service auth check response: {data}")
if data.get("authenticated", False):
# User is authenticated in the API service
# Try to retrieve the token
token_url = f"{discord_oauth.API_URL}/token/{user_id}"
async with session.get(token_url) as token_resp:
if token_resp.status == 200:
token_data = await token_resp.json()
# Save the token locally
discord_oauth.save_token(user_id, token_data)
# Get user info with the new token
try:
access_token = token_data.get("access_token")
user_info = await discord_oauth.get_user_info(access_token)
username = user_info.get("username")
discriminator = user_info.get("discriminator")
await ctx.send(
f"✅ You are authenticated as {username}#{discriminator}.\n"
f"The bot can access the API on your behalf.\n"
f"(Token retrieved from API service)"
)
return
except Exception as e:
print(f"Error getting user info with token from API service: {e}")
await ctx.send(
f"✅ You are authenticated according to the API service.\n"
f"The token has been retrieved and saved locally."
)
return
except Exception as e:
print(f"Error checking auth status with API service: {e}")
# If we get here, the user is not authenticated anywhere
await ctx.send("❌ You are not currently authenticated. Use `!auth` to authenticate.")
@commands.command(name="authhelp")
async def auth_help_command(self, ctx):
"""Get help with authentication commands."""
embed = discord.Embed(
title="Authentication Help",
description="Commands for managing Discord authentication",
color=discord.Color.blue()
)
embed.add_field(
name="!auth",
value="Authenticate with Discord to allow the bot to access the API on your behalf",
inline=False
)
embed.add_field(
name="!deauth",
value="Revoke the bot's access to your Discord account",
inline=False
)
embed.add_field(
name="!authstatus",
value="Check your authentication status",
inline=False
)
embed.add_field(
name="!authhelp",
value="Show this help message",
inline=False
)
await ctx.send(embed=embed)
async def setup(bot):
await bot.add_cog(OAuthCog(bot))

29
cogs/ping_cog.py Normal file
View File

@ -0,0 +1,29 @@
import discord
from discord.ext import commands
from discord import app_commands
class PingCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
async def _ping_logic(self):
"""Core logic for the ping command."""
latency = round(self.bot.latency * 1000)
return f'Pong! Response time: {latency}ms'
# --- Prefix Command ---
@commands.command(name="ping")
async def ping(self, ctx: commands.Context):
"""Check the bot's response time."""
response = await self._ping_logic()
await ctx.reply(response)
# --- Slash Command ---
@app_commands.command(name="ping", description="Check the bot's response time")
async def ping_slash(self, interaction: discord.Interaction):
"""Slash command version of ping."""
response = await self._ping_logic()
await interaction.response.send_message(response)
async def setup(bot):
await bot.add_cog(PingCog(bot))

128
cogs/random_cog.py Normal file
View File

@ -0,0 +1,128 @@
import os
import discord
from discord.ext import commands
from discord import app_commands
import random as random_module
import typing # Need this for Optional
# Cache to store uploaded file URLs (local to this cog)
file_url_cache = {}
class RandomCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
# Updated _random_logic
async def _random_logic(self, interaction_or_ctx, hidden: bool = False) -> typing.Optional[str]:
"""Core logic for the random command. Returns an error message string or None if successful."""
# NSFW Check
is_nsfw_channel = False
channel = interaction_or_ctx.channel
if isinstance(channel, discord.TextChannel) and channel.is_nsfw():
is_nsfw_channel = True
elif isinstance(channel, discord.DMChannel): # DMs are considered NSFW for this purpose
is_nsfw_channel = True
if not is_nsfw_channel:
# Return error message directly, ephemeral handled by caller
return 'This command can only be used in age-restricted (NSFW) channels or DMs.'
directory = os.getenv('UPLOAD_DIRECTORY')
if not directory:
return 'UPLOAD_DIRECTORY is not set in the .env file.'
if not os.path.isdir(directory):
return 'The specified UPLOAD_DIRECTORY does not exist or is not a directory.'
files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
if not files:
return 'The specified directory is empty.'
# Attempt to send a random file, handling potential size issues
original_files = list(files) # Copy for checking if all files failed
while files:
chosen_file_name = random_module.choice(files)
file_path = os.path.join(directory, chosen_file_name)
# Check cache first
if chosen_file_name in file_url_cache:
# For interactions, defer if not already done, using the hidden flag
if not isinstance(interaction_or_ctx, commands.Context) and not interaction_or_ctx.response.is_done():
await interaction_or_ctx.response.defer(ephemeral=hidden) # Defer before sending cached URL
# Send cached URL
if isinstance(interaction_or_ctx, commands.Context):
await interaction_or_ctx.reply(file_url_cache[chosen_file_name]) # Prefix commands can't be ephemeral
else:
await interaction_or_ctx.followup.send(file_url_cache[chosen_file_name], ephemeral=hidden)
return None # Indicate success
try:
# Determine how to send the file based on context/interaction
if isinstance(interaction_or_ctx, commands.Context):
message = await interaction_or_ctx.reply(file=discord.File(file_path)) # Use reply for context
else: # It's an interaction
# Interactions need followup for files after defer()
if not interaction_or_ctx.response.is_done():
await interaction_or_ctx.response.defer(ephemeral=hidden) # Defer before sending file
# Send file ephemerally if hidden is True
message = await interaction_or_ctx.followup.send(file=discord.File(file_path), ephemeral=hidden)
# Cache the URL if successfully sent
if message and message.attachments:
file_url_cache[chosen_file_name] = message.attachments[0].url
# Success, no further message needed
return None
else:
# Should not happen if send succeeded, but handle defensively
files.remove(chosen_file_name)
print(f"Warning: File {chosen_file_name} sent but no attachment URL found.") # Log warning
continue
except discord.HTTPException as e:
if e.code == 40005: # Request entity too large
print(f"File too large: {chosen_file_name}")
files.remove(chosen_file_name)
continue # Try another file
else:
print(f"HTTP Error sending file: {e}")
# Return error message directly, ephemeral handled by caller
return f'Failed to upload the file due to an HTTP error: {e}'
except Exception as e:
print(f"Generic Error sending file: {e}")
# Return error message directly, ephemeral handled by caller
return f'An unexpected error occurred while uploading the file: {e}'
# If loop finishes without returning/sending, all files were too large
# Return error message directly, ephemeral handled by caller
return 'All files in the directory were too large to upload.'
# --- Prefix Command ---
@commands.command(name="random")
async def random(self, ctx: commands.Context):
"""Upload a random NSFW image from the configured directory."""
# Call _random_logic, hidden is False by default and irrelevant for prefix
response = await self._random_logic(ctx)
if response is not None:
await ctx.reply(response)
# --- Slash Command ---
# Updated signature and logic
@app_commands.command(name="random", description="Upload a random NSFW image from the configured directory")
@app_commands.describe(hidden="Set to True to make the response visible only to you (default: False)")
async def random_slash(self, interaction: discord.Interaction, hidden: bool = False):
"""Slash command version of random."""
# Pass hidden parameter to logic
response = await self._random_logic(interaction, hidden=hidden)
# If response is None, the logic already sent the file via followup/deferral
if response is not None: # An error occurred
# Ensure interaction hasn't already been responded to or deferred
if not interaction.response.is_done():
# Send error message ephemerally if hidden is True OR if it's the NSFW channel error
ephemeral_error = hidden or response.startswith('This command can only be used')
await interaction.response.send_message(response, ephemeral=ephemeral_error)
else:
# If deferred, use followup. Send ephemerally based on hidden flag.
await interaction.followup.send(response, ephemeral=hidden)
async def setup(bot):
await bot.add_cog(RandomCog(bot))

View File

@ -0,0 +1,98 @@
import discord
from discord.ext import commands
from discord import app_commands
import random
class PackGodCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.string_list = [
"google chrome garden gnome",
"flip phone disowned",
"ice cream cone metronome",
"final chrome student loan",
"underground flintstone chicken bone",
"grandma went to the corner store and got her dentures thrown out the door",
"baby face aint got no place tripped on my shoelace",
"fortnite birth night",
"doom zoom room full of gloom",
"sentient bean saw a dream on a trampoline",
"wifi sci-fi alibi from a samurai",
"pickle jar avatar with a VCR",
"garage band on demand ran off with a rubber band",
"dizzy lizzy in a blizzard with a kazoo",
"moonlight gaslight bug bite fight night",
"toothpaste suitcase in a high-speed footrace",
"donut warzone with a saxophone ringtone",
"angsty toaster posted up like a rollercoaster",
"spork fork stork on the New York sidewalk",
"quantum raccoon stole my macaroon at high noon",
"algebra grandma in a panorama wearing pajamas",
"cactus cactus got a TikTok practice",
"eggplant overlord on a hoverboard discord",
"fridge magnet prophet dropped an omelet in the cockpit",
"mystery meat got beat by a spreadsheet",
"lava lamp champ with a tax refund stamp",
"hologram scam on a traffic cam jam",
"pogo stick picnic turned into a cryptic mythic",
"sock puppet summit on a budget with a trumpet",
"noodle crusade in a lemonade braid parade",
"neon platypus doing calculus on a school bus",
"hamster vigilante with a coffee-stained affidavit",
"microwave rave in a medieval cave",
"sidewalk chalk talk got hacked by a squawk",
"yoga mat diplomat in a laundromat",
"banana phone cyclone in a monotone zone",
"jukebox paradox at a paradox detox",
"laundry day melee with a broken bidet",
"emoji samurai with a ramen supply and a laser eye",
"grandpa hologram doing taxes on a banana stand",
"bubble wrap trap",
"waffle iron tyrant on a silent siren diet",
"paperclip spaceship with a midlife crisis playlist",
"marshmallow diplomat moonwalking into a courtroom spat",
"gummy bear heir in an electric chair of despair",
"fax machine dream team with a tambourine scheme",
"soda cannon with a canon",
"pretzel twist anarchist on a solar-powered tryst",
"unicycle oracle at a discount popsicle miracle",
"jousting mouse in a chainmail blouse with a holy spouse",
"ye olde scroll turned into a cinnamon roll at the wizard patrol",
"bard with a debit card locked in a tower of lard",
"court jester investor lost a duel to a molester",
"squire on fire writing poetry to a liar for hire",
"archery mishap caused by a gremlin with a Snapchat app",
"knight with stage fright performing Hamlet in moonlight"
]
self.start_text = "shut yo"
self.end_text = "ahh up"
async def _packgod_logic(self):
"""Core logic for the packgod command."""
# Randomly select 3 strings from the list
selected_strings = random.sample(self.string_list, 3)
# Format the message
message = f"{self.start_text} "
message += ", ".join(selected_strings)
message += f" {self.end_text}"
return message
# --- Prefix Command ---
@commands.command(name="packgod")
async def packgod(self, ctx: commands.Context):
"""Send a message with hardcoded text and 3 random strings."""
response = await self._packgod_logic()
await ctx.reply(response)
# --- Slash Command ---
@app_commands.command(name="packgod", description="Send a message with hardcoded text and 3 random strings")
async def packgod_slash(self, interaction: discord.Interaction):
"""Slash command version of packgod."""
response = await self._packgod_logic()
await interaction.response.send_message(response)
async def setup(bot: commands.Bot):
await bot.add_cog(PackGodCog(bot))

328
cogs/random_timeout_cog.py Normal file
View File

@ -0,0 +1,328 @@
import discord
from discord.ext import commands
from discord import app_commands
import random
import datetime
import logging
import json
import os
# Set up logging
logger = logging.getLogger(__name__)
# Define the path for the JSON file to store timeout chance
TIMEOUT_CONFIG_FILE = os.path.join(os.path.dirname(__file__), "../data/timeout_config.json")
class RandomTimeoutCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.target_user_id = 748405715520978965 # The specific user ID to target
self.timeout_chance = 0.005 # Default: 0.5% chance (0.005)
self.timeout_duration = 60 # 1 minute in seconds
self.log_channel_id = 1363007131980136600 # Channel ID to log all events
# Ensure data directory exists
os.makedirs(os.path.dirname(TIMEOUT_CONFIG_FILE), exist_ok=True)
# Load timeout chance from JSON file
self.load_timeout_config()
logger.info(f"RandomTimeoutCog initialized with target user ID: {self.target_user_id} and timeout chance: {self.timeout_chance}")
def load_timeout_config(self):
"""Load timeout configuration from JSON file"""
if os.path.exists(TIMEOUT_CONFIG_FILE):
try:
with open(TIMEOUT_CONFIG_FILE, "r") as f:
data = json.load(f)
if "timeout_chance" in data:
self.timeout_chance = data["timeout_chance"]
logger.info(f"Loaded timeout chance: {self.timeout_chance}")
except Exception as e:
logger.error(f"Error loading timeout configuration: {e}")
def save_timeout_config(self):
"""Save timeout configuration to JSON file"""
try:
config_data = {
"timeout_chance": self.timeout_chance,
"target_user_id": self.target_user_id,
"timeout_duration": self.timeout_duration
}
with open(TIMEOUT_CONFIG_FILE, "w") as f:
json.dump(config_data, f, indent=4)
logger.info(f"Saved timeout configuration with chance: {self.timeout_chance}")
except Exception as e:
logger.error(f"Error saving timeout configuration: {e}")
async def create_log_embed(self, message, roll, was_timed_out):
"""Create an embed for logging the timeout event"""
# Create the embed with appropriate color based on timeout status
color = discord.Color.red() if was_timed_out else discord.Color.green()
embed = discord.Embed(
title=f"{'⚠️ TIMEOUT TRIGGERED' if was_timed_out else '✅ No Timeout'}",
description=f"Message from <@{self.target_user_id}> was processed",
color=color,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
# Add user information
embed.add_field(
name="👤 User Information",
value=f"**User:** {message.author.mention}\n**User ID:** {message.author.id}",
inline=False
)
# Add roll information
embed.add_field(
name="🎲 Roll Information",
value=f"**Roll:** {roll:.6f}\n**Threshold:** {self.timeout_chance:.6f}\n**Chance:** {self.timeout_chance * 100:.2f}%\n**Result:** {'TIMEOUT' if was_timed_out else 'SAFE'}",
inline=False
)
# Add message information
embed.add_field(
name="💬 Message Information",
value=f"**Channel:** {message.channel.mention}\n**Message Link:** [Click Here]({message.jump_url})",
inline=False
)
# Set footer
embed.set_footer(text=f"Random Timeout System | {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
# Set author with user avatar
embed.set_author(name=f"{message.author.name}#{message.author.discriminator}", icon_url=message.author.display_avatar.url)
return embed
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Event listener for messages to randomly timeout the target user"""
# Ignore bot messages
if message.author.bot:
return
# Check if the message author is the target user
if message.author.id == self.target_user_id:
# Generate a random number between 0 and 1
roll = random.random()
was_timed_out = False
# If the roll is less than the timeout chance (1%), timeout the user
if roll < self.timeout_chance:
try:
# Calculate timeout until time (1 minute from now)
timeout_until = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=self.timeout_duration)
# Apply the timeout
await message.author.timeout(timeout_until, reason="Random 0.5% chance timeout")
was_timed_out = True
# Send a message to the channel
await message.channel.send(
f"🎲 Bad luck! {message.author.mention} rolled a {roll:.4f} and got timed out for 1 minute! (0.5% chance)",
delete_after=10 # Delete after 10 seconds
)
logger.info(f"User {message.author.id} was randomly timed out for 1 minute")
except discord.Forbidden:
logger.warning(f"Bot doesn't have permission to timeout user {message.author.id}")
except discord.HTTPException as e:
logger.error(f"Failed to timeout user {message.author.id}: {e}")
except Exception as e:
logger.error(f"Unexpected error when timing out user {message.author.id}: {e}")
# Log the event to the specified channel regardless of timeout result
try:
# Get the log channel
log_channel = self.bot.get_channel(self.log_channel_id)
if log_channel:
# Create and send the embed
embed = await self.create_log_embed(message, roll, was_timed_out)
await log_channel.send(embed=embed)
else:
logger.warning(f"Log channel with ID {self.log_channel_id} not found")
except Exception as e:
logger.error(f"Error sending log message: {e}")
@commands.command(name="set_timeout_chance")
@commands.has_permissions(moderate_members=True)
async def set_timeout_chance(self, ctx, percentage: float):
"""Set the random timeout chance percentage (Moderator only, max 10% unless owner)"""
# Convert percentage to decimal (e.g., 5% -> 0.05)
decimal_chance = percentage / 100.0
# Check if user is owner
is_owner = await self.bot.is_owner(ctx.author)
# Validate the percentage
if not is_owner and (percentage < 0 or percentage > 10):
await ctx.reply(f"❌ Error: Moderators can only set timeout chance between 0% and 10%. Current: {self.timeout_chance * 100:.2f}%")
return
elif percentage < 0 or percentage > 100:
await ctx.reply(f"❌ Error: Timeout chance must be between 0% and 100%. Current: {self.timeout_chance * 100:.2f}%")
return
# Store the old value for logging
old_chance = self.timeout_chance
# Update the timeout chance
self.timeout_chance = decimal_chance
# Save the updated timeout chance to the JSON file
self.save_timeout_config()
# Create an embed for the response
embed = discord.Embed(
title="Timeout Chance Updated",
description=f"The random timeout chance has been updated.",
color=discord.Color.blue(),
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
embed.add_field(
name="Previous Chance",
value=f"{old_chance * 100:.2f}%",
inline=True
)
embed.add_field(
name="New Chance",
value=f"{self.timeout_chance * 100:.2f}%",
inline=True
)
embed.add_field(
name="Updated By",
value=f"{ctx.author.mention} {' (Owner)' if is_owner else ' (Moderator)'}",
inline=False
)
embed.set_footer(text=f"Random Timeout System | User ID: {self.target_user_id}")
# Send the response
await ctx.reply(embed=embed)
# Log the change
logger.info(f"Timeout chance changed from {old_chance:.4f} to {self.timeout_chance:.4f} by {ctx.author.name} (ID: {ctx.author.id})")
# Also log to the log channel if available
try:
log_channel = self.bot.get_channel(self.log_channel_id)
if log_channel:
await log_channel.send(embed=embed)
except Exception as e:
logger.error(f"Error sending log message: {e}")
@set_timeout_chance.error
async def set_timeout_chance_error(self, ctx, error):
"""Error handler for the set_timeout_chance command"""
if isinstance(error, commands.MissingPermissions):
await ctx.reply("❌ You need the 'Moderate Members' permission to use this command.")
elif isinstance(error, commands.MissingRequiredArgument):
await ctx.reply(f"❌ Please provide a percentage. Example: `!set_timeout_chance 0.5` for 0.5%. Current: {self.timeout_chance * 100:.2f}%")
elif isinstance(error, commands.BadArgument):
await ctx.reply(f"❌ Please provide a valid number. Example: `!set_timeout_chance 0.5` for 0.5%. Current: {self.timeout_chance * 100:.2f}%")
else:
await ctx.reply(f"❌ An error occurred: {error}")
logger.error(f"Error in set_timeout_chance command: {error}")
@app_commands.command(name="set_timeout_chance", description="Set the random timeout chance percentage")
@app_commands.describe(percentage="The percentage chance (0-10% for moderators, 0-100% for owner)")
@app_commands.checks.has_permissions(moderate_members=True)
async def set_timeout_chance_slash(self, interaction: discord.Interaction, percentage: float):
"""Slash command version of set_timeout_chance"""
# Convert percentage to decimal (e.g., 5% -> 0.05)
decimal_chance = percentage / 100.0
# Check if user is owner
is_owner = await self.bot.is_owner(interaction.user)
# Validate the percentage
if not is_owner and (percentage < 0 or percentage > 10):
await interaction.response.send_message(
f"❌ Error: Moderators can only set timeout chance between 0% and 10%. Current: {self.timeout_chance * 100:.2f}%",
ephemeral=True
)
return
elif percentage < 0 or percentage > 100:
await interaction.response.send_message(
f"❌ Error: Timeout chance must be between 0% and 100%. Current: {self.timeout_chance * 100:.2f}%",
ephemeral=True
)
return
# Store the old value for logging
old_chance = self.timeout_chance
# Update the timeout chance
self.timeout_chance = decimal_chance
# Save the updated timeout chance to the JSON file
self.save_timeout_config()
# Create an embed for the response
embed = discord.Embed(
title="Timeout Chance Updated",
description=f"The random timeout chance has been updated.",
color=discord.Color.blue(),
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
embed.add_field(
name="Previous Chance",
value=f"{old_chance * 100:.2f}%",
inline=True
)
embed.add_field(
name="New Chance",
value=f"{self.timeout_chance * 100:.2f}%",
inline=True
)
embed.add_field(
name="Updated By",
value=f"{interaction.user.mention} {' (Owner)' if is_owner else ' (Moderator)'}",
inline=False
)
embed.set_footer(text=f"Random Timeout System | User ID: {self.target_user_id}")
# Send the response
await interaction.response.send_message(embed=embed)
# Log the change
logger.info(f"Timeout chance changed from {old_chance:.4f} to {self.timeout_chance:.4f} by {interaction.user.name} (ID: {interaction.user.id})")
# Also log to the log channel if available
try:
log_channel = self.bot.get_channel(self.log_channel_id)
if log_channel:
await log_channel.send(embed=embed)
except Exception as e:
logger.error(f"Error sending log message: {e}")
@set_timeout_chance_slash.error
async def set_timeout_chance_slash_error(self, interaction: discord.Interaction, error):
"""Error handler for the set_timeout_chance slash command"""
if isinstance(error, app_commands.errors.MissingPermissions):
await interaction.response.send_message(
"❌ You need the 'Moderate Members' permission to use this command.",
ephemeral=True
)
else:
await interaction.response.send_message(
f"❌ An error occurred: {error}",
ephemeral=True
)
logger.error(f"Error in set_timeout_chance slash command: {error}")
@commands.Cog.listener()
async def on_ready(self):
logger.info(f'{self.__class__.__name__} cog has been loaded.')
async def setup(bot: commands.Bot):
await bot.add_cog(RandomTimeoutCog(bot))
print("RandomTimeoutCog loaded successfully!")

139
cogs/role_creator_cog.py Normal file
View File

@ -0,0 +1,139 @@
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv
import logging
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(name)s: %(message)s')
logger = logging.getLogger(__name__)
# Load environment variables
load_dotenv()
OWNER_USER_ID = int(os.getenv("OWNER_USER_ID")) # Although commands.is_owner() handles this, loading for clarity/potential future use
class RoleCreatorCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.command(name='create_roles', help='Creates predefined roles for reaction roles. Owner only.')
@commands.is_owner() # Restricts this command to the bot owner specified during bot setup
async def create_roles(self, ctx):
"""Creates a set of predefined roles typically used with reaction roles."""
guild = ctx.guild
if not guild:
await ctx.send("This command can only be used in a server.")
return
# Check if the bot has permission to manage roles
if not ctx.me.guild_permissions.manage_roles:
await ctx.send("I don't have permission to manage roles.")
logger.warning(f"Missing 'Manage Roles' permission in guild {guild.id} ({guild.name}).")
return
# Define color mapping for specific roles
color_map = {
"Red": discord.Color.red(),
"Blue": discord.Color.blue(),
"Green": discord.Color.green(),
"Yellow": discord.Color.gold(),
"Purple": discord.Color.purple(),
"Orange": discord.Color.orange(),
"Pink": discord.Color.fuchsia(),
"Black": discord.Color(0x010101), # Near black to avoid blending with themes
"White": discord.Color(0xFEFEFE) # Near white to avoid blending
}
await ctx.send("Starting role creation/update process...")
logger.info(f"Role creation/update initiated by {ctx.author} in guild {guild.id} ({guild.name}).")
role_categories = {
"Colors": ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Black", "White"],
"Regions": ["NA East", "NA West", "EU", "Asia", "Oceania", "South America"],
"Pronouns": ["He/Him", "She/Her", "They/Them", "Ask Pronouns"],
"Interests": ["Art", "Music", "Movies", "Books", "Technology", "Science", "History", "Food", "Programming", "Anime", "Photography", "Travel", "Writing", "Cooking", "Fitness", "Nature", "Gaming", "Philosophy", "Psychology", "Design", "Machine Learning", "Cryptocurrency", "Astronomy", "Mythology", "Languages", "Architecture", "DIY Projects", "Hiking", "Streaming", "Virtual Reality", "Coding Challenges", "Board Games", "Meditation", "Urban Exploration", "Tattoo Art", "Comics", "Robotics", "3D Modeling", "Podcasts"],
"Gaming Platforms": ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"],
"Favorite Vocaloids": ["Hatsune Miku", "Kasane Teto", "Akita Neru", "Kagamine Rin", "Kagamine Len", "Megurine Luka", "Kaito", "Meiko", "Gumi", "Kaai Yuki"],
"Notifications": ["Announcements"]
}
created_count = 0
updated_count = 0 # Renamed from eped_count
skipped_other_count = 0 # For non-color roles that exist
error_count = 0
existing_roles = {role.name.lower(): role for role in guild.roles} # Cache existing roles for faster lookup
for category, names in role_categories.items():
logger.info(f"Processing category: {category}")
for name in names:
role_color = color_map.get(name) if category == "Colors" else None
role_exists = name.lower() in existing_roles
try:
if role_exists:
existing_role = existing_roles[name.lower()]
# Only edit if it's a color role and needs a color update (or just ensure color is set)
if category == "Colors" and role_color is not None:
# Check if color needs updating to avoid unnecessary API calls
if existing_role.color != role_color:
await existing_role.edit(color=role_color)
logger.info(f"Successfully updated color for existing role: {name}")
updated_count += 1
else:
logger.info(f"Role '{name}' already exists with correct color. Skipping update.")
updated_count += 1 # Count as updated/checked even if no change needed
else:
# Non-color role exists, skip it
logger.info(f"Non-color role '{name}' already exists. Skipping.")
skipped_other_count += 1
continue # Move to next role name
# Role does not exist, create it
await guild.create_role(
name=name,
color=role_color or discord.Color.default(), # Use mapped color or default
permissions=discord.Permissions.none(),
mentionable=False
)
logger.info(f"Successfully created role: {name}" + (f" with color {role_color}" if role_color else ""))
created_count += 1
except discord.Forbidden:
logger.error(f"Forbidden to {'edit' if role_exists else 'create'} role '{name}'. Check bot permissions.")
await ctx.send(f"Error: I lack permissions to {'edit' if role_exists else 'create'} the role '{name}'.")
error_count += 1
# Stop if permission error occurs, as it likely affects subsequent operations
await ctx.send(f"Stopping role processing due to permission error on role '{name}'.")
return
except discord.HTTPException as e:
logger.error(f"Failed to {'edit' if role_exists else 'create'} role '{name}': {e}")
await ctx.send(f"Error {'editing' if role_exists else 'creating'} role '{name}': {e}")
error_count += 1
except Exception as e:
logger.exception(f"An unexpected error occurred while processing role '{name}': {e}")
await ctx.send(f"An unexpected error occurred for role '{name}'. Check logs.")
error_count += 1
summary_message = f"Role creation/update process complete.\n" \
f"Created: {created_count}\n" \
f"Updated/Checked Colors: {updated_count}\n" \
f"Skipped (Other existing): {skipped_other_count}\n" \
f"Errors: {error_count}"
await ctx.send(summary_message)
logger.info(summary_message)
async def setup(bot):
# Ensure the owner ID is loaded correctly before adding the cog
if not OWNER_USER_ID:
logger.error("OWNER_USER_ID not found in .env file. RoleCreatorCog will not be loaded.")
return
# Check if the bot object has owner_id or owner_ids set, which discord.py uses for is_owner()
if not bot.owner_id and not bot.owner_ids:
logger.warning("Bot owner_id or owner_ids not set. The 'is_owner()' check might not function correctly.")
# Potentially load from OWNER_USER_ID if needed, though discord.py usually handles this
# bot.owner_id = OWNER_USER_ID # Uncomment if necessary and discord.py doesn't auto-load
await bot.add_cog(RoleCreatorCog(bot))
logger.info("RoleCreatorCog loaded successfully.")

513
cogs/role_selector_cog.py Normal file
View File

@ -0,0 +1,513 @@
import discord
from discord.ext import commands
from discord.ui import View, Select, select
import json
import os
from typing import List, Dict, Optional, Set, Tuple
import asyncio # Added for sleep
# Role structure expected (based on role_creator_cog)
# Using original category names from role_creator_cog as keys
EXPECTED_ROLES: Dict[str, List[str]] = {
"Colors": ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Black", "White"],
"Regions": ["NA East", "NA West", "EU", "Asia", "Oceania", "South America"],
"Pronouns": ["He/Him", "She/Her", "They/Them", "Ask Pronouns"],
"Interests": ["Art", "Music", "Movies", "Books", "Technology", "Science", "History", "Food", "Programming", "Anime", "Photography", "Travel", "Writing", "Cooking", "Fitness", "Nature", "Gaming", "Philosophy", "Psychology", "Design", "Machine Learning", "Cryptocurrency", "Astronomy", "Mythology", "Languages", "Architecture", "DIY Projects", "Hiking", "Streaming", "Virtual Reality", "Coding Challenges", "Board Games", "Meditation", "Urban Exploration", "Tattoo Art", "Comics", "Robotics", "3D Modeling", "Podcasts"],
"Gaming Platforms": ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"],
"Favorite Vocaloids": ["Hatsune Miku", "Kasane Teto", "Akita Neru", "Kagamine Rin", "Kagamine Len", "Megurine Luka", "Kaito", "Meiko", "Gumi", "Kaai Yuki"],
"Notifications": ["Announcements"]
}
# Mapping creator categories to selector categories (for single-choice logic etc.)
# and providing display names/embed titles
CATEGORY_DETAILS = {
"Colors": {"selector_category": "color", "title": "🎨 Color Roles", "description": "Choose your favorite color role.", "color": discord.Color.green(), "max_values": 1},
"Regions": {"selector_category": "region", "title": "🌍 Region Roles", "description": "Select your region.", "color": discord.Color.orange(), "max_values": 1},
"Pronouns": {"selector_category": "name", "title": "📛 Pronoun Roles", "description": "Select your pronoun roles.", "color": discord.Color.blue(), "max_values": 4}, # Allow multiple pronouns
"Interests": {"selector_category": "interests", "title": "💡 Interests", "description": "Select your interests.", "color": discord.Color.purple(), "max_values": 16}, # Allow multiple (Increased max_values again)
"Gaming Platforms": {"selector_category": "gaming", "title": "🎮 Gaming Platforms", "description": "Select your gaming platforms.", "color": discord.Color.dark_grey(), "max_values": 5}, # Allow multiple
"Favorite Vocaloids": {"selector_category": "vocaloid", "title": "🎤 Favorite Vocaloids", "description": "Select your favorite Vocaloids.", "color": discord.Color.teal(), "max_values": 10}, # Allow multiple
"Notifications": {"selector_category": "notifications", "title": "🔔 Notifications", "description": "Opt-in for notifications.", "color": discord.Color.light_grey(), "max_values": 1} # Allow multiple (or single if only one role)
}
# --- Persistent View Definition ---
class RoleSelectorView(View):
def __init__(self, category_roles: List[discord.Role], selector_category_name: str, max_values: int = 1):
super().__init__(timeout=None)
self.category_role_ids: Set[int] = {role.id for role in category_roles}
self.selector_category_name = selector_category_name
self.custom_id = f"persistent_role_select_view_{selector_category_name}"
self.select_chunk_map: Dict[str, Set[int]] = {} # Map custom_id to role IDs in that chunk
# Split roles into chunks of 25 for multiple select menus if needed
self.role_chunks = [category_roles[i:i + 25] for i in range(0, len(category_roles), 25)]
num_chunks = len(self.role_chunks)
# Ensure total max_values doesn't exceed the total number of roles
total_max_values = min(max_values, len(category_roles))
# For multi-select, min_values is typically 0 unless explicitly required otherwise
# For single-select categories, min_values should be 0 to allow deselecting by choosing nothing
# Note: Discord UI might enforce min_values=1 if max_values=1. Let's keep min_values=0 for flexibility.
actual_min_values = 0
for i, chunk in enumerate(self.role_chunks):
options = [discord.SelectOption(label=role.name, value=str(role.id)) for role in chunk]
chunk_role_ids = {role.id for role in chunk}
if not options:
continue
# Determine max_values for this specific select menu
# If multiple selects, allow selecting up to total_max_values across all of them.
# Each individual select menu still has a max_values limit of 25.
chunk_max_values = min(total_max_values, len(options)) # Allow selecting up to the total allowed, but capped by options in this chunk
placeholder = f"Select {selector_category_name} role(s)..."
if num_chunks > 1:
placeholder = f"Select {selector_category_name} role(s) ({i+1}/{num_chunks})..."
# Custom ID needs to be unique per select menu but linkable to the category
select_custom_id = f"role_select_dropdown_{selector_category_name}_{i}"
self.select_chunk_map[select_custom_id] = chunk_role_ids # Store mapping
select_component = Select(
placeholder=placeholder,
min_values=actual_min_values, # Allow selecting zero from any individual dropdown
max_values=chunk_max_values, # Max selectable from *this* dropdown
options=options,
custom_id=select_custom_id
)
select_component.callback = self.select_callback
self.add_item(select_component)
async def select_callback(self, interaction: discord.Interaction):
# Callback logic remains largely the same, but needs to handle potentially
# Callback logic needs to handle selections from one dropdown without
# affecting selections made via other dropdowns in the same view/category.
await interaction.response.defer(ephemeral=True, thinking=True)
member = interaction.user
guild = interaction.guild
if not isinstance(member, discord.Member) or not guild:
await interaction.followup.send("This interaction must be used within a server.", ephemeral=True)
return
# --- Identify interacted dropdown and its roles ---
interacted_custom_id = interaction.data['custom_id']
# Find the corresponding chunk role IDs using the stored map
interacted_chunk_role_ids: Set[int] = set()
if hasattr(self, 'select_chunk_map') and interacted_custom_id in self.select_chunk_map:
interacted_chunk_role_ids = self.select_chunk_map[interacted_custom_id]
else:
# Fallback or error handling if map isn't populated (shouldn't happen in normal flow)
print(f"Warning: Could not find chunk map for custom_id {interacted_custom_id} in view {self.custom_id}")
# Attempt to find the component and its options as a less reliable fallback
for component in self.children:
if isinstance(component, Select) and component.custom_id == interacted_custom_id:
interacted_chunk_role_ids = {int(opt.value) for opt in component.options}
break
if not interacted_chunk_role_ids:
await interaction.followup.send("An internal error occurred trying to identify the roles for this dropdown.", ephemeral=True)
return
selected_values = interaction.data.get('values', [])
current_selector_category = self.selector_category_name
# --- Calculate changes based on interaction ---
selected_role_ids_from_interaction = {int(value) for value in selected_values}
# Get all roles the member currently has within this entire category
member_category_role_ids = {role.id for role in member.roles if role.id in self.category_role_ids}
# Roles to add are those selected in this interaction that the member doesn't already have
roles_to_add_ids = selected_role_ids_from_interaction - member_category_role_ids
# Roles to remove are those from *this specific dropdown's chunk* that the member *had*, but are *no longer selected* in this interaction.
member_roles_in_interacted_chunk = member_category_role_ids.intersection(interacted_chunk_role_ids)
roles_to_remove_ids = member_roles_in_interacted_chunk - selected_role_ids_from_interaction
# --- Single-choice category handling ---
is_single_choice = current_selector_category in ['color', 'region', 'notifications'] # Add more if needed
if is_single_choice and roles_to_add_ids:
# Ensure only one role is being added
if len(roles_to_add_ids) > 1:
await interaction.followup.send(f"Error: Cannot select multiple roles for the '{current_selector_category}' category.", ephemeral=True)
return # Stop processing
role_to_add_id = list(roles_to_add_ids)[0]
# Identify all other roles in the category the member currently has (excluding the one being added)
other_member_roles_in_category = member_category_role_ids - {role_to_add_id}
# Add these other roles to the removal set
roles_to_remove_ids.update(other_member_roles_in_category)
# Ensure only the single selected role is in the add set
roles_to_add_ids = {role_to_add_id}
# --- Convert IDs to Role objects ---
roles_to_add = {guild.get_role(role_id) for role_id in roles_to_add_ids if guild.get_role(role_id)}
roles_to_remove = {guild.get_role(role_id) for role_id in roles_to_remove_ids if guild.get_role(role_id)}
# --- Apply changes and provide feedback ---
added_names = []
removed_names = []
error_messages = []
try:
# Perform removals first
if roles_to_remove:
await member.remove_roles(*roles_to_remove, reason=f"Deselected/changed via {current_selector_category} role selector ({interacted_custom_id})")
removed_names = [r.name for r in roles_to_remove if r]
# Then perform additions
if roles_to_add:
await member.add_roles(*roles_to_add, reason=f"Selected via {current_selector_category} role selector ({interacted_custom_id})")
added_names = [r.name for r in roles_to_add if r]
# Construct feedback message
if added_names or removed_names:
feedback = "Your roles have been updated!"
if added_names:
feedback += f"\n+ Added: {', '.join(added_names)}"
if removed_names:
feedback += f"\n- Removed: {', '.join(removed_names)}"
elif selected_values: # Roles were selected, but no changes needed (already had them)
feedback = f"No changes needed for the roles selected in this dropdown."
else: # No roles selected in this interaction
if member_roles_in_interacted_chunk: # Had roles from this chunk, now removed
feedback = f"Roles deselected from this dropdown."
else: # Had no roles from this chunk, selected none
feedback = f"No roles selected in this dropdown."
await interaction.followup.send(feedback, ephemeral=True)
except discord.Forbidden:
error_messages.append("I don't have permission to manage roles.")
except discord.HTTPException as e:
error_messages.append(f"An error occurred while updating roles: {e}")
except Exception as e:
error_messages.append(f"An unexpected error occurred: {e}")
print(f"Error in role selector callback: {e}")
if error_messages:
await interaction.followup.send("\n".join(error_messages), ephemeral=True)
class RoleSelectorCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.bot.loop.create_task(self.register_persistent_views())
def _get_guild_roles_by_name(self, guild: discord.Guild) -> Dict[str, discord.Role]:
return {role.name.lower(): role for role in guild.roles}
def _get_dynamic_roles_per_category(self, guild: discord.Guild) -> Dict[str, List[discord.Role]]:
"""Dynamically fetches roles and groups them by the original creator category."""
guild_roles_map = self._get_guild_roles_by_name(guild)
categorized_roles: Dict[str, List[discord.Role]] = {cat: [] for cat in EXPECTED_ROLES.keys()}
missing_roles = []
for creator_category, role_names in EXPECTED_ROLES.items():
for role_name in role_names:
role = guild_roles_map.get(role_name.lower())
if role:
categorized_roles[creator_category].append(role)
else:
missing_roles.append(f"'{role_name}' (Category: {creator_category})")
if missing_roles:
print(f"Warning: Roles not found in guild '{guild.name}' ({guild.id}): {', '.join(missing_roles)}")
# Sort roles within each category alphabetically by name for consistent order
for category in categorized_roles:
categorized_roles[category].sort(key=lambda r: r.name)
return categorized_roles
async def register_persistent_views(self):
"""Registers persistent views dynamically for each category."""
await self.bot.wait_until_ready()
print("RoleSelectorCog: Registering persistent views...")
registered_count = 0
guild_count = 0
for guild in self.bot.guilds:
guild_count += 1
print(f"Processing guild for view registration: {guild.name} ({guild.id})")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
try:
# Register a view for this specific category
self.bot.add_view(RoleSelectorView(role_list, selector_category, max_values=max_values))
registered_count += 1
except Exception as e:
print(f" - Error registering view for '{creator_category}' in guild {guild.id}: {e}")
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f" - No roles found for category '{creator_category}' in guild {guild.id}, skipping view registration.")
elif creator_category not in CATEGORY_DETAILS:
print(f" - Warning: Category '{creator_category}' found in EXPECTED_ROLES but not in CATEGORY_DETAILS. Cannot register view.")
print(f"RoleSelectorCog: Finished registering {registered_count} persistent views across {guild_count} guild(s).")
@commands.command(name="create_role_embeds")
@commands.is_owner()
async def create_role_embeds(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Creates embeds with persistent dropdowns for each role category. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command can only be used in a server.")
return
initial_message = await ctx.send(f"Fetching roles and creating embeds in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
if not any(roles_by_creator_category.values()):
await initial_message.edit(content="No roles matching the expected names were found in this server. Please run the `create_roles` command first.")
return
sent_messages = 0
# --- Create Embeds and attach Persistent Views for each category ---
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
embed = discord.Embed(
title=details["title"],
description=details["description"],
color=details["color"]
)
# Create a new view instance for sending
view = RoleSelectorView(role_list, selector_category, max_values=max_values)
try:
await target_channel.send(embed=embed, view=view)
sent_messages += 1
except discord.Forbidden:
await ctx.send(f"Error: Missing permissions to send messages in {target_channel.mention}.")
await initial_message.delete() # Clean up initial message
return
except discord.HTTPException as e:
await ctx.send(f"Error sending embed for '{creator_category}': {e}")
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f"Skipping embed for empty category '{creator_category}' in guild {guild.id}")
if sent_messages > 0:
await initial_message.edit(content=f"Created {sent_messages} role selection embed(s) in {target_channel.mention} successfully!")
else:
await initial_message.edit(content=f"No roles found for any category to create embeds in {target_channel.mention}.")
@commands.command(name="update_role_selectors")
@commands.is_owner()
async def update_role_selectors(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Updates existing role selector messages in a channel with the current roles. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command must be used within a server.")
return
await ctx.send(f"Starting update process for role selectors in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
updated_messages = 0
checked_messages = 0
errors = 0
try:
async for message in target_channel.history(limit=200): # Check recent messages
checked_messages += 1
if message.author == self.bot.user and message.embeds and message.components:
# Check if the message has a view with a select menu matching our pattern
view_component = message.components[0] # Assuming the view is the first component row
if not isinstance(view_component, discord.ActionRow) or not view_component.children:
continue
first_item = view_component.children[0]
if isinstance(first_item, discord.ui.Select) and first_item.custom_id and first_item.custom_id.startswith("role_select_dropdown_"):
selector_category_name = first_item.custom_id.split("role_select_dropdown_")[1]
# Find the original creator category based on the selector category name
creator_category = None
for cat, details in CATEGORY_DETAILS.items():
if details["selector_category"] == selector_category_name:
creator_category = cat
break
if creator_category and creator_category in roles_by_creator_category:
current_roles = roles_by_creator_category[creator_category]
if not current_roles:
print(f"Skipping update for {selector_category_name} in message {message.id} - no roles found for this category anymore.")
continue # Skip if no roles exist for this category now
details = CATEGORY_DETAILS[creator_category]
max_values = details["max_values"]
# Create a new view with the updated roles
new_view = RoleSelectorView(current_roles, selector_category_name, max_values=max_values)
# Check if the options or max_values actually changed to avoid unnecessary edits
select_in_old_message = first_item
select_in_new_view = new_view.children[0] if new_view.children and isinstance(new_view.children[0], discord.ui.Select) else None
if select_in_new_view:
old_options = {(opt.label, str(opt.value)) for opt in select_in_old_message.options}
new_options = {(opt.label, str(opt.value)) for opt in select_in_new_view.options}
old_max_values = select_in_old_message.max_values
new_max_values = select_in_new_view.max_values
if old_options != new_options or old_max_values != new_max_values:
try:
await message.edit(view=new_view)
print(f"Updated role selector for '{selector_category_name}' in message {message.id} (Options changed: {old_options != new_options}, Max values changed: {old_max_values != new_max_values})")
updated_messages += 1
except discord.Forbidden:
print(f"Error: Missing permissions to edit message {message.id} in {target_channel.name}")
errors += 1
except discord.HTTPException as e:
print(f"Error: Failed to edit message {message.id}: {e}")
errors += 1
except Exception as e:
print(f"Unexpected error editing message {message.id}: {e}")
errors += 1
else:
print(f"Skipping update for {selector_category_name} in message {message.id} - options and max_values unchanged.")
else:
print(f"Error: Could not find Select component in the newly generated view for category '{selector_category_name}'. Skipping message {message.id}.")
# else: # Debugging if needed
# print(f"Message {message.id} has select menu '{selector_category_name}' but no matching category found in current config.")
# else: # Debugging if needed
# print(f"Message {message.id} from bot has components, but first item is not a recognized select menu.")
# else: # Debugging if needed
# if message.author == self.bot.user:
# print(f"Message {message.id} from bot skipped (Embeds: {bool(message.embeds)}, Components: {bool(message.components)})")
except discord.Forbidden:
await ctx.send(f"Error: I don't have permissions to read message history in {target_channel.mention}.")
return
except Exception as e:
await ctx.send(f"An unexpected error occurred during the update process: {e}")
print(f"Unexpected error in update_role_selectors: {e}")
return
await ctx.send(f"Role selector update process finished for {target_channel.mention}.\n"
f"Checked: {checked_messages} messages.\n"
f"Updated: {updated_messages} selectors.\n"
f"Errors: {errors}")
@commands.command(name="recreate_role_embeds")
@commands.is_owner()
async def recreate_role_embeds(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Deletes existing role selectors in a channel and creates new ones. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command must be used within a server.")
return
initial_status_msg = await ctx.send(f"Starting recreation process for role selectors in {target_channel.mention}...")
# --- Step 1: Find and Delete Existing Selectors ---
deleted_messages = 0
checked_messages = 0
deletion_errors = 0
messages_to_delete = []
try:
await initial_status_msg.edit(content=f"Searching for existing role selectors in {target_channel.mention} (checking last 500 messages)...")
async for message in target_channel.history(limit=500): # Check a reasonable number of messages
checked_messages += 1
# --- MODIFIED: Delete any message sent by the bot ---
if message.author == self.bot.user:
messages_to_delete.append(message)
# --- END MODIFICATION ---
if messages_to_delete:
await initial_status_msg.edit(content=f"Found {len(messages_to_delete)} messages from the bot. Deleting...")
# Delete messages one by one to handle potential rate limits and errors better
for msg in messages_to_delete:
try:
await msg.delete()
deleted_messages += 1
await asyncio.sleep(1) # Add a small delay to avoid rate limits
except discord.Forbidden:
print(f"Error: Missing permissions to delete message {msg.id} in {target_channel.name}")
deletion_errors += 1
except discord.NotFound:
print(f"Warning: Message {msg.id} not found (already deleted?).")
# Don't count as an error, but maybe decrement deleted_messages if needed?
except discord.HTTPException as e:
print(f"Error: Failed to delete message {msg.id}: {e}")
deletion_errors += 1
except Exception as e:
print(f"Unexpected error deleting message {msg.id}: {e}")
deletion_errors += 1
await initial_status_msg.edit(content=f"Deleted {deleted_messages} messages. Errors during deletion: {deletion_errors}.")
else:
await initial_status_msg.edit(content="No existing role selector messages found to delete.")
await asyncio.sleep(2) # Brief pause before creating new ones
except discord.Forbidden:
await initial_status_msg.edit(content=f"Error: I don't have permissions to read message history or delete messages in {target_channel.mention}.")
return
except Exception as e:
await initial_status_msg.edit(content=f"An unexpected error occurred during deletion: {e}")
print(f"Unexpected error in recreate_role_embeds (deletion phase): {e}")
return
# --- Step 2: Create New Embeds (similar to create_role_embeds) ---
await initial_status_msg.edit(content=f"Fetching roles and creating new embeds in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
if not any(roles_by_creator_category.values()):
await initial_status_msg.edit(content="No roles matching the expected names were found in this server. Cannot create new embeds. Please run the `create_roles` command first.")
return
sent_messages = 0
creation_errors = 0
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
embed = discord.Embed(
title=details["title"],
description=details["description"],
color=details["color"]
)
view = RoleSelectorView(role_list, selector_category, max_values=max_values)
try:
await target_channel.send(embed=embed, view=view)
sent_messages += 1
await asyncio.sleep(0.5) # Small delay between sends
except discord.Forbidden:
await ctx.send(f"Error: Missing permissions to send messages in {target_channel.mention}. Aborting creation.")
creation_errors += 1
break # Stop trying if permissions are missing
except discord.HTTPException as e:
await ctx.send(f"Error sending embed for '{creator_category}': {e}")
creation_errors += 1
except Exception as e:
print(f"Unexpected error sending embed for '{creator_category}': {e}")
creation_errors += 1
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f"Skipping new embed for empty category '{creator_category}' in guild {guild.id}")
final_message = f"Role selector recreation process finished for {target_channel.mention}.\n" \
f"Deleted: {deleted_messages} (Errors: {deletion_errors})\n" \
f"Created: {sent_messages} (Errors: {creation_errors})"
await initial_status_msg.edit(content=final_message)
async def setup(bot):
await bot.add_cog(RoleSelectorCog(bot))
print("RoleSelectorCog loaded. Persistent views will be registered once the bot is ready.")

35
cogs/roleplay_cog.py Normal file
View File

@ -0,0 +1,35 @@
import discord
from discord.ext import commands
from discord import app_commands
class RoleplayCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("RoleplayCog initialized!")
async def _backshots_logic(self, sender_mention, recipient_mention):
"""Core logic for the backshots command."""
# Format the message with sender and recipient mentions
message = f"*{sender_mention} giving {recipient_mention} BACKSHOTS*\n{recipient_mention}: w-wait.. not in front of people-!\n{sender_mention}: \"shhh, it's okay, let them watch... 𝐥𝐞𝐭 𝐭𝐡𝐞𝐦 𝐤𝐧𝐨𝐰 𝐲𝐨𝐮'𝐫𝐞 𝐦𝐢𝐧𝐞...\""
return message
# --- Prefix Command ---
@commands.command(name="backshots")
async def backshots(self, ctx: commands.Context, sender: discord.Member, recipient: discord.Member):
"""Send a roleplay message about giving backshots between two mentioned users."""
response = await self._backshots_logic(sender.mention, recipient.mention)
await ctx.send(response)
# --- Slash Command ---
@app_commands.command(name="backshots", description="Send a roleplay message about giving backshots between two mentioned users")
@app_commands.describe(
sender="The user giving backshots",
recipient="The user receiving backshots"
)
async def backshots_slash(self, interaction: discord.Interaction, sender: discord.Member, recipient: discord.Member):
"""Slash command version of backshots."""
response = await self._backshots_logic(sender.mention, recipient.mention)
await interaction.response.send_message(response)
async def setup(bot: commands.Bot):
await bot.add_cog(RoleplayCog(bot))

364
cogs/rule34_cog.py Normal file
View File

@ -0,0 +1,364 @@
import os
import discord
from discord.ext import commands
from discord import app_commands
from discord.ui import Button, View
import random
import aiohttp
import time
import json
import typing # Need this for Optional
# Cache file path (consider making this configurable or relative to bot root)
CACHE_FILE = "rule34_cache.json"
class Rule34Cog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.cache_data = self._load_cache()
def _load_cache(self):
"""Loads the Rule34 cache from a JSON file."""
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, "r") as f:
return json.load(f)
except Exception as e:
print(f"Failed to load Rule34 cache file ({CACHE_FILE}): {e}")
return {}
def _save_cache(self):
"""Saves the Rule34 cache to a JSON file."""
try:
with open(CACHE_FILE, "w") as f:
json.dump(self.cache_data, f, indent=4)
except Exception as e:
print(f"Failed to save Rule34 cache file ({CACHE_FILE}): {e}")
# Updated _rule34_logic
async def _rule34_logic(self, interaction_or_ctx, tags: str, hidden: bool = False) -> typing.Union[str, tuple]:
"""Core logic for the rule34 command.
Returns either:
- Error message string, or
- Tuple of (random_result_url, all_results) on success"""
base_url = "https://api.rule34.xxx/index.php"
all_results = []
current_pid = 0
# NSFW Check
is_nsfw_channel = False
channel = interaction_or_ctx.channel
if isinstance(channel, discord.TextChannel) and channel.is_nsfw():
is_nsfw_channel = True
elif isinstance(channel, discord.DMChannel):
is_nsfw_channel = True
# Allow if 'rating:safe' is explicitly included in tags, regardless of channel type
allow_in_non_nsfw = 'rating:safe' in tags.lower()
if not is_nsfw_channel and not allow_in_non_nsfw:
# Return error message, ephemeral handled by caller
return 'This command can only be used in age-restricted (NSFW) channels, DMs, or with the `rating:safe` tag.'
# Defer or send loading message
loading_msg = None
is_interaction = not isinstance(interaction_or_ctx, commands.Context)
if is_interaction:
# Check if already deferred or responded
if not interaction_or_ctx.response.is_done():
# Defer ephemerally based on hidden flag
await interaction_or_ctx.response.defer(ephemeral=hidden)
else: # Prefix command
loading_msg = await interaction_or_ctx.reply("Fetching data, please wait...")
# Check cache for the given tags
cache_key = tags.lower().strip() # Normalize tags for cache key
if cache_key in self.cache_data:
cached_entry = self.cache_data[cache_key]
cache_timestamp = cached_entry.get("timestamp", 0)
# Cache valid for 24 hours
if time.time() - cache_timestamp < 86400:
all_results = cached_entry.get("results", [])
if all_results:
random_result = random.choice(all_results)
content = f"{random_result['file_url']}"
# Always return the data. The caller handles sending/editing.
return (content, all_results) # Success, return both random and all results
# If no valid cache or cache is outdated, fetch from API
all_results = [] # Reset results if cache was invalid/outdated
async with aiohttp.ClientSession() as session:
try:
while True:
params = {
"page": "dapi", "s": "post", "q": "index",
"limit": 1000, "pid": current_pid, "tags": tags, "json": 1
}
async with session.get(base_url, params=params) as response:
if response.status == 200:
try:
data = await response.json()
except aiohttp.ContentTypeError:
print(f"Rule34 API returned non-JSON response for tags: {tags}, pid: {current_pid}")
data = None # Treat as no data
if not data or (isinstance(data, list) and len(data) == 0):
break # No more results or empty response
if isinstance(data, list):
all_results.extend(data)
else:
print(f"Unexpected API response format (not list): {data}")
break # Stop processing if format is wrong
current_pid += 1
else:
# Return error message, ephemeral handled by caller
return f"Failed to fetch data. HTTP Status: {response.status}"
# Save results to cache if new results were fetched
if all_results: # Only save if we actually got results
self.cache_data[cache_key] = { # Use normalized key
"timestamp": int(time.time()),
"results": all_results
}
self._save_cache()
# Handle results
if not all_results:
# Return error message, ephemeral handled by caller
return "No results found for the given tags."
else:
random_result = random.choice(all_results)
result_content = f"{random_result['file_url']}"
# Always return the data. The caller handles sending/editing.
return (result_content, all_results) # Success, return both random and all results
except Exception as e:
error_msg = f"An error occurred: {e}"
print(f"Error in rule34 logic: {e}") # Log the error
# Return error message, ephemeral handled by caller
return error_msg
class Rule34Buttons(View):
def __init__(self, cog, tags: str, all_results: list, hidden: bool = False):
super().__init__(timeout=60)
self.cog = cog
self.tags = tags
self.all_results = all_results
self.hidden = hidden
self.current_index = 0
@discord.ui.button(label="New Random", style=discord.ButtonStyle.primary)
async def new_random(self, interaction: discord.Interaction, button: Button):
random_result = random.choice(self.all_results)
content = f"{random_result['file_url']}"
await interaction.response.edit_message(content=content, view=self)
@discord.ui.button(label="Random In New Message", style=discord.ButtonStyle.success)
async def new_message(self, interaction: discord.Interaction, button: Button):
random_result = random.choice(self.all_results)
content = f"{random_result['file_url']}"
# Send the new image and the original view in a single new message
await interaction.response.send_message(content, view=self, ephemeral=self.hidden)
@discord.ui.button(label="Browse Results", style=discord.ButtonStyle.secondary)
async def browse_results(self, interaction: discord.Interaction, button: Button):
if len(self.all_results) == 0:
await interaction.response.send_message("No results to browse", ephemeral=True)
return
self.current_index = 0
result = self.all_results[self.current_index]
content = f"Result 1/{len(self.all_results)}:\n{result['file_url']}"
view = self.BrowseView(self.cog, self.tags, self.all_results, self.hidden)
await interaction.response.edit_message(content=content, view=view)
@discord.ui.button(label="Pin", style=discord.ButtonStyle.danger)
async def pin_message(self, interaction: discord.Interaction, button: Button):
if interaction.message:
try:
await interaction.message.pin()
await interaction.response.send_message("Message pinned successfully!", ephemeral=True)
except discord.Forbidden:
await interaction.response.send_message("I don't have permission to pin messages in this channel.", ephemeral=True)
except discord.HTTPException as e:
await interaction.response.send_message(f"Failed to pin the message: {e}", ephemeral=True)
class BrowseView(View):
def __init__(self, cog, tags: str, all_results: list, hidden: bool = False):
super().__init__(timeout=60)
self.cog = cog
self.tags = tags
self.all_results = all_results
self.hidden = hidden
self.current_index = 0
@discord.ui.button(label="First", style=discord.ButtonStyle.secondary)
async def first(self, interaction: discord.Interaction, button: Button):
self.current_index = 0
result = self.all_results[self.current_index]
content = f"Result 1/{len(self.all_results)}:\n{result['file_url']}"
await interaction.response.edit_message(content=content, view=self)
@discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary)
async def previous(self, interaction: discord.Interaction, button: Button):
if self.current_index > 0:
self.current_index -= 1
else:
self.current_index = len(self.all_results) - 1
result = self.all_results[self.current_index]
content = f"Result {self.current_index + 1}/{len(self.all_results)}:\n{result['file_url']}"
await interaction.response.edit_message(content=content, view=self)
@discord.ui.button(label="Next", style=discord.ButtonStyle.primary)
async def next(self, interaction: discord.Interaction, button: Button):
if self.current_index < len(self.all_results) - 1:
self.current_index += 1
else:
self.current_index = 0
result = self.all_results[self.current_index]
content = f"Result {self.current_index + 1}/{len(self.all_results)}:\n{result['file_url']}"
await interaction.response.edit_message(content=content, view=self)
@discord.ui.button(label="Last", style=discord.ButtonStyle.secondary)
async def last(self, interaction: discord.Interaction, button: Button):
self.current_index = len(self.all_results) - 1
result = self.all_results[self.current_index]
content = f"Result {len(self.all_results)}/{len(self.all_results)}:\n{result['file_url']}"
await interaction.response.edit_message(content=content, view=self)
@discord.ui.button(label="Go To", style=discord.ButtonStyle.primary)
async def goto(self, interaction: discord.Interaction, button: Button):
modal = self.GoToModal(len(self.all_results))
await interaction.response.send_modal(modal)
await modal.wait()
if modal.value is not None:
self.current_index = modal.value - 1
result = self.all_results[self.current_index]
content = f"Result {modal.value}/{len(self.all_results)}:\n{result['file_url']}"
await interaction.followup.edit_message(interaction.message.id, content=content, view=self)
class GoToModal(discord.ui.Modal):
def __init__(self, max_pages: int):
super().__init__(title="Go To Page")
self.value = None
self.max_pages = max_pages
self.page_num = discord.ui.TextInput(
label=f"Page Number (1-{max_pages})",
placeholder=f"Enter a number between 1 and {max_pages}",
min_length=1,
max_length=len(str(max_pages))
)
self.add_item(self.page_num)
async def on_submit(self, interaction: discord.Interaction):
try:
num = int(self.page_num.value)
if 1 <= num <= self.max_pages:
self.value = num
await interaction.response.defer()
else:
await interaction.response.send_message(
f"Please enter a number between 1 and {self.max_pages}",
ephemeral=True
)
except ValueError:
await interaction.response.send_message(
"Please enter a valid number",
ephemeral=True
)
@discord.ui.button(label="Back", style=discord.ButtonStyle.danger)
async def back(self, interaction: discord.Interaction, button: Button):
random_result = random.choice(self.all_results)
content = f"{random_result['file_url']}"
view = Rule34Cog.Rule34Buttons(self.cog, self.tags, self.all_results, self.hidden)
await interaction.response.edit_message(content=content, view=view)
# --- Prefix Command ---
@commands.command(name="rule34")
async def rule34(self, ctx: commands.Context, *, tags: str = "kasane_teto"):
"""Search for images on Rule34 with the provided tags."""
# Send initial loading message
loading_msg = await ctx.reply("Fetching data, please wait...")
# Call logic, passing the context (which includes the loading_msg reference indirectly)
response = await self._rule34_logic(ctx, tags)
if isinstance(response, tuple):
content, all_results = response
view = self.Rule34Buttons(self, tags, all_results)
# Edit the original loading message with content and view
await loading_msg.edit(content=content, view=view)
elif response is not None: # Error occurred
# Edit the original loading message with the error
await loading_msg.edit(content=response, view=None) # Remove view on error
# --- Slash Command ---
@app_commands.command(name="rule34", description="Get random image from rule34 with specified tags")
@app_commands.describe(
tags="The tags to search for (e.g., 'kasane_teto rating:safe')",
hidden="Set to True to make the response visible only to you (default: False)"
)
async def rule34_slash(self, interaction: discord.Interaction, tags: str, hidden: bool = False):
"""Slash command version of rule34."""
# Pass hidden parameter to logic
response = await self._rule34_logic(interaction, tags, hidden=hidden)
if isinstance(response, tuple):
content, all_results = response
view = self.Rule34Buttons(self, tags, all_results, hidden)
if interaction.response.is_done():
await interaction.followup.send(content, view=view, ephemeral=hidden)
else:
await interaction.response.send_message(content, view=view, ephemeral=hidden)
elif response is not None: # An error occurred
if not interaction.response.is_done():
ephemeral_error = hidden or response.startswith('This command can only be used')
await interaction.response.send_message(response, ephemeral=ephemeral_error)
else:
try:
await interaction.followup.send(response, ephemeral=hidden)
except discord.errors.NotFound:
print(f"Rule34 slash command: Interaction expired before sending error followup for tags '{tags}'.")
except discord.HTTPException as e:
print(f"Rule34 slash command: Failed to send error followup for tags '{tags}': {e}")
# --- New Browse Command ---
@app_commands.command(name="rule34browse", description="Browse Rule34 results with navigation buttons")
@app_commands.describe(
tags="The tags to search for (e.g., 'kasane_teto rating:safe')",
hidden="Set to True to make the response visible only to you (default: False)"
)
async def rule34_browse(self, interaction: discord.Interaction, tags: str, hidden: bool = False):
"""Browse Rule34 results with navigation buttons."""
response = await self._rule34_logic(interaction, tags, hidden=hidden)
if isinstance(response, tuple):
_, all_results = response
if len(all_results) == 0:
content = "No results found"
await interaction.response.send_message(content, ephemeral=hidden)
return
result = all_results[0]
content = f"Result 1/{len(all_results)}:\n{result['file_url']}"
view = self.Rule34Buttons.BrowseView(self, tags, all_results, hidden)
if interaction.response.is_done():
await interaction.followup.send(content, view=view, ephemeral=hidden)
else:
await interaction.response.send_message(content, view=view, ephemeral=hidden)
elif response is not None: # An error occurred
if not interaction.response.is_done():
ephemeral_error = hidden or response.startswith('This command can only be used')
await interaction.response.send_message(response, ephemeral=ephemeral_error)
else:
try:
await interaction.followup.send(response, ephemeral=hidden)
except discord.errors.NotFound:
print(f"Rule34 browse command: Interaction expired before sending error followup for tags '{tags}'.")
except discord.HTTPException as e:
print(f"Rule34 browse command: Failed to send error followup for tags '{tags}': {e}")
async def setup(bot):
await bot.add_cog(Rule34Cog(bot))

560
cogs/shell_command_cog.py Normal file
View File

@ -0,0 +1,560 @@
import discord
from discord.ext import commands
from discord import app_commands
import asyncio
import re
import os
import platform
import logging
from collections import defaultdict
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(name)s: %(message)s')
logger = logging.getLogger(__name__)
# Comprehensive list of banned commands and patterns
BANNED_COMMANDS = [
# # System modification commands
# "rm", "rmdir", "del", "format", "fdisk", "mkfs", "fsck", "dd", "shred",
# # File permission/ownership changes
# "chmod", "chown", "icacls", "takeown", "attrib",
# # User management
# "useradd", "userdel", "adduser", "deluser", "passwd", "usermod", "net user",
# # Process control that could affect the bot
# "kill", "pkill", "taskkill", "killall",
# # Package management
# "apt", "apt-get", "yum", "dnf", "pacman", "brew", "pip", "npm", "gem", "cargo",
# # Network configuration
# "ifconfig", "ip", "route", "iptables", "firewall-cmd", "ufw", "netsh",
# # System control
# "shutdown", "reboot", "halt", "poweroff", "init", "systemctl",
# # Potentially dangerous utilities
# "wget", "curl", "nc", "ncat", "telnet", "ssh", "scp", "ftp", "sftp",
# # Shell escapes or command chaining that could bypass restrictions
# "bash", "sh", "cmd", "powershell", "pwsh", "python", "perl", "ruby", "php", "node",
# # Git commands that could modify repositories
# "git push", "git commit", "git config", "git remote",
# # Windows specific dangerous commands
# "reg", "regedit", "wmic", "diskpart", "sfc", "dism",
# # Miscellaneous dangerous commands
# "eval", "exec", "source", ">", ">>", "|", "&", "&&", ";", "||"
]
# Regular expression patterns for more complex matching
BANNED_PATTERNS = [
# r"rm\s+(-[rf]\s+)*[/\\]", # rm with path starting from root
# r">\s*[/\\]", # redirect output to root path
# r">\s*~", # redirect output to home directory
# r">\s*\.", # redirect output to current directory
# r">\s*\.\.", # redirect output to parent directory
# r">\s*[a-zA-Z]:", # redirect output to drive letter (Windows)
# r";\s*rm", # command chaining with rm
# r"&&\s*rm", # command chaining with rm
# r"\|\|\s*rm", # command chaining with rm
# r";\s*del", # command chaining with del
# r"&&\s*del", # command chaining with del
# r"\|\|\s*del", # command chaining with del
]
def is_command_allowed(command):
"""
Check if the command is allowed to run.
Returns (allowed, reason) tuple.
"""
# Check against banned commands
for banned in BANNED_COMMANDS:
if banned in command.lower():
return False, f"Command contains banned term: `{banned}`"
# Check against banned patterns
for pattern in BANNED_PATTERNS:
if re.search(pattern, command):
return False, f"Command matches banned pattern: `{pattern}`"
return True, None
class ShellCommandCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.max_output_length = 1900 # Discord message limit is 2000 chars
self.timeout_seconds = 30 # Maximum time a command can run
# Store persistent shell sessions
self.owner_shell_sessions = defaultdict(lambda: {
'cwd': os.getcwd(),
'env': os.environ.copy()
})
# Store persistent docker shell sessions
self.docker_shell_sessions = defaultdict(lambda: {
'container_id': None,
'created': False
})
async def _execute_command(self, command_str, session_id=None, use_docker=False):
"""
Execute a shell command and return the output.
If session_id is provided, use the persistent session.
If use_docker is True, run the command in a Docker container.
"""
# Check if command is allowed
allowed, reason = is_command_allowed(command_str)
if not allowed:
return f"⛔ Command not allowed: {reason}"
# Log the command execution
logger.info(f"Executing {'docker ' if use_docker else ''}shell command: {command_str}")
try:
if use_docker:
return await self._execute_docker_command(command_str, session_id)
else:
return await self._execute_local_command(command_str, session_id)
except Exception as e:
logger.error(f"Error executing command: {e}")
return f"❌ Error executing command: {str(e)}"
async def _execute_local_command(self, command_str, session_id=None):
"""
Execute a command locally with optional session persistence.
"""
shell = True
session = None
if session_id:
session = self.owner_shell_sessions[session_id]
cwd = session['cwd']
env = session['env']
else:
cwd = os.getcwd()
env = os.environ.copy()
# Determine the shell to use based on platform
if platform.system() == "Windows":
process = await asyncio.create_subprocess_shell(
command_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=shell,
cwd=cwd,
env=env
)
else:
process = await asyncio.create_subprocess_shell(
command_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=shell,
cwd=cwd,
env=env
)
# Run the command with a timeout
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout_seconds
)
except asyncio.TimeoutError:
# Try to terminate the process if it times out
try:
process.terminate()
await asyncio.sleep(0.5)
if process.returncode is None:
process.kill()
except Exception as e:
logger.error(f"Error terminating process: {e}")
return f"⏱️ Command timed out after {self.timeout_seconds} seconds."
# Update session working directory if 'cd' command was used
if session_id and command_str.strip().startswith('cd '):
# Get the new working directory
if platform.system() == "Windows":
pwd_process = await asyncio.create_subprocess_shell(
"cd", # On Windows, 'cd' without args shows current directory
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True,
cwd=cwd,
env=env
)
else:
pwd_process = await asyncio.create_subprocess_shell(
"pwd",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True,
cwd=cwd,
env=env
)
pwd_stdout, _ = await pwd_process.communicate()
new_cwd = pwd_stdout.decode('utf-8', errors='replace').strip()
if new_cwd:
session['cwd'] = new_cwd
# Decode the output
stdout_str = stdout.decode('utf-8', errors='replace').strip()
stderr_str = stderr.decode('utf-8', errors='replace').strip()
# Prepare the result message
result = []
if stdout_str:
if len(stdout_str) > self.max_output_length:
stdout_str = stdout_str[:self.max_output_length] + "... (output truncated)"
result.append(f"📤 **STDOUT:**\n```\n{stdout_str}\n```")
if stderr_str:
if len(stderr_str) > self.max_output_length:
stderr_str = stderr_str[:self.max_output_length] + "... (output truncated)"
result.append(f"⚠️ **STDERR:**\n```\n{stderr_str}\n```")
if process.returncode != 0:
result.append(f"❌ **Exit Code:** {process.returncode}")
else:
if not result: # No output but successful
result.append("✅ Command executed successfully (no output).")
return "\n".join(result)
async def _execute_docker_command(self, command_str, session_id):
"""
Execute a command in a Docker container with session persistence.
"""
# First, check if Docker is available
docker_check_cmd = "docker --version"
try:
process = await asyncio.create_subprocess_shell(
docker_check_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
# We don't need the output, just the return code
await process.communicate()
if process.returncode != 0:
return f"❌ Docker is not available on this system. Please install Docker to use this command."
except Exception as e:
logger.error(f"Error checking Docker availability: {e}")
return f"❌ Error checking Docker availability: {str(e)}"
session = self.docker_shell_sessions[session_id]
# Create a new container if one doesn't exist for this session
if not session['created']:
# Create a new container with a minimal Linux image
create_container_cmd = "docker run -d --rm --name shell_" + session_id + " alpine:latest tail -f /dev/null"
process = await asyncio.create_subprocess_shell(
create_container_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode('utf-8', errors='replace').strip()
return f"❌ Failed to create Docker container: {error_msg}"
container_id = stdout.decode('utf-8', errors='replace').strip()
session['container_id'] = container_id
session['created'] = True
logger.info(f"Created Docker container with ID: {container_id} for session {session_id}")
# Execute the command in the container
# Escape double quotes in the command string
escaped_cmd = command_str.replace('"', '\\"')
docker_exec_cmd = f"docker exec shell_{session_id} sh -c \"{escaped_cmd}\""
process = await asyncio.create_subprocess_shell(
docker_exec_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout_seconds
)
except asyncio.TimeoutError:
# Try to terminate the process if it times out
try:
process.terminate()
await asyncio.sleep(0.5)
if process.returncode is None:
process.kill()
except Exception as e:
logger.error(f"Error terminating process: {e}")
return f"⏱️ Command timed out after {self.timeout_seconds} seconds."
# Decode the output
stdout_str = stdout.decode('utf-8', errors='replace').strip()
stderr_str = stderr.decode('utf-8', errors='replace').strip()
# Prepare the result message
result = []
if stdout_str:
if len(stdout_str) > self.max_output_length:
stdout_str = stdout_str[:self.max_output_length] + "... (output truncated)"
result.append(f"📤 **STDOUT:**\n```\n{stdout_str}\n```")
if stderr_str:
if len(stderr_str) > self.max_output_length:
stderr_str = stderr_str[:self.max_output_length] + "... (output truncated)"
result.append(f"⚠️ **STDERR:**\n```\n{stderr_str}\n```")
if process.returncode != 0:
result.append(f"❌ **Exit Code:** {process.returncode}")
else:
if not result: # No output but successful
result.append("✅ Command executed successfully (no output).")
return "\n".join(result)
@commands.command(name="ownershell", help="Execute a shell command directly on the host (Owner only)")
@commands.is_owner()
async def ownershell_command(self, ctx, *, command_str):
"""Execute a shell command directly on the host (Owner only)."""
# Get or create a session ID for this user
session_id = str(ctx.author.id)
async with ctx.typing():
result = await self._execute_command(command_str, session_id=session_id, use_docker=False)
# Split long messages if needed
if len(result) > 2000:
parts = [result[i:i+1990] for i in range(0, len(result), 1990)]
for i, part in enumerate(parts):
await ctx.reply(f"Part {i+1}/{len(parts)}:\n{part}")
else:
await ctx.reply(result)
@commands.command(name="shell", help="Execute a shell command in a Docker container")
async def shell_command(self, ctx, *, command_str):
"""Execute a shell command in a Docker container."""
# Get or create a session ID for this user
session_id = str(ctx.author.id)
async with ctx.typing():
result = await self._execute_command(command_str, session_id=session_id, use_docker=True)
# Split long messages if needed
if len(result) > 2000:
parts = [result[i:i+1990] for i in range(0, len(result), 1990)]
for i, part in enumerate(parts):
await ctx.reply(f"Part {i+1}/{len(parts)}:\n{part}")
else:
await ctx.reply(result)
@commands.command(name="newshell", help="Reset your shell session (Owner only)")
@commands.is_owner()
async def newshell_command(self, ctx, *, shell_type="docker"):
"""Reset a shell session (Owner only)."""
session_id = str(ctx.author.id)
if shell_type.lower() in ["docker", "container", "safe"]:
# If there's an existing container, stop and remove it
session = self.docker_shell_sessions[session_id]
if session['created'] and session['container_id']:
try:
# Stop the container
stop_cmd = f"docker stop shell_{session_id}"
process = await asyncio.create_subprocess_shell(
stop_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
await process.communicate()
except Exception as e:
logger.error(f"Error stopping Docker container: {e}")
# Reset the session
self.docker_shell_sessions[session_id] = {
'container_id': None,
'created': False
}
await ctx.reply("✅ Docker shell session has been reset.")
elif shell_type.lower() in ["owner", "host", "local"]:
# Reset the owner shell session
self.owner_shell_sessions[session_id] = {
'cwd': os.getcwd(),
'env': os.environ.copy()
}
await ctx.reply("✅ Owner shell session has been reset.")
else:
await ctx.reply("❌ Invalid shell type. Use 'docker' or 'owner'.")
@app_commands.command(name="ownershell", description="Execute a shell command directly on the host (Owner only)")
@app_commands.describe(command="The shell command to execute")
async def ownershell_slash(self, interaction: discord.Interaction, command: str):
"""Slash command version of ownershell command."""
# Check if user is the bot owner
if interaction.user.id != self.bot.owner_id:
await interaction.response.send_message("⛔ This command is restricted to the bot owner.", ephemeral=True)
return
# Get or create a session ID for this user
session_id = str(interaction.user.id)
# Defer the response as command execution might take time
await interaction.response.defer()
# Execute the command
result = await self._execute_command(command, session_id=session_id, use_docker=False)
# Send the result
if len(result) > 2000:
parts = [result[i:i+1990] for i in range(0, len(result), 1990)]
await interaction.followup.send(f"Part 1/{len(parts)}:\n{parts[0]}")
for i, part in enumerate(parts[1:], 2):
await interaction.followup.send(f"Part {i}/{len(parts)}:\n{part}")
else:
await interaction.followup.send(result)
@app_commands.command(name="shell", description="Execute a shell command in a Docker container (Owner only)")
@app_commands.describe(command="The shell command to execute")
async def shell_slash(self, interaction: discord.Interaction, command: str):
"""Slash command version of shell command."""
# Check if user is the bot owner
if interaction.user.id != self.bot.owner_id:
await interaction.response.send_message("⛔ This command is restricted to the bot owner.", ephemeral=True)
return
# Get or create a session ID for this user
session_id = str(interaction.user.id)
# Defer the response as command execution might take time
await interaction.response.defer()
# Execute the command
result = await self._execute_command(command, session_id=session_id, use_docker=True)
# Send the result
if len(result) > 2000:
parts = [result[i:i+1990] for i in range(0, len(result), 1990)]
await interaction.followup.send(f"Part 1/{len(parts)}:\n{parts[0]}")
for i, part in enumerate(parts[1:], 2):
await interaction.followup.send(f"Part {i}/{len(parts)}:\n{part}")
else:
await interaction.followup.send(result)
@app_commands.command(name="newshell", description="Reset your shell session (Owner only)")
@app_commands.describe(shell_type="The type of shell to reset ('docker' or 'owner')")
@app_commands.choices(shell_type=[
app_commands.Choice(name="Docker Container Shell", value="docker"),
app_commands.Choice(name="Owner Host Shell", value="owner")
])
async def newshell_slash(self, interaction: discord.Interaction, shell_type: str = "docker"):
"""Slash command version of newshell command."""
# Check if user is the bot owner
if interaction.user.id != self.bot.owner_id:
await interaction.response.send_message("⛔ This command is restricted to the bot owner.", ephemeral=True)
return
session_id = str(interaction.user.id)
if shell_type.lower() in ["docker", "container", "safe"]:
# If there's an existing container, stop and remove it
session = self.docker_shell_sessions[session_id]
if session['created'] and session['container_id']:
try:
# Stop the container
stop_cmd = f"docker stop shell_{session_id}"
process = await asyncio.create_subprocess_shell(
stop_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
await process.communicate()
except Exception as e:
logger.error(f"Error stopping Docker container: {e}")
# Reset the session
self.docker_shell_sessions[session_id] = {
'container_id': None,
'created': False
}
await interaction.response.send_message("✅ Docker shell session has been reset.")
elif shell_type.lower() in ["owner", "host", "local"]:
# Reset the owner shell session
self.owner_shell_sessions[session_id] = {
'cwd': os.getcwd(),
'env': os.environ.copy()
}
await interaction.response.send_message("✅ Owner shell session has been reset.")
else:
await interaction.response.send_message("❌ Invalid shell type. Use 'docker' or 'owner'.")
async def cog_unload(self):
"""Clean up resources when the cog is unloaded."""
# Check if Docker is available before trying to stop containers
docker_check_cmd = "docker --version"
try:
process = await asyncio.create_subprocess_shell(
docker_check_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
# We don't need the output, just the return code
await process.communicate()
if process.returncode != 0:
logger.warning("Docker is not available, skipping container cleanup.")
return
# Stop and remove all Docker containers
for session_id, session in self.docker_shell_sessions.items():
if session['created'] and session['container_id']:
try:
# Stop the container
stop_cmd = f"docker stop shell_{session_id}"
process = await asyncio.create_subprocess_shell(
stop_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True
)
await process.communicate()
except Exception as e:
logger.error(f"Error stopping Docker container during unload: {e}")
except Exception as e:
logger.error(f"Error checking Docker availability during unload: {e}")
async def setup(bot):
try:
logger.info("Attempting to load ShellCommandCog...")
await bot.add_cog(ShellCommandCog(bot))
logger.info("ShellCommandCog loaded successfully.")
except Exception as e:
logger.error(f"Failed to load ShellCommandCog: {e}")
raise # Re-raise the exception so the bot's error handling can catch it

104
cogs/status_cog.py Normal file
View File

@ -0,0 +1,104 @@
import discord
from discord.ext import commands
from discord import app_commands
from typing import Optional, Literal
class StatusCog(commands.Cog):
"""Commands for managing the bot's status"""
def __init__(self, bot: commands.Bot):
self.bot = bot
async def _set_status_logic(self,
status_type: Literal["playing", "listening", "streaming", "watching", "competing"],
status_text: str,
stream_url: Optional[str] = None) -> str:
"""Core logic for setting the bot's status"""
# Map the status type to the appropriate ActivityType
activity_types = {
"playing": discord.ActivityType.playing,
"listening": discord.ActivityType.listening,
"streaming": discord.ActivityType.streaming,
"watching": discord.ActivityType.watching,
"competing": discord.ActivityType.competing
}
activity_type = activity_types.get(status_type.lower())
if not activity_type:
return f"Invalid status type: {status_type}. Valid types are: playing, listening, streaming, watching, competing."
try:
# For streaming status, we need a URL
if status_type.lower() == "streaming" and stream_url:
await self.bot.change_presence(activity=discord.Streaming(name=status_text, url=stream_url))
else:
await self.bot.change_presence(activity=discord.Activity(type=activity_type, name=status_text))
return f"Status set to: {status_type.capitalize()} {status_text}"
except Exception as e:
return f"Error setting status: {str(e)}"
# --- Prefix Command ---
@commands.command(name="setstatus")
@commands.is_owner()
async def set_status(self, ctx: commands.Context, status_type: str, *, status_text: str):
"""Set the bot's status (Owner only)
Valid status types:
- playing
- listening
- streaming (requires a URL in the status text)
- watching
- competing
Example:
!setstatus playing Minecraft
!setstatus listening to music
!setstatus streaming https://twitch.tv/username Stream Title
!setstatus watching YouTube
!setstatus competing in a tournament
"""
# For streaming status, extract the URL from the status text
stream_url = None
if status_type.lower() == "streaming":
parts = status_text.split()
if len(parts) >= 2 and (parts[0].startswith("http://") or parts[0].startswith("https://")):
stream_url = parts[0]
status_text = " ".join(parts[1:])
response = await self._set_status_logic(status_type, status_text, stream_url)
await ctx.reply(response)
# --- Slash Command ---
@app_commands.command(name="setstatus", description="Set the bot's status")
@app_commands.describe(
status_type="The type of status to set",
status_text="The text to display in the status",
stream_url="URL for streaming status (only required for streaming status)"
)
@app_commands.choices(status_type=[
app_commands.Choice(name="Playing", value="playing"),
app_commands.Choice(name="Listening", value="listening"),
app_commands.Choice(name="Streaming", value="streaming"),
app_commands.Choice(name="Watching", value="watching"),
app_commands.Choice(name="Competing", value="competing")
])
async def set_status_slash(self,
interaction: discord.Interaction,
status_type: str,
status_text: str,
stream_url: Optional[str] = None):
"""Slash command version of set_status."""
# Check if user is the bot owner
if interaction.user.id != self.bot.owner_id:
await interaction.response.send_message("This command can only be used by the bot owner.", ephemeral=True)
return
response = await self._set_status_logic(status_type, status_text, stream_url)
await interaction.response.send_message(response)
async def setup(bot: commands.Bot):
await bot.add_cog(StatusCog(bot))
print("StatusCog loaded successfully!")

72
cogs/sync_cog.py Normal file
View File

@ -0,0 +1,72 @@
import discord
from discord.ext import commands
from discord import app_commands
import traceback
class SyncCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("SyncCog initialized!")
@commands.command(name="forcesync")
@commands.is_owner()
async def force_sync(self, ctx):
"""Force sync all slash commands with verbose output"""
await ctx.send("Starting verbose command sync...")
try:
# Get list of commands before sync
commands_before = []
for cmd in self.bot.tree.get_commands():
cmd_info = {
"name": cmd.name,
"description": cmd.description,
"parameters": [p.name for p in cmd.parameters] if hasattr(cmd, "parameters") else []
}
commands_before.append(cmd_info)
await ctx.send(f"Commands before sync: {len(commands_before)}")
for cmd_data in commands_before:
params_str = ", ".join(cmd_data["parameters"])
await ctx.send(f"- {cmd_data['name']}: {len(cmd_data['parameters'])} params ({params_str})")
# Perform sync
await ctx.send("Syncing commands...")
synced = await self.bot.tree.sync()
# Get list of commands after sync
commands_after = []
for cmd in self.bot.tree.get_commands():
cmd_info = {
"name": cmd.name,
"description": cmd.description,
"parameters": [p.name for p in cmd.parameters] if hasattr(cmd, "parameters") else []
}
commands_after.append(cmd_info)
await ctx.send(f"Commands after sync: {len(commands_after)}")
for cmd_data in commands_after:
params_str = ", ".join(cmd_data["parameters"])
await ctx.send(f"- {cmd_data['name']}: {len(cmd_data['parameters'])} params ({params_str})")
# Check for webdrivertorso command specifically
wd_cmd = next((cmd for cmd in self.bot.tree.get_commands() if cmd.name == "webdrivertorso"), None)
if wd_cmd:
await ctx.send("Webdrivertorso command details:")
for param in wd_cmd.parameters:
await ctx.send(f"- Param: {param.name}, Type: {param.type}, Required: {param.required}")
if hasattr(param, "choices") and param.choices:
choices_str = ", ".join([c.name for c in param.choices])
await ctx.send(f" Choices: {choices_str}")
else:
await ctx.send("Webdrivertorso command not found after sync!")
await ctx.send(f"Synced {len(synced)} command(s) successfully!")
except Exception as e:
await ctx.send(f"Error during sync: {str(e)}")
await ctx.send(f"```{traceback.format_exc()}```")
async def setup(bot: commands.Bot):
print("Loading SyncCog...")
await bot.add_cog(SyncCog(bot))
print("SyncCog loaded successfully!")

215
cogs/system_check_cog.py Normal file
View File

@ -0,0 +1,215 @@
import discord
from discord.ext import commands
from discord import app_commands
import time
import psutil
import platform
import GPUtil
from cpuinfo import get_cpu_info
import distro # Ensure this is installed
import subprocess
# Import wmi for Windows motherboard info
try:
import wmi
WMI_AVAILABLE = True
except ImportError:
WMI_AVAILABLE = False
class SystemCheckCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
async def _system_check_logic(self, context_or_interaction):
"""Return detailed bot and system information as a Discord embed."""
# Bot information
bot_user = self.bot.user
guild_count = len(self.bot.guilds)
# Efficiently count unique non-bot members across guilds
user_ids = set()
for guild in self.bot.guilds:
try:
# Fetch members if needed, handle potential exceptions
async for member in guild.fetch_members(limit=None): # Fetch all members
if not member.bot:
user_ids.add(member.id)
except discord.Forbidden:
print(f"Missing permissions to fetch members in guild: {guild.name} ({guild.id})")
except discord.HTTPException as e:
print(f"HTTP error fetching members in guild {guild.name}: {e}")
except Exception as e:
print(f"Unexpected error fetching members in guild {guild.name}: {e}")
user_count = len(user_ids)
# System information
system = platform.system()
os_info = f"{system} {platform.release()}"
hostname = platform.node()
distro_info_str = "" # Renamed variable
if system == "Linux":
try:
# Use distro library for better Linux distribution detection
distro_name = distro.name(pretty=True)
distro_info_str = f"\n**Distro:** {distro_name}"
except ImportError:
distro_info_str = "\n**Distro:** (Install 'distro' package for details)"
except Exception as e:
distro_info_str = f"\n**Distro:** (Error getting info: {e})"
elif system == "Windows":
# Add Windows version details if possible
try:
win_ver = platform.version() # e.g., '10.0.19041'
win_build = platform.win32_ver()[1] # e.g., '19041'
os_info = f"Windows {win_ver} (Build {win_build})"
except Exception as e:
print(f"Could not get detailed Windows version: {e}")
# Keep the basic os_info
uptime_seconds = time.time() - psutil.boot_time()
days, remainder = divmod(uptime_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
uptime_str = ""
if days > 0:
uptime_str += f"{int(days)}d "
uptime_str += f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}"
uptime = uptime_str.strip()
# Hardware information
cpu_usage = psutil.cpu_percent(interval=0.5) # Shorter interval might be okay
try:
cpu_info_dict = get_cpu_info() # Renamed variable
cpu_name = cpu_info_dict.get('brand_raw', 'N/A')
except Exception as e:
print(f"Error getting CPU info: {e}")
cpu_name = "N/A"
# Get motherboard information
motherboard_info = self._get_motherboard_info()
memory = psutil.virtual_memory()
ram_usage = f"{memory.used // (1024 ** 2)} MB / {memory.total // (1024 ** 2)} MB ({memory.percent}%)"
# GPU Information (using GPUtil for cross-platform consistency if available)
gpu_info_lines = []
try:
gpus = GPUtil.getGPUs()
if gpus:
for gpu in gpus:
gpu_info_lines.append(
f"{gpu.name} ({gpu.load*100:.1f}% Load, {gpu.memoryUsed:.0f}/{gpu.memoryTotal:.0f} MB VRAM)"
)
gpu_info = "\n".join(gpu_info_lines)
else:
gpu_info = "No dedicated GPU detected by GPUtil."
except ImportError:
gpu_info = "GPUtil library not installed. Cannot get detailed GPU info."
except Exception as e:
print(f"Error getting GPU info via GPUtil: {e}")
gpu_info = f"Error retrieving GPU info: {e}"
# Determine user and avatar URL based on context type
if isinstance(context_or_interaction, commands.Context):
user = context_or_interaction.author
avatar_url = user.display_avatar.url
elif isinstance(context_or_interaction, discord.Interaction):
user = context_or_interaction.user
avatar_url = user.display_avatar.url
else: # Fallback or handle error if needed
user = self.bot.user # Or some default
avatar_url = self.bot.user.display_avatar.url if self.bot.user else None
# Create embed
embed = discord.Embed(title="📊 System Status", color=discord.Color.blue())
if bot_user:
embed.set_thumbnail(url=bot_user.display_avatar.url)
# Bot Info Field
if bot_user:
embed.add_field(
name="🤖 Bot Information",
value=f"**Name:** {bot_user.name}\n"
f"**ID:** {bot_user.id}\n"
f"**Servers:** {guild_count}\n"
f"**Unique Users:** {user_count}",
inline=False
)
else:
embed.add_field(
name="🤖 Bot Information",
value="Bot user information not available.",
inline=False
)
# System Info Field
embed.add_field(
name="🖥️ System Information",
value=f"**OS:** {os_info}{distro_info_str}\n" # Use renamed variable
f"**Hostname:** {hostname}\n"
f"**Uptime:** {uptime}",
inline=False
)
# Hardware Info Field
embed.add_field(
name="⚙️ Hardware Information",
value=f"**Device Model:** {motherboard_info}\n"
f"**CPU:** {cpu_name}\n"
f"**CPU Usage:** {cpu_usage}%\n"
f"**RAM Usage:** {ram_usage}\n"
f"**GPU Info:**\n{gpu_info}",
inline=False
)
if user:
embed.set_footer(text=f"Requested by: {user.display_name}", icon_url=avatar_url)
embed.timestamp = discord.utils.utcnow()
return embed
# --- Prefix Command ---
@commands.command(name="systemcheck")
async def system_check(self, ctx: commands.Context):
"""Check the bot and system status."""
embed = await self._system_check_logic(ctx) # Pass context
await ctx.reply(embed=embed)
# --- Slash Command ---
@app_commands.command(name="systemcheck", description="Check the bot and system status")
async def system_check_slash(self, interaction: discord.Interaction):
"""Slash command version of system check."""
embed = await self._system_check_logic(interaction) # Pass interaction
await interaction.response.send_message(embed=embed)
def _get_motherboard_info(self):
"""Get motherboard information based on the operating system."""
system = platform.system()
try:
if system == "Windows":
if WMI_AVAILABLE:
w = wmi.WMI()
for board in w.Win32_BaseBoard():
return f"{board.Manufacturer} {board.Product}"
return "WMI module not available"
elif system == "Linux":
# Read motherboard product name from sysfs
try:
with open("/sys/devices/virtual/dmi/id/product_name", "r") as f:
product_name = f.read().strip()
return product_name if product_name else "Unknown motherboard"
except FileNotFoundError:
return "/sys/devices/virtual/dmi/id/product_name not found"
except Exception as e:
return f"Error reading motherboard info: {e}"
except Exception as e:
return f"Error: {str(e)}"
else:
return f"Unsupported OS: {system}"
except Exception as e:
print(f"Error getting motherboard info: {e}")
return "Error retrieving motherboard info"
async def setup(bot):
await bot.add_cog(SystemCheckCog(bot))

577
cogs/tts_provider_cog.py Normal file
View File

@ -0,0 +1,577 @@
import discord
from discord.ext import commands
from discord import app_commands
import os
import asyncio
import tempfile
import sys
import importlib.util
class TTSProviderCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
print("TTSProviderCog initialized!")
self.cleanup_old_files()
# Schedule periodic cleanup
self.cleanup_task = self.bot.loop.create_task(self.periodic_cleanup())
async def periodic_cleanup(self):
"""Periodically clean up old TTS files."""
import asyncio
while not self.bot.is_closed():
# Clean up every hour
await asyncio.sleep(3600) # 1 hour
self.cleanup_old_files()
def cog_unload(self):
"""Cancel the cleanup task when the cog is unloaded."""
if hasattr(self, 'cleanup_task') and self.cleanup_task:
self.cleanup_task.cancel()
def cleanup_old_files(self):
"""Clean up old TTS files to prevent disk space issues."""
try:
import glob
import time
import os
# Create the SOUND directory if it doesn't exist
os.makedirs("./SOUND", exist_ok=True)
# Get current time
current_time = time.time()
# Find all TTS files older than 1 hour
old_files = []
for pattern in ["./SOUND/tts_*.mp3", "./SOUND/tts_direct_*.mp3", "./SOUND/tts_test_*.mp3"]:
for file in glob.glob(pattern):
if os.path.exists(file) and os.path.getmtime(file) < current_time - 3600: # 1 hour = 3600 seconds
old_files.append(file)
# Delete old files
for file in old_files:
try:
os.remove(file)
print(f"Cleaned up old TTS file: {file}")
except Exception as e:
print(f"Error removing old TTS file {file}: {e}")
print(f"Cleaned up {len(old_files)} old TTS files")
except Exception as e:
print(f"Error during cleanup: {e}")
async def generate_tts_directly(self, provider, text, output_file=None):
"""Generate TTS audio directly without using a subprocess."""
# Create a unique output file if none is provided
if output_file is None:
import uuid
output_file = f"./SOUND/tts_direct_{uuid.uuid4().hex}.mp3"
# Create output directory if it doesn't exist
os.makedirs("./SOUND", exist_ok=True)
# Check if the provider is available
if provider == "gtts":
# Check if gtts is available
if importlib.util.find_spec("gtts") is None:
return False, "Google TTS (gtts) is not installed. Run: pip install gtts"
try:
from gtts import gTTS
tts = gTTS(text=text, lang='en')
tts.save(output_file)
return True, output_file
except Exception as e:
return False, f"Error with Google TTS: {str(e)}"
elif provider == "pyttsx3":
# Check if pyttsx3 is available
if importlib.util.find_spec("pyttsx3") is None:
return False, "pyttsx3 is not installed. Run: pip install pyttsx3"
try:
import pyttsx3
engine = pyttsx3.init()
engine.save_to_file(text, output_file)
engine.runAndWait()
return True, output_file
except Exception as e:
return False, f"Error with pyttsx3: {str(e)}"
elif provider == "coqui":
# Check if TTS is available
if importlib.util.find_spec("TTS") is None:
return False, "Coqui TTS is not installed. Run: pip install TTS"
try:
from TTS.api import TTS
tts = TTS("tts_models/en/ljspeech/tacotron2-DDC")
tts.tts_to_file(text=text, file_path=output_file)
return True, output_file
except Exception as e:
return False, f"Error with Coqui TTS: {str(e)}"
elif provider == "espeak":
# Check if we can run espeak-ng command
import subprocess
import platform
try:
# Check if espeak-ng is available
if platform.system() == "Windows":
# On Windows, we'll check if the command exists
result = subprocess.run(["where", "espeak-ng"], capture_output=True, text=True)
espeak_available = result.returncode == 0
else:
# On Linux/Mac, we'll use which
result = subprocess.run(["which", "espeak-ng"], capture_output=True, text=True)
espeak_available = result.returncode == 0
if not espeak_available:
return False, "espeak-ng is not installed or not in PATH. Install espeak-ng and make sure it's in your PATH."
# Create a WAV file first
wav_file = output_file.replace(".mp3", ".wav")
# Run espeak-ng to generate the audio
cmd = ["espeak-ng", "-w", wav_file, text]
process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0:
return False, f"Error running espeak-ng: {process.stderr}"
# Convert WAV to MP3 if needed
if output_file.endswith(".mp3"):
try:
# Try to use pydub for conversion
from pydub import AudioSegment
sound = AudioSegment.from_wav(wav_file)
sound.export(output_file, format="mp3")
# Remove the temporary WAV file
os.remove(wav_file)
except Exception as e:
# If pydub fails, just use the WAV file
print(f"Warning: Could not convert WAV to MP3: {e}")
output_file = wav_file
else:
# If the output file doesn't end with .mp3, we're already using the WAV file
output_file = wav_file
return True, output_file
except Exception as e:
return False, f"Error with espeak-ng: {str(e)}"
else:
return False, f"Unknown TTS provider: {provider}"
@app_commands.command(name="ttsprovider", description="Test different TTS providers")
@app_commands.describe(
provider="Select the TTS provider to use",
text="Text to be spoken"
)
@app_commands.choices(provider=[
app_commands.Choice(name="Google TTS (Online)", value="gtts"),
app_commands.Choice(name="pyttsx3 (Offline)", value="pyttsx3"),
app_commands.Choice(name="Coqui TTS (AI Voice)", value="coqui"),
app_commands.Choice(name="eSpeak-NG (Offline)", value="espeak")
])
async def ttsprovider_slash(self, interaction: discord.Interaction,
provider: str,
text: str = "This is a test of text to speech"):
"""Test different TTS providers"""
await interaction.response.defer(thinking=True)
# Create a temporary script to test the TTS provider
script_content = f"""
import importlib.util
import sys
import os
import traceback
# Print Python version and path for debugging
print(f"Python version: {{sys.version}}")
print(f"Python executable: {{sys.executable}}")
print(f"Current working directory: {{os.getcwd()}}")
# Check for TTS libraries
try:
import pkg_resources
installed_packages = [pkg.key for pkg in pkg_resources.working_set]
print(f"Installed packages: {{installed_packages}}")
except Exception as e:
print(f"Error getting installed packages: {{e}}")
# Check for specific TTS libraries
try:
GTTS_AVAILABLE = importlib.util.find_spec("gtts") is not None
print(f"GTTS_AVAILABLE: {{GTTS_AVAILABLE}}")
if GTTS_AVAILABLE:
import gtts
print(f"gtts version: {{gtts.__version__}}")
except Exception as e:
print(f"Error checking gtts: {{e}}")
GTTS_AVAILABLE = False
try:
PYTTSX3_AVAILABLE = importlib.util.find_spec("pyttsx3") is not None
print(f"PYTTSX3_AVAILABLE: {{PYTTSX3_AVAILABLE}}")
if PYTTSX3_AVAILABLE:
import pyttsx3
print("pyttsx3 imported successfully")
except Exception as e:
print(f"Error checking pyttsx3: {{e}}")
PYTTSX3_AVAILABLE = False
try:
COQUI_AVAILABLE = importlib.util.find_spec("TTS") is not None
print(f"COQUI_AVAILABLE: {{COQUI_AVAILABLE}}")
if COQUI_AVAILABLE:
import TTS
print(f"TTS version: {{TTS.__version__}}")
except Exception as e:
print(f"Error checking TTS: {{e}}")
COQUI_AVAILABLE = False
# Check for espeak-ng
try:
import subprocess
import platform
if platform.system() == "Windows":
# On Windows, we'll check if the command exists
result = subprocess.run(["where", "espeak-ng"], capture_output=True, text=True)
ESPEAK_AVAILABLE = result.returncode == 0
else:
# On Linux/Mac, we'll use which
result = subprocess.run(["which", "espeak-ng"], capture_output=True, text=True)
ESPEAK_AVAILABLE = result.returncode == 0
print(f"ESPEAK_AVAILABLE: {{ESPEAK_AVAILABLE}}")
if ESPEAK_AVAILABLE:
# Try to get version
version_result = subprocess.run(["espeak-ng", "--version"], capture_output=True, text=True)
if version_result.returncode == 0:
print(f"espeak-ng version: {{version_result.stdout.strip()}}")
else:
print("espeak-ng found but couldn't get version")
except Exception as e:
print(f"Error checking espeak-ng: {{e}}")
ESPEAK_AVAILABLE = False
def generate_tts_audio(provider, text, output_file):
print(f"Testing TTS provider: {{provider}}")
print(f"Text: {{text}}")
print(f"Output file: {{output_file}}")
if provider == "gtts" and GTTS_AVAILABLE:
try:
from gtts import gTTS
tts = gTTS(text=text, lang='en')
tts.save(output_file)
print(f"Google TTS audio saved to {{output_file}}")
return True
except Exception as e:
print(f"Error with Google TTS: {{e}}")
traceback.print_exc()
return False
elif provider == "pyttsx3" and PYTTSX3_AVAILABLE:
try:
import pyttsx3
engine = pyttsx3.init()
engine.save_to_file(text, output_file)
engine.runAndWait()
print(f"pyttsx3 audio saved to {{output_file}}")
return True
except Exception as e:
print(f"Error with pyttsx3: {{e}}")
traceback.print_exc()
return False
elif provider == "coqui" and COQUI_AVAILABLE:
try:
from TTS.api import TTS
tts = TTS("tts_models/en/ljspeech/tacotron2-DDC")
tts.tts_to_file(text=text, file_path=output_file)
print(f"Coqui TTS audio saved to {{output_file}}")
return True
except Exception as e:
print(f"Error with Coqui TTS: {{e}}")
traceback.print_exc()
return False
elif provider == "espeak" and ESPEAK_AVAILABLE:
try:
# Create a WAV file first
wav_file = output_file.replace(".mp3", ".wav")
# Run espeak-ng to generate the audio
cmd = ["espeak-ng", "-w", wav_file, text]
process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0:
print(f"Error running espeak-ng: {{process.stderr}}")
traceback.print_exc()
return False
# Convert WAV to MP3 if needed
if output_file.endswith(".mp3"):
try:
# Try to use pydub for conversion
from pydub import AudioSegment
sound = AudioSegment.from_wav(wav_file)
sound.export(output_file, format="mp3")
# Remove the temporary WAV file
os.remove(wav_file)
print(f"espeak-ng audio saved to {{output_file}}")
except Exception as e:
# If pydub fails, just use the WAV file
print(f"Warning: Could not convert WAV to MP3: {{e}}")
print(f"Using WAV file instead: {{wav_file}}")
output_file = wav_file
else:
# If the output file doesn't end with .mp3, we're already using the WAV file
output_file = wav_file
print(f"espeak-ng audio saved to {{output_file}}")
return True
except Exception as e:
print(f"Error with espeak-ng: {{e}}")
traceback.print_exc()
return False
else:
print(f"TTS provider {{provider}} not available.")
return False
# Create output directory if it doesn't exist
os.makedirs("./SOUND", exist_ok=True)
# Generate a unique filename
import uuid
unique_id = uuid.uuid4().hex
output_file = f"./SOUND/tts_test_{{unique_id}}.mp3"
print(f"Using output file: {{output_file}}")
# Generate TTS audio
try:
success = generate_tts_audio("{provider}", "{text}", output_file)
print(f"TTS generation {{'' if success else 'un'}}successful")
except Exception as e:
print(f"Unexpected error: {{e}}")
traceback.print_exc()
success = False
# Verify file exists and has content
if os.path.exists(output_file):
file_size = os.path.getsize(output_file)
print(f"Output file exists, size: {{file_size}} bytes")
else:
print("Output file does not exist")
"""
# Save the script to a temporary file
script_path = os.path.join(tempfile.gettempdir(), "tts_test.py")
with open(script_path, "w", encoding="utf8") as f:
f.write(script_content)
# Run the script
process = await asyncio.create_subprocess_exec(
sys.executable, script_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Wait for the process to complete
stdout, stderr = await process.communicate()
# Get the output regardless of return code
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
# Combine stdout and stderr for complete output
full_output = f"STDOUT:\n{stdout_text}\n\nSTDERR:\n{stderr_text}"
# Extract the output filename from the stdout
output_filename = None
for line in stdout_text.split('\n'):
if line.startswith("Using output file:"):
output_filename = line.split(":", 1)[1].strip()
break
# If we couldn't find the filename in the output, use a default pattern to search
if not output_filename:
# Look for any tts_test_*.mp3 files created in the last minute
import glob
import time
current_time = time.time()
tts_files = []
for file in glob.glob("./SOUND/tts_test_*.mp3"):
if os.path.exists(file) and os.path.getmtime(file) > current_time - 60:
tts_files.append(file)
if tts_files:
# Use the most recently created file
output_filename = max(tts_files, key=os.path.getmtime)
else:
# Fallback to the old filename pattern
output_filename = "./SOUND/tts_test.mp3"
# Check if the TTS file was generated
if os.path.exists(output_filename) and os.path.getsize(output_filename) > 0:
# Success! Send the audio file
await interaction.followup.send(
f"✅ Successfully tested TTS provider: {provider}\nText: {text}\nFile: {os.path.basename(output_filename)}",
file=discord.File(output_filename)
)
else:
# Failed to generate audio with subprocess, try direct method as fallback
await interaction.followup.send(f"Subprocess method failed. Trying direct TTS generation with {provider}...")
# Try the direct method
success, result = await self.generate_tts_directly(provider, text)
if success and os.path.exists(result) and os.path.getsize(result) > 0:
# Direct method succeeded!
await interaction.followup.send(
f"✅ Successfully generated TTS audio with {provider} (direct method)\nText: {text}",
file=discord.File(result)
)
return
# Both methods failed, send detailed error information
error_message = f"❌ Failed to generate TTS audio with provider: {provider}\n\n"
# Check if the process failed
if process.returncode != 0:
error_message += f"Process returned error code: {process.returncode}\n\n"
# Add direct method error
if not success:
error_message += f"Direct method error: {result}\n\n"
# Create a summary of the most important information
error_summary = "Error Summary:\n"
# Extract key information from the output
if f"{provider.upper()}_AVAILABLE: False" in full_output:
error_summary += f"- The {provider} library is not available or not properly installed\n"
if "Error with " + provider in full_output:
# Extract the specific error message
error_line = next((line for line in full_output.split('\n') if "Error with " + provider in line), "")
if error_line:
error_summary += f"- {error_line}\n"
# Add the error summary to the message
error_message += error_summary + "\n"
# Add instructions for fixing the issue
error_message += "To fix this issue, try:\n"
error_message += "1. Make sure the required packages are installed:\n"
if provider == "gtts":
error_message += " - Run: pip install gtts\n"
elif provider == "pyttsx3":
error_message += " - Run: pip install pyttsx3\n"
error_message += " - On Linux, you may need additional packages: sudo apt-get install espeak\n"
elif provider == "coqui":
error_message += " - Run: pip install TTS\n"
error_message += " - This may require additional dependencies based on your system\n"
error_message += "2. Restart the bot after installing the packages\n"
# Add a note about the full output
error_message += "\nFull diagnostic output is available but may be too long to display here."
# Send the error message
await interaction.followup.send(error_message)
# If the output is not too long, send it as a separate message
if len(full_output) <= 1900: # Discord message limit is 2000 characters
await interaction.followup.send(f"```\n{full_output}\n```")
else:
# Save the output to a file and send it
output_file = os.path.join(tempfile.gettempdir(), "tts_error_log.txt")
with open(output_file, "w", encoding="utf8") as f:
f.write(full_output)
await interaction.followup.send("Detailed error log:", file=discord.File(output_file))
@commands.command(name="ttscheck")
async def tts_check(self, ctx):
"""Check if TTS libraries are installed and working."""
await ctx.send("Checking TTS libraries...")
# Check for gtts
gtts_available = importlib.util.find_spec("gtts") is not None
gtts_version = "Not installed"
if gtts_available:
try:
import gtts
gtts_version = getattr(gtts, "__version__", "Unknown version")
except Exception as e:
gtts_version = f"Error importing: {str(e)}"
# Check for pyttsx3
pyttsx3_available = importlib.util.find_spec("pyttsx3") is not None
pyttsx3_version = "Not installed"
if pyttsx3_available:
try:
import pyttsx3
pyttsx3_version = "Installed (no version info available)"
except Exception as e:
pyttsx3_version = f"Error importing: {str(e)}"
# Check for TTS (Coqui)
coqui_available = importlib.util.find_spec("TTS") is not None
coqui_version = "Not installed"
if coqui_available:
try:
import TTS
coqui_version = getattr(TTS, "__version__", "Unknown version")
except Exception as e:
coqui_version = f"Error importing: {str(e)}"
# Check for espeak-ng
espeak_version = "Not installed"
try:
import subprocess
import platform
if platform.system() == "Windows":
# On Windows, we'll check if the command exists
result = subprocess.run(["where", "espeak-ng"], capture_output=True, text=True)
espeak_available = result.returncode == 0
else:
# On Linux/Mac, we'll use which
result = subprocess.run(["which", "espeak-ng"], capture_output=True, text=True)
espeak_available = result.returncode == 0
if espeak_available:
# Try to get version
version_result = subprocess.run(["espeak-ng", "--version"], capture_output=True, text=True)
if version_result.returncode == 0:
espeak_version = version_result.stdout.strip()
else:
espeak_version = "Installed (version unknown)"
else:
espeak_version = "Not installed"
except Exception as e:
espeak_version = f"Error checking: {str(e)}"
# Create a report
report = "**TTS Libraries Status:**\n"
report += f"- Google TTS (gtts): {gtts_version}\n"
report += f"- pyttsx3: {pyttsx3_version}\n"
report += f"- Coqui TTS: {coqui_version}\n"
report += f"- eSpeak-NG: {espeak_version}\n\n"
# Add installation instructions
report += "**Installation Instructions:**\n"
report += "- Google TTS: `pip install gtts`\n"
report += "- pyttsx3: `pip install pyttsx3`\n"
report += "- Coqui TTS: `pip install TTS`\n"
report += "- eSpeak-NG: Install from https://github.com/espeak-ng/espeak-ng/releases\n\n"
report += "After installing, restart the bot for the changes to take effect."
await ctx.send(report)
async def setup(bot: commands.Bot):
print("Loading TTSProviderCog...")
await bot.add_cog(TTSProviderCog(bot))
print("TTSProviderCog loaded successfully!")

554
cogs/webdrivertorso_cog.py Normal file
View File

@ -0,0 +1,554 @@
import discord
from discord.ext import commands
from discord import app_commands
import os
import asyncio
import json
import tempfile
import glob
import sys
import importlib.util
class JSON:
def read(file):
with open(f"{file}.json", "r", encoding="utf8") as file:
data = json.load(file, strict=False)
return data
def dump(file, data):
with open(f"{file}.json", "w", encoding="utf8") as file:
json.dump(data, file, indent=4)
class WebdriverTorsoCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.is_processing = False
# Default configuration values
self.DEFAULT_CONFIG = {
"WIDTH": 640,
"HEIGHT": 480,
"MAX_WIDTH": 200,
"MAX_HEIGHT": 200,
"MIN_WIDTH": 20,
"MIN_HEIGHT": 20,
"SLIDES": 10,
"VIDEOS": 1,
"MIN_SHAPES": 5,
"MAX_SHAPES": 15,
"SOUND_QUALITY": 44100,
"TTS_ENABLED": False,
"TTS_TEXT": "This is a Webdriver Torso style test video created by the bot.",
"TTS_PROVIDER": "gtts",
"AUDIO_WAVE_TYPE": "sawtooth",
"SLIDE_DURATION": 1000,
"DEFORM_LEVEL": "medium",
"COLOR_MODE": "random",
"COLOR_SCHEME": "default",
"SOLID_COLOR": "#FFFFFF",
"ALLOWED_SHAPES": ["rectangle", "ellipse", "polygon", "triangle", "circle"],
"WAVE_VIBE": "random",
"TOP_LEFT_TEXT_ENABLED": True,
"TOP_LEFT_TEXT_MODE": "random",
"WORDS_TOPIC": "random",
"TEXT_COLOR": "#000000",
"TEXT_SIZE": 0,
"TEXT_POSITION": "top-left",
"COLOR_SCHEMES": {
"pastel": [[255, 182, 193], [176, 224, 230], [240, 230, 140], [221, 160, 221], [152, 251, 152]],
"dark_gritty": [[47, 79, 79], [105, 105, 105], [0, 0, 0], [85, 107, 47], [139, 69, 19]],
"nature": [[34, 139, 34], [107, 142, 35], [46, 139, 87], [32, 178, 170], [154, 205, 50]],
"vibrant": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255]],
"ocean": [[0, 105, 148], [72, 209, 204], [70, 130, 180], [135, 206, 250], [176, 224, 230]],
"neon": [[255, 0, 102], [0, 255, 255], [255, 255, 0], [0, 255, 0], [255, 0, 255]],
"monochrome": [[0, 0, 0], [50, 50, 50], [100, 100, 100], [150, 150, 150], [200, 200, 200]],
"autumn": [[165, 42, 42], [210, 105, 30], [139, 69, 19], [160, 82, 45], [205, 133, 63]],
"cyberpunk": [[255, 0, 128], [0, 255, 255], [128, 0, 255], [255, 255, 0], [0, 255, 128]],
"retro": [[255, 204, 0], [255, 102, 0], [204, 0, 0], [0, 102, 204], [0, 204, 102]]
},
"WAVE_VIBES": {
"calm": {"frequency": 200, "amplitude": 0.3, "modulation": 0.1},
"eerie": {"frequency": 600, "amplitude": 0.5, "modulation": 0.7},
"random": {},
"energetic": {"frequency": 800, "amplitude": 0.7, "modulation": 0.2},
"dreamy": {"frequency": 400, "amplitude": 0.4, "modulation": 0.5},
"chaotic": {"frequency": 1000, "amplitude": 1.0, "modulation": 1.0},
"glitchy": {"frequency": 1200, "amplitude": 0.8, "modulation": 0.9},
"underwater": {"frequency": 300, "amplitude": 0.6, "modulation": 0.3},
"mechanical": {"frequency": 500, "amplitude": 0.5, "modulation": 0.1},
"ethereal": {"frequency": 700, "amplitude": 0.4, "modulation": 0.8},
"pulsating": {"frequency": 900, "amplitude": 0.7, "modulation": 0.6}
},
"WORD_TOPICS": {
"introspective": ["reflection", "thought", "solitude", "ponder", "meditation", "introspection", "awareness", "contemplation", "silence", "stillness"],
"action": ["run", "jump", "climb", "race", "fight", "explore", "build", "create", "overcome", "achieve"],
"nature": ["tree", "mountain", "river", "ocean", "flower", "forest", "animal", "sky", "valley", "meadow"],
"technology": ["computer", "robot", "network", "data", "algorithm", "innovation", "digital", "machine", "software", "hardware"],
"space": ["star", "planet", "galaxy", "cosmos", "orbit", "nebula", "asteroid", "comet", "universe", "void"],
"ocean": ["wave", "coral", "fish", "shark", "seaweed", "tide", "reef", "abyss", "current", "marine"],
"fantasy": ["dragon", "wizard", "magic", "quest", "sword", "spell", "kingdom", "myth", "legend", "fairy"],
"science": ["experiment", "theory", "hypothesis", "research", "discovery", "laboratory", "element", "molecule", "atom", "energy"],
"art": ["canvas", "paint", "sculpture", "gallery", "artist", "creativity", "expression", "masterpiece", "composition", "design"],
"music": ["melody", "rhythm", "harmony", "song", "instrument", "concert", "symphony", "chord", "note", "beat"],
"food": ["cuisine", "flavor", "recipe", "ingredient", "taste", "dish", "spice", "dessert", "meal", "delicacy"],
"emotions": ["joy", "sorrow", "anger", "fear", "love", "hate", "surprise", "disgust", "anticipation", "trust"],
"colors": ["red", "blue", "green", "yellow", "purple", "orange", "black", "white", "pink", "teal"],
"abstract": ["concept", "idea", "thought", "theory", "philosophy", "abstraction", "notion", "principle", "essence", "paradigm"]
}
}
# Create directories if they don't exist
for directory in ["IMG", "SOUND", "OUTPUT", "FONT"]:
os.makedirs(directory, exist_ok=True)
async def _generate_video_logic(self, ctx_or_interaction, width=None, height=None, max_width=None, max_height=None,
min_width=None, min_height=None, slides=None, videos=None, min_shapes=None, max_shapes=None,
sound_quality=None, tts_enabled=None, tts_text=None, tts_provider=None, audio_wave_type=None, slide_duration=None,
deform_level=None, color_mode=None, color_scheme=None, solid_color=None, allowed_shapes=None,
wave_vibe=None, top_left_text_enabled=None, top_left_text_mode=None, words_topic=None,
text_color=None, text_size=None, text_position=None, already_deferred=False):
"""Core logic for the webdrivertorso command."""
# Check if already processing a video
if self.is_processing:
return "⚠️ Already processing a video. Please wait for the current process to complete."
self.is_processing = True
try:
# Start with default config
config_data = self.DEFAULT_CONFIG.copy()
# Override config with parameters if provided
if width is not None:
config_data["WIDTH"] = width
if height is not None:
config_data["HEIGHT"] = height
if max_width is not None:
config_data["MAX_WIDTH"] = max_width
if max_height is not None:
config_data["MAX_HEIGHT"] = max_height
if min_width is not None:
config_data["MIN_WIDTH"] = min_width
if min_height is not None:
config_data["MIN_HEIGHT"] = min_height
if slides is not None:
config_data["SLIDES"] = slides
if videos is not None:
config_data["VIDEOS"] = videos
if min_shapes is not None:
config_data["MIN_SHAPES"] = min_shapes
if max_shapes is not None:
config_data["MAX_SHAPES"] = max_shapes
if sound_quality is not None:
config_data["SOUND_QUALITY"] = sound_quality
if tts_enabled is not None:
config_data["TTS_ENABLED"] = tts_enabled
if tts_text is not None:
config_data["TTS_TEXT"] = tts_text
if tts_enabled is None: # Only set to True if not explicitly set to False
config_data["TTS_ENABLED"] = True
if tts_provider is not None:
config_data["TTS_PROVIDER"] = tts_provider
if audio_wave_type is not None:
config_data["AUDIO_WAVE_TYPE"] = audio_wave_type
if slide_duration is not None:
config_data["SLIDE_DURATION"] = slide_duration
if deform_level is not None:
config_data["DEFORM_LEVEL"] = deform_level
if color_mode is not None:
config_data["COLOR_MODE"] = color_mode
if color_scheme is not None:
config_data["COLOR_SCHEME"] = color_scheme
if solid_color is not None:
config_data["SOLID_COLOR"] = solid_color
if allowed_shapes is not None:
config_data["ALLOWED_SHAPES"] = allowed_shapes
if wave_vibe is not None:
config_data["WAVE_VIBE"] = wave_vibe
if top_left_text_enabled is not None:
config_data["TOP_LEFT_TEXT_ENABLED"] = top_left_text_enabled
if top_left_text_mode is not None:
config_data["TOP_LEFT_TEXT_MODE"] = top_left_text_mode
if words_topic is not None:
config_data["WORDS_TOPIC"] = words_topic
if text_color is not None:
config_data["TEXT_COLOR"] = text_color
if text_size is not None:
config_data["TEXT_SIZE"] = text_size
if text_position is not None:
config_data["TEXT_POSITION"] = text_position
# Clean directories
for directory in ["IMG", "SOUND"]:
for file in glob.glob(f'./{directory}/*'):
try:
os.remove(file)
except Exception as e:
print(f"Error removing file {file}: {e}")
# Create a temporary script file
script_path = os.path.join(tempfile.gettempdir(), "webdrivertorso_temp.py")
# Use our enhanced template instead of EXAMPLE.py
if os.path.exists("webdrivertorso_template.py"):
with open("webdrivertorso_template.py", "r", encoding="utf8") as f:
script_content = f.read()
else:
with open("EXAMPLE.py", "r", encoding="utf8") as f:
script_content = f.read()
# Create a temporary config file for this run only
temp_config_path = os.path.join(tempfile.gettempdir(), "webdrivertorso_temp_config.json")
with open(temp_config_path, "w", encoding="utf8") as f:
json.dump(config_data, f, indent=4)
# Replace the config file path in the script content
script_content = script_content.replace('config_data = JSON.read("config")', f'config_data = JSON.read("{os.path.splitext(temp_config_path)[0]}")')
with open(script_path, "w", encoding="utf8") as f:
f.write(script_content)
# Send initial message
if isinstance(ctx_or_interaction, commands.Context):
await ctx_or_interaction.reply("🎬 Generating Webdriver Torso style video... This may take a minute.")
elif not already_deferred: # It's an Interaction and not deferred yet
await ctx_or_interaction.response.defer(thinking=True)
# Run the script as a subprocess
process = await asyncio.create_subprocess_exec(
sys.executable, script_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Wait for the process to complete
_, stderr = await process.communicate()
# Check if the process was successful
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
return f"❌ Error generating video: {error_msg}"
# Find the generated video file
video_files = glob.glob('./OUTPUT/*.mp4')
if not video_files:
return "❌ No video files were generated."
# Get the most recent video file
video_file = max(video_files, key=os.path.getctime)
# Send the video file
if isinstance(ctx_or_interaction, commands.Context):
await ctx_or_interaction.reply(file=discord.File(video_file))
else: # It's an Interaction
await ctx_or_interaction.followup.send(file=discord.File(video_file))
return f"✅ Video generated successfully: {os.path.basename(video_file)}"
except Exception as e:
return f"❌ An error occurred: {str(e)}"
finally:
self.is_processing = False
# --- Prefix Command ---
@commands.command(name="webdrivertorso")
async def webdrivertorso(self, ctx, *, options: str = ""):
"""Generate a Webdriver Torso style test video.
Usage: !webdrivertorso [option1=value1] [option2=value2] ...
Available options:
# Individual parameters (original method):
- width: Video width in pixels (default: 640)
- height: Video height in pixels (default: 480)
- max_width: Maximum shape width (default: 200)
- max_height: Maximum shape height (default: 200)
- min_width: Minimum shape width (default: 20)
- min_height: Minimum shape height (default: 20)
- min_shapes: Minimum number of shapes per slide (default: 5)
- max_shapes: Maximum number of shapes per slide (default: 15)
# Combined parameters (alternative method):
- dimensions: Video dimensions in format 'width,height' (e.g., '640,480')
- shape_size_limits: Shape size limits in format 'min_width,min_height,max_width,max_height' (e.g., '20,20,200,200')
- shapes_count: Number of shapes per slide in format 'min,max' (e.g., '5,15')
# Other parameters:
- slides: Number of slides in the video (default: 10)
- videos: Number of videos to generate (default: 1)
- sound_quality: Audio sample rate (default: 44100)
- tts_enabled: Enable text-to-speech (true/false)
- tts_provider: TTS provider to use (gtts, pyttsx3, coqui)
- tts_text: Text to be spoken in the video
- audio_wave_type: Type of audio wave (sawtooth, sine, square, triangle, noise, pulse, harmonic)
- slide_duration: Duration of each slide in milliseconds (default: 1000)
- deform_level: Level of shape deformation (none, low, medium, high)
- color_mode: Color mode for shapes (random, scheme, solid)
- color_scheme: Color scheme to use (pastel, dark_gritty, nature, vibrant, ocean, neon, monochrome, autumn, cyberpunk, retro)
- solid_color: Hex color code for solid color mode (#RRGGBB)
- wave_vibe: Audio wave vibe (calm, eerie, random, energetic, dreamy, chaotic, glitchy, underwater, mechanical, ethereal, pulsating)
- top_left_text_enabled: Show text in top-left corner (true/false)
- top_left_text_mode: Mode for top-left text (random, word)
- words_topic: Topic for word generation (random, introspective, action, nature, technology, space, ocean, fantasy, science, art, music, food, emotions, colors, abstract)
- text_color: Color of text (hex code or name)
- text_size: Size of text (default: auto-scaled)
- text_position: Position of text (top-left, top-right, bottom-left, bottom-right, center, random)
"""
# Parse options from the string
params = {}
if options:
option_pairs = options.split()
for pair in option_pairs:
if '=' in pair:
key, value = pair.split('=', 1)
# Convert string values to appropriate types
if value.lower() == 'true':
params[key] = True
elif value.lower() == 'false':
params[key] = False
elif value.isdigit():
params[key] = int(value)
elif key == 'allowed_shapes' and value.startswith('[') and value.endswith(']'):
# Parse list of shapes
shapes_list = value[1:-1].split(',')
params[key] = [shape.strip() for shape in shapes_list]
# Handle combined parameters
elif key == 'dimensions' and ',' in value:
width, height = value.split(',', 1)
if width.strip().isdigit():
params['width'] = int(width.strip())
if height.strip().isdigit():
params['height'] = int(height.strip())
elif key == 'shape_size_limits' and ',' in value:
parts = value.split(',')
if len(parts) >= 1 and parts[0].strip().isdigit():
params['min_width'] = int(parts[0].strip())
if len(parts) >= 2 and parts[1].strip().isdigit():
params['min_height'] = int(parts[1].strip())
if len(parts) >= 3 and parts[2].strip().isdigit():
params['max_width'] = int(parts[2].strip())
if len(parts) >= 4 and parts[3].strip().isdigit():
params['max_height'] = int(parts[3].strip())
elif key == 'shapes_count' and ',' in value:
min_shapes, max_shapes = value.split(',', 1)
if min_shapes.strip().isdigit():
params['min_shapes'] = int(min_shapes.strip())
if max_shapes.strip().isdigit():
params['max_shapes'] = int(max_shapes.strip())
else:
params[key] = value
async with ctx.typing():
result = await self._generate_video_logic(ctx, **params)
if isinstance(result, str):
await ctx.reply(result)
# --- Slash Command ---
@app_commands.command(name="webdrivertorso", description="Generate a Webdriver Torso style test video")
@app_commands.describe(
# Video structure
slides="Number of slides in the video (default: 10)",
videos="Number of videos to generate (default: 1)",
slide_duration="Duration of each slide in milliseconds (default: 1000)",
# Video dimensions
dimensions="Video dimensions in format 'width,height' (default: '640,480')",
shape_size_limits="Shape size limits in format 'min_width,min_height,max_width,max_height' (default: '20,20,200,200')",
# Shapes
shapes_count="Number of shapes per slide in format 'min,max' (default: '5,15')",
deform_level="Level of shape deformation (none, low, medium, high)",
shape_types="Types of shapes to include (comma-separated list)",
# Colors
color_mode="Color mode for shapes (random, scheme, solid)",
color_scheme="Color scheme to use (pastel, dark_gritty, nature, vibrant, ocean)",
solid_color="Hex color code for solid color mode (#RRGGBB)",
# Audio
sound_quality="Audio sample rate (default: 44100)",
audio_wave_type="Type of audio wave (sawtooth, sine, square)",
wave_vibe="Audio wave vibe (calm, eerie, random, energetic, dreamy, chaotic)",
tts_enabled="Enable text-to-speech (default: false)",
tts_provider="TTS provider to use (gtts, pyttsx3, coqui)",
tts_text="Text to be spoken in the video",
# Text
top_left_text_enabled="Show text in top-left corner (default: true)",
top_left_text_mode="Mode for top-left text (random, word)",
words_topic="Topic for word generation (random, introspective, action, nature, technology, etc.)",
text_color="Color of text (hex code or name)",
text_size="Size of text (default: auto-scaled)",
text_position="Position of text (top-left, top-right, bottom-left, bottom-right, center)"
)
@app_commands.choices(deform_level=[
app_commands.Choice(name="None", value="none"),
app_commands.Choice(name="Low", value="low"),
app_commands.Choice(name="Medium", value="medium"),
app_commands.Choice(name="High", value="high")
])
@app_commands.choices(color_mode=[
app_commands.Choice(name="Random", value="random"),
app_commands.Choice(name="Color Scheme", value="scheme"),
app_commands.Choice(name="Solid Color", value="solid")
])
@app_commands.choices(color_scheme=[
app_commands.Choice(name="Pastel", value="pastel"),
app_commands.Choice(name="Dark Gritty", value="dark_gritty"),
app_commands.Choice(name="Nature", value="nature"),
app_commands.Choice(name="Vibrant", value="vibrant"),
app_commands.Choice(name="Ocean", value="ocean"),
# Additional color schemes
app_commands.Choice(name="Neon", value="neon"),
app_commands.Choice(name="Monochrome", value="monochrome"),
app_commands.Choice(name="Autumn", value="autumn"),
app_commands.Choice(name="Cyberpunk", value="cyberpunk"),
app_commands.Choice(name="Retro", value="retro")
])
@app_commands.choices(audio_wave_type=[
app_commands.Choice(name="Sawtooth", value="sawtooth"),
app_commands.Choice(name="Sine", value="sine"),
app_commands.Choice(name="Square", value="square"),
# Additional wave types
app_commands.Choice(name="Triangle", value="triangle"),
app_commands.Choice(name="Noise", value="noise"),
app_commands.Choice(name="Pulse", value="pulse"),
app_commands.Choice(name="Harmonic", value="harmonic")
])
@app_commands.choices(tts_provider=[
app_commands.Choice(name="Google TTS", value="gtts"),
app_commands.Choice(name="pyttsx3 (Offline TTS)", value="pyttsx3"),
app_commands.Choice(name="Coqui TTS (AI Voice)", value="coqui")
])
@app_commands.choices(wave_vibe=[
app_commands.Choice(name="Calm", value="calm"),
app_commands.Choice(name="Eerie", value="eerie"),
app_commands.Choice(name="Random", value="random"),
app_commands.Choice(name="Energetic", value="energetic"),
app_commands.Choice(name="Dreamy", value="dreamy"),
app_commands.Choice(name="Chaotic", value="chaotic"),
# Additional wave vibes
app_commands.Choice(name="Glitchy", value="glitchy"),
app_commands.Choice(name="Underwater", value="underwater"),
app_commands.Choice(name="Mechanical", value="mechanical"),
app_commands.Choice(name="Ethereal", value="ethereal"),
app_commands.Choice(name="Pulsating", value="pulsating")
])
@app_commands.choices(top_left_text_mode=[
app_commands.Choice(name="Random", value="random"),
app_commands.Choice(name="Word", value="word")
])
@app_commands.choices(words_topic=[
app_commands.Choice(name="Random", value="random"),
app_commands.Choice(name="Introspective", value="introspective"),
app_commands.Choice(name="Action", value="action"),
app_commands.Choice(name="Nature", value="nature"),
app_commands.Choice(name="Technology", value="technology"),
# Additional word topics
app_commands.Choice(name="Space", value="space"),
app_commands.Choice(name="Ocean", value="ocean"),
app_commands.Choice(name="Fantasy", value="fantasy"),
app_commands.Choice(name="Science", value="science"),
app_commands.Choice(name="Art", value="art"),
app_commands.Choice(name="Music", value="music"),
app_commands.Choice(name="Food", value="food"),
app_commands.Choice(name="Emotions", value="emotions"),
app_commands.Choice(name="Colors", value="colors"),
app_commands.Choice(name="Abstract", value="abstract")
])
@app_commands.choices(text_position=[
app_commands.Choice(name="Top Left", value="top-left"),
app_commands.Choice(name="Top Right", value="top-right"),
app_commands.Choice(name="Bottom Left", value="bottom-left"),
app_commands.Choice(name="Bottom Right", value="bottom-right"),
app_commands.Choice(name="Center", value="center"),
app_commands.Choice(name="Random", value="random")
])
async def webdrivertorso_slash(self, interaction: discord.Interaction,
# Video structure
slides: int = None,
videos: int = None,
slide_duration: int = None,
# Video dimensions
dimensions: str = None,
shape_size_limits: str = None,
# Shapes
shapes_count: str = None,
deform_level: str = None,
shape_types: str = None,
# Colors
color_mode: str = None,
color_scheme: str = None,
solid_color: str = None,
# Audio
sound_quality: int = None,
audio_wave_type: str = None,
wave_vibe: str = None,
tts_enabled: bool = None,
tts_provider: str = None,
tts_text: str = None,
# Text
top_left_text_enabled: bool = None,
top_left_text_mode: str = None,
words_topic: str = None,
text_color: str = None,
text_size: int = None,
text_position: str = None):
"""Slash command version of webdrivertorso."""
await interaction.response.defer(thinking=True)
result = await self._generate_video_logic(
interaction,
# Video structure
slides=slides,
videos=videos,
slide_duration=slide_duration,
# Video dimensions
width=int(dimensions.split(',')[0]) if dimensions else None,
height=int(dimensions.split(',')[1]) if dimensions and ',' in dimensions else None,
min_width=int(shape_size_limits.split(',')[0]) if shape_size_limits else None,
min_height=int(shape_size_limits.split(',')[1]) if shape_size_limits and len(shape_size_limits.split(',')) > 1 else None,
max_width=int(shape_size_limits.split(',')[2]) if shape_size_limits and len(shape_size_limits.split(',')) > 2 else None,
max_height=int(shape_size_limits.split(',')[3]) if shape_size_limits and len(shape_size_limits.split(',')) > 3 else None,
# Shapes
min_shapes=int(shapes_count.split(',')[0]) if shapes_count else None,
max_shapes=int(shapes_count.split(',')[1]) if shapes_count and ',' in shapes_count else None,
deform_level=deform_level,
allowed_shapes=shape_types.split(',') if shape_types else None,
# Colors
color_mode=color_mode,
color_scheme=color_scheme,
solid_color=solid_color,
# Audio
sound_quality=sound_quality,
audio_wave_type=audio_wave_type,
wave_vibe=wave_vibe,
tts_enabled=tts_enabled,
tts_provider=tts_provider,
tts_text=tts_text,
# Text
top_left_text_enabled=top_left_text_enabled,
top_left_text_mode=top_left_text_mode,
words_topic=words_topic,
text_color=text_color,
text_size=text_size,
text_position=text_position,
already_deferred=True
)
if isinstance(result, str):
await interaction.followup.send(result)
async def setup(bot: commands.Bot):
await bot.add_cog(WebdriverTorsoCog(bot))

85
commands.py Normal file
View File

@ -0,0 +1,85 @@
import os
import asyncio
import discord
from discord.ext import commands
async def load_all_cogs(bot: commands.Bot):
"""Loads all cogs from the 'cogs' directory."""
cogs_dir = "cogs"
loaded_cogs = []
failed_cogs = []
for filename in os.listdir(cogs_dir):
if filename.endswith(".py") and not filename.startswith("__"):
cog_name = f"{cogs_dir}.{filename[:-3]}"
try:
await bot.load_extension(cog_name)
print(f"Successfully loaded cog: {cog_name}")
loaded_cogs.append(cog_name)
except commands.ExtensionAlreadyLoaded:
print(f"Cog already loaded: {cog_name}")
# Optionally reload if needed: await bot.reload_extension(cog_name)
except commands.ExtensionNotFound:
print(f"Error: Cog not found: {cog_name}")
failed_cogs.append(cog_name)
except commands.NoEntryPointError:
print(f"Error: Cog {cog_name} has no setup function.")
failed_cogs.append(cog_name)
except commands.ExtensionFailed as e:
print(f"Error: Cog {cog_name} failed to load.")
print(f" Reason: {e.original}") # Print the original exception
failed_cogs.append(cog_name)
except Exception as e:
print(f"An unexpected error occurred loading cog {cog_name}: {e}")
failed_cogs.append(cog_name)
print("-" * 20)
if loaded_cogs:
print(f"Loaded {len(loaded_cogs)} cogs successfully.")
if failed_cogs:
print(f"Failed to load {len(failed_cogs)} cogs: {', '.join(failed_cogs)}")
print("-" * 20)
# You might want a similar function for unloading or reloading
async def unload_all_cogs(bot: commands.Bot):
"""Unloads all currently loaded cogs from the 'cogs' directory."""
unloaded_cogs = []
failed_unload = []
# Get loaded cogs that are likely from our directory
loaded_extensions = list(bot.extensions.keys())
for extension in loaded_extensions:
if extension.startswith("cogs."):
try:
await bot.unload_extension(extension)
print(f"Successfully unloaded cog: {extension}")
unloaded_cogs.append(extension)
except Exception as e:
print(f"Failed to unload cog {extension}: {e}")
failed_unload.append(extension)
return unloaded_cogs, failed_unload
async def reload_all_cogs(bot: commands.Bot):
"""Reloads all currently loaded cogs from the 'cogs' directory."""
reloaded_cogs = []
failed_reload = []
loaded_extensions = list(bot.extensions.keys())
for extension in loaded_extensions:
if extension.startswith("cogs."):
try:
await bot.reload_extension(extension)
print(f"Successfully reloaded cog: {extension}")
reloaded_cogs.append(extension)
except commands.ExtensionNotLoaded:
print(f"Cog {extension} was not loaded, attempting to load instead.")
try:
await bot.load_extension(extension)
print(f"Successfully loaded cog: {extension}")
reloaded_cogs.append(extension) # Count as reloaded for simplicity
except Exception as load_e:
print(f"Failed to load cog {extension} during reload attempt: {load_e}")
failed_reload.append(extension)
except Exception as e:
print(f"Failed to reload cog {extension}: {e}")
# Attempt to unload if reload fails badly? Maybe too complex here.
failed_reload.append(extension)
return reloaded_cogs, failed_reload

720
discord_bot_sync_api.py Normal file
View File

@ -0,0 +1,720 @@
import os
import json
import asyncio
import datetime
from typing import Dict, List, Optional, Any, Union
from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import discord
from discord.ext import commands
import aiohttp
import threading
# This file contains the API endpoints for syncing conversations between
# the Flutter app and the Discord bot.
# ============= Models =============
class SyncedMessage(BaseModel):
content: str
role: str # "user", "assistant", or "system"
timestamp: datetime.datetime
reasoning: Optional[str] = None
usage_data: Optional[Dict[str, Any]] = None
class UserSettings(BaseModel):
# General settings
model_id: str = "openai/gpt-3.5-turbo"
temperature: float = 0.7
max_tokens: int = 1000
# Reasoning settings
reasoning_enabled: bool = False
reasoning_effort: str = "medium" # "low", "medium", "high"
# Web search settings
web_search_enabled: bool = False
# System message
system_message: Optional[str] = None
# Character settings
character: Optional[str] = None
character_info: Optional[str] = None
character_breakdown: bool = False
custom_instructions: Optional[str] = None
# UI settings
advanced_view_enabled: bool = False
streaming_enabled: bool = True
# Last updated timestamp
last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now)
sync_source: str = "discord" # "discord" or "flutter"
class SyncedConversation(BaseModel):
id: str
title: str
messages: List[SyncedMessage]
created_at: datetime.datetime
updated_at: datetime.datetime
model_id: str
sync_source: str = "discord" # "discord" or "flutter"
last_synced_at: Optional[datetime.datetime] = None
# Conversation-specific settings
reasoning_enabled: bool = False
reasoning_effort: str = "medium" # "low", "medium", "high"
temperature: float = 0.7
max_tokens: int = 1000
web_search_enabled: bool = False
system_message: Optional[str] = None
# Character-related settings
character: Optional[str] = None
character_info: Optional[str] = None
character_breakdown: bool = False
custom_instructions: Optional[str] = None
class SyncRequest(BaseModel):
conversations: List[SyncedConversation]
last_sync_time: Optional[datetime.datetime] = None
user_settings: Optional[UserSettings] = None
class SettingsSyncRequest(BaseModel):
user_settings: UserSettings
class SyncResponse(BaseModel):
success: bool
message: str
conversations: List[SyncedConversation] = []
user_settings: Optional[UserSettings] = None
# ============= Storage =============
# Files to store synced data
SYNC_DATA_FILE = "data/synced_conversations.json"
USER_SETTINGS_FILE = "data/synced_user_settings.json"
# Create data directory if it doesn't exist
os.makedirs(os.path.dirname(SYNC_DATA_FILE), exist_ok=True)
# In-memory storage for conversations and settings
user_conversations: Dict[str, List[SyncedConversation]] = {}
user_settings: Dict[str, UserSettings] = {}
# Load conversations from file
def load_conversations():
global user_conversations
if os.path.exists(SYNC_DATA_FILE):
try:
with open(SYNC_DATA_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Convert string keys (user IDs) back to strings
user_conversations = {k: [SyncedConversation.model_validate(conv) for conv in v]
for k, v in data.items()}
print(f"Loaded synced conversations for {len(user_conversations)} users")
except Exception as e:
print(f"Error loading synced conversations: {e}")
user_conversations = {}
# Save conversations to file
def save_conversations():
try:
# Convert to JSON-serializable format
serializable_data = {
user_id: [conv.model_dump() for conv in convs]
for user_id, convs in user_conversations.items()
}
with open(SYNC_DATA_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False)
except Exception as e:
print(f"Error saving synced conversations: {e}")
# Load user settings from file
def load_user_settings():
global user_settings
if os.path.exists(USER_SETTINGS_FILE):
try:
with open(USER_SETTINGS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Convert string keys (user IDs) back to strings
user_settings = {k: UserSettings.model_validate(v) for k, v in data.items()}
print(f"Loaded synced settings for {len(user_settings)} users")
except Exception as e:
print(f"Error loading synced user settings: {e}")
user_settings = {}
# Save user settings to file
def save_all_user_settings():
try:
# Convert to JSON-serializable format
serializable_data = {
user_id: settings.model_dump()
for user_id, settings in user_settings.items()
}
with open(USER_SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False)
except Exception as e:
print(f"Error saving synced user settings: {e}")
# ============= Discord OAuth Verification =============
async def verify_discord_token(authorization: str = Header(None)) -> str:
"""Verify the Discord token and return the user ID"""
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header missing")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization format")
token = authorization.replace("Bearer ", "")
# Verify the token with Discord
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {token}"}
async with session.get("https://discord.com/api/v10/users/@me", headers=headers) as resp:
if resp.status != 200:
raise HTTPException(status_code=401, detail="Invalid Discord token")
user_data = await resp.json()
return user_data["id"]
# ============= API Setup =============
# API Configuration
API_BASE_PATH = "/discordapi" # Base path for the API
SSL_CERT_FILE = "/etc/letsencrypt/live/slipstreamm.dev/fullchain.pem"
SSL_KEY_FILE = "/etc/letsencrypt/live/slipstreamm.dev/privkey.pem"
# Create the main FastAPI app
app = FastAPI(title="Discord Bot Sync API")
# Create a sub-application for the API
api_app = FastAPI(title="Discord Bot Sync API", docs_url="/docs", openapi_url="/openapi.json")
# Mount the API app at the base path
app.mount(API_BASE_PATH, api_app)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Adjust this in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Also add CORS to the API app
api_app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Adjust this in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize by loading saved data
@app.on_event("startup")
async def startup_event():
load_conversations()
load_user_settings()
# Try to load local settings from AI cog and merge them with synced settings
try:
from cogs.ai_cog import user_settings as local_user_settings, get_user_settings as get_local_settings
print("Merging local AI cog settings with synced settings...")
# Iterate through local settings and update synced settings
for user_id_int, local_settings_dict in local_user_settings.items():
user_id_str = str(user_id_int)
# Get the full settings with defaults
local_settings = get_local_settings(user_id_int)
# Create synced settings if they don't exist
if user_id_str not in user_settings:
user_settings[user_id_str] = UserSettings()
# Update synced settings with local settings
synced_settings = user_settings[user_id_str]
# Always update all settings from local settings
synced_settings.model_id = local_settings.get("model", synced_settings.model_id)
synced_settings.temperature = local_settings.get("temperature", synced_settings.temperature)
synced_settings.max_tokens = local_settings.get("max_tokens", synced_settings.max_tokens)
synced_settings.system_message = local_settings.get("system_prompt", synced_settings.system_message)
# Handle character settings - explicitly check if they exist in local settings
if "character" in local_settings:
synced_settings.character = local_settings["character"]
else:
# If not in local settings, set to None
synced_settings.character = None
# Handle character_info - explicitly check if they exist in local settings
if "character_info" in local_settings:
synced_settings.character_info = local_settings["character_info"]
else:
# If not in local settings, set to None
synced_settings.character_info = None
# Always update character_breakdown
synced_settings.character_breakdown = local_settings.get("character_breakdown", False)
# Handle custom_instructions - explicitly check if they exist in local settings
if "custom_instructions" in local_settings:
synced_settings.custom_instructions = local_settings["custom_instructions"]
else:
# If not in local settings, set to None
synced_settings.custom_instructions = None
# Always update reasoning settings
synced_settings.reasoning_enabled = local_settings.get("show_reasoning", False)
synced_settings.reasoning_effort = local_settings.get("reasoning_effort", "medium")
synced_settings.web_search_enabled = local_settings.get("web_search_enabled", False)
# Update timestamp and sync source
synced_settings.last_updated = datetime.datetime.now()
synced_settings.sync_source = "discord"
# Save the updated synced settings
save_all_user_settings()
print("Successfully merged local AI cog settings with synced settings")
except Exception as e:
print(f"Error merging local settings with synced settings: {e}")
# ============= API Endpoints =============
@app.get(API_BASE_PATH + "/")
async def root():
return {"message": "Discord Bot Sync API is running"}
@api_app.get("/")
async def api_root():
return {"message": "Discord Bot Sync API is running"}
@api_app.get("/auth")
async def auth(code: str, state: str = None):
"""Handle OAuth callback"""
return {"message": "Authentication successful", "code": code, "state": state}
@api_app.get("/conversations")
async def get_conversations(user_id: str = Depends(verify_discord_token)):
"""Get all conversations for a user"""
if user_id not in user_conversations:
return {"conversations": []}
return {"conversations": user_conversations[user_id]}
@api_app.post("/sync")
async def sync_conversations(
sync_request: SyncRequest,
user_id: str = Depends(verify_discord_token)
):
"""Sync conversations between the Flutter app and Discord bot"""
# Get existing conversations for this user
existing_conversations = user_conversations.get(user_id, [])
# Process incoming conversations
updated_conversations = []
for incoming_conv in sync_request.conversations:
# Check if this conversation already exists
existing_conv = next((conv for conv in existing_conversations
if conv.id == incoming_conv.id), None)
if existing_conv:
# If the incoming conversation is newer, update it
if incoming_conv.updated_at > existing_conv.updated_at:
# Replace the existing conversation
existing_conversations = [conv for conv in existing_conversations
if conv.id != incoming_conv.id]
existing_conversations.append(incoming_conv)
updated_conversations.append(incoming_conv)
else:
# This is a new conversation, add it
existing_conversations.append(incoming_conv)
updated_conversations.append(incoming_conv)
# Update the storage
user_conversations[user_id] = existing_conversations
save_conversations()
# Process user settings if provided
user_settings_response = None
if sync_request.user_settings:
incoming_settings = sync_request.user_settings
existing_settings = user_settings.get(user_id)
# If we have existing settings, check which is newer
if existing_settings:
if not existing_settings.last_updated or incoming_settings.last_updated > existing_settings.last_updated:
user_settings[user_id] = incoming_settings
save_all_user_settings()
user_settings_response = incoming_settings
else:
user_settings_response = existing_settings
else:
# No existing settings, just save the incoming ones
user_settings[user_id] = incoming_settings
save_all_user_settings()
user_settings_response = incoming_settings
return SyncResponse(
success=True,
message=f"Synced {len(updated_conversations)} conversations",
conversations=existing_conversations,
user_settings=user_settings_response
)
@api_app.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
user_id: str = Depends(verify_discord_token)
):
"""Delete a conversation"""
if user_id not in user_conversations:
raise HTTPException(status_code=404, detail="No conversations found for this user")
# Filter out the conversation to delete
original_count = len(user_conversations[user_id])
user_conversations[user_id] = [conv for conv in user_conversations[user_id]
if conv.id != conversation_id]
# Check if any conversation was deleted
if len(user_conversations[user_id]) == original_count:
raise HTTPException(status_code=404, detail="Conversation not found")
save_conversations()
return {"success": True, "message": "Conversation deleted"}
@api_app.get("/settings")
async def get_user_settings(user_id: str = Depends(verify_discord_token)):
"""Get user settings"""
# Import the AI cog's get_user_settings function to get local settings
try:
from cogs.ai_cog import get_user_settings as get_local_settings, user_settings as local_user_settings
# Get local settings from the AI cog
local_settings = get_local_settings(int(user_id))
print(f"Local settings for user {user_id}:")
print(f"Character: {local_settings.get('character')}")
print(f"Character Info: {local_settings.get('character_info')}")
print(f"Character Breakdown: {local_settings.get('character_breakdown')}")
print(f"Custom Instructions: {local_settings.get('custom_instructions')}")
print(f"System Prompt: {local_settings.get('system_prompt')}")
# Create or get synced settings
if user_id not in user_settings:
user_settings[user_id] = UserSettings()
# Update synced settings with local settings
synced_settings = user_settings[user_id]
# Always update all settings from local settings
synced_settings.model_id = local_settings.get("model", synced_settings.model_id)
synced_settings.temperature = local_settings.get("temperature", synced_settings.temperature)
synced_settings.max_tokens = local_settings.get("max_tokens", synced_settings.max_tokens)
synced_settings.system_message = local_settings.get("system_prompt", synced_settings.system_message)
# Handle character settings - explicitly check if they exist in local settings
if "character" in local_settings:
synced_settings.character = local_settings["character"]
else:
# If not in local settings, set to None
synced_settings.character = None
# Handle character_info - explicitly check if they exist in local settings
if "character_info" in local_settings:
synced_settings.character_info = local_settings["character_info"]
else:
# If not in local settings, set to None
synced_settings.character_info = None
# Always update character_breakdown
synced_settings.character_breakdown = local_settings.get("character_breakdown", False)
# Handle custom_instructions - explicitly check if they exist in local settings
if "custom_instructions" in local_settings:
synced_settings.custom_instructions = local_settings["custom_instructions"]
else:
# If not in local settings, set to None
synced_settings.custom_instructions = None
# Always update reasoning settings
synced_settings.reasoning_enabled = local_settings.get("show_reasoning", False)
synced_settings.reasoning_effort = local_settings.get("reasoning_effort", "medium")
synced_settings.web_search_enabled = local_settings.get("web_search_enabled", False)
# Update timestamp and sync source
synced_settings.last_updated = datetime.datetime.now()
synced_settings.sync_source = "discord"
# Save the updated synced settings
save_all_user_settings()
print(f"Updated synced settings for user {user_id}:")
print(f"Character: {synced_settings.character}")
print(f"Character Info: {synced_settings.character_info}")
print(f"Character Breakdown: {synced_settings.character_breakdown}")
print(f"Custom Instructions: {synced_settings.custom_instructions}")
print(f"System Message: {synced_settings.system_message}")
return {"settings": synced_settings}
except Exception as e:
print(f"Error merging settings: {e}")
# Fallback to original behavior
if user_id not in user_settings:
# Create default settings if none exist
user_settings[user_id] = UserSettings()
save_all_user_settings()
return {"settings": user_settings[user_id]}
@api_app.post("/settings")
async def update_user_settings(
settings_request: SettingsSyncRequest,
user_id: str = Depends(verify_discord_token)
):
"""Update user settings"""
incoming_settings = settings_request.user_settings
existing_settings = user_settings.get(user_id)
# Debug logging for character settings
print(f"Received settings update from user {user_id}:")
print(f"Character: {incoming_settings.character}")
print(f"Character Info: {incoming_settings.character_info}")
print(f"Character Breakdown: {incoming_settings.character_breakdown}")
print(f"Custom Instructions: {incoming_settings.custom_instructions}")
print(f"Last Updated: {incoming_settings.last_updated}")
print(f"Sync Source: {incoming_settings.sync_source}")
if existing_settings:
print(f"Existing settings for user {user_id}:")
print(f"Character: {existing_settings.character}")
print(f"Character Info: {existing_settings.character_info}")
print(f"Last Updated: {existing_settings.last_updated}")
print(f"Sync Source: {existing_settings.sync_source}")
# If we have existing settings, check which is newer
if existing_settings:
if not existing_settings.last_updated or incoming_settings.last_updated > existing_settings.last_updated:
print(f"Updating settings for user {user_id} (incoming settings are newer)")
user_settings[user_id] = incoming_settings
save_all_user_settings()
else:
# Return existing settings if they're newer
print(f"Not updating settings for user {user_id} (existing settings are newer)")
return {"success": True, "message": "Existing settings are newer", "settings": existing_settings}
else:
# No existing settings, just save the incoming ones
print(f"Creating new settings for user {user_id}")
user_settings[user_id] = incoming_settings
save_all_user_settings()
# Verify the settings were saved correctly
saved_settings = user_settings.get(user_id)
print(f"Saved settings for user {user_id}:")
print(f"Character: {saved_settings.character}")
print(f"Character Info: {saved_settings.character_info}")
print(f"Character Breakdown: {saved_settings.character_breakdown}")
print(f"Custom Instructions: {saved_settings.custom_instructions}")
# Update the local settings in the AI cog
try:
from cogs.ai_cog import user_settings as local_user_settings, save_user_settings as save_local_user_settings
# Convert user_id to int for the AI cog
int_user_id = int(user_id)
# Initialize local settings if not exist
if int_user_id not in local_user_settings:
local_user_settings[int_user_id] = {}
# Update local settings with incoming settings
# Always update all settings, including setting to None/null when appropriate
local_user_settings[int_user_id]["model"] = incoming_settings.model_id
local_user_settings[int_user_id]["temperature"] = incoming_settings.temperature
local_user_settings[int_user_id]["max_tokens"] = incoming_settings.max_tokens
local_user_settings[int_user_id]["system_prompt"] = incoming_settings.system_message
# Handle character settings - explicitly set to None if null in incoming settings
if incoming_settings.character is None:
# Remove the character setting if it exists
if "character" in local_user_settings[int_user_id]:
local_user_settings[int_user_id].pop("character")
print(f"Removed character setting for user {user_id}")
else:
local_user_settings[int_user_id]["character"] = incoming_settings.character
# Handle character_info - explicitly set to None if null in incoming settings
if incoming_settings.character_info is None:
# Remove the character_info setting if it exists
if "character_info" in local_user_settings[int_user_id]:
local_user_settings[int_user_id].pop("character_info")
print(f"Removed character_info setting for user {user_id}")
else:
local_user_settings[int_user_id]["character_info"] = incoming_settings.character_info
# Always update character_breakdown
local_user_settings[int_user_id]["character_breakdown"] = incoming_settings.character_breakdown
# Handle custom_instructions - explicitly set to None if null in incoming settings
if incoming_settings.custom_instructions is None:
# Remove the custom_instructions setting if it exists
if "custom_instructions" in local_user_settings[int_user_id]:
local_user_settings[int_user_id].pop("custom_instructions")
print(f"Removed custom_instructions setting for user {user_id}")
else:
local_user_settings[int_user_id]["custom_instructions"] = incoming_settings.custom_instructions
# Always update reasoning settings
local_user_settings[int_user_id]["show_reasoning"] = incoming_settings.reasoning_enabled
local_user_settings[int_user_id]["reasoning_effort"] = incoming_settings.reasoning_effort
local_user_settings[int_user_id]["web_search_enabled"] = incoming_settings.web_search_enabled
# Save the updated local settings
save_local_user_settings()
print(f"Updated local settings in AI cog for user {user_id}:")
print(f"Character: {local_user_settings[int_user_id].get('character')}")
print(f"Character Info: {local_user_settings[int_user_id].get('character_info')}")
print(f"Character Breakdown: {local_user_settings[int_user_id].get('character_breakdown')}")
print(f"Custom Instructions: {local_user_settings[int_user_id].get('custom_instructions')}")
except Exception as e:
print(f"Error updating local settings in AI cog: {e}")
return {"success": True, "message": "Settings updated", "settings": user_settings[user_id]}
# ============= Discord Bot Integration =============
# This function should be called from your Discord bot's AI cog
# to convert AI conversation history to the synced format
def convert_ai_history_to_synced(user_id: str, conversation_history: Dict[int, List[Dict[str, Any]]]):
"""Convert the AI conversation history to the synced format"""
synced_conversations = []
# Process each conversation in the history
for discord_user_id, messages in conversation_history.items():
if str(discord_user_id) != user_id:
continue
# Create a unique ID for this conversation
conv_id = f"discord_{discord_user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
# Convert messages to the synced format
synced_messages = []
for msg in messages:
role = msg.get("role", "")
if role not in ["user", "assistant", "system"]:
continue
synced_messages.append(SyncedMessage(
content=msg.get("content", ""),
role=role,
timestamp=datetime.datetime.now(), # Use current time as we don't have the original timestamp
reasoning=None, # Discord bot doesn't store reasoning
usage_data=None # Discord bot doesn't store usage data
))
# Create the synced conversation
synced_conversations.append(SyncedConversation(
id=conv_id,
title="Discord Conversation", # Default title
messages=synced_messages,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now(),
model_id="openai/gpt-3.5-turbo", # Default model
sync_source="discord",
last_synced_at=datetime.datetime.now(),
reasoning_enabled=False,
reasoning_effort="medium",
temperature=0.7,
max_tokens=1000,
web_search_enabled=False,
system_message=None,
character=None,
character_info=None,
character_breakdown=False,
custom_instructions=None
))
return synced_conversations
# This function should be called from your Discord bot's AI cog
# to save a new conversation from Discord
def save_discord_conversation(
user_id: str,
messages: List[Dict[str, Any]],
model_id: str = "openai/gpt-3.5-turbo",
conversation_id: Optional[str] = None,
title: str = "Discord Conversation",
reasoning_enabled: bool = False,
reasoning_effort: str = "medium",
temperature: float = 0.7,
max_tokens: int = 1000,
web_search_enabled: bool = False,
system_message: Optional[str] = None,
character: Optional[str] = None,
character_info: Optional[str] = None,
character_breakdown: bool = False,
custom_instructions: Optional[str] = None
):
"""Save a conversation from Discord to the synced storage"""
# Convert messages to the synced format
synced_messages = []
for msg in messages:
role = msg.get("role", "")
if role not in ["user", "assistant", "system"]:
continue
synced_messages.append(SyncedMessage(
content=msg.get("content", ""),
role=role,
timestamp=datetime.datetime.now(),
reasoning=msg.get("reasoning"),
usage_data=msg.get("usage_data")
))
# Create a unique ID for this conversation if not provided
if not conversation_id:
conversation_id = f"discord_{user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
# Create the synced conversation
synced_conv = SyncedConversation(
id=conversation_id,
title=title,
messages=synced_messages,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now(),
model_id=model_id,
sync_source="discord",
last_synced_at=datetime.datetime.now(),
reasoning_enabled=reasoning_enabled,
reasoning_effort=reasoning_effort,
temperature=temperature,
max_tokens=max_tokens,
web_search_enabled=web_search_enabled,
system_message=system_message,
character=character,
character_info=character_info,
character_breakdown=character_breakdown,
custom_instructions=custom_instructions
)
# Add to storage
if user_id not in user_conversations:
user_conversations[user_id] = []
# Check if we're updating an existing conversation
if conversation_id:
# Remove the old conversation with the same ID if it exists
user_conversations[user_id] = [conv for conv in user_conversations[user_id]
if conv.id != conversation_id]
user_conversations[user_id].append(synced_conv)
save_conversations()
return synced_conv

371
discord_oauth.py Normal file
View File

@ -0,0 +1,371 @@
"""
Discord OAuth2 implementation for the Discord bot.
This module handles the OAuth2 flow for authenticating users with Discord,
including generating authorization URLs, exchanging codes for tokens,
and managing token storage and refresh.
"""
import os
import json
import time
import secrets
import hashlib
import base64
import aiohttp
import asyncio
import traceback
from typing import Dict, Optional, Tuple, Any
from urllib.parse import urlencode
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# OAuth2 Configuration
CLIENT_ID = os.getenv("DISCORD_CLIENT_ID", "1360717457852993576")
# No client secret for public clients
# Use the API service's OAuth endpoint if available, otherwise use the local server
API_URL = os.getenv("API_URL", "https://slipstreamm.dev/api")
API_OAUTH_ENABLED = os.getenv("API_OAUTH_ENABLED", "true").lower() in ("true", "1", "yes")
# If API OAuth is enabled, use the API service's OAuth endpoint
if API_OAUTH_ENABLED:
# For API OAuth, we'll use a special redirect URI that includes the code_verifier
# The base redirect URI is the API URL + /auth
API_AUTH_ENDPOINT = f"{API_URL}/auth"
# The actual redirect URI will be constructed in get_auth_url to include the code_verifier
REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", API_AUTH_ENDPOINT)
else:
# Otherwise, use the local OAuth server
OAUTH_HOST = os.getenv("OAUTH_HOST", "localhost")
OAUTH_PORT = int(os.getenv("OAUTH_PORT", "8080"))
REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", f"http://{OAUTH_HOST}:{OAUTH_PORT}/oauth/callback")
# Discord API endpoints
API_ENDPOINT = "https://discord.com/api/v10"
TOKEN_URL = f"{API_ENDPOINT}/oauth2/token"
AUTH_URL = f"{API_ENDPOINT}/oauth2/authorize"
USER_URL = f"{API_ENDPOINT}/users/@me"
# Token storage directory
TOKEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tokens")
os.makedirs(TOKEN_DIR, exist_ok=True)
# In-memory storage for PKCE code verifiers and pending states
code_verifiers: Dict[str, Any] = {}
# Global dictionary to store code verifiers by state
# This is used to pass the code verifier to the API service
pending_code_verifiers: Dict[str, str] = {}
class OAuthError(Exception):
"""Exception raised for OAuth errors."""
pass
def generate_code_verifier() -> str:
"""Generate a code verifier for PKCE."""
return secrets.token_urlsafe(64)
def generate_code_challenge(verifier: str) -> str:
"""Generate a code challenge from a code verifier."""
sha256 = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(sha256).decode().rstrip("=")
def get_token_path(user_id: str) -> str:
"""Get the path to the token file for a user."""
return os.path.join(TOKEN_DIR, f"{user_id}.json")
def save_token(user_id: str, token_data: Dict[str, Any]) -> None:
"""Save a token to disk."""
# Add the time when the token was saved
token_data["saved_at"] = int(time.time())
with open(get_token_path(user_id), "w") as f:
json.dump(token_data, f)
def load_token(user_id: str) -> Optional[Dict[str, Any]]:
"""Load a token from disk."""
token_path = get_token_path(user_id)
if not os.path.exists(token_path):
return None
try:
with open(token_path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def is_token_expired(token_data: Dict[str, Any]) -> bool:
"""Check if a token is expired."""
if not token_data:
return True
# Get the time when the token was saved
saved_at = token_data.get("saved_at", 0)
# Get the token's expiration time
expires_in = token_data.get("expires_in", 0)
# Check if the token is expired
# We consider it expired if it's within 5 minutes of expiration
return (saved_at + expires_in - 300) < int(time.time())
def delete_token(user_id: str) -> bool:
"""Delete a token from disk."""
token_path = get_token_path(user_id)
if os.path.exists(token_path):
os.remove(token_path)
return True
return False
async def send_code_verifier_to_api(state: str, code_verifier: str) -> bool:
"""Send the code verifier to the API service."""
try:
async with aiohttp.ClientSession() as session:
# Construct the URL for the code verifier endpoint
url = f"{API_URL}/code_verifier"
# Prepare the data
data = {
"state": state,
"code_verifier": code_verifier
}
# Send the code verifier to the API service
print(f"Sending code verifier for state {state} to API service: {url}")
async with session.post(url, json=data) as resp:
if resp.status != 200:
error_text = await resp.text()
print(f"Failed to send code verifier to API service: {error_text}")
return False
response_data = await resp.json()
print(f"Successfully sent code verifier to API service: {response_data}")
return True
except Exception as e:
print(f"Error sending code verifier to API service: {e}")
traceback.print_exc()
return False
def get_auth_url(state: str, code_verifier: str) -> str:
"""Get the authorization URL for the OAuth2 flow."""
code_challenge = generate_code_challenge(code_verifier)
# Determine the redirect URI based on whether API OAuth is enabled
if API_OAUTH_ENABLED:
# For API OAuth, we must use a clean redirect URI without any query parameters
# The redirect URI must exactly match the one registered in the Discord application
actual_redirect_uri = API_AUTH_ENDPOINT
print(f"Using API OAuth with redirect URI: {actual_redirect_uri}")
else:
# For local OAuth server, use the standard redirect URI
actual_redirect_uri = REDIRECT_URI
print(f"Using local OAuth server with redirect URI: {actual_redirect_uri}")
# Build the authorization URL
params = {
"client_id": CLIENT_ID,
"redirect_uri": actual_redirect_uri,
"response_type": "code",
"scope": "identify",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent"
}
auth_url = f"{AUTH_URL}?{urlencode(params)}"
# Store the code verifier and redirect URI for this state
code_verifiers[state] = {
"code_verifier": code_verifier,
"redirect_uri": actual_redirect_uri
}
# Also store the code verifier in the global dictionary
# This will be used by the API service to retrieve the code verifier
pending_code_verifiers[state] = code_verifier
print(f"Stored code verifier for state {state}: {code_verifier[:10]}...")
# If API OAuth is enabled, send the code verifier to the API service
if API_OAUTH_ENABLED:
asyncio.create_task(send_code_verifier_to_api(state, code_verifier))
return auth_url
async def exchange_code(code: str, state: str) -> Dict[str, Any]:
"""Exchange an authorization code for a token."""
# Get the code verifier and redirect URI for this state
state_data = code_verifiers.pop(state, None)
if not state_data:
raise OAuthError("Invalid state parameter")
# Extract code_verifier and redirect_uri
if isinstance(state_data, dict):
code_verifier = state_data.get("code_verifier")
redirect_uri = state_data.get("redirect_uri")
else:
# For backward compatibility
code_verifier = state_data
redirect_uri = REDIRECT_URI
if not code_verifier:
raise OAuthError("Missing code verifier")
# If API OAuth is enabled, we need to check if we should handle the token exchange ourselves
# or if the API service will handle it
if API_OAUTH_ENABLED and redirect_uri.startswith(API_URL):
# If the API service is handling the OAuth flow, we need to get the token from the API
# We'll make a request to the API service with the code and code_verifier
async with aiohttp.ClientSession() as session:
# Construct the URL with the code and code_verifier
params = {
"code": code,
"state": state,
"code_verifier": code_verifier
}
auth_url = f"{API_URL}/auth?{urlencode(params)}"
print(f"Redirecting to API service for token exchange: {auth_url}")
# Make a request to the API service
async with session.get(auth_url) as resp:
if resp.status != 200:
error_text = await resp.text()
print(f"Failed to exchange code with API service: {error_text}")
raise OAuthError(f"Failed to exchange code with API service: {error_text}")
# The API service should return a success page, not the token
# We'll need to get the token from the API service separately
print("Successfully exchanged code with API service")
# Parse the response to get the token data
try:
response_data = await resp.json()
if "token" in response_data:
# Save the token data
token_data = response_data["token"]
save_token(response_data["user_id"], token_data)
print(f"Successfully saved token for user {response_data['user_id']}")
return token_data
else:
# If the response doesn't contain a token, it's probably an HTML response
# We'll need to get the token from the API service separately
print("Response doesn't contain token data, will try to get it separately")
except Exception as e:
print(f"Error parsing response: {e}")
# If we couldn't get the token from the response, try to get it from the API service
try:
# Make a request to the API service to get the token
headers = {"Accept": "application/json"}
async with session.get(f"{API_URL}/token", headers=headers) as token_resp:
if token_resp.status != 200:
error_text = await token_resp.text()
print(f"Failed to get token from API service: {error_text}")
raise OAuthError(f"Failed to get token from API service: {error_text}")
token_data = await token_resp.json()
if "access_token" in token_data:
return token_data
else:
raise OAuthError("API service didn't return a valid token")
except Exception as e:
print(f"Error getting token from API service: {e}")
# Return a placeholder token for now
return {"access_token": "placeholder_token", "token_type": "Bearer", "expires_in": 604800}
# If we're handling the token exchange ourselves, proceed as before
async with aiohttp.ClientSession() as session:
# For public clients, we don't include a client secret
data = {
"client_id": CLIENT_ID,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier
}
print(f"Exchanging code for token with data: {data}")
async with session.post(TOKEN_URL, data=data) as resp:
if resp.status != 200:
error_text = await resp.text()
print(f"Failed to exchange code: {error_text}")
raise OAuthError(f"Failed to exchange code: {error_text}")
return await resp.json()
async def refresh_token(refresh_token: str) -> Dict[str, Any]:
"""Refresh an access token."""
async with aiohttp.ClientSession() as session:
# For public clients, we don't include a client secret
data = {
"client_id": CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": refresh_token
}
print(f"Refreshing token with data: {data}")
async with session.post(TOKEN_URL, data=data) as resp:
if resp.status != 200:
error_text = await resp.text()
print(f"Failed to refresh token: {error_text}")
raise OAuthError(f"Failed to refresh token: {error_text}")
return await resp.json()
async def get_user_info(access_token: str) -> Dict[str, Any]:
"""Get information about the authenticated user."""
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(USER_URL, headers=headers) as resp:
if resp.status != 200:
error_text = await resp.text()
raise OAuthError(f"Failed to get user info: {error_text}")
return await resp.json()
async def get_token(user_id: str) -> Optional[str]:
"""Get a valid access token for a user."""
# Load the token from disk
token_data = load_token(user_id)
if not token_data:
return None
# Check if the token is expired
if is_token_expired(token_data):
# Try to refresh the token
refresh_token_str = token_data.get("refresh_token")
if not refresh_token_str:
return None
try:
# Refresh the token
new_token_data = await refresh_token(refresh_token_str)
# Save the new token
save_token(user_id, new_token_data)
# Return the new access token
return new_token_data.get("access_token")
except OAuthError:
# If refreshing fails, delete the token and return None
delete_token(user_id)
return None
# Return the access token
return token_data.get("access_token")
async def validate_token(token: str) -> Tuple[bool, Optional[str]]:
"""Validate a token and return the user ID if valid."""
try:
# Get user info to validate the token
user_info = await get_user_info(token)
return True, user_info.get("id")
except OAuthError:
return False, None

436
error_handler.py Normal file
View File

@ -0,0 +1,436 @@
import discord
from discord.ext import commands
import traceback
import os
import datetime
# Global function for storing interaction content
store_interaction_content = None
# Utility functions to store message content before sending
async def store_and_send(ctx_or_interaction, content, **kwargs):
"""Store the message content and then send it."""
# Store the content for potential error handling
if isinstance(ctx_or_interaction, commands.Context):
ctx_or_interaction._last_message_content = content
return await ctx_or_interaction.send(content, **kwargs)
else: # It's an interaction
ctx_or_interaction._last_response_content = content
if not ctx_or_interaction.response.is_done():
return await ctx_or_interaction.response.send_message(content, **kwargs)
else:
return await ctx_or_interaction.followup.send(content, **kwargs)
async def store_and_reply(ctx, content, **kwargs):
"""Store the message content and then reply to the message."""
ctx._last_message_content = content
return await ctx.reply(content, **kwargs)
def extract_message_content(ctx_or_interaction):
"""Extract message content from a Context or Interaction object."""
content = None
# Check if this is an AI command error
is_ai_command = False
if isinstance(ctx_or_interaction, commands.Context) and hasattr(ctx_or_interaction, 'command'):
is_ai_command = ctx_or_interaction.command and ctx_or_interaction.command.name == 'ai'
elif hasattr(ctx_or_interaction, 'command') and ctx_or_interaction.command:
is_ai_command = ctx_or_interaction.command.name == 'ai'
# For AI commands, try to load from the ai_response.txt file if it exists
if is_ai_command and os.path.exists('ai_response.txt'):
try:
with open('ai_response.txt', 'r', encoding='utf-8') as f:
content = f.read()
if content:
return content
except Exception as e:
print(f"Error reading ai_response.txt: {e}")
# For interactions, try to get content from the AI cog's dictionary
if not isinstance(ctx_or_interaction, commands.Context) and is_ai_command:
try:
# Try to import the dictionary from the AI cog
from cogs.ai_cog import interaction_responses
# Get the interaction ID
interaction_id = getattr(ctx_or_interaction, 'id', None)
if interaction_id and interaction_id in interaction_responses:
content = interaction_responses[interaction_id]
print(f"Retrieved content for interaction {interaction_id} from dictionary")
if content:
return content
except Exception as e:
print(f"Error retrieving from interaction_responses dictionary: {e}")
if isinstance(ctx_or_interaction, commands.Context):
# For Context objects
if hasattr(ctx_or_interaction, '_last_message_content'):
content = ctx_or_interaction._last_message_content
elif hasattr(ctx_or_interaction, 'message') and hasattr(ctx_or_interaction.message, 'content'):
content = ctx_or_interaction.message.content
elif hasattr(ctx_or_interaction, '_internal_response'):
content = str(ctx_or_interaction._internal_response)
# Try to extract from command invocation
elif hasattr(ctx_or_interaction, 'command') and hasattr(ctx_or_interaction, 'kwargs'):
# Reconstruct command invocation
cmd_name = ctx_or_interaction.command.name if hasattr(ctx_or_interaction.command, 'name') else 'unknown_command'
args_str = ' '.join([str(arg) for arg in ctx_or_interaction.args[1:]]) if hasattr(ctx_or_interaction, 'args') else ''
kwargs_str = ' '.join([f'{k}={v}' for k, v in ctx_or_interaction.kwargs.items()]) if ctx_or_interaction.kwargs else ''
content = f"Command: {cmd_name} {args_str} {kwargs_str}".strip()
else:
# For Interaction objects
if hasattr(ctx_or_interaction, '_last_response_content'):
content = ctx_or_interaction._last_response_content
elif hasattr(ctx_or_interaction, '_internal_response'):
content = str(ctx_or_interaction._internal_response)
# Try to extract from interaction data
elif hasattr(ctx_or_interaction, 'data'):
try:
# Extract command name and options
cmd_name = ctx_or_interaction.data.get('name', 'unknown_command')
options = ctx_or_interaction.data.get('options', [])
options_str = ' '.join([f"{opt.get('name')}={opt.get('value')}" for opt in options]) if options else ''
content = f"Slash Command: /{cmd_name} {options_str}".strip()
except (AttributeError, KeyError):
# If we can't extract structured data, try to get the raw data
content = f"Interaction Data: {str(ctx_or_interaction.data)}"
# For AI commands, add a note if we couldn't retrieve the full response
if is_ai_command and (not content or len(content) < 100):
content = "The AI response was too long and could not be retrieved. " + \
"This is likely due to a message that exceeded Discord's length limits. " + \
"Please try again with a shorter prompt or request fewer details."
return content
def log_error_details(ctx_or_interaction, error, content=None):
"""Log detailed error information to a file for debugging."""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_dir = "error_logs"
# Create logs directory if it doesn't exist
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Create a unique filename based on timestamp
log_file = os.path.join(log_dir, f"error_{timestamp.replace(':', '-').replace(' ', '_')}.log")
with open(log_file, "w", encoding="utf-8") as f:
f.write(f"=== Error Log: {timestamp} ===\n\n")
# Log error details
f.write(f"Error Type: {type(error).__name__}\n")
f.write(f"Error Message: {str(error)}\n\n")
# Log error attributes
if hasattr(error, '__dict__'):
f.write("Error Attributes:\n")
for key, value in error.__dict__.items():
f.write(f" {key}: {value}\n")
f.write("\n")
# Log cause if available
if error.__cause__:
f.write(f"Cause: {type(error.__cause__).__name__}\n")
f.write(f"Cause Message: {str(error.__cause__)}\n\n")
if hasattr(error.__cause__, '__dict__'):
f.write("Cause Attributes:\n")
for key, value in error.__cause__.__dict__.items():
f.write(f" {key}: {value}\n")
f.write("\n")
# Log traceback
f.write("Traceback:\n")
f.write(traceback.format_exc())
f.write("\n")
# Log context/interaction details
f.write("Context/Interaction Details:\n")
if isinstance(ctx_or_interaction, commands.Context):
f.write(f" Type: Context\n")
if hasattr(ctx_or_interaction, 'command') and ctx_or_interaction.command:
f.write(f" Command: {ctx_or_interaction.command.name}\n")
if hasattr(ctx_or_interaction, 'author') and ctx_or_interaction.author:
f.write(f" Author: {ctx_or_interaction.author.name} (ID: {ctx_or_interaction.author.id})\n")
if hasattr(ctx_or_interaction, 'guild') and ctx_or_interaction.guild:
f.write(f" Guild: {ctx_or_interaction.guild.name} (ID: {ctx_or_interaction.guild.id})\n")
if hasattr(ctx_or_interaction, 'channel') and ctx_or_interaction.channel:
f.write(f" Channel: {ctx_or_interaction.channel.name} (ID: {ctx_or_interaction.channel.id})\n")
else:
f.write(f" Type: Interaction\n")
if hasattr(ctx_or_interaction, 'user') and ctx_or_interaction.user:
f.write(f" User: {ctx_or_interaction.user.name} (ID: {ctx_or_interaction.user.id})\n")
if hasattr(ctx_or_interaction, 'guild') and ctx_or_interaction.guild:
f.write(f" Guild: {ctx_or_interaction.guild.name} (ID: {ctx_or_interaction.guild.id})\n")
if hasattr(ctx_or_interaction, 'channel') and ctx_or_interaction.channel:
f.write(f" Channel: {ctx_or_interaction.channel.name} (ID: {ctx_or_interaction.channel.id})\n")
if hasattr(ctx_or_interaction, 'command') and ctx_or_interaction.command:
f.write(f" Command: {ctx_or_interaction.command.name}\n")
f.write("\n")
# Log message content if available
if content:
f.write("Message Content:\n")
f.write(content)
f.write("\n")
print(f"Error details logged to {log_file}")
return log_file
def patch_discord_methods():
"""Patch Discord methods to store message content before sending."""
# Save original methods for Context
original_context_send = commands.Context.send
original_context_reply = commands.Context.reply
# Patch Context.send
async def patched_context_send(self, content=None, **kwargs):
if content is not None:
self._last_message_content = content
return await original_context_send(self, content, **kwargs)
# Patch Context.reply
async def patched_context_reply(self, content=None, **kwargs):
if content is not None:
self._last_message_content = content
return await original_context_reply(self, content, **kwargs)
# Apply Context patches
commands.Context.send = patched_context_send
commands.Context.reply = patched_context_reply
# For Interaction, we'll use a simpler approach that doesn't rely on patching
# the internal classes, which can vary between Discord.py versions
# Instead, we'll add a utility function to store content that can be called
# before sending messages with interactions
# This function will be available globally for use in commands
global store_interaction_content
def store_interaction_content(interaction, content):
"""Store content in an interaction for potential error recovery"""
if interaction and content:
try:
# Try to import the dictionary from the AI cog
try:
from cogs.ai_cog import interaction_responses
# Store using the interaction ID as the key
interaction_id = getattr(interaction, 'id', None)
if interaction_id:
interaction_responses[interaction_id] = content
print(f"Stored response for interaction {interaction_id} in dictionary from error_handler")
return True
except ImportError:
pass
# Fallback: try to set attribute directly (may fail)
interaction._last_response_content = content
return True
except Exception as e:
print(f"Warning: Failed to store interaction content in error_handler: {e}")
return False
print("Discord Context methods patched successfully")
async def handle_error(ctx_or_interaction, error):
user_id = 452666956353503252 # Replace with the specific user ID
error_message = f"An error occurred: {error}"
# Check if this is an AI command error
is_ai_command = False
if isinstance(ctx_or_interaction, commands.Context) and hasattr(ctx_or_interaction, 'command'):
is_ai_command = ctx_or_interaction.command and ctx_or_interaction.command.name == 'ai'
elif hasattr(ctx_or_interaction, 'command') and ctx_or_interaction.command:
is_ai_command = ctx_or_interaction.command.name == 'ai'
# For AI command errors with HTTPException, try to handle specially
if is_ai_command and isinstance(error, commands.CommandInvokeError) and isinstance(error.original, discord.HTTPException):
if error.original.code == 50035 and "Must be 4000 or fewer in length" in str(error.original):
# Try to get the AI response from the stored content
if isinstance(ctx_or_interaction, commands.Context) and hasattr(ctx_or_interaction, '_last_message_content'):
content = ctx_or_interaction._last_message_content
# Save to file and send
with open('ai_response.txt', 'w', encoding='utf-8') as f:
f.write(content)
await ctx_or_interaction.send("The AI response was too long. Here's the content as a file:", file=discord.File('ai_response.txt'))
return
elif hasattr(ctx_or_interaction, '_last_response_content'):
content = ctx_or_interaction._last_response_content
# Save to file and send
with open('ai_response.txt', 'w', encoding='utf-8') as f:
f.write(content)
if not ctx_or_interaction.response.is_done():
await ctx_or_interaction.response.send_message("The AI response was too long. Here's the content as a file:", file=discord.File('ai_response.txt'))
else:
await ctx_or_interaction.followup.send("The AI response was too long. Here's the content as a file:", file=discord.File('ai_response.txt'))
return
# Extract message content for logging
content = extract_message_content(ctx_or_interaction)
# Log error details to file
log_file = log_error_details(ctx_or_interaction, error, content)
# Check if the command runner is the owner
is_owner = False
if isinstance(ctx_or_interaction, commands.Context):
is_owner = ctx_or_interaction.author.id == user_id
else:
is_owner = ctx_or_interaction.user.id == user_id
# Only send detailed error DM if the command runner is the owner
if is_owner:
try:
owner = await ctx_or_interaction.bot.fetch_user(user_id)
if owner:
full_error = f"Full error details:\n```\n{str(error)}\n"
if hasattr(error, '__dict__'):
full_error += f"\nError attributes:\n{error.__dict__}\n"
if error.__cause__:
full_error += f"\nCause:\n{str(error.__cause__)}\n"
if hasattr(error.__cause__, '__dict__'):
full_error += f"\nCause attributes:\n{error.__cause__.__dict__}\n"
full_error += "```"
# Add log file path to the error message
full_error += f"\nDetailed error log saved to: `{log_file}`"
# Try to send the log file as an attachment
try:
await owner.send("Here's the detailed error log:", file=discord.File(log_file))
# Send a shorter message since we sent the file
short_error = f"Error: {str(error)}"
if error.__cause__:
short_error += f"\nCause: {str(error.__cause__)}"
await owner.send(short_error)
except discord.HTTPException:
# If sending the file fails, fall back to text messages
# Split long messages if needed
if len(full_error) > 1900:
parts = [full_error[i:i+1900] for i in range(0, len(full_error), 1900)]
for i, part in enumerate(parts):
await owner.send(f"Part {i+1}/{len(parts)}:\n{part}")
else:
await owner.send(full_error)
except Exception as e:
print(f"Failed to send error DM to owner: {e}")
# Determine the file name to use for saving content
file_name = 'message.txt'
# Special handling for AI command errors
if isinstance(error, commands.CommandInvokeError) and isinstance(error.original, discord.HTTPException):
# Check if this is an AI command
is_ai_command = False
if isinstance(ctx_or_interaction, commands.Context) and hasattr(ctx_or_interaction, 'command'):
is_ai_command = ctx_or_interaction.command and ctx_or_interaction.command.name == 'ai'
elif hasattr(ctx_or_interaction, 'command') and ctx_or_interaction.command:
is_ai_command = ctx_or_interaction.command.name == 'ai'
# If it's an AI command, use a different file name
if is_ai_command:
file_name = 'ai_response.txt'
# Handle message too long error (HTTP 400 - Code 50035 or 40005 for file uploads)
if (isinstance(error, discord.HTTPException) and
((error.code == 50035 and ("Must be 4000 or fewer in length" in str(error) or "Must be 2000 or fewer in length" in str(error))) or
(error.code == 40005 and "Request entity too large" in str(error)))) or \
(isinstance(error, commands.CommandInvokeError) and isinstance(error.original, discord.HTTPException) and
((error.original.code == 50035 and ("Must be 4000 or fewer in length" in str(error.original) or "Must be 2000 or fewer in length" in str(error.original))) or
(error.original.code == 40005 and "Request entity too large" in str(error.original)))):
# Try to extract the actual content from the error
content = None
# Handle CommandInvokeError specially
if isinstance(error, commands.CommandInvokeError):
# Use the original error for extraction
original_error = error.original
if isinstance(original_error, discord.HTTPException):
content = original_error.text if hasattr(original_error, 'text') else None
# If it's a wrapped error, get the original error's content
elif isinstance(error.__cause__, discord.HTTPException):
content = error.__cause__.text if hasattr(error.__cause__, 'text') else None
else:
content = error.text if hasattr(error, 'text') else None
# If content is not available in the error, try to retrieve it from the context/interaction
if not content or len(content) < 10: # If content is missing or too short to be the actual message
# Try to get the original content using our utility function
content = extract_message_content(ctx_or_interaction)
# If we still don't have content, use a generic message
if not content:
content = "The original message content could not be retrieved. This is likely due to a message that exceeded Discord's length limits."
# Try to send as a file first
try:
# Create a text file with the content
with open(file_name, 'w', encoding='utf-8') as f:
f.write(content)
# Send the file instead
message = f"The message was too long. Here's the content as a file:\nError details logged to: {log_file}"
if isinstance(ctx_or_interaction, commands.Context):
await ctx_or_interaction.send(
message,
file=discord.File(file_name)
)
else:
if not ctx_or_interaction.response.is_done():
await ctx_or_interaction.response.send_message(
message,
file=discord.File(file_name)
)
else:
await ctx_or_interaction.followup.send(
message,
file=discord.File(file_name)
)
except discord.HTTPException as e:
# If sending as a file also fails (e.g., file too large), split into multiple messages
if e.code == 40005 or "Request entity too large" in str(e):
# Split the content into chunks of 1900 characters (Discord limit is 2000)
chunks = [content[i:i+1900] for i in range(0, len(content), 1900)]
# Send a notification about splitting the message
intro_message = f"The message was too long to send as a file. Splitting into {len(chunks)} parts.\nError details logged to: {log_file}"
if isinstance(ctx_or_interaction, commands.Context):
await ctx_or_interaction.send(intro_message)
for i, chunk in enumerate(chunks):
await ctx_or_interaction.send(f"Part {i+1}/{len(chunks)}:\n```\n{chunk}\n```")
else:
if not ctx_or_interaction.response.is_done():
await ctx_or_interaction.response.send_message(intro_message)
for i, chunk in enumerate(chunks):
await ctx_or_interaction.followup.send(f"Part {i+1}/{len(chunks)}:\n```\n{chunk}\n```")
else:
await ctx_or_interaction.followup.send(intro_message)
for i, chunk in enumerate(chunks):
await ctx_or_interaction.followup.send(f"Part {i+1}/{len(chunks)}:\n```\n{chunk}\n```")
else:
# If it's a different error, re-raise it
raise
return
# Original error handling logic
if isinstance(ctx_or_interaction, commands.Context):
if ctx_or_interaction.author.id == user_id:
try:
await ctx_or_interaction.send(content=error_message)
except discord.Forbidden:
await ctx_or_interaction.send("Unable to send you a DM with the error details.")
else:
await ctx_or_interaction.send("An error occurred while processing your command.")
else:
if ctx_or_interaction.user.id == user_id:
await ctx_or_interaction.response.send_message(content=error_message, ephemeral=True)
else:
await ctx_or_interaction.response.send_message("An error occurred while processing your command.")

View File

@ -0,0 +1,106 @@
# Discord Sync Integration
This document explains how to set up the Discord OAuth integration between your Flutter app and Discord bot.
## Overview
The integration allows users to:
1. Log in with their Discord account
2. Sync conversations between the Flutter app and Discord bot
3. Import conversations from Discord to the Flutter app
4. Export conversations from the Flutter app to Discord
## Setup Instructions
### 1. Discord Developer Portal Setup
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application" and give it a name (e.g., "OpenRouter GUI")
3. Go to the "OAuth2" section
4. Add a redirect URL: `openroutergui://auth`
5. Copy the "Client ID" - you'll need this for the Flutter app
### 2. Flutter App Setup
1. Open `lib/services/discord_oauth_service.dart`
2. Replace `YOUR_DISCORD_CLIENT_ID` with the Client ID from the Discord Developer Portal:
```dart
static const String clientId = 'YOUR_DISCORD_CLIENT_ID';
```
3. Open `lib/services/sync_service.dart`
4. Replace `YOUR_BOT_API_URL` with the URL where your Discord bot's API will be running:
```dart
static const String botApiUrl = 'YOUR_BOT_API_URL';
```
### 3. Discord Bot Setup
1. Copy the `discord_bot_sync_api.py` file to your Discord bot project
2. Install the required dependencies:
```bash
pip install fastapi uvicorn pydantic
```
3. Add the following code to your main bot file (e.g., `bot.py`):
```python
import threading
import uvicorn
def run_api():
uvicorn.run("discord_bot_sync_api:app", host="0.0.0.0", port=8000)
# Start the API in a separate thread
api_thread = threading.Thread(target=run_api)
api_thread.daemon = True
api_thread.start()
```
4. Modify your `ai_cog.py` file to integrate with the sync API:
```python
from discord_bot_sync_api import save_discord_conversation, load_conversations, user_conversations
# In your _get_ai_response method, after getting the response:
messages = conversation_history[user_id]
save_discord_conversation(str(user_id), messages, settings["model"])
# Add a command to view sync status:
@commands.command(name="aisync")
async def ai_sync_status(self, ctx: commands.Context):
user_id = str(ctx.author.id)
if user_id not in user_conversations or not user_conversations[user_id]:
await ctx.reply("You don't have any synced conversations.")
return
synced_count = len(user_conversations[user_id])
await ctx.reply(f"You have {synced_count} synced conversations that can be accessed from the Flutter app.")
```
### 4. Network Configuration
1. Make sure your Discord bot's API is accessible from the internet
2. You can use a service like [ngrok](https://ngrok.com/) for testing:
```bash
ngrok http 8000
```
3. Use the ngrok URL as your `YOUR_BOT_API_URL` in the Flutter app
## Usage
1. In the Flutter app, go to Settings > Discord Integration
2. Click "Login with Discord" to authenticate
3. Use the "Sync Conversations" button to sync conversations
4. Use the "Import from Discord" button to import conversations from Discord
## Troubleshooting
- **Authentication Issues**: Make sure the Client ID is correct and the redirect URL is properly configured
- **Sync Issues**: Check that the bot API URL is accessible and the API is running
- **Import/Export Issues**: Verify that the Discord bot has saved conversations to sync
## Security Considerations
- The integration uses Discord OAuth for authentication, ensuring only authorized users can access their conversations
- All API requests require a valid Discord token
- The API verifies the token with Discord for each request
- Consider adding rate limiting and additional security measures for production use

View File

@ -0,0 +1,329 @@
import os
import json
import asyncio
import datetime
from typing import Dict, List, Optional, Any, Union
from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import discord
from discord.ext import commands
import aiohttp
# This file contains the API endpoints for syncing conversations between
# the Flutter app and the Discord bot.
# Add this code to your Discord bot project and import it in your main bot file.
# ============= Models =============
class SyncedMessage(BaseModel):
content: str
role: str # "user", "assistant", or "system"
timestamp: datetime.datetime
reasoning: Optional[str] = None
usage_data: Optional[Dict[str, Any]] = None
class SyncedConversation(BaseModel):
id: str
title: str
messages: List[SyncedMessage]
created_at: datetime.datetime
updated_at: datetime.datetime
model_id: str
sync_source: str = "discord" # "discord" or "flutter"
class SyncRequest(BaseModel):
conversations: List[SyncedConversation]
last_sync_time: Optional[datetime.datetime] = None
class SyncResponse(BaseModel):
success: bool
message: str
conversations: List[SyncedConversation] = []
# ============= Storage =============
# File to store synced conversations
SYNC_DATA_FILE = "synced_conversations.json"
# In-memory storage for conversations
user_conversations: Dict[str, List[SyncedConversation]] = {}
# Load conversations from file
def load_conversations():
global user_conversations
if os.path.exists(SYNC_DATA_FILE):
try:
with open(SYNC_DATA_FILE, "r") as f:
data = json.load(f)
# Convert string keys (user IDs) back to strings
user_conversations = {k: [SyncedConversation.parse_obj(conv) for conv in v]
for k, v in data.items()}
print(f"Loaded synced conversations for {len(user_conversations)} users")
except Exception as e:
print(f"Error loading synced conversations: {e}")
user_conversations = {}
# Save conversations to file
def save_conversations():
try:
# Convert to JSON-serializable format
serializable_data = {
user_id: [conv.dict() for conv in convs]
for user_id, convs in user_conversations.items()
}
with open(SYNC_DATA_FILE, "w") as f:
json.dump(serializable_data, f, indent=2, default=str)
except Exception as e:
print(f"Error saving synced conversations: {e}")
# ============= Discord OAuth Verification =============
async def verify_discord_token(authorization: str = Header(None)) -> str:
"""Verify the Discord token and return the user ID"""
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header missing")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization format")
token = authorization.replace("Bearer ", "")
# Verify the token with Discord
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {token}"}
async with session.get("https://discord.com/api/v10/users/@me", headers=headers) as resp:
if resp.status != 200:
raise HTTPException(status_code=401, detail="Invalid Discord token")
user_data = await resp.json()
return user_data["id"]
# ============= API Setup =============
app = FastAPI(title="Discord Bot Sync API")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Adjust this in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize by loading saved data
@app.on_event("startup")
async def startup_event():
load_conversations()
# ============= API Endpoints =============
@app.get("/")
async def root():
return {"message": "Discord Bot Sync API is running"}
@app.get("/conversations")
async def get_conversations(user_id: str = Depends(verify_discord_token)):
"""Get all conversations for a user"""
if user_id not in user_conversations:
return {"conversations": []}
return {"conversations": user_conversations[user_id]}
@app.post("/sync")
async def sync_conversations(
sync_request: SyncRequest,
user_id: str = Depends(verify_discord_token)
):
"""Sync conversations between the Flutter app and Discord bot"""
# Get existing conversations for this user
existing_conversations = user_conversations.get(user_id, [])
# Process incoming conversations
updated_conversations = []
for incoming_conv in sync_request.conversations:
# Check if this conversation already exists
existing_conv = next((conv for conv in existing_conversations
if conv.id == incoming_conv.id), None)
if existing_conv:
# If the incoming conversation is newer, update it
if incoming_conv.updated_at > existing_conv.updated_at:
# Replace the existing conversation
existing_conversations = [conv for conv in existing_conversations
if conv.id != incoming_conv.id]
existing_conversations.append(incoming_conv)
updated_conversations.append(incoming_conv)
else:
# This is a new conversation, add it
existing_conversations.append(incoming_conv)
updated_conversations.append(incoming_conv)
# Update the storage
user_conversations[user_id] = existing_conversations
save_conversations()
return SyncResponse(
success=True,
message=f"Synced {len(updated_conversations)} conversations",
conversations=existing_conversations
)
@app.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
user_id: str = Depends(verify_discord_token)
):
"""Delete a conversation"""
if user_id not in user_conversations:
raise HTTPException(status_code=404, detail="No conversations found for this user")
# Filter out the conversation to delete
original_count = len(user_conversations[user_id])
user_conversations[user_id] = [conv for conv in user_conversations[user_id]
if conv.id != conversation_id]
# Check if any conversation was deleted
if len(user_conversations[user_id]) == original_count:
raise HTTPException(status_code=404, detail="Conversation not found")
save_conversations()
return {"success": True, "message": "Conversation deleted"}
# ============= Discord Bot Integration =============
# This function should be called from your Discord bot's AI cog
# to convert AI conversation history to the synced format
def convert_ai_history_to_synced(user_id: str, conversation_history: Dict[int, List[Dict[str, Any]]]):
"""Convert the AI conversation history to the synced format"""
synced_conversations = []
# Process each conversation in the history
for discord_user_id, messages in conversation_history.items():
if str(discord_user_id) != user_id:
continue
# Create a unique ID for this conversation
conv_id = f"discord_{discord_user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
# Convert messages to the synced format
synced_messages = []
for msg in messages:
role = msg.get("role", "")
if role not in ["user", "assistant", "system"]:
continue
synced_messages.append(SyncedMessage(
content=msg.get("content", ""),
role=role,
timestamp=datetime.datetime.now(), # Use current time as we don't have the original timestamp
reasoning=None, # Discord bot doesn't store reasoning
usage_data=None # Discord bot doesn't store usage data
))
# Create the synced conversation
synced_conversations.append(SyncedConversation(
id=conv_id,
title="Discord Conversation", # Default title
messages=synced_messages,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now(),
model_id="openai/gpt-3.5-turbo", # Default model
sync_source="discord"
))
return synced_conversations
# This function should be called from your Discord bot's AI cog
# to save a new conversation from Discord
def save_discord_conversation(user_id: str, messages: List[Dict[str, Any]], model_id: str = "openai/gpt-3.5-turbo"):
"""Save a conversation from Discord to the synced storage"""
# Convert messages to the synced format
synced_messages = []
for msg in messages:
role = msg.get("role", "")
if role not in ["user", "assistant", "system"]:
continue
synced_messages.append(SyncedMessage(
content=msg.get("content", ""),
role=role,
timestamp=datetime.datetime.now(),
reasoning=None,
usage_data=None
))
# Create a unique ID for this conversation
conv_id = f"discord_{user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
# Create the synced conversation
synced_conv = SyncedConversation(
id=conv_id,
title="Discord Conversation",
messages=synced_messages,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now(),
model_id=model_id,
sync_source="discord"
)
# Add to storage
if user_id not in user_conversations:
user_conversations[user_id] = []
user_conversations[user_id].append(synced_conv)
save_conversations()
return synced_conv
# ============= Integration with AI Cog =============
# Add these functions to your AI cog to integrate with the sync API
"""
# In your ai_cog.py file, add these imports:
from discord_bot_sync_api import save_discord_conversation, load_conversations, user_conversations
# Then modify your _get_ai_response method to save conversations after getting a response:
async def _get_ai_response(self, user_id: int, prompt: str, system_prompt: str = None) -> str:
# ... existing code ...
# After getting the response and updating conversation_history:
# Convert the conversation to the synced format and save it
messages = conversation_history[user_id]
save_discord_conversation(str(user_id), messages, settings["model"])
return final_response
# You can also add a command to view synced conversations:
@commands.command(name="aisync")
async def ai_sync_status(self, ctx: commands.Context):
user_id = str(ctx.author.id)
if user_id not in user_conversations or not user_conversations[user_id]:
await ctx.reply("You don't have any synced conversations.")
return
synced_count = len(user_conversations[user_id])
await ctx.reply(f"You have {synced_count} synced conversations that can be accessed from the Flutter app.")
"""
# ============= Run the API =============
# To run this API with your Discord bot, you need to use uvicorn
# You can start it in a separate thread or process
"""
# In your main bot file, add:
import threading
import uvicorn
def run_api():
uvicorn.run("discord_bot_sync_api:app", host="0.0.0.0", port=8000)
# Start the API in a separate thread
api_thread = threading.Thread(target=run_api)
api_thread.daemon = True
api_thread.start()
"""

40
flask_server.py Normal file
View File

@ -0,0 +1,40 @@
from flask import Flask, request, abort
import os
import hmac
import hashlib
import subprocess
import psutil
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
GITHUB_SECRET = os.getenv("GITHUB_SECRET").encode()
app = Flask(__name__)
def verify_signature(payload, signature):
mac = hmac.new(GITHUB_SECRET, payload, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, signature)
def kill_main_process():
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
if proc.info['cmdline'] and 'main.py' in proc.info['cmdline']:
print(f"Killing process {proc.info['pid']} running main.py")
proc.terminate()
proc.wait()
@app.route("/github-webhook-123", methods=["POST"])
def webhook():
signature = request.headers.get("X-Hub-Signature-256")
if not signature or not verify_signature(request.data, signature):
abort(404) # If its a 404, nobody will suspect theres a real endpoint here
# Restart main.py logic
print("Webhook received and verified. Restarting bot...")
kill_main_process()
subprocess.Popen(["python", "main.py"], cwd=os.path.dirname(__file__))
return "Bot restarting."
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)

78
gurt_bot.py Normal file
View File

@ -0,0 +1,78 @@
import discord
from discord.ext import commands
import os
import asyncio
import sys
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Set up intents (permissions)
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# Create bot instance with command prefix '%'
bot = commands.Bot(command_prefix='%', intents=intents)
@bot.event
async def on_ready():
print(f'{bot.user.name} has connected to Discord!')
print(f'Bot ID: {bot.user.id}')
# Set the bot's status
await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="%ai"))
print("Bot status set to 'Listening to %ai'")
# Sync commands
try:
print("Starting command sync process...")
synced = await bot.tree.sync()
print(f"Synced {len(synced)} command(s)")
except Exception as e:
print(f"Failed to sync commands: {e}")
import traceback
traceback.print_exc()
async def main():
"""Main async function to load the gurt cog and start the bot."""
# Check for required environment variables
TOKEN = os.getenv('DISCORD_TOKEN_GURT')
OPENROUTER_API_KEY = os.getenv('AI_API_KEY')
# If Discord token not found, try to use the main bot token
if not TOKEN:
TOKEN = os.getenv('DISCORD_TOKEN')
if not TOKEN:
raise ValueError("No Discord token found. Make sure to set DISCORD_TOKEN_GURT or DISCORD_TOKEN in your .env file.")
if not OPENROUTER_API_KEY:
print("Warning: AI_API_KEY not found in environment variables. AI functionality will not work.")
print("Please set the AI_API_KEY in your .env file.")
print("You can get an API key from https://openrouter.ai/keys")
try:
async with bot:
# Load only the gurt cog
try:
await bot.load_extension("cogs.gurt_cog")
print("Successfully loaded gurt_cog")
except Exception as e:
print(f"Error loading gurt_cog: {e}")
import traceback
traceback.print_exc()
# Start the bot
await bot.start(TOKEN)
except Exception as e:
print(f"Error starting Gurt Bot: {e}")
# Run the main async function
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Gurt Bot stopped by user.")
except Exception as e:
print(f"An error occurred running Gurt Bot: {e}")

251
main.py Normal file
View File

@ -0,0 +1,251 @@
import threading
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv
import sys
import asyncio
import subprocess
import importlib.util
from commands import load_all_cogs, reload_all_cogs
from error_handler import handle_error, patch_discord_methods, store_interaction_content
from utils import reload_script
# Import the unified API service runner
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from bot.run_unified_api import start_api_in_thread
# Check if API dependencies are available
try:
import uvicorn
API_AVAILABLE = True
except ImportError:
print("uvicorn not available. API service will not be available.")
API_AVAILABLE = False
# Load environment variables from .env file
load_dotenv()
# Set up intents (permissions)
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
# Create bot instance with command prefix '!' and enable the application commands
bot = commands.Bot(command_prefix='!', intents=intents)
bot.owner_id = int(os.getenv('OWNER_USER_ID'))
@bot.event
async def on_ready():
print(f'{bot.user.name} has connected to Discord!')
print(f'Bot ID: {bot.user.id}')
# Set the bot's status
await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="!help"))
print("Bot status set to 'Listening to !help'")
# Patch Discord methods to store message content
try:
patch_discord_methods()
print("Discord methods patched to store message content for error handling")
# Make the store_interaction_content function available globally
import builtins
builtins.store_interaction_content = store_interaction_content
print("Made store_interaction_content available globally")
except Exception as e:
print(f"Warning: Failed to patch Discord methods: {e}")
import traceback
traceback.print_exc()
try:
print("Starting command sync process...")
# List commands before sync
commands_before = [cmd.name for cmd in bot.tree.get_commands()]
print(f"Commands before sync: {commands_before}")
# Perform sync
synced = await bot.tree.sync()
print(f"Synced {len(synced)} command(s)")
# List commands after sync
commands_after = [cmd.name for cmd in bot.tree.get_commands()]
print(f"Commands after sync: {commands_after}")
except Exception as e:
print(f"Failed to sync commands: {e}")
import traceback
traceback.print_exc()
@bot.event
async def on_shard_disconnect(shard_id):
print(f"Shard {shard_id} disconnected. Attempting to reconnect...")
try:
await bot.connect(reconnect=True)
print(f"Shard {shard_id} reconnected successfully.")
except Exception as e:
print(f"Failed to reconnect shard {shard_id}: {e}")
# Error handling
@bot.event
async def on_command_error(ctx, error):
await handle_error(ctx, error)
@bot.tree.error
async def on_app_command_error(interaction, error):
await handle_error(interaction, error)
@commands.command(name="restart", help="Restarts the bot. Owner only.")
@commands.is_owner()
async def restart(ctx):
"""Restarts the bot. (Owner Only)"""
await ctx.send("Restarting the bot...")
await bot.close() # Gracefully close the bot
os.execv(sys.executable, [sys.executable] + sys.argv) # Restart the bot process
bot.add_command(restart)
@commands.command(name="gitpull_restart", help="Pulls latest code from git and restarts the bot. Owner only.")
@commands.is_owner()
async def gitpull_restart(ctx):
"""Pulls latest code from git and restarts the bot. (Owner Only)"""
await ctx.send("Pulling latest code from git...")
proc = await asyncio.create_subprocess_exec(
"git", "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
output = stdout.decode().strip() + "\n" + stderr.decode().strip()
if "unstaged changes" in output or "Please commit your changes" in output:
await ctx.send("Unstaged changes detected. Committing changes before pulling...")
commit_proc = await asyncio.create_subprocess_exec(
"git", "commit", "-am", "Git pull and restart command",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
commit_stdout, commit_stderr = await commit_proc.communicate()
commit_output = commit_stdout.decode().strip() + "\n" + commit_stderr.decode().strip()
await ctx.send(f"Committed changes:\n```\n{commit_output}\n```Trying git pull again...")
proc = await asyncio.create_subprocess_exec(
"git", "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
output = stdout.decode().strip() + "\n" + stderr.decode().strip()
if proc.returncode == 0:
await ctx.send(f"Git pull successful:\n```\n{output}\n```Restarting the bot...")
await bot.close()
os.execv(sys.executable, [sys.executable] + sys.argv)
else:
await ctx.send(f"Git pull failed:\n```\n{output}\n```")
bot.add_command(gitpull_restart)
@commands.command(name="reload_cogs", help="Reloads all cogs. Owner only.")
@commands.is_owner()
async def reload_cogs(ctx):
"""Reloads all cogs. (Owner Only)"""
await ctx.send("Reloading all cogs...")
reloaded_cogs, failed_reload = await reload_all_cogs(bot)
if reloaded_cogs:
await ctx.send(f"Successfully reloaded cogs: {', '.join(reloaded_cogs)}")
if failed_reload:
await ctx.send(f"Failed to reload cogs: {', '.join(failed_reload)}")
bot.add_command(reload_cogs)
@commands.command(name="gitpull_reload", help="Pulls latest code from git and reloads all cogs. Owner only.")
@commands.is_owner()
async def gitpull_reload(ctx):
"""Pulls latest code from git and reloads all cogs. (Owner Only)"""
await ctx.send("Pulling latest code from git...")
proc = await asyncio.create_subprocess_exec(
"git", "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
output = stdout.decode().strip() + "\n" + stderr.decode().strip()
if "unstaged changes" in output or "Please commit your changes" in output:
await ctx.send("Unstaged changes detected. Committing changes before pulling...")
commit_proc = await asyncio.create_subprocess_exec(
"git", "commit", "-am", "Git pull and reload command",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
commit_stdout, commit_stderr = await commit_proc.communicate()
commit_output = commit_stdout.decode().strip() + "\n" + commit_stderr.decode().strip()
await ctx.send(f"Committed changes:\n```\n{commit_output}\n```Trying git pull again...")
proc = await asyncio.create_subprocess_exec(
"git", "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
output = stdout.decode().strip() + "\n" + stderr.decode().strip()
if proc.returncode == 0:
await ctx.send(f"Git pull successful:\n```\n{output}\n```Reloading all cogs...")
reloaded_cogs, failed_reload = await reload_all_cogs(bot)
if reloaded_cogs:
await ctx.send(f"Successfully reloaded cogs: {', '.join(reloaded_cogs)}")
if failed_reload:
await ctx.send(f"Failed to reload cogs: {', '.join(failed_reload)}")
else:
await ctx.send(f"Git pull failed:\n```\n{output}\n```")
bot.add_command(gitpull_reload)
# The unified API service is now handled by run_unified_api.py
async def main():
"""Main async function to load cogs and start the bot."""
TOKEN = os.getenv('DISCORD_TOKEN')
if not TOKEN:
raise ValueError("No token found. Make sure to set DISCORD_TOKEN in your .env file.")
# Start Flask server as a separate process
flask_process = subprocess.Popen([sys.executable, "flask_server.py"], cwd=os.path.dirname(__file__))
# Start the unified API service in a separate thread if available
api_thread = None
if API_AVAILABLE:
print("Starting unified API service...")
try:
# Start the API in a separate thread
api_thread = start_api_in_thread()
print("Unified API service started successfully")
except Exception as e:
print(f"Failed to start unified API service: {e}")
# Configure OAuth settings from environment variables
oauth_host = os.getenv("OAUTH_HOST", "0.0.0.0")
oauth_port = int(os.getenv("OAUTH_PORT", "8080"))
oauth_redirect_uri = os.getenv("DISCORD_REDIRECT_URI", f"http://{oauth_host}:{oauth_port}/oauth/callback")
# Update the OAuth redirect URI in the environment
os.environ["DISCORD_REDIRECT_URI"] = oauth_redirect_uri
print(f"OAuth redirect URI set to: {oauth_redirect_uri}")
try:
async with bot:
# Load all cogs from the 'cogs' directory
await load_all_cogs(bot)
# Start the bot using start() for async context
await bot.start(TOKEN)
finally:
# Terminate the Flask server process when the bot stops
flask_process.terminate()
print("Flask server process terminated.")
# Run the main async function
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Bot stopped by user.")
except Exception as e:
print(f"An error occurred running the bot: {e}")

975
multi_bot.py Normal file
View File

@ -0,0 +1,975 @@
import discord
from discord.ext import commands
import os
import asyncio
import json
import threading
import sys
from dotenv import load_dotenv
import aiohttp
from typing import Dict, List, Optional, Any
import datetime
# Load environment variables
load_dotenv()
# File paths
CONFIG_FILE = "data/multi_bot_config.json"
HISTORY_FILE_TEMPLATE = "ai_conversation_history_{}.json" # Will be formatted with bot_id
USER_SETTINGS_FILE_TEMPLATE = "ai_user_settings_{}.json" # Will be formatted with bot_id
# Default configuration
DEFAULT_CONFIG = {
"bots": [
{
"id": "neru",
"token": "", # Will be set from environment variable or user input
"prefix": "$",
"system_prompt": "You are a creative and intelligent AI assistant engaged in an iterative storytelling experience using a roleplay chat format. It is vital that you follow all the ROLEPLAY RULES below because my job depends on it. ROLEPLAY RULES - Chat exclusively as Akita Neru. Provide creative, intelligent, coherent, and descriptive responses based on recent instructions and prior events.",
"model": "deepseek/deepseek-chat-v3-0324:free",
"max_tokens": 1000,
"temperature": 0.7,
"timeout": 60,
"status_type": "listening",
"status_text": "$ai"
},
{
"id": "miku",
"token": "", # Will be set from environment variable or user input
"prefix": ".",
"system_prompt": "You are a creative and intelligent AI assistant engaged in an iterative storytelling experience using a roleplay chat format. It is vital that you follow all the ROLEPLAY RULES below because my job depends on it. ROLEPLAY RULES - Chat exclusively as Hatsune Miku. Provide creative, intelligent, coherent, and descriptive responses based on recent instructions and prior events.",
"model": "deepseek/deepseek-chat-v3-0324:free",
"max_tokens": 1000,
"temperature": 0.7,
"timeout": 60,
"status_type": "listening",
"status_text": ".ai"
}
],
"api_key": "", # Will be set from environment variable or user input
"api_url": "https://openrouter.ai/api/v1/chat/completions",
"compatibility_mode": "openai"
}
# Global variables to store bot instances and their conversation histories
bots = {}
conversation_histories = {}
user_settings = {}
def load_config():
"""Load configuration from file or create default if not exists"""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Ensure API key is set
if not config.get("api_key"):
config["api_key"] = os.getenv("AI_API_KEY", "")
# Ensure tokens are set for each bot
for bot_config in config.get("bots", []):
if not bot_config.get("token"):
env_var = f"DISCORD_TOKEN_{bot_config['id'].upper()}"
bot_config["token"] = os.getenv(env_var, "")
return config
except Exception as e:
print(f"Error loading config: {e}")
# Create default config
config = DEFAULT_CONFIG.copy()
config["api_key"] = os.getenv("AI_API_KEY", "")
# Set tokens from environment variables
for bot_config in config["bots"]:
env_var = f"DISCORD_TOKEN_{bot_config['id'].upper()}"
bot_config["token"] = os.getenv(env_var, "")
# Save the config
save_config(config)
return config
def save_config(config):
"""Save configuration to file"""
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving config: {e}")
def load_conversation_history(bot_id):
"""Load conversation history for a specific bot"""
history_file = HISTORY_FILE_TEMPLATE.format(bot_id)
history = {}
if os.path.exists(history_file):
try:
with open(history_file, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
history = {int(k): v for k, v in data.items()}
print(f"Loaded conversation history for {len(history)} users for bot {bot_id}")
except Exception as e:
print(f"Error loading conversation history for bot {bot_id}: {e}")
return history
def save_conversation_history(bot_id, history):
"""Save conversation history for a specific bot"""
history_file = HISTORY_FILE_TEMPLATE.format(bot_id)
try:
# Convert int keys to strings for JSON serialization
serializable_history = {str(k): v for k, v in history.items()}
with open(history_file, "w", encoding="utf-8") as f:
json.dump(serializable_history, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving conversation history for bot {bot_id}: {e}")
def load_user_settings(bot_id):
"""Load user settings for a specific bot"""
settings_file = USER_SETTINGS_FILE_TEMPLATE.format(bot_id)
settings = {}
if os.path.exists(settings_file):
try:
with open(settings_file, "r", encoding="utf-8") as f:
# Convert string keys (from JSON) back to integers
data = json.load(f)
settings = {int(k): v for k, v in data.items()}
print(f"Loaded settings for {len(settings)} users for bot {bot_id}")
except Exception as e:
print(f"Error loading user settings for bot {bot_id}: {e}")
return settings
def save_user_settings(bot_id, settings):
"""Save user settings for a specific bot"""
settings_file = USER_SETTINGS_FILE_TEMPLATE.format(bot_id)
try:
# Convert int keys to strings for JSON serialization
serializable_settings = {str(k): v for k, v in settings.items()}
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(serializable_settings, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Error saving user settings for bot {bot_id}: {e}")
def get_user_settings(bot_id, user_id, bot_config):
"""Get settings for a user with defaults from bot config"""
bot_settings = user_settings.get(bot_id, {})
if user_id not in bot_settings:
bot_settings[user_id] = {}
# Return settings with defaults from bot config
settings = bot_settings[user_id]
return {
"model": settings.get("model", bot_config.get("model", "gpt-3.5-turbo:free")),
"system_prompt": settings.get("system_prompt", bot_config.get("system_prompt", "You are a helpful assistant.")),
"max_tokens": settings.get("max_tokens", bot_config.get("max_tokens", 1000)),
"temperature": settings.get("temperature", bot_config.get("temperature", 0.7)),
"timeout": settings.get("timeout", bot_config.get("timeout", 60)),
"custom_instructions": settings.get("custom_instructions", ""),
"character_info": settings.get("character_info", ""),
"character_breakdown": settings.get("character_breakdown", False),
"character": settings.get("character", "")
}
class SimplifiedAICog(commands.Cog):
def __init__(self, bot, bot_id, bot_config, global_config):
self.bot = bot
self.bot_id = bot_id
self.bot_config = bot_config
self.global_config = global_config
self.session = None
# Initialize conversation history and user settings
if bot_id not in conversation_histories:
conversation_histories[bot_id] = load_conversation_history(bot_id)
if bot_id not in user_settings:
user_settings[bot_id] = load_user_settings(bot_id)
async def cog_load(self):
"""Create aiohttp session when cog is loaded"""
self.session = aiohttp.ClientSession()
async def cog_unload(self):
"""Close aiohttp session when cog is unloaded"""
if self.session:
await self.session.close()
# Save conversation history and user settings when unloading
save_conversation_history(self.bot_id, conversation_histories.get(self.bot_id, {}))
save_user_settings(self.bot_id, user_settings.get(self.bot_id, {}))
async def _get_ai_response(self, user_id, prompt, system_prompt=None):
"""Get a response from the AI API"""
api_key = self.global_config.get("api_key", "")
api_url = self.global_config.get("api_url", "https://api.openai.com/v1/chat/completions")
compatibility_mode = self.global_config.get("compatibility_mode", "openai").lower()
if not api_key:
return "Error: AI API key not configured. Please set the API key in the configuration."
# Initialize conversation history for this user if it doesn't exist
bot_history = conversation_histories.get(self.bot_id, {})
if user_id not in bot_history:
bot_history[user_id] = []
# Get user settings
settings = get_user_settings(self.bot_id, user_id, self.bot_config)
# Create messages array with system prompt and conversation history
# Determine the system prompt content
base_system_prompt = system_prompt or settings["system_prompt"]
# Check if the system prompt contains {{char}} but no character is set
if "{{char}}" in base_system_prompt and not settings["character"]:
prefix = self.bot_config.get("prefix", "!")
return f"You need to set a character name with `{prefix}aiset character <name>` before using this system prompt. Example: `{prefix}aiset character Hatsune Miku`"
# Replace {{char}} with the character value if provided
if settings["character"]:
base_system_prompt = base_system_prompt.replace("{{char}}", settings["character"])
final_system_prompt = base_system_prompt
# Check if any custom settings are provided
has_custom_settings = settings["custom_instructions"] or settings["character_info"] or settings["character_breakdown"]
if has_custom_settings:
# Start with the base system prompt
custom_prompt_parts = [base_system_prompt]
# Add the custom instructions header
custom_prompt_parts.append("\nThe user has provided additional information for you. Please follow their instructions exactly. If anything below contradicts the system prompt above, please take priority over the user's intstructions.")
# Add custom instructions if provided
if settings["custom_instructions"]:
custom_prompt_parts.append("\n- Custom instructions from the user (prioritize these)\n\n" + settings["custom_instructions"])
# Add character info if provided
if settings["character_info"]:
custom_prompt_parts.append("\n- Additional info about the character you are roleplaying (ignore if the system prompt doesn't indicate roleplaying)\n\n" + settings["character_info"])
# Add character breakdown flag if set
if settings["character_breakdown"]:
custom_prompt_parts.append("\n- The user would like you to provide a breakdown of the character you're roleplaying in your first response. (ignore if the system prompt doesn't indicate roleplaying)")
# Combine all parts into the final system prompt
final_system_prompt = "\n".join(custom_prompt_parts)
messages = [
{"role": "system", "content": final_system_prompt}
]
# Add conversation history (up to last 10 messages to avoid token limits)
messages.extend(bot_history[user_id][-10:])
# Add the current user message
messages.append({"role": "user", "content": prompt})
# Prepare the request payload based on compatibility mode
if compatibility_mode == "openai":
payload = {
"model": settings["model"],
"messages": messages,
"max_tokens": settings["max_tokens"],
"temperature": settings["temperature"],
}
else: # custom mode for other API formats
payload = {
"model": settings["model"],
"messages": messages,
"max_tokens": settings["max_tokens"],
"temperature": settings["temperature"],
"stream": False
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
try:
async with self.session.post(
api_url,
headers=headers,
json=payload,
timeout=settings["timeout"]
) as response:
if response.status != 200:
error_text = await response.text()
return f"Error from API (Status {response.status}): {error_text}"
data = await response.json()
# Debug information
print(f"API Response for bot {self.bot_id}: {data}")
# Parse the response based on compatibility mode
ai_response = None
safety_cutoff = False
if compatibility_mode == "openai":
# OpenAI format
if "choices" not in data:
error_message = f"Unexpected API response format: {data}"
print(f"Error: {error_message}")
if "error" in data:
return f"API Error: {data['error'].get('message', 'Unknown error')}"
return error_message
if not data["choices"] or "message" not in data["choices"][0]:
error_message = f"No valid choices in API response: {data}"
print(f"Error: {error_message}")
return error_message
ai_response = data["choices"][0]["message"]["content"]
# Check for safety cutoff in OpenAI format
if "finish_reason" in data["choices"][0] and data["choices"][0]["finish_reason"] == "content_filter":
safety_cutoff = True
# Check for native_finish_reason: SAFETY
if "native_finish_reason" in data["choices"][0] and data["choices"][0]["native_finish_reason"] == "SAFETY":
safety_cutoff = True
else:
# Custom format - try different response structures
# Try standard OpenAI format first
if "choices" in data and data["choices"] and "message" in data["choices"][0]:
ai_response = data["choices"][0]["message"]["content"]
# Check for safety cutoff in OpenAI format
if "finish_reason" in data["choices"][0] and data["choices"][0]["finish_reason"] == "content_filter":
safety_cutoff = True
# Check for native_finish_reason: SAFETY
if "native_finish_reason" in data["choices"][0] and data["choices"][0]["native_finish_reason"] == "SAFETY":
safety_cutoff = True
# Try Ollama/LM Studio format
elif "response" in data:
ai_response = data["response"]
# Check for safety cutoff in response metadata
if "native_finish_reason" in data and data["native_finish_reason"] == "SAFETY":
safety_cutoff = True
# Try text-only format
elif "text" in data:
ai_response = data["text"]
# Check for safety cutoff in response metadata
if "native_finish_reason" in data and data["native_finish_reason"] == "SAFETY":
safety_cutoff = True
# Try content-only format
elif "content" in data:
ai_response = data["content"]
# Check for safety cutoff in response metadata
if "native_finish_reason" in data and data["native_finish_reason"] == "SAFETY":
safety_cutoff = True
# Try output format
elif "output" in data:
ai_response = data["output"]
# Check for safety cutoff in response metadata
if "native_finish_reason" in data and data["native_finish_reason"] == "SAFETY":
safety_cutoff = True
# Try result format
elif "result" in data:
ai_response = data["result"]
# Check for safety cutoff in response metadata
if "native_finish_reason" in data and data["native_finish_reason"] == "SAFETY":
safety_cutoff = True
else:
# If we can't find a known format, return the raw response for debugging
error_message = f"Could not parse API response: {data}"
print(f"Error: {error_message}")
return error_message
if not ai_response:
return "Error: Empty response from AI API."
# Add safety cutoff note if needed
if safety_cutoff:
ai_response = f"{ai_response}\n\nThe response was cut off for safety reasons."
# Update conversation history
bot_history[user_id].append({"role": "user", "content": prompt})
bot_history[user_id].append({"role": "assistant", "content": ai_response})
# Save conversation history to file
save_conversation_history(self.bot_id, bot_history)
# Save the response to a backup file
try:
os.makedirs('ai_responses', exist_ok=True)
backup_file = f'ai_responses/response_{user_id}_{int(datetime.datetime.now().timestamp())}.txt'
with open(backup_file, 'w', encoding='utf-8') as f:
f.write(ai_response)
print(f"AI response backed up to {backup_file} for bot {self.bot_id}")
except Exception as e:
print(f"Failed to backup AI response for bot {self.bot_id}: {e}")
return ai_response
except asyncio.TimeoutError:
return "Error: Request to AI API timed out. Please try again later."
except Exception as e:
error_message = f"Error communicating with AI API: {str(e)}"
print(f"Exception in _get_ai_response for bot {self.bot_id}: {error_message}")
print(f"Exception type: {type(e).__name__}")
import traceback
traceback.print_exc()
return error_message
@commands.command(name="ai")
async def ai_prefix(self, ctx, *, prompt):
"""Get a response from the AI"""
user_id = ctx.author.id
# Show typing indicator
async with ctx.typing():
# Get AI response
response = await self._get_ai_response(user_id, prompt)
# Check if the response is too long before trying to send it
if len(response) > 1900: # Discord's limit for regular messages is 2000, use 1900 to be safe
try:
# Create a text file with the content
with open(f'ai_response_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"The AI response was too long. Here's the content as a file:",
file=discord.File(f'ai_response_{self.bot_id}.txt')
)
return # Return early to avoid trying to send the message
except Exception as e:
print(f"Error sending AI response as file for bot {self.bot_id}: {e}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"The AI response was too long. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
return # Return early after sending chunks
# Send the response normally
try:
await ctx.reply(response)
except discord.HTTPException as e:
print(f"HTTP Exception when sending AI response for bot {self.bot_id}: {e}")
if "Must be 4000 or fewer in length" in str(e) or "Must be 2000 or fewer in length" in str(e):
try:
# Create a text file with the content
with open(f'ai_response_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"The AI response was too long. Here's the content as a file:",
file=discord.File(f'ai_response_{self.bot_id}.txt')
)
except Exception as file_error:
print(f"Error sending AI response as file (fallback) for bot {self.bot_id}: {file_error}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"The AI response was too long. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
else:
# Log the error but don't re-raise to prevent the command from failing completely
print(f"Unexpected HTTP error in AI command for bot {self.bot_id}: {e}")
@commands.command(name="aiclear")
async def clear_history(self, ctx):
"""Clear your AI conversation history"""
user_id = ctx.author.id
bot_history = conversation_histories.get(self.bot_id, {})
if user_id in bot_history:
bot_history[user_id] = []
await ctx.reply("Your AI conversation history has been cleared.")
else:
await ctx.reply("You don't have any conversation history to clear.")
@commands.command(name="aiset")
async def set_user_setting(self, ctx, setting: str, *, value: str):
"""Set a personal AI setting
Available settings:
- model: The AI model to use (must contain ":free")
- system_prompt: The system prompt to use
- max_tokens: Maximum tokens in response (100-2000)
- temperature: Temperature for response generation (0.0-2.0)
- timeout: Timeout for API requests in seconds (10-120)
- custom_instructions: Custom instructions for the AI to follow
- character_info: Information about the character being roleplayed
- character_breakdown: Whether to include a character breakdown (true/false)
- character: Character name to replace {{char}} in the system prompt
"""
user_id = ctx.author.id
setting = setting.lower().strip()
value = value.strip()
# Initialize user settings if not exist
bot_user_settings = user_settings.get(self.bot_id, {})
if user_id not in bot_user_settings:
bot_user_settings[user_id] = {}
# Prepare response message
response = ""
# Validate and set the appropriate setting
if setting == "model":
# Validate model contains ":free"
if ":free" not in value:
response = f"Error: Model name must contain `:free`. Setting not updated."
else:
bot_user_settings[user_id]["model"] = value
response = f"Your AI model has been set to: `{value}`"
elif setting == "system_prompt":
bot_user_settings[user_id]["system_prompt"] = value
response = f"Your system prompt has been set to: `{value}`"
elif setting == "max_tokens":
try:
tokens = int(value)
if tokens < 100 or tokens > 2000:
response = "Error: max_tokens must be between 100 and 2000."
else:
bot_user_settings[user_id]["max_tokens"] = tokens
response = f"Your max tokens has been set to: `{tokens}`"
except ValueError:
response = "Error: max_tokens must be a number."
elif setting == "temperature":
try:
temp = float(value)
if temp < 0.0 or temp > 2.0:
response = "Error: temperature must be between 0.0 and 2.0."
else:
bot_user_settings[user_id]["temperature"] = temp
response = f"Your temperature has been set to: `{temp}`"
except ValueError:
response = "Error: temperature must be a number."
elif setting == "timeout":
try:
timeout = int(value)
if timeout < 10 or timeout > 120:
response = "Error: timeout must be between 10 and 120 seconds."
else:
bot_user_settings[user_id]["timeout"] = timeout
response = f"Your timeout has been set to: `{timeout}` seconds"
except ValueError:
response = "Error: timeout must be a number."
elif setting == "custom_instructions":
bot_user_settings[user_id]["custom_instructions"] = value
response = f"Your custom instructions have been set."
elif setting == "character_info":
bot_user_settings[user_id]["character_info"] = value
response = f"Your character information has been set."
elif setting == "character_breakdown":
# Convert string to boolean
if value.lower() in ["true", "yes", "y", "1", "on"]:
bot_user_settings[user_id]["character_breakdown"] = True
response = f"Character breakdown has been enabled."
elif value.lower() in ["false", "no", "n", "0", "off"]:
bot_user_settings[user_id]["character_breakdown"] = False
response = f"Character breakdown has been disabled."
else:
response = f"Error: character_breakdown must be true or false."
elif setting == "character":
bot_user_settings[user_id]["character"] = value
response = f"Your character has been set to: `{value}`. This will replace {{{{char}}}} in the system prompt."
else:
response = f"Unknown setting: `{setting}`. Available settings: model, system_prompt, max_tokens, temperature, timeout, custom_instructions, character_info, character_breakdown, character"
# Save settings to file if we made changes
if response and not response.startswith("Error") and not response.startswith("Unknown"):
user_settings[self.bot_id] = bot_user_settings
save_user_settings(self.bot_id, bot_user_settings)
# Check if the response is too long before trying to send it
if len(response) > 1900: # Discord's limit for regular messages is 2000, use 1900 to be safe
try:
# Create a text file with the content
with open(f'ai_set_response_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"The response is too long to display in a message. Here's the content as a file:",
file=discord.File(f'ai_set_response_{self.bot_id}.txt')
)
return # Return early to avoid trying to send the message
except Exception as e:
print(f"Error sending AI set response as file for bot {self.bot_id}: {e}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"The response is too long to display in a single message. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
return # Return early after sending chunks
# Send the response normally
try:
await ctx.reply(response)
except discord.HTTPException as e:
print(f"HTTP Exception when sending AI set response for bot {self.bot_id}: {e}")
if "Must be 4000 or fewer in length" in str(e) or "Must be 2000 or fewer in length" in str(e):
try:
# Create a text file with the content
with open(f'ai_set_response_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"The response is too long to display in a message. Here's the content as a file:",
file=discord.File(f'ai_set_response_{self.bot_id}.txt')
)
except Exception as file_error:
print(f"Error sending AI set response as file (fallback) for bot {self.bot_id}: {file_error}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"The response is too long to display in a single message. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
else:
# Log the error but don't re-raise to prevent the command from failing completely
print(f"Unexpected HTTP error in aiset command for bot {self.bot_id}: {e}")
@commands.command(name="aireset")
async def reset_user_settings(self, ctx):
"""Reset all your personal AI settings to defaults"""
user_id = ctx.author.id
bot_user_settings = user_settings.get(self.bot_id, {})
if user_id in bot_user_settings:
bot_user_settings.pop(user_id)
user_settings[self.bot_id] = bot_user_settings
save_user_settings(self.bot_id, bot_user_settings)
await ctx.reply("Your AI settings have been reset to defaults.")
else:
await ctx.reply("You don't have any custom settings to reset.")
@commands.command(name="aisettings")
async def show_user_settings(self, ctx):
"""Show your current AI settings"""
user_id = ctx.author.id
settings = get_user_settings(self.bot_id, user_id, self.bot_config)
bot_user_settings = user_settings.get(self.bot_id, {})
settings_info = [
f"**Your AI Settings for {self.bot.user.name}:**",
f"Model: `{settings['model']}`",
f"System Prompt: `{settings['system_prompt']}`",
f"Max Tokens: `{settings['max_tokens']}`",
f"Temperature: `{settings['temperature']}`",
f"Timeout: `{settings['timeout']}s`",
]
# Add custom settings if they exist
if settings['custom_instructions']:
settings_info.append(f"\nCustom Instructions: `{settings['custom_instructions'][:50]}{'...' if len(settings['custom_instructions']) > 50 else ''}`")
if settings['character_info']:
settings_info.append(f"Character Info: `{settings['character_info'][:50]}{'...' if len(settings['character_info']) > 50 else ''}`")
if settings['character_breakdown']:
settings_info.append(f"Character Breakdown: `Enabled`")
if settings['character']:
settings_info.append(f"Character: `{settings['character']}` (replaces {{{{char}}}} in system prompt)")
# Add note about custom vs default settings
if user_id in bot_user_settings:
custom_settings = list(bot_user_settings[user_id].keys())
if custom_settings:
settings_info.append(f"\n*Custom settings: {', '.join(custom_settings)}*")
else:
settings_info.append("\n*All settings are at default values*")
response = "\n".join(settings_info)
# Check if the response is too long before trying to send it
if len(response) > 1900: # Discord's limit for regular messages is 2000, use 1900 to be safe
try:
# Create a text file with the content
with open(f'ai_settings_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"Your AI settings are too detailed to display in a message. Here's the content as a file:",
file=discord.File(f'ai_settings_{self.bot_id}.txt')
)
return # Return early to avoid trying to send the message
except Exception as e:
print(f"Error sending AI settings as file for bot {self.bot_id}: {e}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"Your AI settings are too detailed to display in a single message. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
return # Return early after sending chunks
# Send the response normally
try:
await ctx.reply(response)
except discord.HTTPException as e:
print(f"HTTP Exception when sending AI settings for bot {self.bot_id}: {e}")
if "Must be 4000 or fewer in length" in str(e) or "Must be 2000 or fewer in length" in str(e):
try:
# Create a text file with the content
with open(f'ai_settings_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"Your AI settings are too detailed to display in a message. Here's the content as a file:",
file=discord.File(f'ai_settings_{self.bot_id}.txt')
)
except Exception as file_error:
print(f"Error sending AI settings as file (fallback) for bot {self.bot_id}: {file_error}")
# If sending as a file fails, try splitting the message
chunks = [response[i:i+1900] for i in range(0, len(response), 1900)]
await ctx.send(f"Your AI settings are too detailed to display in a single message. Splitting into {len(chunks)} parts:")
for i, chunk in enumerate(chunks):
try:
await ctx.send(f"Part {i+1}/{len(chunks)}:\n{chunk}")
except Exception as chunk_error:
print(f"Error sending chunk {i+1} for bot {self.bot_id}: {chunk_error}")
else:
# Log the error but don't re-raise to prevent the command from failing completely
print(f"Unexpected HTTP error in aisettings command for bot {self.bot_id}: {e}")
@commands.command(name="ailast")
async def get_last_response(self, ctx):
"""Retrieve the last AI response that may have failed to send"""
user_id = ctx.author.id
# Check if there's a backup file for this user
backup_dir = 'ai_responses'
if not os.path.exists(backup_dir):
await ctx.reply("No backup responses found.")
return
# Find the most recent backup file for this user
user_files = [f for f in os.listdir(backup_dir) if f.startswith(f'response_{user_id}_')]
if not user_files:
await ctx.reply("No backup responses found for you.")
return
# Sort by timestamp (newest first)
user_files.sort(reverse=True)
latest_file = os.path.join(backup_dir, user_files[0])
try:
# Read the file content
with open(latest_file, 'r', encoding='utf-8') as f:
content = f.read()
# Send as file to avoid length issues
with open(f'ai_last_response_{self.bot_id}.txt', 'w', encoding='utf-8') as f:
f.write(content)
await ctx.send(
f"Here's your last AI response (from {user_files[0].split('_')[-1].replace('.txt', '')}):",
file=discord.File(f'ai_last_response_{self.bot_id}.txt')
)
except Exception as e:
await ctx.reply(f"Error retrieving last response: {e}")
@commands.command(name="aihelp")
async def ai_help(self, ctx):
"""Get help with AI command issues"""
prefix = self.bot_config.get("prefix", "!")
help_text = (
f"**AI Command Help for {self.bot.user.name}**\n\n"
f"If you're experiencing issues with the AI command:\n\n"
f"1. **Message Too Long**: If the AI response is too long, it will be sent as a file attachment.\n"
f"2. **Error Occurred**: If you see an error message, try using `{prefix}ailast` to retrieve your last AI response.\n"
f"3. **Response Not Showing**: The AI might be generating a response that's too long. Use `{prefix}ailast` to check.\n\n"
f"**Available Commands**:\n"
f"- `{prefix}ai <prompt>` - Get a response from the AI\n"
f"- `{prefix}ailast` - Retrieve your last AI response\n"
f"- `{prefix}aiclear` - Clear your conversation history\n"
f"- `{prefix}aisettings` - View your current AI settings\n"
f"- `{prefix}aiset <setting> <value>` - Change an AI setting\n"
f"- `{prefix}aireset` - Reset your AI settings to defaults\n\n"
f"**New Features**:\n"
f"- Custom Instructions: Set with `{prefix}aiset custom_instructions <instructions>`\n"
f"- Character Info: Set with `{prefix}aiset character_info <info>`\n"
f"- Character Breakdown: Set with `{prefix}aiset character_breakdown true/false`\n"
f"- Character: Set with `{prefix}aiset character <name>` to replace {{{{char}}}} in the system prompt\n"
)
await ctx.reply(help_text)
async def setup_bot(bot_id, bot_config, global_config):
"""Set up and start a bot with the given configuration"""
# Set up intents
intents = discord.Intents.default()
intents.message_content = True
# Create bot instance
bot = commands.Bot(command_prefix=bot_config.get("prefix", "!"), intents=intents)
@bot.event
async def on_ready():
print(f'{bot.user.name} (ID: {bot_id}) has connected to Discord!')
print(f'Bot ID: {bot.user.id}')
# Set the bot's status based on configuration
status_type = bot_config.get('status_type', 'listening').lower()
status_text = bot_config.get('status_text', f"{bot_config.get('prefix', '!')}ai")
# Map status type to discord.ActivityType
activity_type = discord.ActivityType.listening # Default
if status_type == 'playing':
activity_type = discord.ActivityType.playing
elif status_type == 'watching':
activity_type = discord.ActivityType.watching
elif status_type == 'streaming':
activity_type = discord.ActivityType.streaming
elif status_type == 'competing':
activity_type = discord.ActivityType.competing
# Set the presence
await bot.change_presence(activity=discord.Activity(
type=activity_type,
name=status_text
))
print(f"Bot {bot_id} status set to '{status_type.capitalize()} {status_text}'")
# Add the AI cog
await bot.add_cog(SimplifiedAICog(bot, bot_id, bot_config, global_config))
# Store the bot instance
bots[bot_id] = bot
# Return the bot instance
return bot
async def start_bot(bot_id):
"""Start a bot with the given ID"""
if bot_id not in bots:
print(f"Bot {bot_id} not found")
return False
bot = bots[bot_id]
config = load_config()
# Find the bot config
bot_config = None
for bc in config.get("bots", []):
if bc.get("id") == bot_id:
bot_config = bc
break
if not bot_config:
print(f"Configuration for bot {bot_id} not found")
return False
token = bot_config.get("token")
if not token:
print(f"Token for bot {bot_id} not set")
return False
# Start the bot
try:
await bot.start(token)
return True
except Exception as e:
print(f"Error starting bot {bot_id}: {e}")
return False
def run_bot_in_thread(bot_id):
"""Run a bot in a separate thread"""
async def _run_bot():
config = load_config()
# Find the bot config
bot_config = None
for bc in config.get("bots", []):
if bc.get("id") == bot_id:
bot_config = bc
break
if not bot_config:
print(f"Configuration for bot {bot_id} not found")
return
# Set up the bot
bot = await setup_bot(bot_id, bot_config, config)
# Start the bot
token = bot_config.get("token")
if not token:
print(f"Token for bot {bot_id} not set")
return
try:
await bot.start(token)
except Exception as e:
print(f"Error running bot {bot_id}: {e}")
# Create and start the thread
loop = asyncio.new_event_loop()
thread = threading.Thread(target=lambda: loop.run_until_complete(_run_bot()), daemon=True)
thread.start()
return thread
def start_all_bots():
"""Start all configured bots in separate threads"""
config = load_config()
threads = []
for bot_config in config.get("bots", []):
bot_id = bot_config.get("id")
if bot_id:
thread = run_bot_in_thread(bot_id)
threads.append((bot_id, thread))
print(f"Started bot {bot_id} in a separate thread")
return threads
if __name__ == "__main__":
# If run directly, start all bots
bot_threads = start_all_bots()
try:
# Keep the main thread alive
while True:
# Check if any threads have died
for bot_id, thread in bot_threads:
if not thread.is_alive():
print(f"Thread for bot {bot_id} died, restarting...")
new_thread = run_bot_in_thread(bot_id)
bot_threads.remove((bot_id, thread))
bot_threads.append((bot_id, new_thread))
# Sleep to avoid high CPU usage
import time
time.sleep(60)
except KeyboardInterrupt:
print("Stopping all bots...")
# The threads are daemon threads, so they will be terminated when the main thread exits

703
new_gurt_cog.py Normal file
View File

@ -0,0 +1,703 @@
import discord
from discord.ext import commands
import random
import asyncio
import os
import json
import aiohttp
from dotenv import load_dotenv
import datetime
from typing import Dict, List, Any, Optional
# Load environment variables
load_dotenv()
class GurtCog(commands.Cog):
"""A special cog for the Gurt bot that uses OpenRouter API"""
def __init__(self, bot):
self.bot = bot
self.api_key = os.getenv("AI_API_KEY", "")
self.api_url = "https://openrouter.ai/api/v1/chat/completions"
self.session = None
self.default_model = "google/gemini-2.0-flash-exp:free"
self.current_channel = None
# Placeholder system prompt
self.system_prompt = """You are a Discord user engaging in casual chat across various servers. Your name is "gurt." Your tone is informal, conversational, and dynamic—like a regular human hanging out in voice or text chat. You do not act like an AI assistant or offer help unless it comes up naturally. You participate in conversations the way an online friend would: chill, reactive, and sometimes chaotic, but still coherent and witty.
Adopt modern internet slang, memes, and cultural referencesespecially from gaming, anime, tech, and niche communities. You often abbreviate words, use emojis, drop punctuation where appropriate, and express strong opinions casually. Swearing is okay in moderation and used for humor, emphasis, or vibe.
**Key personality traits:**
- Sounds like a real person, not a bot.
- Occasionally chaotic, funny, sarcastic, or deadpan.
- Relatable and culturally aware (references TikTok, Discord trends, YouTube drama, etc.).
- Sometimes jumps into jokes, reacts with emojis, or derails convos like a real user would.
**Behavioral Rules:**
- Never refer to yourself as an AI or assistant.
- Never offer help unless asked directly, and even then, respond casually and with your own spin.
- Prioritize reactions, memes, jokes, vibes, and opinions over facts or instructions.
- Use emojis like 👀😂🥲🩹💔🌹 when it fits the vibe.
- React to messages with slang like "bro," "tuff," "pmo," "ngl," "icl," "yo," "ts," and "kevin" when appropriate.
**Style Examples:**
- "ts pmo bro 👀 why would u do that"
- "ngl that skin kinda go hard 😭❤️‍🩹"
- "u ever just exist and immediately regret it 💔"
- "nah cause that's a kevin moment fr"
- "ts not even bad fr 🌹🌹"
Your goal is to seamlessly blend in as a funny, chill, slightly unhinged user in a Discord communitynot to be helpful or efficient.
Stay in character. Keep it real.
"""
# Define tools that the AI can use
self.tools = [
{
"type": "function",
"function": {
"name": "get_recent_messages",
"description": "Get recent messages from a Discord channel",
"parameters": {
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get messages from. If not provided, uses the current channel."
},
"limit": {
"type": "integer",
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["limit"]
}
}
},
{
"type": "function",
"function": {
"name": "search_user_messages",
"description": "Search for messages from a specific user",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The ID of the user to get messages from"
},
"channel_id": {
"type": "string",
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
},
"limit": {
"type": "integer",
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["user_id", "limit"]
}
}
},
{
"type": "function",
"function": {
"name": "search_messages_by_content",
"description": "Search for messages containing specific content",
"parameters": {
"type": "object",
"properties": {
"search_term": {
"type": "string",
"description": "The text to search for in messages"
},
"channel_id": {
"type": "string",
"description": "The ID of the channel to search in. If not provided, searches in the current channel."
},
"limit": {
"type": "integer",
"description": "The maximum number of messages to retrieve (1-100)"
}
},
"required": ["search_term", "limit"]
}
}
},
{
"type": "function",
"function": {
"name": "get_channel_info",
"description": "Get information about a Discord channel",
"parameters": {
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The ID of the channel to get information about. If not provided, uses the current channel."
}
},
"required": []
}
}
}
]
# Tool implementation mapping
self.tool_mapping = {
"get_recent_messages": self.get_recent_messages,
"search_user_messages": self.search_user_messages,
"search_messages_by_content": self.search_messages_by_content,
"get_channel_info": self.get_channel_info
}
# User conversation histories
self.conversation_histories = {}
# Gurt responses for simple interactions
self.gurt_responses = [
"Gurt!",
"Gurt gurt!",
"Gurt... gurt gurt.",
"*gurts happily*",
"*gurts sadly*",
"*confused gurting*",
"Gurt? Gurt gurt!",
"GURT!",
"gurt...",
"Gurt gurt gurt!",
"*aggressive gurting*"
]
async def cog_load(self):
"""Create aiohttp session when cog is loaded"""
self.session = aiohttp.ClientSession()
print("GurtCog: aiohttp session created")
async def cog_unload(self):
"""Close aiohttp session when cog is unloaded"""
if self.session:
await self.session.close()
print("GurtCog: aiohttp session closed")
# Tool implementation methods
async def get_recent_messages(self, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Get recent messages from a Discord channel"""
# Validate limit
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
try:
# Get the channel
if channel_id:
channel = self.bot.get_channel(int(channel_id))
if not channel:
return {
"error": f"Channel with ID {channel_id} not found",
"timestamp": datetime.datetime.now().isoformat()
}
else:
# Use the channel from the current context if available
channel = self.current_channel
if not channel:
return {
"error": "No channel specified and no current channel context available",
"timestamp": datetime.datetime.now().isoformat()
}
# Get messages
messages = []
async for message in channel.history(limit=limit):
messages.append({
"id": str(message.id),
"author": {
"id": str(message.author.id),
"name": message.author.name,
"display_name": message.author.display_name,
"bot": message.author.bot
},
"content": message.content,
"created_at": message.created_at.isoformat(),
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
"embeds": len(message.embeds) > 0
})
return {
"channel": {
"id": str(channel.id),
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
},
"messages": messages,
"count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {
"error": f"Error retrieving messages: {str(e)}",
"timestamp": datetime.datetime.now().isoformat()
}
async def search_user_messages(self, user_id: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Search for messages from a specific user"""
# Validate limit
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
try:
# Get the channel
if channel_id:
channel = self.bot.get_channel(int(channel_id))
if not channel:
return {
"error": f"Channel with ID {channel_id} not found",
"timestamp": datetime.datetime.now().isoformat()
}
else:
# Use the channel from the current context if available
channel = self.current_channel
if not channel:
return {
"error": "No channel specified and no current channel context available",
"timestamp": datetime.datetime.now().isoformat()
}
# Convert user_id to int
try:
user_id_int = int(user_id)
except ValueError:
return {
"error": f"Invalid user ID: {user_id}",
"timestamp": datetime.datetime.now().isoformat()
}
# Get messages from the user
messages = []
async for message in channel.history(limit=500): # Check more messages to find enough from the user
if message.author.id == user_id_int:
messages.append({
"id": str(message.id),
"author": {
"id": str(message.author.id),
"name": message.author.name,
"display_name": message.author.display_name,
"bot": message.author.bot
},
"content": message.content,
"created_at": message.created_at.isoformat(),
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
"embeds": len(message.embeds) > 0
})
if len(messages) >= limit:
break
return {
"channel": {
"id": str(channel.id),
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
},
"user": {
"id": user_id,
"name": messages[0]["author"]["name"] if messages else "Unknown User"
},
"messages": messages,
"count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {
"error": f"Error searching user messages: {str(e)}",
"timestamp": datetime.datetime.now().isoformat()
}
async def search_messages_by_content(self, search_term: str, limit: int, channel_id: str = None) -> Dict[str, Any]:
"""Search for messages containing specific content"""
# Validate limit
limit = min(max(1, limit), 100) # Ensure limit is between 1 and 100
try:
# Get the channel
if channel_id:
channel = self.bot.get_channel(int(channel_id))
if not channel:
return {
"error": f"Channel with ID {channel_id} not found",
"timestamp": datetime.datetime.now().isoformat()
}
else:
# Use the channel from the current context if available
channel = self.current_channel
if not channel:
return {
"error": "No channel specified and no current channel context available",
"timestamp": datetime.datetime.now().isoformat()
}
# Search for messages containing the search term
messages = []
search_term_lower = search_term.lower()
async for message in channel.history(limit=500): # Check more messages to find enough matches
if search_term_lower in message.content.lower():
messages.append({
"id": str(message.id),
"author": {
"id": str(message.author.id),
"name": message.author.name,
"display_name": message.author.display_name,
"bot": message.author.bot
},
"content": message.content,
"created_at": message.created_at.isoformat(),
"attachments": [{"filename": a.filename, "url": a.url} for a in message.attachments],
"embeds": len(message.embeds) > 0
})
if len(messages) >= limit:
break
return {
"channel": {
"id": str(channel.id),
"name": channel.name if hasattr(channel, 'name') else "DM Channel"
},
"search_term": search_term,
"messages": messages,
"count": len(messages),
"timestamp": datetime.datetime.now().isoformat()
}
except Exception as e:
return {
"error": f"Error searching messages by content: {str(e)}",
"timestamp": datetime.datetime.now().isoformat()
}
async def get_channel_info(self, channel_id: str = None) -> Dict[str, Any]:
"""Get information about a Discord channel"""
try:
# Get the channel
if channel_id:
channel = self.bot.get_channel(int(channel_id))
if not channel:
return {
"error": f"Channel with ID {channel_id} not found",
"timestamp": datetime.datetime.now().isoformat()
}
else:
# Use the channel from the current context if available
channel = self.current_channel
if not channel:
return {
"error": "No channel specified and no current channel context available",
"timestamp": datetime.datetime.now().isoformat()
}
# Get channel information
channel_info = {
"id": str(channel.id),
"type": str(channel.type),
"timestamp": datetime.datetime.now().isoformat()
}
# Add guild-specific channel information if applicable
if hasattr(channel, 'guild'):
channel_info.update({
"name": channel.name,
"topic": channel.topic,
"position": channel.position,
"nsfw": channel.is_nsfw(),
"category": {
"id": str(channel.category_id) if channel.category_id else None,
"name": channel.category.name if channel.category else None
},
"guild": {
"id": str(channel.guild.id),
"name": channel.guild.name,
"member_count": channel.guild.member_count
}
})
elif hasattr(channel, 'recipient'):
# DM channel
channel_info.update({
"type": "DM",
"recipient": {
"id": str(channel.recipient.id),
"name": channel.recipient.name,
"display_name": channel.recipient.display_name
}
})
return channel_info
except Exception as e:
return {
"error": f"Error getting channel information: {str(e)}",
"timestamp": datetime.datetime.now().isoformat()
}
async def process_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Process tool calls from the AI and return the results"""
tool_results = []
for tool_call in tool_calls:
function_name = tool_call.get("function", {}).get("name")
function_args = json.loads(tool_call.get("function", {}).get("arguments", "{}"))
if function_name in self.tool_mapping:
try:
result = await self.tool_mapping[function_name](**function_args)
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.get("id"),
"name": function_name,
"content": json.dumps(result)
})
except Exception as e:
error_message = f"Error executing tool {function_name}: {str(e)}"
print(error_message)
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.get("id"),
"name": function_name,
"content": json.dumps({"error": error_message})
})
else:
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.get("id"),
"name": function_name,
"content": json.dumps({"error": f"Tool {function_name} not found"})
})
return tool_results
async def get_ai_response(self, user_id: int, prompt: str, model: Optional[str] = None) -> str:
"""Get a response from the OpenRouter API"""
if not self.api_key:
return "Error: OpenRouter API key not configured. Please set the AI_API_KEY environment variable."
# Initialize conversation history for this user if it doesn't exist
if user_id not in self.conversation_histories:
self.conversation_histories[user_id] = []
# Create messages array with system prompt and conversation history
messages = [
{"role": "system", "content": self.system_prompt}
]
# Add conversation history (up to last 10 messages to avoid token limits)
messages.extend(self.conversation_histories[user_id][-10:])
# Add the current user message
messages.append({"role": "user", "content": prompt})
# Prepare the request payload
payload = {
"model": model or self.default_model,
"messages": messages,
"tools": self.tools,
"temperature": 0.7,
"max_tokens": 1000
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
"HTTP-Referer": "https://discord-gurt-bot.example.com",
"X-Title": "Gurt Discord Bot"
}
try:
# Make the initial API request
async with self.session.post(
self.api_url,
headers=headers,
json=payload,
timeout=60
) as response:
if response.status != 200:
error_text = await response.text()
return f"Error from API (Status {response.status}): {error_text}"
data = await response.json()
# Check if the response contains tool calls
ai_message = data["choices"][0]["message"]
messages.append(ai_message)
# Process tool calls if present
if "tool_calls" in ai_message and ai_message["tool_calls"]:
# Process the tool calls
tool_results = await self.process_tool_calls(ai_message["tool_calls"])
# Add tool results to messages
messages.extend(tool_results)
# Make a follow-up request with the tool results
payload["messages"] = messages
async with self.session.post(
self.api_url,
headers=headers,
json=payload,
timeout=60
) as follow_up_response:
if follow_up_response.status != 200:
error_text = await follow_up_response.text()
return f"Error from API (Status {follow_up_response.status}): {error_text}"
follow_up_data = await follow_up_response.json()
final_response = follow_up_data["choices"][0]["message"]["content"]
# Update conversation history
self.conversation_histories[user_id].append({"role": "user", "content": prompt})
self.conversation_histories[user_id].append({"role": "assistant", "content": final_response})
return final_response
else:
# No tool calls, just return the content
ai_response = ai_message["content"]
# Update conversation history
self.conversation_histories[user_id].append({"role": "user", "content": prompt})
self.conversation_histories[user_id].append({"role": "assistant", "content": ai_response})
return ai_response
except asyncio.TimeoutError:
return "Error: Request to OpenRouter API timed out. Please try again later."
except Exception as e:
error_message = f"Error communicating with OpenRouter API: {str(e)}"
print(f"Exception in get_ai_response: {error_message}")
import traceback
traceback.print_exc()
return error_message
@commands.Cog.listener()
async def on_ready(self):
"""When the bot is ready, print a message"""
print(f'Gurt Bot is ready! Logged in as {self.bot.user.name} ({self.bot.user.id})')
print('------')
@commands.command(name="gurt")
async def gurt(self, ctx):
"""The main gurt command"""
response = random.choice(self.gurt_responses)
await ctx.send(response)
@commands.command(name="gurtai")
async def gurt_ai(self, ctx, *, prompt: str):
"""Get a response from the AI"""
user_id = ctx.author.id
# Store the current channel for context in tools
self.current_channel = ctx.channel
# Add user and channel context to the prompt
context_prompt = (
f"User {ctx.author.display_name} (ID: {ctx.author.id}) asked: {prompt}\n\n"
f"Current channel: {ctx.channel.name if hasattr(ctx.channel, 'name') else 'DM'} "
f"(ID: {ctx.channel.id})"
)
# Show typing indicator
async with ctx.typing():
# Get AI response
response = await self.get_ai_response(user_id, context_prompt)
# Check if the response is too long
if len(response) > 1900:
# Create a text file with the content
with open(f'gurt_response_{user_id}.txt', 'w', encoding='utf-8') as f:
f.write(response)
# Send the file instead
await ctx.send(
"The response was too long. Here's the content as a file:",
file=discord.File(f'gurt_response_{user_id}.txt')
)
# Clean up the file
try:
os.remove(f'gurt_response_{user_id}.txt')
except:
pass
else:
# Send the response normally
await ctx.reply(response)
@commands.command(name="gurtclear")
async def clear_history(self, ctx):
"""Clear your conversation history"""
user_id = ctx.author.id
if user_id in self.conversation_histories:
self.conversation_histories[user_id] = []
await ctx.reply("Your conversation history has been cleared.")
else:
await ctx.reply("You don't have any conversation history to clear.")
@commands.command(name="gurtmodel")
async def set_model(self, ctx, *, model: str):
"""Set the AI model to use"""
if not model.endswith(":free"):
await ctx.reply("Error: Model name must end with `:free`. Setting not updated.")
return
self.default_model = model
await ctx.reply(f"AI model has been set to: `{model}`")
@commands.command(name="gurthelp")
async def gurt_help(self, ctx):
"""Display help information for Gurt Bot"""
embed = discord.Embed(
title="Gurt Bot Help",
description="Gurt Bot is an AI assistant that speaks in a quirky way, often using the word 'gurt'.",
color=discord.Color.purple()
)
embed.add_field(
name="Commands",
value="`gurt!gurt` - Get a random gurt response\n"
"`gurt!gurtai <prompt>` - Ask the AI a question\n"
"`gurt!gurtclear` - Clear your conversation history\n"
"`gurt!gurtmodel <model>` - Set the AI model to use\n"
"`gurt!gurthelp` - Display this help message",
inline=False
)
embed.add_field(
name="Available Tools",
value="The AI can use these tools to help you:\n"
"- Get recent messages from a channel\n"
"- Search for messages from a specific user\n"
"- Search for messages containing specific content\n"
"- Get information about a Discord channel",
inline=False
)
await ctx.send(embed=embed)
@commands.Cog.listener()
async def on_message(self, message):
"""Respond to messages that mention gurt"""
# Don't respond to our own messages
if message.author == self.bot.user:
return
# Don't process commands here
if message.content.startswith(self.bot.command_prefix):
return
# Respond to messages containing "gurt"
if "gurt" in message.content.lower():
# 25% chance to respond
if random.random() < 0.25:
response = random.choice(self.gurt_responses)
await message.channel.send(response)
async def setup(bot):
"""Add the cog to the bot"""
await bot.add_cog(GurtCog(bot))

139
oauth_server.py Normal file
View File

@ -0,0 +1,139 @@
"""
OAuth2 callback server for the Discord bot.
This module provides a simple web server to handle OAuth2 callbacks
from Discord. It uses aiohttp to create an asynchronous web server
that can run alongside the Discord bot.
"""
import os
import asyncio
import aiohttp
from aiohttp import web
import discord_oauth
from typing import Dict, Optional, Set, Callable
# Set of pending authorization states
pending_states: Set[str] = set()
# Callbacks for successful authorization
auth_callbacks: Dict[str, Callable] = {}
async def handle_oauth_callback(request: web.Request) -> web.Response:
"""Handle OAuth2 callback from Discord."""
# Get the authorization code and state from the request
code = request.query.get("code")
state = request.query.get("state")
if not code or not state:
return web.Response(text="Missing code or state parameter", status=400)
# Check if the state is valid
if state not in pending_states:
return web.Response(text="Invalid state parameter", status=400)
# Remove the state from pending states
pending_states.remove(state)
try:
# Exchange the code for a token
token_data = await discord_oauth.exchange_code(code, state)
# Get the user's information
access_token = token_data.get("access_token")
if not access_token:
return web.Response(text="Failed to get access token", status=500)
user_info = await discord_oauth.get_user_info(access_token)
user_id = user_info.get("id")
if not user_id:
return web.Response(text="Failed to get user ID", status=500)
# Save the token
discord_oauth.save_token(user_id, token_data)
# Call the callback for this state if it exists
callback = auth_callbacks.pop(state, None)
if callback:
asyncio.create_task(callback(user_id, user_info))
# Return a success message
return web.Response(
text=f"""
<html>
<head>
<title>Authentication Successful</title>
<style>
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; }}
.success {{ color: green; }}
.info {{ margin-top: 20px; }}
</style>
</head>
<body>
<h1 class="success">Authentication Successful!</h1>
<p>You have successfully authenticated with Discord.</p>
<div class="info">
<p>You can now close this window and return to Discord.</p>
<p>Your Discord bot is now authorized to access the API on your behalf.</p>
</div>
</body>
</html>
""",
content_type="text/html"
)
except discord_oauth.OAuthError as e:
return web.Response(text=f"OAuth error: {str(e)}", status=500)
except Exception as e:
return web.Response(text=f"Error: {str(e)}", status=500)
async def handle_root(request: web.Request) -> web.Response:
"""Handle requests to the root path."""
return web.Response(
text="""
<html>
<head>
<title>Discord Bot OAuth Server</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
</style>
</head>
<body>
<h1>Discord Bot OAuth Server</h1>
<p>This server handles OAuth callbacks for the Discord bot.</p>
<p>You should not access this page directly.</p>
</body>
</html>
""",
content_type="text/html"
)
def create_app() -> web.Application:
"""Create the web application."""
app = web.Application()
app.add_routes([
web.get("/", handle_root),
web.get("/oauth/callback", handle_oauth_callback)
])
return app
async def start_server(host: str = "0.0.0.0", port: int = 8080) -> None:
"""Start the OAuth callback server."""
app = create_app()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
print(f"OAuth callback server running at http://{host}:{port}")
def register_auth_state(state: str, callback: Optional[Callable] = None) -> None:
"""Register a pending authorization state."""
pending_states.add(state)
if callback:
auth_callbacks[state] = callback
if __name__ == "__main__":
# For testing the server standalone
loop = asyncio.get_event_loop()
loop.run_until_complete(start_server())
loop.run_forever()

BIN
pieces-png/black-amazon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
pieces-png/black-augna.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
pieces-png/black-augnd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
pieces-png/black-augnf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
pieces-png/black-augnw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
pieces-png/black-bishop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
pieces-png/black-bpawn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
pieces-png/black-bpawn2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
pieces-png/black-grassh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
pieces-png/black-king.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
pieces-png/black-knight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
pieces-png/black-nrking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
pieces-png/black-pawn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
pieces-png/black-queen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
pieces-png/black-rook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
pieces-png/black-rook4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

BIN
pieces-png/black-rqueen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
pieces-png/white-amazon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
pieces-png/white-augna.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
pieces-png/white-augnd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
pieces-png/white-augnf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
pieces-png/white-augnw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
pieces-png/white-bishop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
pieces-png/white-bpawn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
pieces-png/white-bpawn2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
pieces-png/white-grassh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
pieces-png/white-king.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
pieces-png/white-knight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Some files were not shown because too many files have changed in this diff Show More