commit 1b5cb61fe9a3f3f0db561688877d9b5b06cb6266 Author: w Date: Thu May 15 22:14:41 2025 -0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d3cf3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/venv/* +gacha_game.db +__pycache__/ +bot/client.py \ No newline at end of file diff --git a/bot/add_character.py b/bot/add_character.py new file mode 100644 index 0000000..68528c3 --- /dev/null +++ b/bot/add_character.py @@ -0,0 +1,63 @@ +import requests +from misskey.exceptions import MisskeyAPIException +from client import client_connection +from db_utils import get_db_connection + +def add_character(name: str, rarity: int, weight: float, image_url: str) -> tuple[int, str]: + """ + Adds a character to the database, uploading the image from a public URL to the bot's Misskey Drive. + + Args: + name (str): Character name. + rarity (int): Character rarity (e.g., 1-5). + weight (float): Pull weight (e.g., 0.02). + image_url (str): Public URL of the image from the post (e.g., from note['files'][i]['url']). + + Returns: + tuple[int, str]: Character ID and bot's Drive file_id. + + Raises: + ValueError: If inputs are invalid. + RuntimeError: If image download/upload or database operation fails. + """ + try: + # Validate inputs + if not name or not name.strip(): + raise ValueError("Character name cannot be empty.") + if not isinstance(rarity, int) or rarity < 1: + raise ValueError("Rarity must be a positive integer.") + if not isinstance(weight, (int, float)) or weight <= 0: + raise ValueError("Weight must be a positive number.") + if not image_url: + raise ValueError("Image URL must be provided.") + + # Download image + response = requests.get(image_url, stream=True) + if response.status_code != 200: + raise RuntimeError(f"Failed to download image from {image_url}") + + # Upload to bot's Drive + mk = client_connection() + try: + media = mk.drive_files_create(response.raw) + file_id = media["id"] + except MisskeyAPIException as e: + raise RuntimeError(f"Failed to upload image to bot's Drive: {e}") from e + + # Insert into database + conn = get_db_connection() + cur = conn.cursor() + cur.execute( + 'INSERT INTO characters (name, rarity, weight, file_id) VALUES (?, ?, ?, ?)', + (name.strip(), rarity, float(weight), file_id) + ) + conn.commit() + character_id = cur.lastrowid + + return character_id, file_id + + except Exception as e: + raise + finally: + if 'conn' in locals(): + conn.close() \ No newline at end of file diff --git a/bot/bot_app.py b/bot/bot_app.py new file mode 100644 index 0000000..2159260 --- /dev/null +++ b/bot/bot_app.py @@ -0,0 +1,85 @@ +import misskey +import os +import asyncio +from parsing import parse_notification +from db_utils import get_or_create_user, add_pull, get_config, set_config +from client import client_connection + +# Initialize the Misskey client + +# Function to create a note when a user pulls a character in the gacha game + + +# Set the access token and instance URL (replace with your own values) + +# Initialize the Misskey client + +client = client_connection() + +import time + +# Define your whitelist +whitelisted_instances = [] + +def stream_notifications(): + print("Starting filtered notification stream...") + + last_seen_id = get_config("last_seen_notif_id") + + while True: + try: + notifications = client.i_notifications() + + if notifications: + # Oldest to newest + notifications.reverse() + + new_last_seen_id = last_seen_id + + for notification in notifications: + notif_id = notification.get("id") + + # Skip old or same ID notifications + if last_seen_id is not None and notif_id <= last_seen_id: + continue + + user = notification.get("user", {}) + username = user.get("username", "unknown") + host = user.get("host") # None if local user + + instance = host if host else "local" + + if instance in whitelisted_instances or instance == "local": + note = notification.get("note", {}).get("text", "") + notif_type = notification.get("type", "unknown") + + print(f"πŸ“¨ [{notif_type}] from @{username}@{instance}") + print(f"πŸ’¬ {note}") + print("-" * 30) + + # 🧠 Send to the parser + parse_notification(notification,client) + + else: + print(f"⚠️ Blocked notification from untrusted instance: {host}") + + # Update only if this notif_id is greater + if new_last_seen_id is None or notif_id > new_last_seen_id: + new_last_seen_id = notif_id + + # Save the latest seen ID + if new_last_seen_id and new_last_seen_id != last_seen_id: + set_config("last_seen_notif_id", new_last_seen_id) + last_seen_id = new_last_seen_id + + time.sleep(5) + + except Exception as e: + print(f"Error: {e}") + time.sleep(5) + + + + +if __name__ == "__main__": + stream_notifications() diff --git a/bot/db_utils.py b/bot/db_utils.py new file mode 100644 index 0000000..1da2ab7 --- /dev/null +++ b/bot/db_utils.py @@ -0,0 +1,51 @@ +import sqlite3 +import random + +DB_PATH = "./gacha_game.db" # Adjust if needed + +def get_db_connection(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def get_or_create_user(username): + conn = get_db_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute('SELECT * FROM users WHERE username = ?', (username,)) + user = cur.fetchone() + if user: + conn.close() + return user['id'] + + # New user starts with has_rolled = False + cur.execute( + 'INSERT INTO users (username, has_rolled) VALUES (?, ?)', + (username, False) + ) + conn.commit() + user_id = cur.lastrowid + conn.close() + return user_id + +def add_pull(user_id, character_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute('INSERT INTO pulls (user_id, character_id) VALUES (?, ?)', (user_id, character_id)) + conn.commit() + conn.close() + +def get_config(key): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT value FROM config WHERE key = ?", (key,)) + row = cur.fetchone() + conn.close() + return row[0] if row else None + +def set_config(key, value): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value)) + conn.commit() + conn.close() \ No newline at end of file diff --git a/bot/gacha_response.py b/bot/gacha_response.py new file mode 100644 index 0000000..10f053c --- /dev/null +++ b/bot/gacha_response.py @@ -0,0 +1,46 @@ +import random +from db_utils import get_or_create_user, add_pull, get_db_connection +from add_character import add_character + +def get_character(): + conn = get_db_connection() + cur = conn.cursor() + cur.execute('SELECT * FROM characters') + characters = cur.fetchall() + conn.close() + + if not characters: + return None, None + + weights = [c['weight'] for c in characters] + chosen = random.choices(characters, weights=weights, k=1)[0] + + return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity'] + + +def gacha_response(command,full_user, arguments,note_obj): + + if command == "roll": + user_id = get_or_create_user(full_user) + character_id, character_name, file_id, rarity = get_character() + + if not character_id: + return(f"@{full_user} Uwaaa... something went wrong! No characters found. 😿") + + add_pull(user_id,character_id) + stars = '⭐️' * rarity + return([f"@{full_user} 🎲 Congrats! You rolled **{character_name}** she's {stars} stars! She's all yours now~ πŸ’–βœ¨",[file_id]]) + + if command == "create": + # Example call from bot logic + image_url = note_obj.get("files", [{}])[0].get("url") if note_obj.get("files") else None + if not image_url: + return("You need an image to create a character, dumbass.") + + character_id, file_id = add_character( + name=arguments[0], + rarity=int(arguments[1]), + weight=float(arguments[2]), + image_url=image_url + ) + return([f"Added {arguments[0]}, ID {character_id}.",[file_id]]) \ No newline at end of file diff --git a/bot/parsing.py b/bot/parsing.py new file mode 100644 index 0000000..9c6cdcf --- /dev/null +++ b/bot/parsing.py @@ -0,0 +1,69 @@ +import random, re +from gacha_response import gacha_response + + +def parse_notification(notification,client): + + # We get the type of notification to filter the ones that we actually want to parse + + notif_type = notification.get("type") + if not((notif_type == "mention") or (notif_type == "reply")): + + return # Ignora todo lo que no sea una menciΓ³n + + # We want the visibility to be related to the type that was received (so if people don't want to dump a bunch of notes on home they don't have to) + + visibility = notification["note"]["visibility"] + if visibility == "specified": + visibility = "specified" + else: + visibility = "home" + + + + user = notification.get("user", {}) + username = user.get("username", "unknown") + host = user.get("host") + full_user = f"@{username}" if not host else f"@{username}@{host}" + + note_obj = notification.get("note", {}) + note_text = note_obj.get("text") + note_id = note_obj.get("id") + + note = note_text.strip().lower() if note_text else "" + + waifumelon_variants = [ + "@waifumelon", # simple mention + "@waifumelon@mai.waifuism.life", # fully qualified + ] + + if any(variant in note for variant in waifumelon_variants): + # Find command and arguments after the mention + # Removes all mentions (regex = mentions that start with @ and may contain @domain) + cleaned_text = re.sub(r"@\w+(?:@\S+)?", "", note).strip() + parts = cleaned_text.split() + print(cleaned_text) + print(parts) + + command = parts[0].lower() if parts else None + arguments = parts[1:] if len(parts) > 1 else [] + else: + command = None + if command: + response = gacha_response(command.lower(),full_user, arguments, note_obj) + if response: + if type(response) == str: + client.notes_create( + text=response, + reply_id=note_id, + visibility=visibility + ) + else: + client.notes_create( + text=response[0], + reply_id=note_id, + visibility=visibility, + file_ids=response[1] + #visible_user_ids=[] #todo: write actual visible users ids so pleromers can use the bot privately + + ) \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..63d0b43 --- /dev/null +++ b/db.py @@ -0,0 +1,61 @@ +import sqlite3 + +# Connect to SQLite database (or create it if it doesn't exist) +conn = sqlite3.connect('gacha_game.db') +cursor = conn.cursor() + +# Create tables +cursor.execute(''' +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + has_rolled BOOLEAN NOT NULL DEFAULT 0 +) +''') + +cursor.execute(''' +CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rarity INTEGER NOT NULL, + weight REAL NOT NULL, + file_id TEXT NOT NULL +) +''') + +cursor.execute(''' +CREATE TABLE IF NOT EXISTS pulls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + character_id INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (character_id) REFERENCES characters(id) +) +''') + +cursor.execute(""" + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + +""" # Insert example characters into the database if they don't already exist +characters = [ + ('Murakami-san', 1, 0.35), + ('Mastodon-kun', 2, 0.25), + ('Pleroma-tan', 3, 0.2), + ('Misskey-tan', 4, 0.15), + ('Syuilo-mama', 5, 0.05) +] + + +cursor.executemany(''' +INSERT OR IGNORE INTO characters (name, rarity, weight) VALUES (?, ?, ?) +''', characters) +""" + +# Commit changes and close +conn.commit() +conn.close() diff --git a/dev_runner.py b/dev_runner.py new file mode 100644 index 0000000..b39a434 --- /dev/null +++ b/dev_runner.py @@ -0,0 +1,46 @@ +import subprocess +import os +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +import time + +class ReloadHandler(FileSystemEventHandler): + def __init__(self, script_path): + self.script_path = script_path + self.process = None + self.last_modified = 0 + self.debounce_interval = 1 # Wait 1 second to avoid double triggers + self.start_process() + + def start_process(self): + if self.process: + self.process.terminate() + print(f"πŸ” Starting {self.script_path}...") + self.process = subprocess.Popen(["python3", self.script_path]) + + def on_modified(self, event): + if event.src_path.endswith(".py"): + current_time = time.time() + if current_time - self.last_modified > self.debounce_interval: + print(f"Detected change in {event.src_path}") + self.start_process() + self.last_modified = current_time + +if __name__ == "__main__": + script_path = "bot/bot_app.py" + if not os.path.exists(script_path): + print(f"Error: {script_path} does not exist") + exit(1) + + event_handler = ReloadHandler(script_path) + observer = Observer() + observer.schedule(event_handler, path=".", recursive=True) + observer.start() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("Stopping...") + observer.stop() + observer.join() \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..609650f --- /dev/null +++ b/readme.md @@ -0,0 +1,55 @@ +General + +```mermaid +flowchart TD + + subgraph Player Interaction + A1[Misskey bot] + A2[Web] + end + + subgraph Misskey + B1[Misskey instance] + end + + subgraph Bot + C1[Bot core in Python] + C2[Notification parser] + C3[Gacha roll logic] + C4[Database interface] + C5[Misskey API poster] + end + + subgraph Website + D1[Flask backend] + D2[User account system] + D3[Image gallery] + end + + subgraph Backend + E1[Shared database] + E2[Virtual environment] + E3[Debian Linux server] + end + + A1 <-->|Send or receive mention| B1 + B1 -->|Send mention| C2 + C2 -->|Command and information| C3 + C3 <-->|Ask for command information and confirmed roll setting| C4 + C4 <--> E1 + C3 -->|Command result and info| C5 + C5 -->|Resulting Mention| B1 + + A2 --> D1 + D1 --> D2 + D1 --> D3 + D2 --> E1 + D3 --> E1 + + C1 --> E2 + D1 --> E2 + + E1 --> E3 + B1 --> E3 + +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3e1228 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +blinker==1.9.0 +click==8.1.8 +Flask==3.1.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +Werkzeug==3.1.3 diff --git a/startup.sh b/startup.sh new file mode 100755 index 0000000..7c216b6 --- /dev/null +++ b/startup.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Navigate to the project directory (optional) +cd "$(dirname "$0")" + +# Activate virtual environment +source venv/bin/activate + +# Start the bot +echo "Starting bot..." +python3 bot/bot_app.py & + +# Save the bot PID so you can stop it later if needed +BOT_PID=$! + +# Start the website +echo "Starting web server..." +python3 web/app.py & + +# Save the web PID too +WEB_PID=$! + +# On CTRL+C or termination, kill both +trap "echo 'Stopping...'; kill $BOT_PID $WEB_PID; exit" SIGINT SIGTERM + +# Wait for both processes (if you want it to stay attached) +wait $BOT_PID $WEB_PID diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..f68084c --- /dev/null +++ b/web/app.py @@ -0,0 +1,59 @@ +from flask import Flask, render_template +import sqlite3 + +app = Flask(__name__) +DB_PATH = "./gacha_game.db" # Adjust path if needed + +def get_db_connection(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +@app.route('/') +def index(): + conn = get_db_connection() + users = conn.execute('SELECT id, username FROM users').fetchall() + top_users = conn.execute(''' + SELECT users.id, users.username, COUNT(pulls.id) AS pull_count + FROM users + LEFT JOIN pulls ON users.id = pulls.user_id + GROUP BY users.id + ORDER BY pull_count DESC + LIMIT 5 + ''').fetchall() + + conn.close() + return render_template('index.html', users=users, top_users=top_users) + +@app.route('/user/') +def user_profile(user_id): + conn = get_db_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,)) + user = cursor.fetchone() + + cursor.execute(''' + SELECT pulls.timestamp, characters.name as character_name, characters.rarity + FROM pulls + JOIN characters ON pulls.character_id = characters.id + WHERE pulls.user_id = ? + ORDER BY pulls.timestamp DESC + ''', (user_id,)) + pulls = cursor.fetchall() + + conn.close() + return render_template('user.html', user=user, pulls=pulls) + +@app.route('/about') +def about(): + return render_template('about.html') + +@app.route('/submit') +def submit_character(): + return render_template('submit.html') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web/templates/about.html b/web/templates/about.html new file mode 100644 index 0000000..89519db --- /dev/null +++ b/web/templates/about.html @@ -0,0 +1,13 @@ + + + + About - Misskey Gacha Center + + +

About This Gacha

+

This is a playful Misskey-themed gacha tracker made with Flask and SQLite.

+

All rolls are stored, stats are tracked, and characters are added manually for now.

+

Built with love, chaos, and way too much caffeine β˜•.

+ ← Back to Home + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..e9b1680 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,137 @@ + + + + Misskey Gacha Center + + + + +
+

Misskey Gacha Center

+

Track your luck. Compare your pulls. Compete with friends.

+
+ +
+ +

πŸŽ–οΈ Leaderboard: Most Rolls

+ {% for user in top_users %} +
+ {{ loop.index }}. {{ user['username'] }} β€” {{ user['pull_count'] }} rolls +
+ {% else %} +

No pulls yet. Be the first to roll!

+ {% endfor %} + +

πŸ“‹ All Registered Users

+ + +
+ πŸš€ This is a fun little gacha tracker! More features coming soon. Want to help shape it? +
+ +
+ + + + + diff --git a/web/templates/submit.html b/web/templates/submit.html new file mode 100644 index 0000000..5dc5ac9 --- /dev/null +++ b/web/templates/submit.html @@ -0,0 +1,12 @@ + + + + Submit a Character - Misskey Gacha Center + + +

Submit a Character

+

Want to add a new character to the gacha pool?

+

This feature will be available soon. Stay tuned!

+ ← Back to Home + + diff --git a/web/templates/user.html b/web/templates/user.html new file mode 100644 index 0000000..0004052 --- /dev/null +++ b/web/templates/user.html @@ -0,0 +1,77 @@ + + + + {{ user['username'] }}'s Rolls + + + + +
+

{{ user['username'] }}'s Gacha Rolls

+

User ID: {{ user['id'] }}

+

Total Rolls: {{ pulls | length }}

+
+ +
+

Pull History

+
    + {% for pull in pulls %} +
  • + {{ pull['timestamp'] }}
    + {{ pull['character_name'] }} + {{ 'β˜…' * pull['rarity'] }} +
  • + {% endfor %} +
+
+ + ← Back to Users + + +