diff --git a/.gitignore b/.gitignore index cf286ec..2d80b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ # Ignore configuration files /config/*.yml /config/*.secret +/nginx/user_conf.d/*.conf +/nginx/nginx_secrets/*.ini +*.env +/.venv/ # Ignore logs *.log # Python specific -__pycache__ \ No newline at end of file +__pycache__ + +# Ignore media files +/media/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e97ba7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use the official Python image from the Docker Hub +FROM python:3.11-slim + +# Install ffmpeg +RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements file into the container +COPY requirements.txt . + +# Install the dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code into the container +COPY . . + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Expose the port the app runs on +EXPOSE 8000 + +# Run the application +CMD ["python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index a395172..25aa0c4 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,41 @@ Mongodb: ``` use sourcebot db.tiktok_db.createIndex( { "tiktok_id": 1 }, { unique: true } ) -``` \ No newline at end of file +``` + +# Local Configuration + +Set variables in `config/main.yml` + +Required env variables: +``` +discord: + token: + role_channel: + logs_channel: + sauce_channels; + money_guilds: + +mongodb: + uri: + db: +``` + +# Docker Instructions + +You must configure the following files for Docker: + +- `config/main.yml` + - Bot configuration + - See `config/main.yaml.example` +- `user_conf.d/nginx.conf` + - NGINX configuration for GIF/Media conversion hosting + - See, rename & copy `nginx.conf.example` into the `user_conf.d` folder + - Change all instances of `STATIC.EXAMPLE.COM` to your desired hostname +- `nginx-certbot.env` + - Let's Encrypt configuration + - See `nginx-certbot.env.example` +- `nginx/nginx_secrets/cloudflare.ini` + - Optional for Cloudflare DNS Let's Encrypt challenge + - This can be subbed for other DNS API challenges + - Can also be removed if you want to use default web challenge (godspeed) \ No newline at end of file diff --git a/config.py b/config.py index 6642e9b..b74faf1 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ # Third-party libraries import yaml -MAIN_CONFIG = "code/config/main.yml" +MAIN_CONFIG = "config/main.yml" # Load config files with open(MAIN_CONFIG) as file: diff --git a/config/main.yml.example b/config/main.yml.example index 0237afc..b7d4474 100644 --- a/config/main.yml.example +++ b/config/main.yml.example @@ -1,13 +1,21 @@ discord: token: "discord-bot-token" role_channel: "role-channel" - logs_channel: 567890123456789012 + logs_channel: 123456789012345678 sauce_channels: # Channels ids where source providing should work - 123456789012345678 - 234567890123456789 money_guilds: - 345678901234567890 - 456789012345678901 + debug: + link_detection: true + handler_detection: true + delete_original_embeds: true + +mongodb: + uri: "mongodb://mongodb:27017/" # use mongodb://127.0.0.1/sourcebot for local development, leave alone for docker + db: "sourcebot" telegram: token: "-" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..482413a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,54 @@ +services: + sourcebot: + build: . + container_name: sourcebot + environment: + - PYTHONUNBUFFERED=1 + volumes: + - .:/app + - media:/media + ports: + - "8000:8000" + depends_on: + - mongodb + develop: + watch: + - action: rebuild + path: . + ignore: + - "*.pyc" + - "__pycache__/" + - ".git/" + - "media/" + - "*.md" + + mongodb: + image: mongo:4.4 + container_name: mongodb + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + nginx: + image: jonasal/nginx-certbot:latest + container_name: nginx + env_file: + - ./nginx-certbot.env + volumes: + - ./media:/media + - nginx_secrets:/etc/letsencrypt # Docker managed volume (see list at the bottom) + - ./nginx/nginx_secrets/cloudflare.ini:/etc/letsencrypt/cloudflare.ini # Optional DNS INI file, remove if not needed + - ./nginx/user_conf.d:/etc/nginx/user_conf.d # or a host mount with a relative or full path. + ports: + - 443:443 + depends_on: + - sourcebot + + +volumes: + mongo_data: + media: + nginx_secrets: + user_conf.d: + \ No newline at end of file diff --git a/handlers.py b/handlers.py index 49ada65..326d046 100644 --- a/handlers.py +++ b/handlers.py @@ -398,7 +398,7 @@ async def tiktok(**kwargs): tiktok_id = url.split('/')[-1] # Prepare mongodb connection - client = MongoClient("mongodb://127.0.0.1/sourcebot") + client = MongoClient(config['mongodb']['uri']) cached_data = client['sourcebot']['tiktok_db'].find_one({ 'tiktok_id': int(tiktok_id) }) diff --git a/main.py b/main.py index 898d2ad..92a529b 100755 --- a/main.py +++ b/main.py @@ -28,6 +28,9 @@ # Spoiler regular expression spoiler_regex = re.compile(r"(\|\|.*?\|\||\<.*?\>|\`.*?\`)", re.DOTALL) +# URL detection regular expression +url_regex = re.compile(r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+[^\s]*') + # Role reactions async def handle_reaction(payload): ''' @@ -60,7 +63,7 @@ async def handle_reaction(payload): return # Search for role in mongodb - client = MongoClient('mongodb://127.0.0.1/sourcebot') + client = MongoClient(config['mongodb']['uri']) result = client['sourcebot']['roles'].find_one({ 'guild': payload.guild_id, 'emoji': emoji @@ -109,12 +112,22 @@ async def on_message(message: discord.Message): ''' Events for each message (main functionality of the bot) ''' + # Optional logging - DEBUG USE ONLY, not recommended for production + #print(f"(Diagnostic) Message from {message.author}: {message.content}") + if message.author == bot.user: return # Process prefix commands await bot.process_commands(message) + # Debug message for links in messages + if url_regex.search(message.content) and config['discord'].get('debug', {}).get('link_detection', False): + print(f"[DEBUG] Link detected in message from {message.author} (ID: {message.author.id}) in channel {message.channel.name if hasattr(message.channel, 'name') else 'DM'}") + print(f"[DEBUG] Message content: {message.content}") + print(f"[DEBUG] Links found: {[match.group(0) for match in url_regex.finditer(message.content)]}") + print("-" * 50) + # Ignore text in valid spoiler tag content = re.sub(spoiler_regex, '', message.content) @@ -164,10 +177,30 @@ async def on_message(message: discord.Message): ) if isinstance(files, list): - # Debug logs + # Debug logs to console + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Handler successfully processed: {parser['function'].__name__}") + print(f"[DEBUG] Match groups: {match.groups()}") + print(f"[DEBUG] From user: {message.author} in channel: {message.channel.name if hasattr(message.channel, 'name') else 'DM'}") + print("-" * 50) + + # Debug logs to Discord channel logs_channel = bot.get_channel(config['discord']['logs_channel']) await logs_channel.send(f"```\n{message.author=}\n{message.channel=}\n{match.groups()=}\n```") + # Delete original message embeds if configured + if config['discord'].get('delete_original_embeds', False) and not isinstance(message.channel, discord.DMChannel): + try: + await message.edit(suppress=True) + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Deleted embeds from message: {message.id}") + except discord.Forbidden: + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] No permission to delete embeds from message: {message.id}") + except Exception as e: + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Error deleting embeds from message: {message.id}, Error: {str(e)}") + for i in range(0, len(files), 10): await message.channel.send(files=[ discord.File(file) for file in files[i:i+10] ]) await logs_channel.send(files=[ discord.File(file) for file in files[i:i+10] ]) @@ -180,11 +213,31 @@ async def on_message(message: discord.Message): ) if isinstance(output, list): - # Debug logs + # Debug logs to console + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Handler successfully processed: {parser['function'].__name__}") + print(f"[DEBUG] Match groups: {match.groups()}") + print(f"[DEBUG] From user: {message.author} in channel: {message.channel.name if hasattr(message.channel, 'name') else 'DM'}") + print("-" * 50) + + # Debug logs to Discord channel logs_channel = bot.get_channel(config['discord']['logs_channel']) if parser['function'] is not handlers.youtube: await logs_channel.send(f"```\n{message.author=}\n{message.channel=}\n{match.groups()=}\n```") + # Delete original message embeds if configured + if config['discord'].get('delete_original_embeds', False) and not isinstance(message.channel, discord.DMChannel): + try: + await message.edit(suppress=True) + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Deleted embeds from message: {message.id}") + except discord.Forbidden: + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] No permission to delete embeds from message: {message.id}") + except Exception as e: + if config['discord'].get('debug', {}).get('handler_detection', False): + print(f"[DEBUG] Error deleting embeds from message: {message.id}, Error: {str(e)}") + for kwargs in output: await message.channel.send(**kwargs) if parser['function'] is not handlers.youtube: @@ -210,7 +263,7 @@ async def _tiktok(ctx): ''' Posts a random tiktok from sourcebot's collection. ''' - client = MongoClient('mongodb://127.0.0.1/sourcebot') + client = MongoClient(config['mongodb']['uri']) tiktok = client['sourcebot']['tiktok_db'].aggregate([{ "$sample": { "size": 1 } }]).next() await ctx.respond(f"{config['media']['url']}/tiktok-{tiktok['tiktok_id']}.mp4") @@ -267,7 +320,7 @@ async def _list(ctx): Returns current list of roles configured for sourcebot. ''' embed = discord.Embed(title="Current settings", colour=discord.Colour(0x8ba089)) - client = MongoClient('mongodb://127.0.0.1/sourcebot') + client = MongoClient(config['mongodb']['uri']) for role in client['sourcebot']['roles'].find({'guild': ctx.guild.id}): embed.add_field(name=role['emoji'], value=f"<@&{role['role']}>") await ctx.respond(embed=embed) @@ -278,7 +331,7 @@ async def _add(ctx, emoji: str, *, role: discord.Role): ''' Adds a new role reaction to the sourcebot. ''' - client = MongoClient('mongodb://127.0.0.1/sourcebot') + client = MongoClient(config['mongodb']['uri']) client['sourcebot']['roles'].insert_one({ 'guild': ctx.guild.id, 'emoji': emoji, @@ -292,7 +345,7 @@ async def _remove(ctx, emoji: str): ''' Removes a role reaction from sourcebot list. ''' - client = MongoClient('mongodb://127.0.0.1/sourcebot') + client = MongoClient(config['mongodb']['uri']) client['sourcebot']['roles'].delete_one({ 'guild': ctx.guild.id, 'emoji': emoji diff --git a/nginx-certbot.env.example b/nginx-certbot.env.example new file mode 100644 index 0000000..51aecf4 --- /dev/null +++ b/nginx-certbot.env.example @@ -0,0 +1,17 @@ +# Required +CERTBOT_EMAIL=your@email.org + +# Optional (Defaults) +DHPARAM_SIZE=2048 +ELLIPTIC_CURVE=secp256r1 +RENEWAL_INTERVAL=8d +RSA_KEY_SIZE=2048 +STAGING=0 +USE_ECDSA=1 + +# Advanced (Defaults) +# Ideally use dns-cloudflare (or other DNS provider) instead of webroot +CERTBOT_AUTHENTICATOR=webroot +CERTBOT_DNS_PROPAGATION_SECONDS="" +DEBUG=0 +USE_LOCAL_CA=0 \ No newline at end of file diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..d377a46 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,62 @@ + +server { + server_name STATIC.EXAMPLE.COM; + listen 443 ssl; + listen [::]:443 ssl ipv6only=on; + + if ($scheme != "https") { + return 301 https://$host$request_uri; + } + + # Cloudflare IPv4 https://www.cloudflare.com/ips-v4/ + + set_real_ip_from 173.245.48.0/20; + set_real_ip_from 103.21.244.0/22; + set_real_ip_from 103.22.200.0/22; + set_real_ip_from 103.31.4.0/22; + set_real_ip_from 141.101.64.0/18; + set_real_ip_from 108.162.192.0/18; + set_real_ip_from 190.93.240.0/20; + set_real_ip_from 188.114.96.0/20; + set_real_ip_from 197.234.240.0/22; + set_real_ip_from 198.41.128.0/17; + set_real_ip_from 162.158.0.0/15; + set_real_ip_from 104.16.0.0/13; + set_real_ip_from 104.24.0.0/14; + set_real_ip_from 172.64.0.0/13; + set_real_ip_from 131.0.72.0/22; + + # Cloudflare IPv6 https://www.cloudflare.com/ips-v6/ + set_real_ip_from 2400:cb00::/32; + set_real_ip_from 2606:4700::/32; + set_real_ip_from 2803:f800::/32; + set_real_ip_from 2405:b500::/32; + set_real_ip_from 2405:8100::/32; + set_real_ip_from 2a06:98c0::/29; + set_real_ip_from 2c0f:f248::/32; + + real_ip_header CF-Connecting-IP; + + index index.html; + root /media; + + #location /vr { + # auth_basic "restricted"; + # auth_basic_user_file "/etc/nginx/.htpasswd"; + # autoindex on; + # autoindex_exact_size off; + #} + + + ssl_certificate /etc/letsencrypt/live/STATIC.EXAMPLE.COM/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/STATIC.EXAMPLE.COM/privkey.pem; + + # access_log /var/log/nginx/STATIC.EXAMPLE.COM cf_custom; + +} + +#server { + # Drop any request that does not match any of the other server names. +# listen 443 ssl default_server; +# ssl_reject_handshake on; +#} \ No newline at end of file diff --git a/nginx/nginx_secrets/cloudflare.ini.example b/nginx/nginx_secrets/cloudflare.ini.example new file mode 100644 index 0000000..5d1bef2 --- /dev/null +++ b/nginx/nginx_secrets/cloudflare.ini.example @@ -0,0 +1,2 @@ +# Cloudflare API token used by Certbot +dns_cloudflare_api_token = 0123456789abcdef0123456789abcdef01234567 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b80830e..42157fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ pydantic==2.9.2 pydantic_core==2.23.4 pymongo==4.8.0 pyparsing==3.1.4 -pysaucenao==1.6.2 +pysaucenao>=1.6.2 PySocks==1.7.1 python-dateutil==2.8.2 python-magic==0.4.27