From 6b6777cb891c4d04be16aa9abbeed35d1e6d547f Mon Sep 17 00:00:00 2001
From: w
Date: Sun, 18 May 2025 18:43:50 -0300
Subject: [PATCH 01/43] docs typo
---
bot/parsing.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/parsing.py b/bot/parsing.py
index c17a276..2842e3c 100644
--- a/bot/parsing.py
+++ b/bot/parsing.py
@@ -3,7 +3,7 @@ import config
from gacha_response import gacha_response
def parse_notification(notification,client):
- '''Oarses any notifications received by the bot and sends any commands to
+ '''Parses any notifications received by the bot and sends any commands to
gacha_response()'''
# We get the type of notification to filter the ones that we actually want
From 24309ce900c303e4acd0f64dbaee95107961d4f8 Mon Sep 17 00:00:00 2001
From: w
Date: Mon, 19 May 2025 00:14:47 -0300
Subject: [PATCH 02/43] Gacha_response changed to response
---
bot/bot_app.py | 45 +++++++++++++++++++++++---
bot/parsing.py | 34 ++-----------------
bot/{gacha_response.py => response.py} | 8 +++--
3 files changed, 47 insertions(+), 40 deletions(-)
rename bot/{gacha_response.py => response.py} (92%)
diff --git a/bot/bot_app.py b/bot/bot_app.py
index 5806478..81c920c 100644
--- a/bot/bot_app.py
+++ b/bot/bot_app.py
@@ -4,6 +4,7 @@ import misskey
from parsing import parse_notification
from db_utils import get_or_create_user, add_pull, get_config, set_config
from client import client_connection
+from response import generate_response
# Initialize the Misskey client
client = client_connection()
@@ -44,15 +45,49 @@ def stream_notifications():
instance = host if host else "local"
if instance in whitelisted_instances or instance == "local":
- note = notification.get("note", {}).get("text", "")
+ note = notification.get("note", {})
+ note_text = note.get("text", "")
+ note_id = note.get("id")
notif_type = notification.get("type", "unknown")
+ # 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 = "home"
+
print(f"๐จ [{notif_type}] from @{username}@{instance}")
- print(f"๐ฌ {note}")
+ print(f"๐ฌ {note_text}")
print("-" * 30)
- # ๐ง Send to the parser
- 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 notif_type in ('mention', 'reply'):
+ # ๐ง Send to the parser
+ parsed_command = parse_notification(notification,client)
+ # Get the response
+ response = generate_response(parsed_command)
+ if isinstance(response, str):
+ client.notes_create(
+ text=response,
+ reply_id=note_id,
+ visibility=visibility
+ )
+ elif response:
+ 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
+ )
+
+
+
else:
print(f"โ ๏ธ Blocked notification from untrusted instance: {host}")
@@ -70,7 +105,7 @@ def stream_notifications():
except Exception as e:
print(f"An exception has occured: {e}\n{traceback.format_exc()}")
- time.sleep(5)
+ time.sleep(2)
diff --git a/bot/parsing.py b/bot/parsing.py
index 2842e3c..bbefe70 100644
--- a/bot/parsing.py
+++ b/bot/parsing.py
@@ -1,23 +1,11 @@
import random, re
import config
-from gacha_response import gacha_response
def parse_notification(notification,client):
'''Parses any notifications received by the bot and sends any commands to
gacha_response()'''
- # 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 in ('mention', 'reply'):
- return # Ignore anything that isn't a mention
-
- # 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 = "home"
+
# Get the full Activitypub ID of the user
user = notification.get("user", {})
@@ -50,22 +38,4 @@ def parse_notification(notification,client):
command = parts[0].lower() if parts else None
arguments = parts[1:] if len(parts) > 1 else []
- # TODO: move response generation to a different function
- response = gacha_response(command.lower(),full_user, arguments, note_obj)
- if not response:
- return
-
- if isinstance(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
- )
+ return [command,full_user, arguments, note_obj]
diff --git a/bot/gacha_response.py b/bot/response.py
similarity index 92%
rename from bot/gacha_response.py
rename to bot/response.py
index e703aff..2c58c43 100644
--- a/bot/gacha_response.py
+++ b/bot/response.py
@@ -26,11 +26,13 @@ def is_float(val):
return False
-# TODO: See issue #3, separate command parsing from game logic.
-def gacha_response(command,full_user, arguments,note_obj):
- '''Parses a given command with arguments, processes the game state and
+def generate_response(parsed_command):
+
+ '''Given a command with arguments, processes the game state and
returns a response'''
+ command, full_user, arguments, note_obj = parsed_command
+
if command == "roll":
user_id = get_or_create_user(full_user)
character_id, character_name, file_id, rarity = get_character()
From d2a7e523e86787b7c5d04c5c074d63d8932b93e2 Mon Sep 17 00:00:00 2001
From: VD15
Date: Mon, 19 May 2025 19:53:59 +0100
Subject: [PATCH 03/43] Overhaul notification parsing
---
bot/bot_app.py | 118 ++++----------------------------------------
bot/config.py | 3 ++
bot/notification.py | 106 +++++++++++++++++++++++++++++++++++++++
example_config.ini | 8 ++-
4 files changed, 125 insertions(+), 110 deletions(-)
create mode 100644 bot/notification.py
diff --git a/bot/bot_app.py b/bot/bot_app.py
index 81c920c..b65ef3a 100644
--- a/bot/bot_app.py
+++ b/bot/bot_app.py
@@ -1,114 +1,14 @@
import time
-import traceback
-import misskey
-from parsing import parse_notification
-from db_utils import get_or_create_user, add_pull, get_config, set_config
+import misskey as misskey
from client import client_connection
-from response import generate_response
-# Initialize the Misskey client
-client = client_connection()
-
-# Define your whitelist
-# TODO: move to config
-whitelisted_instances: list[str] = []
-
-def stream_notifications():
- print("Starting filtered notification stream...")
-
- last_seen_id = get_config("last_seen_notif_id")
+from config import NOTIFICATION_POLL_INTERVAL
+from notification import process_notifications
+if __name__ == '__main__':
+ # Initialize the Misskey client
+ client = client_connection()
+ print('Listening for notifications...')
while True:
- try:
- # May be able to mark notifications as read using misskey.py and
- # filter them out here. This function also takes a since_id we
- # could use as well
- 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", {})
- note_text = note.get("text", "")
- note_id = note.get("id")
- notif_type = notification.get("type", "unknown")
-
- # 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 = "home"
-
- print(f"๐จ [{notif_type}] from @{username}@{instance}")
- print(f"๐ฌ {note_text}")
- print("-" * 30)
-
-
- # We get the type of notification to filter the ones that we actually want
- # to parse
-
- notif_type = notification.get("type")
- if notif_type in ('mention', 'reply'):
- # ๐ง Send to the parser
- parsed_command = parse_notification(notification,client)
- # Get the response
- response = generate_response(parsed_command)
- if isinstance(response, str):
- client.notes_create(
- text=response,
- reply_id=note_id,
- visibility=visibility
- )
- elif response:
- 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
- )
-
-
-
-
- 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"An exception has occured: {e}\n{traceback.format_exc()}")
- time.sleep(2)
-
-
-
-
-if __name__ == "__main__":
- stream_notifications()
+ if not process_notifications(client):
+ time.sleep(NOTIFICATION_POLL_INTERVAL)
diff --git a/bot/config.py b/bot/config.py
index b24293c..5b73661 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -20,3 +20,6 @@ DB_PATH = config['application']['DatabaseLocation']
# Fedi handles in the traditional 'user@domain.tld' style, allows these users
# to use extra admin exclusive commands with the bot'''
ADMINS = config['application']['DefaultAdmins']
+
+NOTIFICATION_POLL_INTERVAL = int(config['application']['NotificationPollInterval'])
+NOTIFICATION_BATCH_SIZE = int(config['application']['NotificationBatchSize'])
diff --git a/bot/notification.py b/bot/notification.py
new file mode 100644
index 0000000..0570cd8
--- /dev/null
+++ b/bot/notification.py
@@ -0,0 +1,106 @@
+import traceback
+from misskey.exceptions import MisskeyAPIException
+
+from config import NOTIFICATION_BATCH_SIZE
+from parsing import parse_notification
+from db_utils import get_config, set_config
+from response import generate_response
+
+# Define your whitelist
+# TODO: move to config
+WHITELISTED_INSTANCES: list[str] = []
+
+def process_notification(client, notification):
+ '''Processes an individual notification'''
+ notif_id = notification.get('id')
+
+ user = notification.get('user', {})
+ username = user.get('username', 'unknown')
+ host = user.get('host') # None if local user
+ instance = host if host else 'local'
+
+ if not (instance in WHITELISTED_INSTANCES or instance == 'local'):
+ print(f'โ ๏ธ Blocked notification from untrusted instance: {instance}')
+
+ # Copy visibility of the post that was received when replying (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 = 'home'
+
+ notif_type = notification.get('type', 'unknown')
+ print(f'๐จ [{notif_type}] from @{username}@{instance}')
+
+ # ๐ง Send to the parser
+ parsed_command = parse_notification(notification, client)
+
+ # Get the note Id to reply to
+ note_id = notification.get('note', {}).get('id')
+
+ # Get the response
+ # TODO: Formalize exactly *what* is returned by this. Ideally just want to
+ # handle two cases here: either we have a response, or we don't.
+ # TODO: Return dictionaries instead of tuples. They handle multiple
+ # elements a lot better as they're not position dependent
+ response = generate_response(parsed_command)
+ if isinstance(response, str):
+ client.notes_create(
+ text=response,
+ reply_id=note_id,
+ visibility=visibility
+ )
+ elif response:
+ 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
+ )
+ return notif_id
+
+def process_notifications(client):
+ '''Processes a batch of unread notifications. Returns False if there are
+ no more notifications to process.'''
+
+ last_seen_id = get_config('last_seen_notif_id')
+ # process_notification writes to last_seen_id, so make a copy
+ new_last_seen_id = last_seen_id
+
+ try:
+ notifications = client.i_notifications(
+ # Fetch notifications we haven't seen yet
+ since_id=last_seen_id,
+ # Let misskey handle the filtering
+ include_types=['mention', 'reply'],
+ # And handle the batch size while we're at it
+ limit=NOTIFICATION_BATCH_SIZE
+ )
+
+ # No notifications. Wait the poll period.
+ if not notifications:
+ return False
+
+ # Iterate oldest to newest
+ for notification in notifications:
+ try:
+ # Process notification and update new last seen id
+ new_last_seen_id = process_notification(client, notification)
+ except Exception as e:
+ print(f'An exception has occured while processing a notification: {e}')
+ print(traceback.format_exc())
+
+ # If we got as many notifications as we requested, there are probably
+ # more in the queue
+ return len(notifications) == NOTIFICATION_BATCH_SIZE
+
+ except MisskeyAPIException as e:
+ print(f'An exception has occured while reading notifications: {e}\n')
+ print(traceback.format_exc())
+ finally:
+ # Quality jank right here, but finally lets us update the last_seen_id
+ # even if we hit an exception or return early
+ if new_last_seen_id != last_seen_id:
+ set_config('last_seen_notif_id', new_last_seen_id)
+
+ return False
diff --git a/example_config.ini b/example_config.ini
index 4412c92..44cc2f9 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -14,4 +14,10 @@ InstanceUrl = http://example.tld
DefaultAdmins = ['admin@example.tld']
; SQLite Database location
-DatabaseLocation = ./gacha_game.db
\ No newline at end of file
+DatabaseLocation = ./gacha_game.db
+
+; Number of seconds to sleep while awaiting new notifications
+NotificationPollInterval = 5
+
+; Number of notifications to process at once (limit 100)
+NotificationBatchSize = 10
From 9be92afce35b52cb6d217477dad3e09cf8e1e437 Mon Sep 17 00:00:00 2001
From: VD15
Date: Tue, 20 May 2025 09:21:06 +0100
Subject: [PATCH 04/43] Fix notification loop
---
bot/notification.py | 24 ++++++++++++++++--------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/bot/notification.py b/bot/notification.py
index 0570cd8..71ec814 100644
--- a/bot/notification.py
+++ b/bot/notification.py
@@ -12,8 +12,6 @@ WHITELISTED_INSTANCES: list[str] = []
def process_notification(client, notification):
'''Processes an individual notification'''
- notif_id = notification.get('id')
-
user = notification.get('user', {})
username = user.get('username', 'unknown')
host = user.get('host') # None if local user
@@ -29,7 +27,8 @@ def process_notification(client, notification):
visibility = 'home'
notif_type = notification.get('type', 'unknown')
- print(f'๐จ [{notif_type}] from @{username}@{instance}')
+ notif_id = notification.get('id')
+ print(f'๐จ <{notif_id}> [{notif_type}] from @{username}@{instance}')
# ๐ง Send to the parser
parsed_command = parse_notification(notification, client)
@@ -57,7 +56,6 @@ def process_notification(client, notification):
file_ids=response[1]
#visible_user_ids=[] #todo: write actual visible users ids so pleromers can use the bot privately
)
- return notif_id
def process_notifications(client):
'''Processes a batch of unread notifications. Returns False if there are
@@ -69,7 +67,10 @@ def process_notifications(client):
try:
notifications = client.i_notifications(
- # Fetch notifications we haven't seen yet
+ # Fetch notifications we haven't seen yet. This option is a bit
+ # tempermental, sometimes it'll include since_id, sometimes it
+ # won't. We need to keep track of what notifications we've
+ # already processed.
since_id=last_seen_id,
# Let misskey handle the filtering
include_types=['mention', 'reply'],
@@ -84,8 +85,15 @@ def process_notifications(client):
# Iterate oldest to newest
for notification in notifications:
try:
- # Process notification and update new last seen id
- new_last_seen_id = process_notification(client, notification)
+ # Skip if we've processed already
+ notif_id = notification.get('id')
+ if notif_id <= last_seen_id:
+ continue
+
+ # Update new_last_seen_id and process
+ new_last_seen_id = notif_id
+ process_notification(client, notification)
+
except Exception as e:
print(f'An exception has occured while processing a notification: {e}')
print(traceback.format_exc())
@@ -100,7 +108,7 @@ def process_notifications(client):
finally:
# Quality jank right here, but finally lets us update the last_seen_id
# even if we hit an exception or return early
- if new_last_seen_id != last_seen_id:
+ if new_last_seen_id > last_seen_id:
set_config('last_seen_notif_id', new_last_seen_id)
return False
From ff20e2682148b2fa0cfd80c4fb029332185ca1bb Mon Sep 17 00:00:00 2001
From: w
Date: Thu, 22 May 2025 00:20:32 -0300
Subject: [PATCH 05/43] Whitelist fix
---
bot/notification.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/bot/notification.py b/bot/notification.py
index 71ec814..e0ae020 100644
--- a/bot/notification.py
+++ b/bot/notification.py
@@ -19,6 +19,7 @@ def process_notification(client, notification):
if not (instance in WHITELISTED_INSTANCES or instance == 'local'):
print(f'โ ๏ธ Blocked notification from untrusted instance: {instance}')
+ return
# Copy visibility of the post that was received when replying (so if people
# don't want to dump a bunch of notes on home they don't have to)
From 47272aee4fc3a7888c7a887869be1ce48fd34b2c Mon Sep 17 00:00:00 2001
From: VD15
Date: Fri, 23 May 2025 23:44:03 +0100
Subject: [PATCH 06/43] Add a roll timeout to the bot
---
bot/add_character.py | 7 +--
bot/config.py | 24 ++++----
bot/db_utils.py | 12 +++-
bot/response.py | 130 ++++++++++++++++++++++++++++++-------------
example_config.ini | 36 ++++++------
5 files changed, 135 insertions(+), 74 deletions(-)
diff --git a/bot/add_character.py b/bot/add_character.py
index 68528c3..aae3fb3 100644
--- a/bot/add_character.py
+++ b/bot/add_character.py
@@ -32,7 +32,7 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl
raise ValueError("Image URL must be provided.")
# Download image
- response = requests.get(image_url, stream=True)
+ response = requests.get(image_url, stream=True, timeout=30)
if response.status_code != 200:
raise RuntimeError(f"Failed to download image from {image_url}")
@@ -55,9 +55,6 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl
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
+ conn.close()
diff --git a/bot/config.py b/bot/config.py
index 5b73661..643aeb1 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -4,22 +4,20 @@ config = configparser.ConfigParser()
config.read('config.ini')
# Username for the bot
-USER = config['application']['BotUser']
-
+USER = config['credentials']['User']
# API key for the bot
-KEY = config['application']['ApiKey']
+KEY = config['credentials']['Token']
# Bot's Misskey instance URL
-INSTANCE = config['application']['InstanceUrl']
-
-# SQLite Database location
-DB_PATH = config['application']['DatabaseLocation']
-
-# Extra stuff for control of the bot
+INSTANCE = config['credentials']['Instance']
# TODO: move this to db
# Fedi handles in the traditional 'user@domain.tld' style, allows these users
-# to use extra admin exclusive commands with the bot'''
-ADMINS = config['application']['DefaultAdmins']
+# to use extra admin exclusive commands with the bot
+ADMINS = config['application']['DefaultAdmins']
+# SQLite Database location
+DB_PATH = config['application']['DatabaseLocation']
-NOTIFICATION_POLL_INTERVAL = int(config['application']['NotificationPollInterval'])
-NOTIFICATION_BATCH_SIZE = int(config['application']['NotificationBatchSize'])
+NOTIFICATION_POLL_INTERVAL = int(config['notification']['PollInterval'])
+NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
+
+GACHA_ROLL_INTERVAL = int(config['gacha']['RollInterval'])
diff --git a/bot/db_utils.py b/bot/db_utils.py
index bb80d98..a521ec5 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -1,5 +1,4 @@
import sqlite3
-import random
import config
DB_PATH = config.DB_PATH
@@ -40,6 +39,17 @@ def add_pull(user_id, character_id):
conn.commit()
conn.close()
+def get_last_rolled_at(user_id):
+ '''Gets the timestamp when the user last rolled'''
+ conn = get_db_connection()
+ cur = conn.cursor()
+ cur.execute("SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp DESC", \
+ (user_id,))
+ row = cur.fetchone()
+ conn.close()
+ return row[0] if row else None
+
+
def get_config(key):
'''Reads the value for a specified config key from the db'''
conn = get_db_connection()
diff --git a/bot/response.py b/bot/response.py
index 2c58c43..b009344 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,6 +1,8 @@
import random
-from db_utils import get_or_create_user, add_pull, get_db_connection
+from datetime import datetime, timedelta, timezone
+from db_utils import get_or_create_user, add_pull, get_db_connection, get_last_rolled_at
from add_character import add_character
+from config import GACHA_ROLL_INTERVAL
def get_character():
''' Gets a random character from the database'''
@@ -18,54 +20,104 @@ def get_character():
return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity']
+def do_roll(full_user):
+ '''Determines whether the user can roll, then pulls a random character'''
+ user_id = get_or_create_user(full_user)
+
+ # Get date of user's last roll
+ date = get_last_rolled_at(user_id)
+
+ # No date means it's users first roll
+ if date:
+ # SQLite timestamps returned by the DB are always in UTC
+ # Below timestamps are to be converted to UTC
+ prev = datetime.strptime(date + '+0000', '%Y-%m-%d %H:%M:%S%z')
+ now = datetime.now(timezone.utc)
+
+ time_since_last_roll = now - prev
+ roll_interval = timedelta(seconds=GACHA_ROLL_INTERVAL)
+ duration = roll_interval - time_since_last_roll
+
+ # User needs to wait before they can roll again
+ if time_since_last_roll < roll_interval:
+ remaining_duration = None
+ if duration.seconds > 3600:
+ remaining_duration = f'{-(duration.seconds // -3600)} hours'
+ elif duration.seconds > 60:
+ remaining_duration = f'{-(duration.seconds // -60)} minutes'
+ else:
+ remaining_duration = f'{duration.seconds} seconds'
+
+ return f'{full_user} โฑ๏ธ Please wait another {remaining_duration} before rolling again.'
+
+ 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 {stars} **{character_name}**\n\
+ She's all yours now~ ๐โจ",[file_id]])
+
def is_float(val):
+ '''Returns true if `val` can be converted to a float'''
try:
float(val)
return True
except ValueError:
return False
+def do_create(full_user, arguments, note_obj):
+ '''Creates a character'''
+ # 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 f'{full_user}{full_user} You need an image to create a character, dumbass.'
+
+ if len(arguments) != 3:
+ return '{full_user}Please specify the following attributes in order: \
+ name, rarity, drop weighting'
+
+ if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5):
+ return f'{full_user}Invalid rarity: \'{arguments[1]}\' must be a number between 1 and 5'
+
+ if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0):
+ return f'{full_user}Invalid drop weight: \'{arguments[2]}\' \
+ must be a decimal value between 0.0 and 1.0'
+
+ character_id, file_id = add_character(
+ name=arguments[0],
+ rarity=int(arguments[1]),
+ weight=float(arguments[2]),
+ image_url=image_url
+ )
+ return([f'{full_user}Added {arguments[0]}, ID {character_id}.',[file_id]])
+
+
+def do_help(full_user):
+ '''Provides a list of commands that the bot can do.'''
+ return f'{full_user} Here\'s what I can do:\n \
+ - `roll` Pulls a random character.\
+ - `create ` Creates a character using a given image.\
+ - `help` Shows this message'
+
+def do_invalid_command(command, full_user):
+ '''Generic response when an unknown or invalid command is sent'''
+ return f'{full_user} Unrecognised command: {command}\n\
+ Message \'help\' to get a list of valid commands'
def generate_response(parsed_command):
-
'''Given a command with arguments, processes the game state and
returns a response'''
command, full_user, arguments, note_obj = parsed_command
-
- if command == "roll":
- user_id = get_or_create_user(full_user)
- character_id, character_name, file_id, rarity = get_character()
-
- if not character_id:
- #TODO: Can't have tuples of a single element
- # Return these as a dict or object instead.
- 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 {stars} **{character_name}**\nShe'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."
-
- if len(arguments) != 3:
- return "Please specify the following attributes in order: name, rarity, drop weighting"
-
- if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5):
- return f"Invalid rarity: '{arguments[1]}' must be a number between 1 and 5"
-
- if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0):
- return f"Invalid drop weight: '{arguments[2]}' must be a decimal value between 0.0 and 1.0"
-
- 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]])
- return None
+ match command:
+ case 'roll':
+ return do_roll(full_user)
+ case 'create':
+ return do_create(full_user, arguments, note_obj)
+ case 'help':
+ return do_help(command)
+ case _:
+ return do_invalid_command(command, full_user)
diff --git a/example_config.ini b/example_config.ini
index 44cc2f9..d7f1c14 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -1,23 +1,27 @@
; Rename me to config.ini and put your values in here
[application]
-; Full fedi handle of the bot user
-BotUser = @bot@example.tld
-
-; API key for the bot
-; Generate one by going to Settings > API > Generate access token
-ApiKey = abcdefghijklmnopqrstuvwxyz012345
-
-; Fully qualified URL of the instance hosting the bot
-InstanceUrl = http://example.tld
-
; Comma separated list of fedi handles for any administrator users
-DefaultAdmins = ['admin@example.tld']
-
+; More can be added through the application
+DefaultAdmins = ['admin@example.tld']
; SQLite Database location
DatabaseLocation = ./gacha_game.db
-; Number of seconds to sleep while awaiting new notifications
-NotificationPollInterval = 5
+[gacha]
+; Number of seconds players have to wait between rolls
+RollInterval = 72000
+
+[notification]
+; Number of seconds to sleep while awaiting new notifications
+PollInterval = 5
+; Number of notifications to process at once (max 100)
+BatchSize = 10
+
+[credentials]
+; Fully qualified URL of the instance hosting the bot
+Instance = http://example.tld
+; Full fedi handle of the bot user
+User = @bot@example.tld
+; API key for the bot
+; Generate one by going to Settings > API > Generate access token
+Token = abcdefghijklmnopqrstuvwxyz012345
-; Number of notifications to process at once (limit 100)
-NotificationBatchSize = 10
From 4a7c9239fcc74fcf57327e6eb865d5d5bea1aab6 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 24 May 2025 22:43:53 +0100
Subject: [PATCH 07/43] Remove 'Invalid command response
---
bot/response.py | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/bot/response.py b/bot/response.py
index b009344..55100ad 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -94,7 +94,6 @@ def do_create(full_user, arguments, note_obj):
)
return([f'{full_user}Added {arguments[0]}, ID {character_id}.',[file_id]])
-
def do_help(full_user):
'''Provides a list of commands that the bot can do.'''
return f'{full_user} Here\'s what I can do:\n \
@@ -102,11 +101,6 @@ def do_help(full_user):
- `create ` Creates a character using a given image.\
- `help` Shows this message'
-def do_invalid_command(command, full_user):
- '''Generic response when an unknown or invalid command is sent'''
- return f'{full_user} Unrecognised command: {command}\n\
- Message \'help\' to get a list of valid commands'
-
def generate_response(parsed_command):
'''Given a command with arguments, processes the game state and
returns a response'''
@@ -120,4 +114,4 @@ def generate_response(parsed_command):
case 'help':
return do_help(command)
case _:
- return do_invalid_command(command, full_user)
+ return None
From e8774cb8bdc6c81baa0bb6fde1c9751fd74c880d Mon Sep 17 00:00:00 2001
From: chris
Date: Sat, 24 May 2025 21:15:16 -0500
Subject: [PATCH 08/43] initialize last_seen_notif_id in db.py
---
db.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/db.py b/db.py
index 63d0b43..ffcfe2e 100644
--- a/db.py
+++ b/db.py
@@ -41,6 +41,9 @@ cursor.execute("""
)
""")
+# Initialize essential config key
+cursor.execute('INSERT INTO config VALUES ("last_seen_notif_id", 0)')
+
""" # Insert example characters into the database if they don't already exist
characters = [
('Murakami-san', 1, 0.35),
From bdd2f20b842ccea9de368bddae849a5a8e28194b Mon Sep 17 00:00:00 2001
From: VD15
Date: Mon, 26 May 2025 13:11:48 +0100
Subject: [PATCH 09/43] Implement connection pooling
---
bot/add_character.py | 65 +++++++++++++++--------------------
bot/bot_app.py | 4 +++
bot/db_utils.py | 81 +++++++++++++++++++++++++-------------------
bot/response.py | 23 ++-----------
4 files changed, 80 insertions(+), 93 deletions(-)
diff --git a/bot/add_character.py b/bot/add_character.py
index aae3fb3..e17a4c4 100644
--- a/bot/add_character.py
+++ b/bot/add_character.py
@@ -1,11 +1,12 @@
import requests
from misskey.exceptions import MisskeyAPIException
from client import client_connection
-from db_utils import get_db_connection
+from db_utils import insert_character
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.
+ Adds a character to the database, uploading the image from a public URL to
+ the bot's Misskey Drive.
Args:
name (str): Character name.
@@ -20,41 +21,29 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl
ValueError: If inputs are invalid.
RuntimeError: If image download/upload or database operation fails.
"""
+ # 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, timeout=30)
+ if response.status_code != 200:
+ raise RuntimeError(f"Failed to download image from {image_url}")
+
+ # Upload to bot's Drive
+ mk = client_connection()
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.")
+ 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
- # Download image
- response = requests.get(image_url, stream=True, timeout=30)
- 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
- finally:
- if 'conn' in locals():
- conn.close()
+ # Insert into database
+ character_id = insert_character(name.strip(), rarity, float(weight), file_id)
+ return character_id, file_id
diff --git a/bot/bot_app.py b/bot/bot_app.py
index b65ef3a..825695e 100644
--- a/bot/bot_app.py
+++ b/bot/bot_app.py
@@ -1,6 +1,7 @@
import time
import misskey as misskey
from client import client_connection
+import db_utils as db
from config import NOTIFICATION_POLL_INTERVAL
from notification import process_notifications
@@ -8,6 +9,9 @@ from notification import process_notifications
if __name__ == '__main__':
# Initialize the Misskey client
client = client_connection()
+ # Connect to DB
+ db.connect()
+
print('Listening for notifications...')
while True:
if not process_notifications(client):
diff --git a/bot/db_utils.py b/bot/db_utils.py
index a521ec5..daff8c5 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -1,68 +1,79 @@
+from random import choices
import sqlite3
import config
DB_PATH = config.DB_PATH
+CONNECTION: sqlite3.Connection
+CURSOR: sqlite3.Cursor
-def get_db_connection():
+def connect() -> None:
'''Creates a connection to the database'''
- conn = sqlite3.connect(DB_PATH)
- conn.row_factory = sqlite3.Row
- return conn
+ print('Connecting to the database...')
+ global CONNECTION
+ global CURSOR
+ CONNECTION = sqlite3.connect(DB_PATH, autocommit=True)
+ CONNECTION.row_factory = sqlite3.Row
+ CURSOR = CONNECTION.cursor()
+
+def get_random_character():
+ ''' Gets a random character from the database'''
+ CURSOR.execute('SELECT * FROM characters')
+ characters = CURSOR.fetchall()
+
+ if not characters:
+ return None, None, None, None
+
+ weights = [c['weight'] for c in characters]
+ chosen = choices(characters, weights=weights, k=1)[0]
+
+ return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity']
def get_or_create_user(username):
'''Retrieves an ID for a given user, if the user does not exist, it will be
created.'''
- conn = get_db_connection()
- conn.row_factory = sqlite3.Row
- cur = conn.cursor()
- cur.execute('SELECT id FROM users WHERE username = ?', (username,))
- user = cur.fetchone()
+ CURSOR.execute('SELECT id FROM users WHERE username = ?', (username,))
+ user = CURSOR.fetchone()
if user:
- conn.close()
return user[0]
# New user starts with has_rolled = False
- cur.execute(
+ CURSOR.execute(
'INSERT INTO users (username, has_rolled) VALUES (?, ?)',
(username, False)
)
- conn.commit()
- user_id = cur.lastrowid
- conn.close()
+ user_id = CURSOR.lastrowid
return user_id
-def add_pull(user_id, character_id):
+def insert_character(name: str, rarity: int, weight: float, file_id: str) -> int:
+ '''Inserts a character'''
+ CURSOR.execute(
+ 'INSERT INTO characters (name, rarity, weight, file_id) VALUES (?, ?, ?, ?)',
+ (name, rarity, weight, file_id)
+ )
+ character_id = CURSOR.lastrowid
+ return character_id if character_id else 0
+
+def insert_pull(user_id, character_id):
'''Creates a pull in the database'''
- 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()
+ CURSOR.execute(
+ 'INSERT INTO pulls (user_id, character_id) VALUES (?, ?)',
+ (user_id, character_id)
+ )
def get_last_rolled_at(user_id):
'''Gets the timestamp when the user last rolled'''
- conn = get_db_connection()
- cur = conn.cursor()
- cur.execute("SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp DESC", \
+ CURSOR.execute("SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp DESC", \
(user_id,))
- row = cur.fetchone()
- conn.close()
+ row = CURSOR.fetchone()
return row[0] if row else None
def get_config(key):
'''Reads the value for a specified config key from the db'''
- conn = get_db_connection()
- cur = conn.cursor()
- cur.execute("SELECT value FROM config WHERE key = ?", (key,))
- row = cur.fetchone()
- conn.close()
+ CURSOR.execute("SELECT value FROM config WHERE key = ?", (key,))
+ row = CURSOR.fetchone()
return row[0] if row else None
def set_config(key, value):
'''Writes the value for a specified config key to the db'''
- conn = get_db_connection()
- cur = conn.cursor()
- cur.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value))
- conn.commit()
- conn.close()
+ CURSOR.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value))
diff --git a/bot/response.py b/bot/response.py
index 55100ad..ea0abe5 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,25 +1,8 @@
-import random
from datetime import datetime, timedelta, timezone
-from db_utils import get_or_create_user, add_pull, get_db_connection, get_last_rolled_at
+from db_utils import get_or_create_user, insert_pull, get_last_rolled_at, get_random_character
from add_character import add_character
from config import GACHA_ROLL_INTERVAL
-def get_character():
- ''' Gets a random character from the database'''
- conn = get_db_connection()
- cur = conn.cursor()
- cur.execute('SELECT * FROM characters')
- characters = cur.fetchall()
- conn.close()
-
- if not characters:
- return None, None, 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 do_roll(full_user):
'''Determines whether the user can roll, then pulls a random character'''
user_id = get_or_create_user(full_user)
@@ -50,12 +33,12 @@ def do_roll(full_user):
return f'{full_user} โฑ๏ธ Please wait another {remaining_duration} before rolling again.'
- character_id, character_name, file_id, rarity = get_character()
+ character_id, character_name, file_id, rarity = get_random_character()
if not character_id:
return f'{full_user} Uwaaa... something went wrong! No characters found. ๐ฟ'
- add_pull(user_id,character_id)
+ insert_pull(user_id,character_id)
stars = 'โญ๏ธ' * rarity
return([f"@{full_user} ๐ฒ Congrats! You rolled {stars} **{character_name}**\n\
She's all yours now~ ๐โจ",[file_id]])
From 25a72b30025863c1f9ce61055a971c066d3a7334 Mon Sep 17 00:00:00 2001
From: VD15
Date: Mon, 26 May 2025 20:49:21 +0100
Subject: [PATCH 10/43] Add database migration system
---
db.py | 64 ---------------------
migrations/0000_setup.sql | 28 +++++++++
migrations/0001_fix_notif_id.sql | 1 +
setup_db.py | 99 ++++++++++++++++++++++++++++++++
4 files changed, 128 insertions(+), 64 deletions(-)
delete mode 100644 db.py
create mode 100644 migrations/0000_setup.sql
create mode 100644 migrations/0001_fix_notif_id.sql
create mode 100644 setup_db.py
diff --git a/db.py b/db.py
deleted file mode 100644
index ffcfe2e..0000000
--- a/db.py
+++ /dev/null
@@ -1,64 +0,0 @@
-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
- )
- """)
-
-# Initialize essential config key
-cursor.execute('INSERT INTO config VALUES ("last_seen_notif_id", 0)')
-
-""" # 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/migrations/0000_setup.sql b/migrations/0000_setup.sql
new file mode 100644
index 0000000..4dcbc64
--- /dev/null
+++ b/migrations/0000_setup.sql
@@ -0,0 +1,28 @@
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ has_rolled BOOLEAN NOT NULL DEFAULT 0
+);
+
+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
+);
+
+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)
+);
+
+CREATE TABLE IF NOT EXISTS config (
+ key TEXT PRIMARY KEY,
+ value TEXT
+);
+INSERT OR IGNORE INTO config VALUES ("schema_version", 0);
diff --git a/migrations/0001_fix_notif_id.sql b/migrations/0001_fix_notif_id.sql
new file mode 100644
index 0000000..3494d6e
--- /dev/null
+++ b/migrations/0001_fix_notif_id.sql
@@ -0,0 +1 @@
+INSERT OR IGNORE INTO config VALUES ("last_seen_notif_id", 0);
diff --git a/setup_db.py b/setup_db.py
new file mode 100644
index 0000000..e247386
--- /dev/null
+++ b/setup_db.py
@@ -0,0 +1,99 @@
+import sqlite3
+import os
+import argparse
+from configparser import ConfigParser
+from typing import List, Tuple
+
+class DBNotFoundError(Exception):
+ pass
+
+class InvalidMigrationError(Exception):
+ pass
+
+def get_migrations() -> List[Tuple[int, str]] | InvalidMigrationError:
+ '''Returns a list of migration files in numeric order.'''
+ # Store transaction id and filename separately
+ sql_files: List[Tuple[int, str]] = []
+ migrations_dir = 'migrations'
+
+ for filename in os.listdir(migrations_dir):
+ joined_path = os.path.join(migrations_dir, filename)
+
+ # Ignore anything that isn't a .sql file
+ if not (os.path.isfile(joined_path) and filename.endswith('.sql')):
+ print(f'{filename} is not a .sql file, ignoring...')
+ continue
+
+ parts = filename.split('_', 1)
+
+ # Invalid filename format
+ if len(parts) < 2 or not parts[0].isdigit():
+ raise InvalidMigrationError(f'Invalid migration file: {filename}')
+
+ sql_files.append((int(parts[0]), joined_path))
+
+ # Get sorted list of files by migration number
+ sql_files.sort(key=lambda x: x[0])
+ return sql_files
+
+def perform_migration(cursor: sqlite3.Cursor, migration: tuple[int, str]) -> None:
+ '''Performs a migration on the DB'''
+ print(f'Performing migration {migration[1]}...')
+
+ # Open and execute the sql script
+ with open(migration[1], encoding='utf-8') as file:
+ script = file.read()
+ cursor.executescript(script)
+ # Update the schema version
+ cursor.execute('UPDATE config SET value = ? WHERE key = "schema_version"', (migration[0],))
+
+def get_db_path() -> str | DBNotFoundError:
+ '''Gets the DB path from config.ini'''
+ config = ConfigParser()
+ config.read('config.ini')
+ db_path = config['application']['DatabaseLocation']
+ if not db_path:
+ raise DBNotFoundError
+ return db_path
+
+def get_current_migration(cursor: sqlite3.Cursor) -> int:
+ '''Gets the current schema version of the database'''
+ try:
+ cursor.execute('SELECT value FROM config WHERE key = ?', ('schema_version',))
+ version = cursor.fetchone()
+ return -1 if not version else int(version[0])
+ except sqlite3.Error:
+ print('Error getting schema version')
+ # Database has not been initialized yet
+ return -1
+
+def main():
+ '''Does the thing'''
+ # Connect to the DB
+ db_path = get_db_path()
+ conn = sqlite3.connect(db_path, autocommit=False)
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ # Obtain list of migrations to run
+ migrations = get_migrations()
+ # Determine schema version
+ current_migration = get_current_migration(cursor)
+ print(f'Current schema version: {current_migration}')
+
+ # Run any migrations newer than current schema
+ for migration in migrations:
+ if migration[0] <= current_migration:
+ print(f'Migration already up: {migration[1]}')
+ continue
+ try:
+ perform_migration(cursor, migration)
+ conn.commit()
+ except Exception as ex:
+ print(f'An error occurred while applying migration: {ex}, aborting...')
+ conn.rollback()
+ break
+ conn.close()
+
+if __name__ == '__main__':
+ main()
From 6912758a440d921587b0b542fffc25b058780f99 Mon Sep 17 00:00:00 2001
From: VD15
Date: Mon, 26 May 2025 20:53:04 +0100
Subject: [PATCH 11/43] Update exceptions
---
setup_db.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/setup_db.py b/setup_db.py
index e247386..075c3cf 100644
--- a/setup_db.py
+++ b/setup_db.py
@@ -1,14 +1,15 @@
import sqlite3
+import traceback
import os
import argparse
from configparser import ConfigParser
from typing import List, Tuple
class DBNotFoundError(Exception):
- pass
+ '''Could not find the database location'''
class InvalidMigrationError(Exception):
- pass
+ '''Migration file has an invalid name'''
def get_migrations() -> List[Tuple[int, str]] | InvalidMigrationError:
'''Returns a list of migration files in numeric order.'''
@@ -91,6 +92,7 @@ def main():
conn.commit()
except Exception as ex:
print(f'An error occurred while applying migration: {ex}, aborting...')
+ print(traceback.format_exc())
conn.rollback()
break
conn.close()
From f3a32f1176de69e2f69cd43aec2036643a593cf1 Mon Sep 17 00:00:00 2001
From: VD15
Date: Mon, 26 May 2025 20:54:52 +0100
Subject: [PATCH 12/43] Update exceptions again
---
setup_db.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup_db.py b/setup_db.py
index 075c3cf..fbb264e 100644
--- a/setup_db.py
+++ b/setup_db.py
@@ -91,7 +91,7 @@ def main():
perform_migration(cursor, migration)
conn.commit()
except Exception as ex:
- print(f'An error occurred while applying migration: {ex}, aborting...')
+ print(f'An error occurred while applying {migration[1]}: {ex}, aborting...')
print(traceback.format_exc())
conn.rollback()
break
From 37ac7dbb0cfd2077af42b353584465607d6f307d Mon Sep 17 00:00:00 2001
From: VD15
Date: Thu, 29 May 2025 13:27:56 +0100
Subject: [PATCH 13/43] Add multi-env support
---
.gitignore | 5 +--
bot/config.py | 21 ++++++++++-
readme.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++----
setup_db.py | 34 ++++++++++++++++--
4 files changed, 147 insertions(+), 12 deletions(-)
diff --git a/.gitignore b/.gitignore
index 960a84d..5b5cba0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -181,5 +181,6 @@ cython_debug/
.cursorindexingignore
# Custom stuff
-gacha_game.db
-config.ini
+gacha_game*.db
+gacha_game*.db.*
+config*.ini
diff --git a/bot/config.py b/bot/config.py
index 643aeb1..16b2f1f 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -1,7 +1,26 @@
'''Essentials for the bot to function'''
import configparser
+from os import environ, path
+
+class ConfigError(Exception):
+ '''Could not find config file'''
+
+def get_config_file() -> str:
+ '''Gets the path to the config file in the current environment'''
+ env: str | None = environ.get('KEMOVERSE_ENV')
+ if not env:
+ raise ConfigError('Error: KEMOVERSE_ENV is unset')
+ if not (env in ['prod', 'dev']):
+ raise ConfigError(f'Error: Invalid environment: {env}')
+
+ config_path: str = f'config_{env}.ini'
+
+ if not path.isfile(config_path):
+ raise ConfigError(f'Could not find {config_path}')
+ return config_path
+
config = configparser.ConfigParser()
-config.read('config.ini')
+config.read(get_config_file())
# Username for the bot
USER = config['credentials']['User']
diff --git a/readme.md b/readme.md
index cf4470c..68a5985 100644
--- a/readme.md
+++ b/readme.md
@@ -1,6 +1,10 @@
# Kemoverse
A gacha-style bot for the Fediverse built with Python. Users can roll for characters, trade, duel, and perhaps engage with popularity-based mechanics. Currently designed for use with Misskey. Name comes from Kemonomimi and Fediverse.
+=======
+## Installation
+
+## Roadmap

@@ -11,18 +15,23 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
- ๐ด Cards stats system
- ๐ง Core database structure for characters and stats
- ๐ฆ Basic support for storing pulls per user
+- โฑ๏ธ Time-based limitations on rolls
### ๐งฉ In Progress
- ๐ Whitelist system to limit access
-- โฑ๏ธ Time-based limitations on rolls
-- โ๏ธ Dueling system
+- โ ๏ธ Explicit account creation/deletion
-## ๐ง Planned Features (Long Term)
+## ๐ง Roadmap
+
+[See our v2.0 board for more details](https://git.waifuism.life/waifu/kemoverse/projects/3)
### ๐ Gameplay & Collection
- ๐ **Trading system** between users
- โญ **Favorite characters** (pin them or set profiles)
- ๐ข **Public post announcements** for rare card pulls
+- ๐ **Stats** for cards
+- ๐ฎ **Games** to play
+ - โ๏ธ Dueling
- ๐งฎ **Leaderboards**
- Most traded Characters
- Most owned Characters
@@ -39,7 +48,7 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
## ๐๏ธ Tech Stack
-- Python (3.11+)
+- Python (3.12+)
- SQLite
- Fediverse API integration (via Misskey endpoints)
- Flask
@@ -49,10 +58,88 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
The bot is meant to feel *light, fun, and competitive*. Mixing social, gacha and duel tactics.
-## ๐งช Getting Started (coming soon)
+## ๐งช Installation
-Instructions on installing dependencies, initializing the database, and running the bot locally will go here.
+1. Download and install dependencies
+Clone the repo
+
+```sh
+git clone https://git.waifuism.life/waifu/kemoverse.git
+cd kemoverse
+```
+
+Setup a virtual environment (Optional, recommended)
+
+```sh
+python3 -m venv venv
+source venv/bin/activate
+```
+
+Install project dependencies via pip
+
+```sh
+python3 -m pip install -r requirements.txt
+```
+
+2. Setup config file
+
+A sample config file is included with the project as a template: `example_config.ini`
+
+Create a copy of this file and replace its' values with your own. Consult the
+template for more information about individual config values and their meaning.
+
+Config files are environment-specific. Use `config_dev.ini` for development and
+`config_prod.ini` for production. Switch between environments using the
+`KEMOVERSE_ENV` environment variable.
+
+```sh
+cp example_config.ini config_dev.ini
+# Edit config_dev.ini
+```
+
+4. Setup database
+
+To set up the database, run:
+
+```sh
+KEMOVERSE_ENV=dev python3 setup_db.py
+```
+
+5. Run the bot
+
+```sh
+KEMOVERSE_ENV=dev ./startup.sh
+```
+
+If all goes well, you should now be able to interact with the bot.
+
+6. Running in production
+
+To run the the in a production environment, use `KEMOVERSE_ENV=prod`. You will
+also need to create a `config_prod.ini` file and run the database setup step
+again if pointing prod to a different database. (you are pointing dev and prod
+to different databases, right? ๐คจ)
+
+7. Updating
+
+To update the bot, first pull new changes from upstream:
+
+```sh
+git pull
+```
+
+Then run any database migrations. We recommend testing in dev beforehand to
+make sure nothing breaks in the update process.
+
+**Always backup your prod database before running any migrations!**
+
+```sh
+# Backup database file
+cp gacha_game_dev.db gacha_game_dev.db.bak
+# Run migrations
+KEMOVERSE_ENV=dev python3 setup_db.py
+```
```mermaid
flowchart TD
diff --git a/setup_db.py b/setup_db.py
index fbb264e..241bb4e 100644
--- a/setup_db.py
+++ b/setup_db.py
@@ -11,6 +11,12 @@ class DBNotFoundError(Exception):
class InvalidMigrationError(Exception):
'''Migration file has an invalid name'''
+class KemoverseEnvUnset(Exception):
+ '''KEMOVERSE_ENV is not set or has an invalid value'''
+
+class ConfigError(Exception):
+ '''Could not find the config file for the current environment'''
+
def get_migrations() -> List[Tuple[int, str]] | InvalidMigrationError:
'''Returns a list of migration files in numeric order.'''
# Store transaction id and filename separately
@@ -50,11 +56,22 @@ def perform_migration(cursor: sqlite3.Cursor, migration: tuple[int, str]) -> Non
def get_db_path() -> str | DBNotFoundError:
'''Gets the DB path from config.ini'''
+ env = os.environ.get('KEMOVERSE_ENV')
+ if not (env and env in ['prod', 'dev']):
+ raise KemoverseEnvUnset
+
+ print(f'Running in "{env}" mode')
+
+ config_path = f'config_{env}.ini'
+
+ if not os.path.isfile(config_path):
+ raise ConfigError(f'Could not find {config_path}')
+
config = ConfigParser()
- config.read('config.ini')
+ config.read(config_path)
db_path = config['application']['DatabaseLocation']
if not db_path:
- raise DBNotFoundError
+ raise DBNotFoundError()
return db_path
def get_current_migration(cursor: sqlite3.Cursor) -> int:
@@ -71,7 +88,18 @@ def get_current_migration(cursor: sqlite3.Cursor) -> int:
def main():
'''Does the thing'''
# Connect to the DB
- db_path = get_db_path()
+ db_path = ''
+ try:
+ db_path = get_db_path()
+ except ConfigError as ex:
+ print(ex)
+ return
+ except KemoverseEnvUnset:
+ print('Error: KEMOVERSE_ENV is either not set or has an invalid value.')
+ print('Please set KEMOVERSE_ENV to either "dev" or "prod" before running.')
+ print(traceback.format_exc())
+ return
+
conn = sqlite3.connect(db_path, autocommit=False)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
From 8fb91f77543b8df97db655e2ad1642040c1cec0f Mon Sep 17 00:00:00 2001
From: VD15
Date: Thu, 29 May 2025 13:32:26 +0100
Subject: [PATCH 14/43] Fix spacing in readme
---
readme.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/readme.md b/readme.md
index 68a5985..f7b9a38 100644
--- a/readme.md
+++ b/readme.md
@@ -1,7 +1,9 @@
# Kemoverse
A gacha-style bot for the Fediverse built with Python. Users can roll for characters, trade, duel, and perhaps engage with popularity-based mechanics. Currently designed for use with Misskey. Name comes from Kemonomimi and Fediverse.
+
=======
+
## Installation
## Roadmap
From 4f853df32c61eeb008eab061bde5d5948e6c1db8 Mon Sep 17 00:00:00 2001
From: VD15
Date: Thu, 29 May 2025 13:32:48 +0100
Subject: [PATCH 15/43] Fix spacing in readme again
---
readme.md | 2 --
1 file changed, 2 deletions(-)
diff --git a/readme.md b/readme.md
index f7b9a38..3b04aa7 100644
--- a/readme.md
+++ b/readme.md
@@ -2,8 +2,6 @@
A gacha-style bot for the Fediverse built with Python. Users can roll for characters, trade, duel, and perhaps engage with popularity-based mechanics. Currently designed for use with Misskey. Name comes from Kemonomimi and Fediverse.
-=======
-
## Installation
## Roadmap
From 846130771e780829e8431858584dd9abe6cb040a Mon Sep 17 00:00:00 2001
From: VD15
Date: Thu, 29 May 2025 13:34:39 +0100
Subject: [PATCH 16/43] Update vocabulary
---
readme.md | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/readme.md b/readme.md
index 3b04aa7..3f2cbc0 100644
--- a/readme.md
+++ b/readme.md
@@ -12,9 +12,8 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
### โ Implemented
- ๐ฒ Character roll system
-- ๐ด Cards stats system
-- ๐ง Core database structure for characters and stats
-- ๐ฆ Basic support for storing pulls per user
+- ๐ง Core database structure for cards
+- ๐ฆ Basic support for storing pulls per player
- โฑ๏ธ Time-based limitations on rolls
### ๐งฉ In Progress
@@ -26,18 +25,18 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
[See our v2.0 board for more details](https://git.waifuism.life/waifu/kemoverse/projects/3)
### ๐ Gameplay & Collection
-- ๐ **Trading system** between users
+- ๐ **Trading system** between players
- โญ **Favorite characters** (pin them or set profiles)
- ๐ข **Public post announcements** for rare card pulls
- ๐ **Stats** for cards
- ๐ฎ **Games** to play
- โ๏ธ Dueling
- ๐งฎ **Leaderboards**
- - Most traded Characters
- - Most owned Characters
- - Most voted Characters
- - Most popular Characters (via usage-based popularity metrics)
- - Users with the rarest Characters
+ - Most traded cards
+ - Most owned cards
+ - Most voted cards
+ - Most popular cards (via usage-based popularity metrics)
+ - Users with the rarest cards
### ๐จ Card Aesthetics
- ๐ผ๏ธ Simple card template for character rendering
From de7670204adb8594cd8df2e9c576bfe9c0a22140 Mon Sep 17 00:00:00 2001
From: VD15
Date: Thu, 29 May 2025 13:36:30 +0100
Subject: [PATCH 17/43] Replace numbered list with h3
---
readme.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/readme.md b/readme.md
index 3f2cbc0..40d723d 100644
--- a/readme.md
+++ b/readme.md
@@ -59,7 +59,7 @@ The bot is meant to feel *light, fun, and competitive*. Mixing social, gacha and
## ๐งช Installation
-1. Download and install dependencies
+### Download and install dependencies
Clone the repo
@@ -81,7 +81,7 @@ Install project dependencies via pip
python3 -m pip install -r requirements.txt
```
-2. Setup config file
+### Setup config file
A sample config file is included with the project as a template: `example_config.ini`
@@ -97,7 +97,7 @@ cp example_config.ini config_dev.ini
# Edit config_dev.ini
```
-4. Setup database
+### Setup database
To set up the database, run:
@@ -105,7 +105,7 @@ To set up the database, run:
KEMOVERSE_ENV=dev python3 setup_db.py
```
-5. Run the bot
+### Run the bot
```sh
KEMOVERSE_ENV=dev ./startup.sh
@@ -113,14 +113,14 @@ KEMOVERSE_ENV=dev ./startup.sh
If all goes well, you should now be able to interact with the bot.
-6. Running in production
+### Running in production
To run the the in a production environment, use `KEMOVERSE_ENV=prod`. You will
also need to create a `config_prod.ini` file and run the database setup step
again if pointing prod to a different database. (you are pointing dev and prod
to different databases, right? ๐คจ)
-7. Updating
+### Updating
To update the bot, first pull new changes from upstream:
From eaa630890635e1a9770b2712ce2e610dcc48b776 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 31 May 2025 22:54:04 +0100
Subject: [PATCH 18/43] Fix case sensitivity for bot username
---
bot/config.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/config.py b/bot/config.py
index 16b2f1f..89cffac 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -23,11 +23,11 @@ config = configparser.ConfigParser()
config.read(get_config_file())
# Username for the bot
-USER = config['credentials']['User']
+USER = config['credentials']['User'].lower()
# API key for the bot
KEY = config['credentials']['Token']
# Bot's Misskey instance URL
-INSTANCE = config['credentials']['Instance']
+INSTANCE = config['credentials']['Instance'].lower()
# TODO: move this to db
# Fedi handles in the traditional 'user@domain.tld' style, allows these users
From 7b9a5cf428114d6a76582e16e445073866e25c7b Mon Sep 17 00:00:00 2001
From: VD15
Date: Sun, 1 Jun 2025 12:24:53 +0100
Subject: [PATCH 19/43] Reduce reliance on tuples
---
.gitignore | 1 +
bot/add_character.py | 46 ++++++++-----
bot/client.py | 3 +-
bot/config.py | 13 ++--
bot/custom_types.py | 21 ++++++
bot/db_utils.py | 53 ++++++++++-----
bot/notification.py | 55 +++++++++-------
bot/parsing.py | 27 +++++---
bot/response.py | 149 +++++++++++++++++++++++++++++++++----------
9 files changed, 263 insertions(+), 105 deletions(-)
create mode 100644 bot/custom_types.py
diff --git a/.gitignore b/.gitignore
index 5b5cba0..11530b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,6 +147,7 @@ venv.bak/
/site
# mypy
+.mypy.ini
.mypy_cache/
.dmypy.json
dmypy.json
diff --git a/bot/add_character.py b/bot/add_character.py
index e17a4c4..e8d078b 100644
--- a/bot/add_character.py
+++ b/bot/add_character.py
@@ -2,9 +2,15 @@ import requests
from misskey.exceptions import MisskeyAPIException
from client import client_connection
from db_utils import insert_character
+from custom_types import Character
-def add_character(name: str, rarity: int, weight: float, image_url: str) -> tuple[int, str]:
- """
+
+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.
@@ -12,7 +18,8 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl
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']).
+ 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.
@@ -20,30 +27,39 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl
Raises:
ValueError: If inputs are invalid.
RuntimeError: If image download/upload or database operation fails.
- """
+ '''
+
+ stripped_name = name.strip()
+
# 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 stripped_name:
+ raise ValueError('Character name cannot be empty.')
+ if rarity < 1:
+ raise ValueError('Rarity must be a positive integer.')
+ if weight <= 0:
+ raise ValueError('Weight must be a positive number.')
if not image_url:
- raise ValueError("Image URL must be provided.")
+ raise ValueError('Image URL must be provided.')
# Download image
response = requests.get(image_url, stream=True, timeout=30)
if response.status_code != 200:
- raise RuntimeError(f"Failed to download image from {image_url}")
+ 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"]
+ file_id = media['id']
except MisskeyAPIException as e:
- raise RuntimeError(f"Failed to upload image to bot's Drive: {e}") from e
+ raise RuntimeError(f'Failed to upload image to bot\'s Drive: {e}')\
+ from e
# Insert into database
- character_id = insert_character(name.strip(), rarity, float(weight), file_id)
+ character_id = insert_character(
+ stripped_name,
+ rarity,
+ float(weight),
+ file_id
+ )
return character_id, file_id
diff --git a/bot/client.py b/bot/client.py
index 2413226..57f9f4e 100644
--- a/bot/client.py
+++ b/bot/client.py
@@ -1,5 +1,6 @@
import misskey
import config
-def client_connection():
+
+def client_connection() -> misskey.Misskey:
return misskey.Misskey(address=config.INSTANCE, i=config.KEY)
diff --git a/bot/config.py b/bot/config.py
index 89cffac..3621103 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -2,9 +2,11 @@
import configparser
from os import environ, path
+
class ConfigError(Exception):
'''Could not find config file'''
+
def get_config_file() -> str:
'''Gets the path to the config file in the current environment'''
env: str | None = environ.get('KEMOVERSE_ENV')
@@ -19,24 +21,25 @@ def get_config_file() -> str:
raise ConfigError(f'Could not find {config_path}')
return config_path
+
config = configparser.ConfigParser()
config.read(get_config_file())
# Username for the bot
-USER = config['credentials']['User'].lower()
+USER = config['credentials']['User'].lower()
# API key for the bot
-KEY = config['credentials']['Token']
+KEY = config['credentials']['Token']
# Bot's Misskey instance URL
INSTANCE = config['credentials']['Instance'].lower()
# TODO: move this to db
# Fedi handles in the traditional 'user@domain.tld' style, allows these users
# to use extra admin exclusive commands with the bot
-ADMINS = config['application']['DefaultAdmins']
+ADMINS = config['application']['DefaultAdmins']
# SQLite Database location
-DB_PATH = config['application']['DatabaseLocation']
+DB_PATH = config['application']['DatabaseLocation']
NOTIFICATION_POLL_INTERVAL = int(config['notification']['PollInterval'])
-NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
+NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
GACHA_ROLL_INTERVAL = int(config['gacha']['RollInterval'])
diff --git a/bot/custom_types.py b/bot/custom_types.py
new file mode 100644
index 0000000..0c23cb6
--- /dev/null
+++ b/bot/custom_types.py
@@ -0,0 +1,21 @@
+from typing import TypedDict, List, Dict, Any
+
+BotResponse = TypedDict('BotResponse', {
+ 'message': str,
+ 'attachment_urls': List[str] | None
+})
+
+Character = TypedDict('Character', {
+ 'id': int,
+ 'name': str,
+ 'rarity': int,
+ 'weight': float,
+ 'image_url': str
+})
+
+ParsedNotification = TypedDict('ParsedNotification', {
+ 'author': str,
+ 'command': str | None,
+ 'arguments': List[str],
+ 'note_obj': Dict[str, Any]
+})
diff --git a/bot/db_utils.py b/bot/db_utils.py
index daff8c5..6f8ae28 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -1,11 +1,13 @@
from random import choices
import sqlite3
import config
+from custom_types import Character
DB_PATH = config.DB_PATH
CONNECTION: sqlite3.Connection
CURSOR: sqlite3.Cursor
+
def connect() -> None:
'''Creates a connection to the database'''
print('Connecting to the database...')
@@ -15,26 +17,34 @@ def connect() -> None:
CONNECTION.row_factory = sqlite3.Row
CURSOR = CONNECTION.cursor()
-def get_random_character():
+
+def get_random_character() -> Character | None:
''' Gets a random character from the database'''
CURSOR.execute('SELECT * FROM characters')
characters = CURSOR.fetchall()
if not characters:
- return None, None, None, None
+ return None
weights = [c['weight'] for c in characters]
chosen = choices(characters, weights=weights, k=1)[0]
- return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity']
+ return {
+ 'id': chosen['id'],
+ 'name': chosen['name'],
+ 'rarity': chosen['rarity'],
+ 'weight': chosen['weight'],
+ 'image_url': chosen['file_id']
+ }
-def get_or_create_user(username):
+
+def get_or_create_user(username: str) -> int:
'''Retrieves an ID for a given user, if the user does not exist, it will be
created.'''
CURSOR.execute('SELECT id FROM users WHERE username = ?', (username,))
user = CURSOR.fetchone()
if user:
- return user[0]
+ return int(user[0])
# New user starts with has_rolled = False
CURSOR.execute(
@@ -42,38 +52,47 @@ def get_or_create_user(username):
(username, False)
)
user_id = CURSOR.lastrowid
- return user_id
+ return user_id if user_id else 0
-def insert_character(name: str, rarity: int, weight: float, file_id: str) -> int:
+
+def insert_character(
+ name: str, rarity: int, weight: float, file_id: str) -> int:
'''Inserts a character'''
CURSOR.execute(
- 'INSERT INTO characters (name, rarity, weight, file_id) VALUES (?, ?, ?, ?)',
+ 'INSERT INTO characters (name, rarity, weight, file_id) VALUES \
+(?, ?, ?, ?)',
(name, rarity, weight, file_id)
)
character_id = CURSOR.lastrowid
return character_id if character_id else 0
-def insert_pull(user_id, character_id):
+
+def insert_pull(user_id: int, character_id: int) -> None:
'''Creates a pull in the database'''
CURSOR.execute(
'INSERT INTO pulls (user_id, character_id) VALUES (?, ?)',
(user_id, character_id)
)
-def get_last_rolled_at(user_id):
+
+def get_last_rolled_at(user_id: int) -> int:
'''Gets the timestamp when the user last rolled'''
- CURSOR.execute("SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp DESC", \
- (user_id,))
+ CURSOR.execute(
+ "SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp \
+DESC",
+ (user_id,))
row = CURSOR.fetchone()
- return row[0] if row else None
+ return row[0] if row else 0
-def get_config(key):
+def get_config(key: str) -> str:
'''Reads the value for a specified config key from the db'''
CURSOR.execute("SELECT value FROM config WHERE key = ?", (key,))
row = CURSOR.fetchone()
- return row[0] if row else None
+ return row[0] if row else ''
-def set_config(key, value):
+
+def set_config(key: str, value: str) -> None:
'''Writes the value for a specified config key to the db'''
- CURSOR.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value))
+ CURSOR.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
+ (key, value))
diff --git a/bot/notification.py b/bot/notification.py
index e0ae020..9427dbf 100644
--- a/bot/notification.py
+++ b/bot/notification.py
@@ -1,16 +1,23 @@
import traceback
+from typing import Dict, Any
+
+import misskey
from misskey.exceptions import MisskeyAPIException
from config import NOTIFICATION_BATCH_SIZE
from parsing import parse_notification
from db_utils import get_config, set_config
from response import generate_response
+from custom_types import BotResponse
# Define your whitelist
# TODO: move to config
WHITELISTED_INSTANCES: list[str] = []
-def process_notification(client, notification):
+
+def process_notification(
+ client: misskey.Misskey,
+ notification: Dict[str, Any]) -> None:
'''Processes an individual notification'''
user = notification.get('user', {})
username = user.get('username', 'unknown')
@@ -32,33 +39,32 @@ def process_notification(client, notification):
print(f'๐จ <{notif_id}> [{notif_type}] from @{username}@{instance}')
# ๐ง Send to the parser
- parsed_command = parse_notification(notification, client)
+ parsed_notification = parse_notification(notification, client)
+
+ if not parsed_notification:
+ return
# Get the note Id to reply to
note_id = notification.get('note', {}).get('id')
# Get the response
- # TODO: Formalize exactly *what* is returned by this. Ideally just want to
- # handle two cases here: either we have a response, or we don't.
- # TODO: Return dictionaries instead of tuples. They handle multiple
- # elements a lot better as they're not position dependent
- response = generate_response(parsed_command)
- if isinstance(response, str):
- client.notes_create(
- text=response,
- reply_id=note_id,
- visibility=visibility
- )
- elif response:
- 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
- )
+ response: BotResponse | None = generate_response(parsed_notification)
-def process_notifications(client):
+ if not response:
+ return
+
+ client.notes_create(
+ text=response['message'],
+ reply_id=note_id,
+ visibility=visibility,
+ file_ids=response['attachment_urls']
+ # TODO: write actual visible users ids so pleromers can use the bot
+ # privately
+ # visible_user_ids=[]
+ )
+
+
+def process_notifications(client: misskey.Misskey) -> bool:
'''Processes a batch of unread notifications. Returns False if there are
no more notifications to process.'''
@@ -87,7 +93,7 @@ def process_notifications(client):
for notification in notifications:
try:
# Skip if we've processed already
- notif_id = notification.get('id')
+ notif_id = notification.get('id', '')
if notif_id <= last_seen_id:
continue
@@ -96,7 +102,8 @@ def process_notifications(client):
process_notification(client, notification)
except Exception as e:
- print(f'An exception has occured while processing a notification: {e}')
+ print(f'An exception has occured while processing a \
+notification: {e}')
print(traceback.format_exc())
# If we got as many notifications as we requested, there are probably
diff --git a/bot/parsing.py b/bot/parsing.py
index bbefe70..eece077 100644
--- a/bot/parsing.py
+++ b/bot/parsing.py
@@ -1,11 +1,16 @@
-import random, re
+import re
+from typing import Dict, Any
+
+import misskey
+
import config
+from custom_types import ParsedNotification
-def parse_notification(notification,client):
- '''Parses any notifications received by the bot and sends any commands to
- gacha_response()'''
-
+def parse_notification(
+ notification: Dict[str, Any],
+ client: misskey.Misskey) -> ParsedNotification | None:
+ '''Parses any notifications received by the bot'''
# Get the full Activitypub ID of the user
user = notification.get("user", {})
@@ -28,14 +33,20 @@ def parse_notification(notification,client):
# Make sure the notification text explicitly mentions the bot
if not any(variant in note for variant in username_variants):
- return
+ return None
# Find command and arguments after the mention
- # Removes all mentions (regex = mentions that start with @ and may contain @domain)
+ # 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()
command = parts[0].lower() if parts else None
arguments = parts[1:] if len(parts) > 1 else []
- return [command,full_user, arguments, note_obj]
+ return {
+ 'author': full_user,
+ 'command': command,
+ 'arguments': arguments,
+ 'note_obj': note_obj
+ }
diff --git a/bot/response.py b/bot/response.py
index ea0abe5..567962d 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,9 +1,13 @@
from datetime import datetime, timedelta, timezone
-from db_utils import get_or_create_user, insert_pull, get_last_rolled_at, get_random_character
+from typing import TypedDict, Any, List, Dict
+from db_utils import get_or_create_user, insert_pull, get_last_rolled_at, \
+ get_random_character
from add_character import add_character
from config import GACHA_ROLL_INTERVAL
+from custom_types import BotResponse, ParsedNotification
-def do_roll(full_user):
+
+def do_roll(full_user: str) -> BotResponse:
'''Determines whether the user can roll, then pulls a random character'''
user_id = get_or_create_user(full_user)
@@ -14,7 +18,7 @@ def do_roll(full_user):
if date:
# SQLite timestamps returned by the DB are always in UTC
# Below timestamps are to be converted to UTC
- prev = datetime.strptime(date + '+0000', '%Y-%m-%d %H:%M:%S%z')
+ prev = datetime.strptime(str(date) + '+0000', '%Y-%m-%d %H:%M:%S%z')
now = datetime.now(timezone.utc)
time_since_last_roll = now - prev
@@ -31,19 +35,31 @@ def do_roll(full_user):
else:
remaining_duration = f'{duration.seconds} seconds'
- return f'{full_user} โฑ๏ธ Please wait another {remaining_duration} before rolling again.'
+ return {
+ 'message': f'{full_user} โฑ๏ธ Please wait another \
+{remaining_duration} before rolling again.',
+ 'attachment_urls': None
+ }
- character_id, character_name, file_id, rarity = get_random_character()
+ character = get_random_character()
- if not character_id:
- return f'{full_user} Uwaaa... something went wrong! No characters found. ๐ฟ'
+ if not character:
+ return {
+ 'message': f'{full_user} Uwaaa... something went wrong! No \
+characters found. ๐ฟ',
+ 'attachment_urls': None
+ }
- insert_pull(user_id,character_id)
- stars = 'โญ๏ธ' * rarity
- return([f"@{full_user} ๐ฒ Congrats! You rolled {stars} **{character_name}**\n\
- She's all yours now~ ๐โจ",[file_id]])
+ insert_pull(user_id, character['id'])
+ stars = 'โญ๏ธ' * character['rarity']
+ return {
+ 'message': f'@{full_user} ๐ฒ Congrats! You rolled {stars} \
+**{character['name']}**\nShe\'s all yours now~ ๐โจ',
+ 'attachment_urls': [character['image_url']]
+ }
-def is_float(val):
+
+def is_float(val: Any) -> bool:
'''Returns true if `val` can be converted to a float'''
try:
float(val)
@@ -51,23 +67,42 @@ def is_float(val):
except ValueError:
return False
-def do_create(full_user, arguments, note_obj):
+
+def do_create(
+ full_user: str,
+ arguments: List[str],
+ note_obj: Dict[str, Any]) -> BotResponse:
'''Creates a character'''
# Example call from bot logic
- image_url = note_obj.get('files', [{}])[0].get('url') if note_obj.get('files') else None
+ image_url = note_obj.get('files', [{}])[0].get('url') \
+ if note_obj.get('files') else None
+
if not image_url:
- return f'{full_user}{full_user} You need an image to create a character, dumbass.'
+ return {
+ 'message': f'{full_user} You need an image to create a character, \
+dumbass.',
+ 'attachment_urls': None
+ }
if len(arguments) != 3:
- return '{full_user}Please specify the following attributes in order: \
- name, rarity, drop weighting'
+ return {
+ 'message': f'{full_user} Please specify the following attributes \
+in order: name, rarity, drop weighting',
+ 'attachment_urls': None
+ }
if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5):
- return f'{full_user}Invalid rarity: \'{arguments[1]}\' must be a number between 1 and 5'
-
+ return {
+ 'message': f'{full_user} Invalid rarity: \'{arguments[1]}\' must \
+be a number between 1 and 5',
+ 'attachment_urls': None
+ }
if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0):
- return f'{full_user}Invalid drop weight: \'{arguments[2]}\' \
- must be a decimal value between 0.0 and 1.0'
+ return {
+ 'message': f'{full_user} Invalid drop weight: \'{arguments[2]}\' \
+must be a decimal value between 0.0 and 1.0',
+ 'attachment_urls': None
+ }
character_id, file_id = add_character(
name=arguments[0],
@@ -75,26 +110,70 @@ def do_create(full_user, arguments, note_obj):
weight=float(arguments[2]),
image_url=image_url
)
- return([f'{full_user}Added {arguments[0]}, ID {character_id}.',[file_id]])
+ return {
+ 'message': f'{full_user} Added {arguments[0]}, ID {character_id}.',
+ 'attachment_urls': [file_id]
+ }
-def do_help(full_user):
+
+def do_help(author: str) -> BotResponse:
'''Provides a list of commands that the bot can do.'''
- return f'{full_user} Here\'s what I can do:\n \
- - `roll` Pulls a random character.\
- - `create ` Creates a character using a given image.\
- - `help` Shows this message'
+ return {
+ 'message': f'{author} Here\'s what I can do:\n\
+- `roll` Pulls a random character.\n\
+- `create ` Creates a character using a given image.\n\
+- `help` Shows this message.',
+ 'attachment_urls': None
+ }
-def generate_response(parsed_command):
+
+def do_signup() -> BotResponse:
+ return {
+ 'message': '',
+ 'attachment_urls': None
+ }
+
+
+def generate_response(notification: ParsedNotification) -> BotResponse | None:
'''Given a command with arguments, processes the game state and
returns a response'''
- command, full_user, arguments, note_obj = parsed_command
+ # Temporary response variable
+ res: BotResponse | None = None
+ # TODO: Check if the user has an account
+ author = notification['author']
+ user_id = get_or_create_user(author)
+ command = notification['command']
+ # Check if the user is an administrator
+ # user_is_administrator = user_is_administrator()
+
+ # Unrestricted commands
match command:
- case 'roll':
- return do_roll(full_user)
- case 'create':
- return do_create(full_user, arguments, note_obj)
+ case 'signup':
+ res = do_signup()
case 'help':
- return do_help(command)
+ res = do_help(author)
case _:
- return None
+ pass
+
+ if not user_id:
+ return res
+
+ # User commands
+ match command:
+ case 'delete_account':
+ pass
+ case 'roll':
+ res = do_roll(author)
+ case 'create':
+ res = do_create(
+ author,
+ notification['arguments'],
+ notification['note_obj']
+ )
+ case _:
+ pass
+ # if not user_is_administrator:
+ return res
+
+ # Administrator commands go here
From da2ca4cfec1039861e952b750fad01122a85dbd5 Mon Sep 17 00:00:00 2001
From: w
Date: Sun, 1 Jun 2025 18:08:51 -0300
Subject: [PATCH 20/43] Split get_or_create_user
---
bot/db_utils.py | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/bot/db_utils.py b/bot/db_utils.py
index daff8c5..6f94cc8 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -28,21 +28,19 @@ def get_random_character():
return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity']
-def get_or_create_user(username):
- '''Retrieves an ID for a given user, if the user does not exist, it will be
- created.'''
+def get_player(username):
+ '''Retrieve a player ID by username, or return None if not found.'''
CURSOR.execute('SELECT id FROM users WHERE username = ?', (username,))
user = CURSOR.fetchone()
- if user:
- return user[0]
+ return user[0] if user else None
- # New user starts with has_rolled = False
+def insert_player(username):
+ '''Insert a new player with default has_rolled = False and return their user ID.'''
CURSOR.execute(
'INSERT INTO users (username, has_rolled) VALUES (?, ?)',
(username, False)
)
- user_id = CURSOR.lastrowid
- return user_id
+ return CURSOR.lastrowid
def insert_character(name: str, rarity: int, weight: float, file_id: str) -> int:
'''Inserts a character'''
From a0ed6ded41c0ef242040d6dda40b5c220d98d15f Mon Sep 17 00:00:00 2001
From: w
Date: Sun, 1 Jun 2025 22:03:37 -0300
Subject: [PATCH 21/43] adding signup command
---
bot/response.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/bot/response.py b/bot/response.py
index ea0abe5..3d0a348 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,12 +1,14 @@
from datetime import datetime, timedelta, timezone
-from db_utils import get_or_create_user, insert_pull, get_last_rolled_at, get_random_character
+from db_utils import get_player, insert_player, insert_pull, get_last_rolled_at, get_random_character
from add_character import add_character
from config import GACHA_ROLL_INTERVAL
def do_roll(full_user):
'''Determines whether the user can roll, then pulls a random character'''
- user_id = get_or_create_user(full_user)
+ user_id = get_player(full_user)
+ if not user_id:
+ return f'@{full_user} ๐ You havenโt signed up yet! Use the `signup` command to start playing.'
# Get date of user's last roll
date = get_last_rolled_at(user_id)
@@ -43,6 +45,16 @@ def do_roll(full_user):
return([f"@{full_user} ๐ฒ Congrats! You rolled {stars} **{character_name}**\n\
She's all yours now~ ๐โจ",[file_id]])
+def do_signup(full_user):
+ '''Registers a new user if they havenโt signed up yet.'''
+ user_id = get_player(full_user)
+
+ if user_id:
+ return f'@{full_user} ๐ Youโre already signed up! Let the rolling begin~ ๐ฒ'
+
+ new_user_id = insert_player(full_user)
+ return f'@{full_user} โ Signed up successfully! Your gacha destiny begins now... โจ Use the roll command to start!'
+
def is_float(val):
'''Returns true if `val` can be converted to a float'''
try:
@@ -96,5 +108,7 @@ def generate_response(parsed_command):
return do_create(full_user, arguments, note_obj)
case 'help':
return do_help(command)
+ case 'signup':
+ return do_signup(full_user)
case _:
return None
From dce2c9072b3187c7fa4ce88fa3036a6fe07ae612 Mon Sep 17 00:00:00 2001
From: w
Date: Sun, 1 Jun 2025 23:25:18 -0300
Subject: [PATCH 22/43] account deletion
---
bot/db_utils.py | 28 ++++++++++++++++++++++++++++
bot/response.py | 24 +++++++++++++++++++++++-
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 6f94cc8..a9f3f06 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -42,6 +42,34 @@ def insert_player(username):
)
return CURSOR.lastrowid
+def delete_player(username):
+ '''Permanently deletes a user and all their pulls.'''
+ CURSOR.execute(
+ 'SELECT id FROM users WHERE username = ?',
+ (username,)
+ )
+ user = CURSOR.fetchone()
+
+ if not user:
+ return False # No such user
+
+ user_id = user[0]
+
+ # Delete pulls
+ CURSOR.execute(
+ 'DELETE FROM pulls WHERE user_id = ?',
+ (user_id,)
+ )
+
+ # Delete user
+ CURSOR.execute(
+ 'DELETE FROM users WHERE id = ?',
+ (user_id,)
+ )
+
+ return True
+
+
def insert_character(name: str, rarity: int, weight: float, file_id: str) -> int:
'''Inserts a character'''
CURSOR.execute(
diff --git a/bot/response.py b/bot/response.py
index 3d0a348..85a4073 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone
-from db_utils import get_player, insert_player, insert_pull, get_last_rolled_at, get_random_character
+from db_utils import get_player, insert_player, delete_player, insert_pull, get_last_rolled_at, get_random_character
from add_character import add_character
from config import GACHA_ROLL_INTERVAL
@@ -94,7 +94,25 @@ def do_help(full_user):
return f'{full_user} Here\'s what I can do:\n \
- `roll` Pulls a random character.\
- `create ` Creates a character using a given image.\
+ - `signup` Registers your account.\
+ - `delete_account` Deletes your account.\
- `help` Shows this message'
+
+def delete_account(full_user):
+ return (
+ f'@{full_user} โ ๏ธ This will permanently delete your account and all your cards.\n'
+ 'If youโre sure, reply with `confirm_delete` to proceed.\n\n'
+ '**There is no undo.** Your gacha luck will be lost to the void... ๐โจ'
+ )
+
+def confirm_delete(full_user):
+ success = delete_player(full_user)
+
+ if not success:
+ return f'@{full_user} โ No account found to delete. Maybe itโs already gone?'
+
+ return f'@{full_user} ๐งผ Your account and all your cards have been deleted. RIP your gacha history ๐๏ธโจ'
+
def generate_response(parsed_command):
'''Given a command with arguments, processes the game state and
@@ -110,5 +128,9 @@ def generate_response(parsed_command):
return do_help(command)
case 'signup':
return do_signup(full_user)
+ case 'delete_account':
+ return delete_account(full_user)
+ case 'confirm_delete':
+ return confirm_delete(full_user)
case _:
return None
From b7d82d9117330ba9a303d5b13d5a4d5e032d3972 Mon Sep 17 00:00:00 2001
From: w
Date: Mon, 2 Jun 2025 00:37:39 -0300
Subject: [PATCH 23/43] fix
---
bot/db_utils.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 88660d9..5597bc5 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -44,7 +44,7 @@ def get_player(username: str) -> int:
if user:
return int(user[0])
-def insert_player(username):
+def insert_player(username: str) -> int:
'''Insert a new player with default has_rolled = False and return their user ID.'''
CURSOR.execute(
'INSERT INTO users (username, has_rolled) VALUES (?, ?)',
@@ -52,7 +52,7 @@ def insert_player(username):
)
return CURSOR.lastrowid
-def delete_player(username):
+def delete_player(username: str) -> bool:
'''Permanently deletes a user and all their pulls.'''
CURSOR.execute(
'SELECT id FROM users WHERE username = ?',
From 3a09b481e520dcb3271729de626a41a0bb82edfa Mon Sep 17 00:00:00 2001
From: w
Date: Mon, 2 Jun 2025 01:03:06 -0300
Subject: [PATCH 24/43] more fixes
---
bot/response.py | 50 ++++++++++++++++++++++++-------------------------
1 file changed, 24 insertions(+), 26 deletions(-)
diff --git a/bot/response.py b/bot/response.py
index 17a12dd..6966d1e 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -7,12 +7,14 @@ from config import GACHA_ROLL_INTERVAL
from custom_types import BotResponse, ParsedNotification
-def do_roll(full_user: str) -> BotResponse:
+def do_roll(author: str) -> BotResponse:
'''Determines whether the user can roll, then pulls a random character'''
- user_id = get_player(full_user)
-
+ user_id = get_player(author)
if not user_id:
- return f'@{full_user} ๐ You havenโt signed up yet! Use the `signup` command to start playing.'
+ return {
+ 'message':f'{author} ๐ You havenโt signed up yet! Use the `signup` command to start playing.',
+ 'attachment_urls': None
+ }
# Get date of user's last roll
date = get_last_rolled_at(user_id)
@@ -38,7 +40,7 @@ def do_roll(full_user: str) -> BotResponse:
remaining_duration = f'{duration.seconds} seconds'
return {
- 'message': f'{full_user} โฑ๏ธ Please wait another \
+ 'message': f'{author} โฑ๏ธ Please wait another \
{remaining_duration} before rolling again.',
'attachment_urls': None
}
@@ -47,7 +49,7 @@ def do_roll(full_user: str) -> BotResponse:
if not character:
return {
- 'message': f'{full_user} Uwaaa... something went wrong! No \
+ 'message': f'{author} Uwaaa... something went wrong! No \
characters found. ๐ฟ',
'attachment_urls': None
}
@@ -55,7 +57,7 @@ characters found. ๐ฟ',
insert_pull(user_id, character['id'])
stars = 'โญ๏ธ' * character['rarity']
return {
- 'message': f'@{full_user} ๐ฒ Congrats! You rolled {stars} \
+ 'message': f'{author} ๐ฒ Congrats! You rolled {stars} \
**{character['name']}**\nShe\'s all yours now~ ๐โจ',
'attachment_urls': [character['image_url']]
}
@@ -66,13 +68,13 @@ def do_signup(author: str) -> BotResponse:
if user_id:
return {
- f'@{author} ๐ Youโre already signed up! Let the rolling begin~ ๐ฒ',
+ 'message':f'{author} ๐ Youโre already signed up! Let the rolling begin~ ๐ฒ',
'attachment_urls': None
}
new_user_id = insert_player(author)
return {
- 'message': f'@{author} โ Signed up successfully! Your gacha destiny begins now... โจ Use the roll command to start!',
+ 'message': f'{author} โ Signed up successfully! Your gacha destiny begins now... โจ Use the roll command to start!',
'attachment_urls': None
}
@@ -86,7 +88,7 @@ def is_float(val: Any) -> bool:
def do_create(
- full_user: str,
+ author: str,
arguments: List[str],
note_obj: Dict[str, Any]) -> BotResponse:
'''Creates a character'''
@@ -96,27 +98,27 @@ def do_create(
if not image_url:
return {
- 'message': f'{full_user} You need an image to create a character, \
+ 'message': f'{author} You need an image to create a character, \
dumbass.',
'attachment_urls': None
}
if len(arguments) != 3:
return {
- 'message': f'{full_user} Please specify the following attributes \
+ 'message': f'{author} Please specify the following attributes \
in order: name, rarity, drop weighting',
'attachment_urls': None
}
if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5):
return {
- 'message': f'{full_user} Invalid rarity: \'{arguments[1]}\' must \
+ 'message': f'{author} Invalid rarity: \'{arguments[1]}\' must \
be a number between 1 and 5',
'attachment_urls': None
}
if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0):
return {
- 'message': f'{full_user} Invalid drop weight: \'{arguments[2]}\' \
+ 'message': f'{author} Invalid drop weight: \'{arguments[2]}\' \
must be a decimal value between 0.0 and 1.0',
'attachment_urls': None
}
@@ -128,7 +130,7 @@ must be a decimal value between 0.0 and 1.0',
image_url=image_url
)
return {
- 'message': f'{full_user} Added {arguments[0]}, ID {character_id}.',
+ 'message': f'{author} Added {arguments[0]}, ID {character_id}.',
'attachment_urls': [file_id]
}
@@ -136,7 +138,7 @@ must be a decimal value between 0.0 and 1.0',
def do_help(author: str) -> BotResponse:
'''Provides a list of commands that the bot can do.'''
return {
- 'message':f'{full_user} Here\'s what I can do:\n \
+ 'message':f'{author} Here\'s what I can do:\n \
- `roll` Pulls a random character.\
- `create ` Creates a character using a given image.\
- `signup` Registers your account.\
@@ -147,7 +149,7 @@ def do_help(author: str) -> BotResponse:
def delete_account(author: str) -> BotResponse:
return {
- 'message':f'@{author} โ ๏ธ This will permanently delete your account and all your cards.\n'
+ 'message':f'{author} โ ๏ธ This will permanently delete your account and all your cards.\n'
'If youโre sure, reply with `confirm_delete` to proceed.\n\n'
'**There is no undo.** Your gacha luck will be lost to the void... ๐โจ',
'attachment_urls': None
@@ -164,7 +166,7 @@ def confirm_delete(author: str) -> BotResponse:
}
return {
- 'message':f'@{author} ๐งผ Your account and all your cards have been deleted. RIP your gacha history ๐๏ธโจ',
+ 'message':f'{author} ๐งผ Your account and all your cards have been deleted. RIP your gacha history ๐๏ธโจ',
'attachment_urls': None
}
@@ -177,7 +179,7 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
res: BotResponse | None = None
# TODO: Check if the user has an account
author = notification['author']
- user_id = get_or_create_user(author)
+ user_id = get_player(author)
command = notification['command']
# Check if the user is an administrator
# user_is_administrator = user_is_administrator()
@@ -185,11 +187,11 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
# Unrestricted commands
match command:
case 'signup':
- res = do_signup()
+ res = do_signup(author)
case 'help':
res = do_help(author)
-
-
+ case 'roll':
+ res = do_roll(author)
case _:
pass
@@ -198,10 +200,6 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
# User commands
match command:
- case 'delete_account':
- pass
- case 'roll':
- res = do_roll(author)
case 'create':
res = do_create(
author,
From c2fd4356225607c45a162370ec229662a1d5c871 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 22:53:10 -0300
Subject: [PATCH 25/43] weighing migration
---
migrations/0002_weigh_infer.sql | 1 +
1 file changed, 1 insertion(+)
create mode 100644 migrations/0002_weigh_infer.sql
diff --git a/migrations/0002_weigh_infer.sql b/migrations/0002_weigh_infer.sql
new file mode 100644
index 0000000..28a1002
--- /dev/null
+++ b/migrations/0002_weigh_infer.sql
@@ -0,0 +1 @@
+ALTER TABLE characters DROP COLUMN weight;
From 0c2e3754da7f5e47cb9d094eaabac7af372777c2 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 22:54:25 -0300
Subject: [PATCH 26/43] weigh response
---
bot/response.py | 13 +++----------
1 file changed, 3 insertions(+), 10 deletions(-)
diff --git a/bot/response.py b/bot/response.py
index 567962d..1911c29 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -84,10 +84,10 @@ dumbass.',
'attachment_urls': None
}
- if len(arguments) != 3:
+ if len(arguments) != 2:
return {
'message': f'{full_user} Please specify the following attributes \
-in order: name, rarity, drop weighting',
+in order: name, rarity',
'attachment_urls': None
}
@@ -97,17 +97,10 @@ in order: name, rarity, drop weighting',
be a number between 1 and 5',
'attachment_urls': None
}
- if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0):
- return {
- 'message': f'{full_user} Invalid drop weight: \'{arguments[2]}\' \
-must be a decimal value between 0.0 and 1.0',
- 'attachment_urls': None
- }
character_id, file_id = add_character(
name=arguments[0],
rarity=int(arguments[1]),
- weight=float(arguments[2]),
image_url=image_url
)
return {
@@ -121,7 +114,7 @@ def do_help(author: str) -> BotResponse:
return {
'message': f'{author} Here\'s what I can do:\n\
- `roll` Pulls a random character.\n\
-- `create ` Creates a character using a given image.\n\
+- `create ` Creates a character using a given image.\n\
- `help` Shows this message.',
'attachment_urls': None
}
From 4c2b3f589f9e7a6b518e942b5e59952e99a29044 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 22:55:08 -0300
Subject: [PATCH 27/43] db_utils weigh change test
---
bot/db_utils.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 6f8ae28..6159129 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -26,14 +26,14 @@ def get_random_character() -> Character | None:
if not characters:
return None
- weights = [c['weight'] for c in characters]
+ weights = [config.RARITY_TO_WEIGHT[c['rarity']] for c in characters]
chosen = choices(characters, weights=weights, k=1)[0]
return {
'id': chosen['id'],
'name': chosen['name'],
'rarity': chosen['rarity'],
- 'weight': chosen['weight'],
+ 'weight': config.RARITY_TO_WEIGHT[chosen['rarity']],
'image_url': chosen['file_id']
}
From 9f9c0344618f6bf734505292000c297e3cd9ec4b Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 22:55:39 -0300
Subject: [PATCH 28/43] weight config
---
bot/config.py | 11 +++++++++++
example_config.ini | 7 +++++++
2 files changed, 18 insertions(+)
diff --git a/bot/config.py b/bot/config.py
index 3621103..fb34223 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -21,6 +21,15 @@ def get_config_file() -> str:
raise ConfigError(f'Could not find {config_path}')
return config_path
+def get_rarity_to_weight(config_section):
+ """Parses Rarity_X keys from config and returns a {rarity: weight} dict."""
+ rarity_weights = {}
+ for key, value in config_section.items():
+ if key.startswith("Rarity_"):
+ rarity = int(key.removeprefix("Rarity_"))
+ rarity_weights[rarity] = float(value)
+ return rarity_weights
+
config = configparser.ConfigParser()
config.read(get_config_file())
@@ -43,3 +52,5 @@ NOTIFICATION_POLL_INTERVAL = int(config['notification']['PollInterval'])
NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
GACHA_ROLL_INTERVAL = int(config['gacha']['RollInterval'])
+
+RARITY_TO_WEIGHT = get_rarity_to_weight(config['gacha'])
\ No newline at end of file
diff --git a/example_config.ini b/example_config.ini
index d7f1c14..5f897cb 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -9,6 +9,13 @@ DatabaseLocation = ./gacha_game.db
[gacha]
; Number of seconds players have to wait between rolls
RollInterval = 72000
+; Rarity drop weights (1 to 5 stars)
+; Format: rarity=weight per line
+Rarity_1 = 0.7 ; common
+Rarity_2 = 0.2 ; uncommon
+Rarity_3 = 0.08 ; rare
+Rarity_4 = 0.015 ; epic
+Rarity_5 = 0.005 ; legendary
[notification]
; Number of seconds to sleep while awaiting new notifications
From 3de6a9ac3dd47bcf4c3e99f080149784066587e6 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 23:23:42 -0300
Subject: [PATCH 29/43] remove unused parts of the code
---
bot/db_utils.py | 3 ---
bot/response.py | 9 ++-------
2 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 5597bc5..bf4b25f 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -60,9 +60,6 @@ def delete_player(username: str) -> bool:
)
user = CURSOR.fetchone()
- if not user:
- return False # No such user
-
user_id = user[0]
# Delete pulls
diff --git a/bot/response.py b/bot/response.py
index 6966d1e..bd56f20 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -157,13 +157,8 @@ def delete_account(author: str) -> BotResponse:
}
def confirm_delete(author: str) -> BotResponse:
- success = delete_player(author)
-
- if not success:
- return {
- 'message':f'@{author} โ No account found to delete. Maybe itโs already gone?',
- 'attachment_urls': None
- }
+
+ delete_player(author)
return {
'message':f'{author} ๐งผ Your account and all your cards have been deleted. RIP your gacha history ๐๏ธโจ',
From cbdbefb5fc10ad3b6c7311bd259db020e8f524c5 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 3 Jun 2025 23:08:15 -0300
Subject: [PATCH 30/43] weight add_character change
---
bot/add_character.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/bot/add_character.py b/bot/add_character.py
index e8d078b..18b0f98 100644
--- a/bot/add_character.py
+++ b/bot/add_character.py
@@ -3,12 +3,12 @@ from misskey.exceptions import MisskeyAPIException
from client import client_connection
from db_utils import insert_character
from custom_types import Character
+from config import RARITY_TO_WEIGHT
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
@@ -17,7 +17,6 @@ def add_character(
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']).
@@ -36,8 +35,8 @@ def add_character(
raise ValueError('Character name cannot be empty.')
if rarity < 1:
raise ValueError('Rarity must be a positive integer.')
- if weight <= 0:
- raise ValueError('Weight must be a positive number.')
+ if rarity not in RARITY_TO_WEIGHT.keys():
+ raise ValueError(f'Invalid rarity: {rarity}')
if not image_url:
raise ValueError('Image URL must be provided.')
@@ -59,7 +58,7 @@ def add_character(
character_id = insert_character(
stripped_name,
rarity,
- float(weight),
+ RARITY_TO_WEIGHT[rarity],
file_id
)
return character_id, file_id
From 956d5927cd2265c0ce99cc4663c48125c290d6af Mon Sep 17 00:00:00 2001
From: w
Date: Sat, 7 Jun 2025 01:01:06 -0300
Subject: [PATCH 31/43] small config fix
---
bot/config.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/config.py b/bot/config.py
index fb34223..227f949 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -25,8 +25,8 @@ def get_rarity_to_weight(config_section):
"""Parses Rarity_X keys from config and returns a {rarity: weight} dict."""
rarity_weights = {}
for key, value in config_section.items():
- if key.startswith("Rarity_"):
- rarity = int(key.removeprefix("Rarity_"))
+ if key.startswith("rarity_"):
+ rarity = int(key.removeprefix("rarity_"))
rarity_weights[rarity] = float(value)
return rarity_weights
From 8569f28f3c0ff683445205a4e9cae0a7c7eef072 Mon Sep 17 00:00:00 2001
From: w
Date: Sat, 7 Jun 2025 01:42:30 -0300
Subject: [PATCH 32/43] example config fix
---
example_config.ini | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/example_config.ini b/example_config.ini
index 5f897cb..af7e0f2 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -11,11 +11,12 @@ DatabaseLocation = ./gacha_game.db
RollInterval = 72000
; Rarity drop weights (1 to 5 stars)
; Format: rarity=weight per line
-Rarity_1 = 0.7 ; common
-Rarity_2 = 0.2 ; uncommon
-Rarity_3 = 0.08 ; rare
-Rarity_4 = 0.015 ; epic
-Rarity_5 = 0.005 ; legendary
+; In order: common, uncommon, rare, epic and legendary (Example values below)
+Rarity_1 = 0.7
+Rarity_2 = 0.2
+Rarity_3 = 0.08
+Rarity_4 = 0.015
+Rarity_5 = 0.005
[notification]
; Number of seconds to sleep while awaiting new notifications
From fde6e1167a8143a7ebfda9bab09c8fa70c1cc751 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 7 Jun 2025 19:23:17 +0100
Subject: [PATCH 33/43] Add administrators
---
bot/{add_character.py => add_card.py} | 20 ++--
bot/bot_app.py | 3 +
bot/config.py | 10 +-
bot/custom_types.py | 2 +-
bot/db_utils.py | 128 ++++++++++++++++---------
bot/response.py | 105 +++++++++++---------
example_config.ini | 2 +-
migrations/0003_rename_tables.sql | 4 +
migrations/0004_add_administrators.sql | 1 +
9 files changed, 171 insertions(+), 104 deletions(-)
rename bot/{add_character.py => add_card.py} (76%)
create mode 100644 migrations/0003_rename_tables.sql
create mode 100644 migrations/0004_add_administrators.sql
diff --git a/bot/add_character.py b/bot/add_card.py
similarity index 76%
rename from bot/add_character.py
rename to bot/add_card.py
index 18b0f98..fcaab43 100644
--- a/bot/add_character.py
+++ b/bot/add_card.py
@@ -1,27 +1,27 @@
import requests
from misskey.exceptions import MisskeyAPIException
from client import client_connection
-from db_utils import insert_character
-from custom_types import Character
+from db_utils import insert_card
+from custom_types import Card
from config import RARITY_TO_WEIGHT
-def add_character(
+def add_card(
name: str,
rarity: int,
image_url: str) -> tuple[int, str]:
'''
- Adds a character to the database, uploading the image from a public URL to
+ Adds a card 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).
+ name (str): Card name.
+ rarity (int): Card rarity (e.g., 1-5).
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.
+ tuple[int, str]: Card ID and bot's Drive file_id.
Raises:
ValueError: If inputs are invalid.
@@ -32,7 +32,7 @@ def add_character(
# Validate inputs
if not stripped_name:
- raise ValueError('Character name cannot be empty.')
+ raise ValueError('Card name cannot be empty.')
if rarity < 1:
raise ValueError('Rarity must be a positive integer.')
if rarity not in RARITY_TO_WEIGHT.keys():
@@ -55,10 +55,10 @@ def add_character(
from e
# Insert into database
- character_id = insert_character(
+ card_id = insert_card(
stripped_name,
rarity,
RARITY_TO_WEIGHT[rarity],
file_id
)
- return character_id, file_id
+ return card_id, file_id
diff --git a/bot/bot_app.py b/bot/bot_app.py
index 825695e..ed2772b 100644
--- a/bot/bot_app.py
+++ b/bot/bot_app.py
@@ -12,6 +12,9 @@ if __name__ == '__main__':
# Connect to DB
db.connect()
+ # Setup default administrators
+ db.setup_administrators()
+
print('Listening for notifications...')
while True:
if not process_notifications(client):
diff --git a/bot/config.py b/bot/config.py
index 227f949..af806f9 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -1,5 +1,6 @@
'''Essentials for the bot to function'''
import configparser
+import json
from os import environ, path
@@ -21,7 +22,9 @@ def get_config_file() -> str:
raise ConfigError(f'Could not find {config_path}')
return config_path
-def get_rarity_to_weight(config_section):
+
+def get_rarity_to_weight(
+ config_section: configparser.SectionProxy) -> dict[int, float]:
"""Parses Rarity_X keys from config and returns a {rarity: weight} dict."""
rarity_weights = {}
for key, value in config_section.items():
@@ -41,10 +44,9 @@ KEY = config['credentials']['Token']
# Bot's Misskey instance URL
INSTANCE = config['credentials']['Instance'].lower()
-# TODO: move this to db
# Fedi handles in the traditional 'user@domain.tld' style, allows these users
# to use extra admin exclusive commands with the bot
-ADMINS = config['application']['DefaultAdmins']
+ADMINS = json.loads(config['application']['DefaultAdmins'])
# SQLite Database location
DB_PATH = config['application']['DatabaseLocation']
@@ -53,4 +55,4 @@ NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
GACHA_ROLL_INTERVAL = int(config['gacha']['RollInterval'])
-RARITY_TO_WEIGHT = get_rarity_to_weight(config['gacha'])
\ No newline at end of file
+RARITY_TO_WEIGHT = get_rarity_to_weight(config['gacha'])
diff --git a/bot/custom_types.py b/bot/custom_types.py
index 0c23cb6..7fc7885 100644
--- a/bot/custom_types.py
+++ b/bot/custom_types.py
@@ -5,7 +5,7 @@ BotResponse = TypedDict('BotResponse', {
'attachment_urls': List[str] | None
})
-Character = TypedDict('Character', {
+Card = TypedDict('Card', {
'id': int,
'name': str,
'rarity': int,
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 68409be..f7edd83 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -1,7 +1,7 @@
from random import choices
import sqlite3
import config
-from custom_types import Character
+from custom_types import Card
DB_PATH = config.DB_PATH
CONNECTION: sqlite3.Connection
@@ -18,16 +18,38 @@ def connect() -> None:
CURSOR = CONNECTION.cursor()
-def get_random_character() -> Character | None:
- ''' Gets a random character from the database'''
- CURSOR.execute('SELECT * FROM characters')
- characters = CURSOR.fetchall()
+def setup_administrators() -> None:
+ '''Creates administrator players for each handle in the config file'''
+ # Get default admins from config
+ for username in config.ADMINS:
+ player_id = get_player(username)
+ if player_id == 0:
+ # Create player if not exists
+ print(f'Creating administrator player: {username}')
+ CURSOR.execute(
+ 'INSERT INTO players (username, has_rolled, is_administrator) \
+ VALUES (?, ?, ?)',
+ (username, False, True)
+ )
+ else:
+ # Update is_administrator if exists
+ print(f'Granting administrator to player: {username}')
+ CURSOR.execute(
+ 'UPDATE players SET is_administrator = 1 WHERE id = ?',
+ (player_id,)
+ )
- if not characters:
+
+def get_random_card() -> Card | None:
+ ''' Gets a random card from the database'''
+ CURSOR.execute('SELECT * FROM cards')
+ cards = CURSOR.fetchall()
+
+ if not cards:
return None
- weights = [config.RARITY_TO_WEIGHT[c['rarity']] for c in characters]
- chosen = choices(characters, weights=weights, k=1)[0]
+ weights = [config.RARITY_TO_WEIGHT[c['rarity']] for c in cards]
+ chosen = choices(cards, weights=weights, k=1)[0]
return {
'id': chosen['id'],
@@ -37,73 +59,89 @@ def get_random_character() -> Character | None:
'image_url': chosen['file_id']
}
+
def get_player(username: str) -> int:
'''Retrieve a player ID by username, or return None if not found.'''
- CURSOR.execute('SELECT id FROM users WHERE username = ?', (username,))
- user = CURSOR.fetchone()
- if user:
- return int(user[0])
-
-def insert_player(username: str) -> int:
- '''Insert a new player with default has_rolled = False and return their user ID.'''
CURSOR.execute(
- 'INSERT INTO users (username, has_rolled) VALUES (?, ?)',
- (username, False)
- )
- return CURSOR.lastrowid
-
-def delete_player(username: str) -> bool:
- '''Permanently deletes a user and all their pulls.'''
- CURSOR.execute(
- 'SELECT id FROM users WHERE username = ?',
+ 'SELECT id FROM players WHERE username = ?',
(username,)
)
- user = CURSOR.fetchone()
+ player = CURSOR.fetchone()
+ if player:
+ return int(player[0])
+ return 0
- user_id = user[0]
+
+def insert_player(username: str) -> int:
+ '''Insert a new player with default has_rolled = False and return their
+ player ID.'''
+ CURSOR.execute(
+ 'INSERT INTO players (username, has_rolled) VALUES (?, ?)',
+ (username, False)
+ )
+ return CURSOR.lastrowid if CURSOR.lastrowid else 0
+
+
+def delete_player(username: str) -> bool:
+ '''Permanently deletes a player and all their pulls.'''
+ CURSOR.execute(
+ 'SELECT id FROM players WHERE username = ?',
+ (username,)
+ )
+ player = CURSOR.fetchone()
+
+ player_id = player[0]
# Delete pulls
CURSOR.execute(
- 'DELETE FROM pulls WHERE user_id = ?',
- (user_id,)
+ 'DELETE FROM pulls WHERE player_id = ?',
+ (player_id,)
)
- # Delete user
+ # Delete player
CURSOR.execute(
- 'DELETE FROM users WHERE id = ?',
- (user_id,)
+ 'DELETE FROM players WHERE id = ?',
+ (player_id,)
)
return True
-
-def insert_character(
- name: str, rarity: int, weight: float, file_id: str) -> int:
- '''Inserts a character'''
+def is_player_administrator(player_id: int) -> bool:
CURSOR.execute(
- 'INSERT INTO characters (name, rarity, weight, file_id) VALUES \
+ 'SELECT is_administrator FROM PLAYERS WHERE id = ? LIMIT 1',
+ (player_id,)
+ )
+ row = CURSOR.fetchone()
+ return row[0] if row else False
+
+
+def insert_card(
+ name: str, rarity: int, weight: float, file_id: str) -> int:
+ '''Inserts a card'''
+ CURSOR.execute(
+ 'INSERT INTO cards (name, rarity, weight, file_id) VALUES \
(?, ?, ?, ?)',
(name, rarity, weight, file_id)
)
- character_id = CURSOR.lastrowid
- return character_id if character_id else 0
+ card_id = CURSOR.lastrowid
+ return card_id if card_id else 0
-def insert_pull(user_id: int, character_id: int) -> None:
+def insert_pull(player_id: int, card_id: int) -> None:
'''Creates a pull in the database'''
CURSOR.execute(
- 'INSERT INTO pulls (user_id, character_id) VALUES (?, ?)',
- (user_id, character_id)
+ 'INSERT INTO pulls (player_id, card_id) VALUES (?, ?)',
+ (player_id, card_id)
)
-def get_last_rolled_at(user_id: int) -> int:
- '''Gets the timestamp when the user last rolled'''
+def get_last_rolled_at(player_id: int) -> int:
+ '''Gets the timestamp when the player last rolled'''
CURSOR.execute(
- "SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp \
+ "SELECT timestamp FROM pulls WHERE player_id = ? ORDER BY timestamp \
DESC",
- (user_id,))
+ (player_id,))
row = CURSOR.fetchone()
return row[0] if row else 0
diff --git a/bot/response.py b/bot/response.py
index 3fde3ed..aa7b8b7 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,19 +1,20 @@
from datetime import datetime, timedelta, timezone
from typing import TypedDict, Any, List, Dict
-from db_utils import get_player, insert_player, delete_player, insert_pull, get_last_rolled_at, \
- get_random_character
-from add_character import add_character
+from db_utils import get_player, insert_player, delete_player, insert_pull, \
+ get_last_rolled_at, get_random_card, is_player_administrator
+from add_card import add_card
from config import GACHA_ROLL_INTERVAL
from custom_types import BotResponse, ParsedNotification
def do_roll(author: str) -> BotResponse:
- '''Determines whether the user can roll, then pulls a random character'''
+ '''Determines whether the user can roll, then pulls a random card'''
user_id = get_player(author)
if not user_id:
return {
- 'message':f'{author} ๐ You havenโt signed up yet! Use the `signup` command to start playing.',
- 'attachment_urls': None
+ 'message': f'{author} ๐ You havenโt signed up yet! Use the \
+`signup` command to start playing.',
+ 'attachment_urls': None
}
# Get date of user's last roll
date = get_last_rolled_at(user_id)
@@ -45,39 +46,43 @@ def do_roll(author: str) -> BotResponse:
'attachment_urls': None
}
- character = get_random_character()
+ card = get_random_card()
- if not character:
+ if not card:
return {
'message': f'{author} Uwaaa... something went wrong! No \
-characters found. ๐ฟ',
+cards found. ๐ฟ',
'attachment_urls': None
}
- insert_pull(user_id, character['id'])
- stars = 'โญ๏ธ' * character['rarity']
+ insert_pull(user_id, card['id'])
+ stars = 'โญ๏ธ' * card['rarity']
return {
'message': f'{author} ๐ฒ Congrats! You rolled {stars} \
-**{character['name']}**\nShe\'s all yours now~ ๐โจ',
- 'attachment_urls': [character['image_url']]
+**{card['name']}**\nShe\'s all yours now~ ๐โจ',
+ 'attachment_urls': [card['image_url']]
}
+
def do_signup(author: str) -> BotResponse:
'''Registers a new user if they havenโt signed up yet.'''
user_id = get_player(author)
if user_id:
return {
- 'message':f'{author} ๐ Youโre already signed up! Let the rolling begin~ ๐ฒ',
+ 'message': f'{author} ๐ Youโre already signed up! Let the rolling \
+begin~ ๐ฒ',
'attachment_urls': None
}
new_user_id = insert_player(author)
return {
- 'message': f'{author} โ Signed up successfully! Your gacha destiny begins now... โจ Use the roll command to start!',
+ 'message': f'{author} โ Signed up successfully! Your gacha \
+destiny begins now... โจ Use the roll command to start!',
'attachment_urls': None
}
+
def is_float(val: Any) -> bool:
'''Returns true if `val` can be converted to a float'''
try:
@@ -91,14 +96,14 @@ def do_create(
author: str,
arguments: List[str],
note_obj: Dict[str, Any]) -> BotResponse:
- '''Creates a character'''
+ '''Creates a card'''
# 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 {
- 'message': f'{author} You need an image to create a character, \
+ 'message': f'{author} You need an image to create a card, \
dumbass.',
'attachment_urls': None
}
@@ -123,13 +128,13 @@ must be a decimal value between 0.0 and 1.0',
'attachment_urls': None
}
- character_id, file_id = add_character(
+ card_id, file_id = add_card(
name=arguments[0],
rarity=int(arguments[1]),
image_url=image_url
)
return {
- 'message': f'{author} Added {arguments[0]}, ID {character_id}.',
+ 'message': f'{author} Added {arguments[0]}, ID {card_id}.',
'attachment_urls': [file_id]
}
@@ -137,30 +142,43 @@ must be a decimal value between 0.0 and 1.0',
def do_help(author: str) -> BotResponse:
'''Provides a list of commands that the bot can do.'''
return {
- 'message':f'{author} Here\'s what I can do:\n \
- - `roll` Pulls a random character.\
- - `create ` Creates a character using a given image.\
- - `signup` Registers your account.\
- - `delete_account` Deletes your account.\
- - `help` Shows this message',
- 'attachment_urls': None
+ 'message': f'{author} Here\'s what I can do:\n\
+- `roll` Pulls a random card.\n\
+- `create ` Creates a card using a given image.\n\
+- `signup` Registers your account.\n\
+- `delete_account` Deletes your account.\n\
+- `help` Shows this message',
+ 'attachment_urls': None
}
-
+
+
def delete_account(author: str) -> BotResponse:
return {
- 'message':f'{author} โ ๏ธ This will permanently delete your account and all your cards.\n'
- 'If youโre sure, reply with `confirm_delete` to proceed.\n\n'
+ 'message': f'{author} โ ๏ธ This will permanently delete your account \
+and all your cards.\n'
+ 'If youโre sure, reply with `confirm_delete_account` to proceed.\n\n'
'**There is no undo.** Your gacha luck will be lost to the void... ๐โจ',
'attachment_urls': None
}
+
def confirm_delete(author: str) -> BotResponse:
-
delete_player(author)
return {
- 'message':f'{author} ๐งผ Your account and all your cards have been deleted. RIP your gacha history ๐๏ธโจ',
+ 'message': f'{author} ๐งผ Your account and all your cards have been \
+deleted. RIP your gacha history ๐๏ธโจ',
+ 'attachment_urls': None
+ }
+
+
+def do_admin_test(author: str) -> BotResponse:
+ player_id = get_player(author)
+ is_admin = is_player_administrator(player_id)
+ return {
+ 'message': f'{author} You are {"not " if not is_admin else ""}an \
+admin.',
'attachment_urls': None
}
@@ -171,25 +189,23 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
# Temporary response variable
res: BotResponse | None = None
- # TODO: Check if the user has an account
author = notification['author']
- user_id = get_player(author)
+ player_id = get_player(author)
command = notification['command']
- # Check if the user is an administrator
- # user_is_administrator = user_is_administrator()
# Unrestricted commands
match command:
+ case 'roll':
+ res = do_roll(author)
case 'signup':
res = do_signup(author)
case 'help':
res = do_help(author)
- case 'roll':
- res = do_roll(author)
case _:
pass
- if not user_id:
+ # Commands beyond this point require the user to have an account
+ if not player_id:
return res
# User commands
@@ -200,15 +216,18 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
notification['arguments'],
notification['note_obj']
)
- case 'signup':
- res = do_signup(author)
case 'delete_account':
res = delete_account(author)
- case 'confirm_delete':
+ case 'confirm_delete_account':
res = confirm_delete(author)
+ case 'admin_test':
+ res = do_admin_test(author)
case _:
pass
- # if not user_is_administrator:
- return res
+
+ # Commands beyond this point require the user to be an administrator
+ if not is_player_administrator(player_id):
+ return res
# Administrator commands go here
+ return res
diff --git a/example_config.ini b/example_config.ini
index af7e0f2..25402e4 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -2,7 +2,7 @@
[application]
; Comma separated list of fedi handles for any administrator users
; More can be added through the application
-DefaultAdmins = ['admin@example.tld']
+DefaultAdmins = ["@localadmin", "remoteadmin@example.tld"]
; SQLite Database location
DatabaseLocation = ./gacha_game.db
diff --git a/migrations/0003_rename_tables.sql b/migrations/0003_rename_tables.sql
new file mode 100644
index 0000000..a3ba3a7
--- /dev/null
+++ b/migrations/0003_rename_tables.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users RENAME TO players;
+ALTER TABLE characters RENAME TO cards;
+ALTER TABLE pulls RENAME user_id TO player_id;
+ALTER TABLE pulls RENAME character_id TO card_id;
diff --git a/migrations/0004_add_administrators.sql b/migrations/0004_add_administrators.sql
new file mode 100644
index 0000000..7503e21
--- /dev/null
+++ b/migrations/0004_add_administrators.sql
@@ -0,0 +1 @@
+ALTER TABLE players ADD COLUMN is_administrator BOOLEAN NOT NULL DEFAULT 0;
From 8ae6e25b95bca5643055d72d336c1b66c58de426 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 7 Jun 2025 20:40:27 +0100
Subject: [PATCH 34/43] Add instance whitelist
---
bot/config.py | 2 ++
bot/db_utils.py | 29 ++++++++++++++++
bot/notification.py | 6 ++--
bot/response.py | 81 +++++++++++++++++++++++++++++++++------------
example_config.ini | 5 ++-
5 files changed, 98 insertions(+), 25 deletions(-)
diff --git a/bot/config.py b/bot/config.py
index af806f9..9737608 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -49,6 +49,8 @@ INSTANCE = config['credentials']['Instance'].lower()
ADMINS = json.loads(config['application']['DefaultAdmins'])
# SQLite Database location
DB_PATH = config['application']['DatabaseLocation']
+# Whether to enable the instance whitelist
+USE_WHITELIST = config['application']['UseWhitelist']
NOTIFICATION_POLL_INTERVAL = int(config['notification']['PollInterval'])
NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize'])
diff --git a/bot/db_utils.py b/bot/db_utils.py
index f7edd83..802e3f0 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -146,6 +146,35 @@ DESC",
return row[0] if row else 0
+def add_to_whitelist(instance: str) -> bool:
+ '''Adds an instance to the whitelist, returns false if instance was already
+ present'''
+ try:
+ CURSOR.execute(
+ 'INSERT INTO instance_whitelist (tld) VALUES (?)', (instance,))
+ return True
+ except sqlite3.IntegrityError:
+ return False
+
+
+def remove_from_whitelist(instance: str) -> bool:
+ '''Removes an instance to the whitelist, returns false if instance was not
+ present'''
+ CURSOR.execute(
+ 'DELETE FROM instance_whitelist WHERE tld = ?', (instance,))
+ return CURSOR.rowcount > 0
+
+
+def is_whitelisted(instance: str) -> bool:
+ '''Checks whether an instance is in the whitelist'''
+ if instance == 'local':
+ return True
+ CURSOR.execute(
+ 'SELECT * FROM instance_whitelist WHERE tld = ?', (instance,))
+ row = CURSOR.fetchone()
+ return row is not None
+
+
def get_config(key: str) -> str:
'''Reads the value for a specified config key from the db'''
CURSOR.execute("SELECT value FROM config WHERE key = ?", (key,))
diff --git a/bot/notification.py b/bot/notification.py
index 9427dbf..faac1d3 100644
--- a/bot/notification.py
+++ b/bot/notification.py
@@ -4,9 +4,9 @@ from typing import Dict, Any
import misskey
from misskey.exceptions import MisskeyAPIException
-from config import NOTIFICATION_BATCH_SIZE
+from config import NOTIFICATION_BATCH_SIZE, USE_WHITELIST
from parsing import parse_notification
-from db_utils import get_config, set_config
+from db_utils import get_config, set_config, is_whitelisted
from response import generate_response
from custom_types import BotResponse
@@ -24,7 +24,7 @@ def process_notification(
host = user.get('host') # None if local user
instance = host if host else 'local'
- if not (instance in WHITELISTED_INSTANCES or instance == 'local'):
+ if USE_WHITELIST and not is_whitelisted(instance):
print(f'โ ๏ธ Blocked notification from untrusted instance: {instance}')
return
diff --git a/bot/response.py b/bot/response.py
index aa7b8b7..16bd05e 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -1,7 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import TypedDict, Any, List, Dict
-from db_utils import get_player, insert_player, delete_player, insert_pull, \
- get_last_rolled_at, get_random_card, is_player_administrator
+import db_utils as db
from add_card import add_card
from config import GACHA_ROLL_INTERVAL
from custom_types import BotResponse, ParsedNotification
@@ -9,7 +8,7 @@ from custom_types import BotResponse, ParsedNotification
def do_roll(author: str) -> BotResponse:
'''Determines whether the user can roll, then pulls a random card'''
- user_id = get_player(author)
+ user_id = db.get_player(author)
if not user_id:
return {
'message': f'{author} ๐ You havenโt signed up yet! Use the \
@@ -17,7 +16,7 @@ def do_roll(author: str) -> BotResponse:
'attachment_urls': None
}
# Get date of user's last roll
- date = get_last_rolled_at(user_id)
+ date = db.get_last_rolled_at(user_id)
# No date means it's users first roll
if date:
@@ -46,7 +45,7 @@ def do_roll(author: str) -> BotResponse:
'attachment_urls': None
}
- card = get_random_card()
+ card = db.get_random_card()
if not card:
return {
@@ -55,7 +54,7 @@ cards found. ๐ฟ',
'attachment_urls': None
}
- insert_pull(user_id, card['id'])
+ db.insert_pull(user_id, card['id'])
stars = 'โญ๏ธ' * card['rarity']
return {
'message': f'{author} ๐ฒ Congrats! You rolled {stars} \
@@ -66,7 +65,7 @@ cards found. ๐ฟ',
def do_signup(author: str) -> BotResponse:
'''Registers a new user if they havenโt signed up yet.'''
- user_id = get_player(author)
+ user_id = db.get_player(author)
if user_id:
return {
@@ -75,7 +74,7 @@ begin~ ๐ฒ',
'attachment_urls': None
}
- new_user_id = insert_player(author)
+ new_user_id = db.insert_player(author)
return {
'message': f'{author} โ Signed up successfully! Your gacha \
destiny begins now... โจ Use the roll command to start!',
@@ -164,7 +163,7 @@ and all your cards.\n'
def confirm_delete(author: str) -> BotResponse:
- delete_player(author)
+ db.delete_player(author)
return {
'message': f'{author} ๐งผ Your account and all your cards have been \
@@ -173,14 +172,43 @@ deleted. RIP your gacha history ๐๏ธโจ',
}
-def do_admin_test(author: str) -> BotResponse:
- player_id = get_player(author)
- is_admin = is_player_administrator(player_id)
- return {
- 'message': f'{author} You are {"not " if not is_admin else ""}an \
-admin.',
- 'attachment_urls': None
- }
+def do_whitelist(author: str, args: list[str]) -> BotResponse:
+ if len(args) == 0:
+ return {
+ 'message': f'{author} Please specify an instance to whitelist',
+ 'attachment_urls': None
+ }
+
+ if db.add_to_whitelist(args[0]):
+ return {
+ 'message': f'{author} Whitelisted instance: {args[0]}',
+ 'attachment_urls': None
+ }
+ else:
+ return {
+ 'message': f'{author} Instance already whitelisted: {args[0]}',
+ 'attachment_urls': None
+ }
+
+
+def do_unwhitelist(author: str, args: list[str]) -> BotResponse:
+ if len(args) == 0:
+ return {
+ 'message': f'{author} Please specify an instance to remove from \
+the whitelist',
+ 'attachment_urls': None
+ }
+
+ if db.remove_from_whitelist(args[0]):
+ return {
+ 'message': f'{author} Unwhitelisted instance: {args[0]}',
+ 'attachment_urls': None
+ }
+ else:
+ return {
+ 'message': f'{author} Instance not whitelisted: {args[0]}',
+ 'attachment_urls': None
+ }
def generate_response(notification: ParsedNotification) -> BotResponse | None:
@@ -190,7 +218,7 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
# Temporary response variable
res: BotResponse | None = None
author = notification['author']
- player_id = get_player(author)
+ player_id = db.get_player(author)
command = notification['command']
# Unrestricted commands
@@ -220,14 +248,25 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
res = delete_account(author)
case 'confirm_delete_account':
res = confirm_delete(author)
- case 'admin_test':
- res = do_admin_test(author)
case _:
pass
# Commands beyond this point require the user to be an administrator
- if not is_player_administrator(player_id):
+ if not db.is_player_administrator(player_id):
return res
+ # Admin commands
+ match command:
+ case 'whitelist':
+ res = do_whitelist(author, notification['arguments'])
+ case 'unwhitelist':
+ res = do_unwhitelist(author, notification['arguments'])
+ # case 'ban':
+ # res = do_ban(author, notification['arguments'])
+ # case 'unban':
+ # res = do_unban(author, notification['arguments'])
+ case _:
+ pass
+
# Administrator commands go here
return res
diff --git a/example_config.ini b/example_config.ini
index 25402e4..0ea2422 100644
--- a/example_config.ini
+++ b/example_config.ini
@@ -2,9 +2,12 @@
[application]
; Comma separated list of fedi handles for any administrator users
; More can be added through the application
-DefaultAdmins = ["@localadmin", "remoteadmin@example.tld"]
+DefaultAdmins = ["@localadmin", "@remoteadmin@example.tld"]
; SQLite Database location
DatabaseLocation = ./gacha_game.db
+; Whether to lmit access to the bot via an instance whitelist
+; The whitelist can be adjusted via the application
+UseWhitelist = False
[gacha]
; Number of seconds players have to wait between rolls
From e41c965538e1b8231c0b74fc4796a8cad256aef7 Mon Sep 17 00:00:00 2001
From: w
Date: Sat, 7 Jun 2025 17:26:51 -0300
Subject: [PATCH 35/43] Added the pwm patch
---
.gitignore | 2 +
web/app.py | 14 ++++-
web/static/style.css | 92 ++++++++++++++++++++++++++++
web/templates/_base.html | 30 ++++++++++
web/templates/_error.html | 8 +++
web/templates/about.html | 12 ++--
web/templates/index.html | 122 +++-----------------------------------
web/templates/submit.html | 12 ++--
web/templates/user.html | 60 +------------------
9 files changed, 163 insertions(+), 189 deletions(-)
create mode 100644 web/static/style.css
create mode 100644 web/templates/_base.html
create mode 100644 web/templates/_error.html
diff --git a/.gitignore b/.gitignore
index 11530b5..e5543ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -185,3 +185,5 @@ cython_debug/
gacha_game*.db
gacha_game*.db.*
config*.ini
+
+.idea
\ No newline at end of file
diff --git a/web/app.py b/web/app.py
index f68084c..61ed38f 100644
--- a/web/app.py
+++ b/web/app.py
@@ -1,6 +1,8 @@
-from flask import Flask, render_template
import sqlite3
+from flask import Flask, render_template, abort
+from werkzeug.exceptions import HTTPException
+
app = Flask(__name__)
DB_PATH = "./gacha_game.db" # Adjust path if needed
@@ -9,6 +11,14 @@ def get_db_connection():
conn.row_factory = sqlite3.Row
return conn
+@app.errorhandler(HTTPException)
+def handle_exception(error):
+ return render_template("_error.html", error=error), error.code
+
+@app.route("/i404")
+def i404():
+ return abort(404)
+
@app.route('/')
def index():
conn = get_db_connection()
@@ -33,6 +43,8 @@ def user_profile(user_id):
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))
user = cursor.fetchone()
+ if user is None:
+ abort(404)
cursor.execute('''
SELECT pulls.timestamp, characters.name as character_name, characters.rarity
diff --git a/web/static/style.css b/web/static/style.css
new file mode 100644
index 0000000..74231c1
--- /dev/null
+++ b/web/static/style.css
@@ -0,0 +1,92 @@
+body {
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ background-color: #f4f6fa;
+ color: #333;
+ margin: 0;
+ padding: 0;
+ }
+
+ header {
+ background-color: #7289da;
+ color: white;
+ padding: 20px;
+ text-align: center;
+ }
+
+ header h1 {
+ margin: 0;
+ font-size: 2.5em;
+ }
+
+ header p {
+ margin-top: 5px;
+ font-size: 1.1em;
+ }
+
+ .container {
+ max-width: 800px;
+ margin: 30px auto;
+ padding: 20px;
+ background-color: #ffffff;
+ border-radius: 10px;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.07);
+ }
+
+ h2 {
+ border-bottom: 1px solid #ccc;
+ padding-bottom: 8px;
+ margin-top: 30px;
+ }
+
+ ul {
+ list-style-type: none;
+ padding-left: 0;
+ }
+
+ li {
+ margin: 10px 0;
+ }
+
+ a {
+ text-decoration: none;
+ color: #2c3e50;
+ font-weight: bold;
+ background-color: #e3eaf3;
+ padding: 8px 12px;
+ border-radius: 6px;
+ display: inline-block;
+ transition: background-color 0.2s;
+ }
+
+ a:hover {
+ background-color: #cdd8e6;
+ }
+
+ .leaderboard-entry {
+ margin-bottom: 8px;
+ padding: 6px 10px;
+ background: #f9fafc;
+ border-left: 4px solid #7289da;
+ border-radius: 5px;
+ }
+
+ footer {
+ text-align: center;
+ margin-top: 40px;
+ font-size: 0.9em;
+ color: #888;
+ }
+
+ .note {
+ background: #fcfcf0;
+ border: 1px dashed #bbb;
+ padding: 10px;
+ border-radius: 8px;
+ margin-top: 30px;
+ font-size: 0.95em;
+ color: #666;
+ }
+
+ .footer-link {
+ margin: 0 10px;
+ }
\ No newline at end of file
diff --git a/web/templates/_base.html b/web/templates/_base.html
new file mode 100644
index 0000000..a1f1b22
--- /dev/null
+++ b/web/templates/_base.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ {% if title %}
+ {{ title }}
+ {% else %}
+ {% block title %}{% endblock %}
+ {% endif %}
+ | Kemoverse
+
+
+
+
+ {% block header %}{% endblock %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/web/templates/about.html b/web/templates/about.html
index 89519db..184fde9 100644
--- a/web/templates/about.html
+++ b/web/templates/about.html
@@ -1,13 +1,9 @@
-
-
-
- About - Misskey Gacha Center
-
-
+{% extends "_base.html" %}
+
+{% block content %}
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
-
-
+{% endblock %}
\ No newline at end of file
diff --git a/web/templates/index.html b/web/templates/index.html
index e9b1680..f9bf7a0 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -1,110 +1,11 @@
-
-
-
- Misskey Gacha Center
-
-
-
-
-
-
Misskey Gacha Center
-
Track your luck. Compare your pulls. Compete with friends.
-
-
-
+{% block content %}
๐๏ธ Leaderboard: Most Rolls
{% for user in top_users %}
@@ -125,13 +26,4 @@
๐ This is a fun little gacha tracker! More features coming soon. Want to help shape it?
-
-
-
-
-
-
-
+{% endblock %}
\ No newline at end of file
diff --git a/web/templates/submit.html b/web/templates/submit.html
index 5dc5ac9..d0424b8 100644
--- a/web/templates/submit.html
+++ b/web/templates/submit.html
@@ -1,12 +1,8 @@
-
-
-
- Submit a Character - Misskey Gacha Center
-
-
+{% extends "_base.html" %}
+
+{% block content %}
Submit a Character
Want to add a new character to the gacha pool?
This feature will be available soon. Stay tuned!
โ Back to Home
-
-
+{% endblock %}
\ No newline at end of file
diff --git a/web/templates/user.html b/web/templates/user.html
index 0004052..8010538 100644
--- a/web/templates/user.html
+++ b/web/templates/user.html
@@ -1,57 +1,5 @@
-
-
-
- {{ user['username'] }}'s Rolls
-
-
-
-
+{% extends "_base.html" %}
+{% block content %}
{{ user['username'] }}'s Gacha Rolls
User ID: {{ user['id'] }}
@@ -72,6 +20,4 @@
โ Back to Users
-
-
-
+{% endblock %}
\ No newline at end of file
From 97b30c79f44ef28dc72f945a1bfbf3a555c673d6 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 7 Jun 2025 23:18:39 +0100
Subject: [PATCH 36/43] Add admin commands
---
bot/db_utils.py | 42 ++++++++++++++++++++---
bot/notification.py | 7 +++-
bot/parsing.py | 18 +++++-----
bot/response.py | 56 ++++++++++++++++++++++++++++---
migrations/0005_add_whitelist.sql | 7 ++++
5 files changed, 111 insertions(+), 19 deletions(-)
create mode 100644 migrations/0005_add_whitelist.sql
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 802e3f0..94e915e 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -90,6 +90,9 @@ def delete_player(username: str) -> bool:
)
player = CURSOR.fetchone()
+ if not player:
+ return False
+
player_id = player[0]
# Delete pulls
@@ -107,10 +110,40 @@ def delete_player(username: str) -> bool:
return True
-def is_player_administrator(player_id: int) -> bool:
+def ban_player(username: str) -> bool:
+ '''Adds a player to the ban list.'''
+ try:
+ CURSOR.execute(
+ 'INSERT INTO banned_players (handle) VALUES (?)',
+ (username,)
+ )
+ return True
+ except sqlite3.IntegrityError:
+ return False
+
+
+def unban_player(username: str) -> bool:
+ '''Removes a player from the ban list.'''
CURSOR.execute(
- 'SELECT is_administrator FROM PLAYERS WHERE id = ? LIMIT 1',
- (player_id,)
+ 'DELETE FROM banned_players WHERE handle = ?',
+ (username,)
+ )
+ return CURSOR.rowcount > 0
+
+
+def is_player_banned(username: str) -> bool:
+ CURSOR.execute(
+ 'SELECT * FROM banned_players WHERE handle = ?',
+ (username,)
+ )
+ row = CURSOR.fetchone()
+ return row is not None
+
+
+def is_player_administrator(username: str) -> bool:
+ CURSOR.execute(
+ 'SELECT is_administrator FROM players WHERE username = ? LIMIT 1',
+ (username,)
)
row = CURSOR.fetchone()
return row[0] if row else False
@@ -151,7 +184,8 @@ def add_to_whitelist(instance: str) -> bool:
present'''
try:
CURSOR.execute(
- 'INSERT INTO instance_whitelist (tld) VALUES (?)', (instance,))
+ 'INSERT INTO instance_whitelist (tld) VALUES (?)', (instance,)
+ )
return True
except sqlite3.IntegrityError:
return False
diff --git a/bot/notification.py b/bot/notification.py
index faac1d3..deb8ec6 100644
--- a/bot/notification.py
+++ b/bot/notification.py
@@ -6,7 +6,7 @@ from misskey.exceptions import MisskeyAPIException
from config import NOTIFICATION_BATCH_SIZE, USE_WHITELIST
from parsing import parse_notification
-from db_utils import get_config, set_config, is_whitelisted
+from db_utils import get_config, set_config, is_whitelisted, is_player_banned
from response import generate_response
from custom_types import BotResponse
@@ -44,6 +44,11 @@ def process_notification(
if not parsed_notification:
return
+ author = parsed_notification['author']
+ if is_player_banned(author):
+ print(f'โ ๏ธ Blocked notification from banned player: {author}')
+ return
+
# Get the note Id to reply to
note_id = notification.get('note', {}).get('id')
diff --git a/bot/parsing.py b/bot/parsing.py
index eece077..e1e8583 100644
--- a/bot/parsing.py
+++ b/bot/parsing.py
@@ -24,6 +24,8 @@ def parse_notification(
note_id = note_obj.get("id")
note = note_text.strip().lower() if note_text else ""
+ # Split words into tokens
+ parts = note.split()
# Check for both short and fully-qualified name mentions
username_variants = [
@@ -31,18 +33,16 @@ def parse_notification(
f'@{config.USER.split("@")[1]}'
]
- # Make sure the notification text explicitly mentions the bot
- if not any(variant in note for variant in username_variants):
+ # Notifs must consist of the initial mention and at least one other token
+ if len(parts) <= 1:
return None
- # 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()
+ # Make sure the first token is a mention to the bot
+ if not parts[0] in username_variants:
+ return None
- command = parts[0].lower() if parts else None
- arguments = parts[1:] if len(parts) > 1 else []
+ command = parts[1].lower()
+ arguments = parts[2:] if len(parts) > 2 else []
return {
'author': full_user,
diff --git a/bot/response.py b/bot/response.py
index 16bd05e..f21d7b0 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -211,6 +211,52 @@ the whitelist',
}
+def do_ban(author: str, args: list[str]) -> BotResponse:
+ if len(args) == 0:
+ return {
+ 'message': f'{author} Please specify a user to ban',
+ 'attachment_urls': None
+ }
+
+ if db.is_player_administrator(args[0]):
+ return {
+ 'message': f'{author} Cannot ban other administrators.',
+ 'attachment_urls': None
+ }
+
+ if db.ban_player(args[0]):
+ # Delete banned player's account
+ db.delete_player(args[0])
+ return {
+ 'message': f'{author} ๐จ **BONK!** Get banned, {args[0]}!',
+ 'attachment_urls': None
+ }
+ else:
+ return {
+ 'message': f'{author} Player is already banned: {args[0]}',
+ 'attachment_urls': None
+ }
+
+
+def do_unban(author: str, args: list[str]) -> BotResponse:
+ if len(args) == 0:
+ return {
+ 'message': f'{author} Please specify a user to unban',
+ 'attachment_urls': None
+ }
+
+ if db.unban_player(args[0]):
+ return {
+ 'message': f'{author} Player unbanned: {args[0]}!',
+ 'attachment_urls': None
+ }
+ else:
+ return {
+ 'message': f'{author} Player was not banned: {args[0]}',
+ 'attachment_urls': None
+ }
+
+
def generate_response(notification: ParsedNotification) -> BotResponse | None:
'''Given a command with arguments, processes the game state and
returns a response'''
@@ -252,7 +298,7 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
pass
# Commands beyond this point require the user to be an administrator
- if not db.is_player_administrator(player_id):
+ if not db.is_player_administrator(author):
return res
# Admin commands
@@ -261,10 +307,10 @@ def generate_response(notification: ParsedNotification) -> BotResponse | None:
res = do_whitelist(author, notification['arguments'])
case 'unwhitelist':
res = do_unwhitelist(author, notification['arguments'])
- # case 'ban':
- # res = do_ban(author, notification['arguments'])
- # case 'unban':
- # res = do_unban(author, notification['arguments'])
+ case 'ban':
+ res = do_ban(author, notification['arguments'])
+ case 'unban':
+ res = do_unban(author, notification['arguments'])
case _:
pass
diff --git a/migrations/0005_add_whitelist.sql b/migrations/0005_add_whitelist.sql
new file mode 100644
index 0000000..d24f2e3
--- /dev/null
+++ b/migrations/0005_add_whitelist.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS instance_whitelist (
+ tld TEXT UNIQUE PRIMARY KEY
+);
+
+CREATE TABLE IF NOT EXISTS banned_players (
+ handle TEXT UNIQUE PRIMARY KEY
+);
From 4b1b8a53c7d691bfd2eda1d76d038298173b6ecf Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 7 Jun 2025 23:25:48 +0100
Subject: [PATCH 37/43] Fix nonstandard apostrophe
---
bot/response.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/response.py b/bot/response.py
index f21d7b0..b49de1e 100644
--- a/bot/response.py
+++ b/bot/response.py
@@ -155,7 +155,7 @@ def delete_account(author: str) -> BotResponse:
return {
'message': f'{author} โ ๏ธ This will permanently delete your account \
and all your cards.\n'
- 'If youโre sure, reply with `confirm_delete_account` to proceed.\n\n'
+ 'If you\'re sure, reply with `confirm_delete_account` to proceed.\n\n'
'**There is no undo.** Your gacha luck will be lost to the void... ๐โจ',
'attachment_urls': None
From 59915be66170ffcf5596a9d30d384c7d7e899646 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sat, 7 Jun 2025 23:59:07 +0100
Subject: [PATCH 38/43] Enable WAL for DB connections
---
.gitignore | 4 ++--
bot/db_utils.py | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index e5543ec..b0a1050 100644
--- a/.gitignore
+++ b/.gitignore
@@ -183,7 +183,7 @@ cython_debug/
# Custom stuff
gacha_game*.db
-gacha_game*.db.*
+gacha_game*.db*
config*.ini
-.idea
\ No newline at end of file
+.idea
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 94e915e..72b431f 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -16,6 +16,7 @@ def connect() -> None:
CONNECTION = sqlite3.connect(DB_PATH, autocommit=True)
CONNECTION.row_factory = sqlite3.Row
CURSOR = CONNECTION.cursor()
+ CURSOR.execute('pragma journal_mode=wal')
def setup_administrators() -> None:
From 1368c907a222d02387c23d956b50005501315a65 Mon Sep 17 00:00:00 2001
From: VD15
Date: Sun, 8 Jun 2025 00:09:02 +0100
Subject: [PATCH 39/43] Revert "Enable WAL for DB connections"
This reverts commit 59915be66170ffcf5596a9d30d384c7d7e899646.
---
.gitignore | 4 ++--
bot/db_utils.py | 1 -
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index b0a1050..e5543ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -183,7 +183,7 @@ cython_debug/
# Custom stuff
gacha_game*.db
-gacha_game*.db*
+gacha_game*.db.*
config*.ini
-.idea
+.idea
\ No newline at end of file
diff --git a/bot/db_utils.py b/bot/db_utils.py
index 72b431f..94e915e 100644
--- a/bot/db_utils.py
+++ b/bot/db_utils.py
@@ -16,7 +16,6 @@ def connect() -> None:
CONNECTION = sqlite3.connect(DB_PATH, autocommit=True)
CONNECTION.row_factory = sqlite3.Row
CURSOR = CONNECTION.cursor()
- CURSOR.execute('pragma journal_mode=wal')
def setup_administrators() -> None:
From bad06c4f231fa31b938613c875fdf9aa00530cbf Mon Sep 17 00:00:00 2001
From: w
Date: Mon, 9 Jun 2025 23:40:37 -0300
Subject: [PATCH 40/43] docs index
---
docs/index.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 docs/index.md
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..42cc59c
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,21 @@
+# ๐ฒ Kemoverse Gacha Game Documentation
+
+Welcome to the developer documentation for **Kemoverse**, a gacha trading card game in the Fediverse!
+Features collectible cards, rarity-based pulls, and integration with Misskey.
+Name comes from Kemonomimi and Fediverse.
+---
+
+## ๐ Table of Contents
+
+- [Installation](./install.md)
+- [Game Design](./design.md)
+- [Bot Architecture](./bot.md)
+- [Database Structure](./database.md)
+- [Card System](./cards.md)
+- [Web UI](./web.md)
+- [Theming and Assets](./theme.md)
+- [Contributing](./contributing.md)
+- [FAQ](./faq.md)
+
+---
+
From 3626949020839ce5392f6b852dfafc0e6d218050 Mon Sep 17 00:00:00 2001
From: w
Date: Mon, 9 Jun 2025 23:59:12 -0300
Subject: [PATCH 41/43] cleaned readme
---
docs/index.md | 3 ++
docs/install.md | 83 ++++++++++++++++++++++++++++++++++++++++++++
readme.md | 92 ++-----------------------------------------------
3 files changed, 89 insertions(+), 89 deletions(-)
create mode 100644 docs/install.md
diff --git a/docs/index.md b/docs/index.md
index 42cc59c..3b6683c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,8 +1,11 @@
# ๐ฒ Kemoverse Gacha Game Documentation
Welcome to the developer documentation for **Kemoverse**, a gacha trading card game in the Fediverse!
+
Features collectible cards, rarity-based pulls, and integration with Misskey.
+
Name comes from Kemonomimi and Fediverse.
+
---
## ๐ Table of Contents
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 0000000..e5f8658
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,83 @@
+
+## ๐งช Installation
+
+### Download and install dependencies
+
+Clone the repo
+
+```sh
+git clone https://git.waifuism.life/waifu/kemoverse.git
+cd kemoverse
+```
+
+Setup a virtual environment (Optional, recommended)
+
+```sh
+python3 -m venv venv
+source venv/bin/activate
+```
+
+Install project dependencies via pip
+
+```sh
+python3 -m pip install -r requirements.txt
+```
+
+### Setup config file
+
+A sample config file is included with the project as a template: `example_config.ini`
+
+Create a copy of this file and replace its' values with your own. Consult the
+template for more information about individual config values and their meaning.
+
+Config files are environment-specific. Use `config_dev.ini` for development and
+`config_prod.ini` for production. Switch between environments using the
+`KEMOVERSE_ENV` environment variable.
+
+```sh
+cp example_config.ini config_dev.ini
+# Edit config_dev.ini
+```
+
+### Setup database
+
+To set up the database, run:
+
+```sh
+KEMOVERSE_ENV=dev python3 setup_db.py
+```
+
+### Run the bot
+
+```sh
+KEMOVERSE_ENV=dev ./startup.sh
+```
+
+If all goes well, you should now be able to interact with the bot.
+
+### Running in production
+
+To run the the in a production environment, use `KEMOVERSE_ENV=prod`. You will
+also need to create a `config_prod.ini` file and run the database setup step
+again if pointing prod to a different database. (you are pointing dev and prod
+to different databases, right? ๐คจ)
+
+### Updating
+
+To update the bot, first pull new changes from upstream:
+
+```sh
+git pull
+```
+
+Then run any database migrations. We recommend testing in dev beforehand to
+make sure nothing breaks in the update process.
+
+**Always backup your prod database before running any migrations!**
+
+```sh
+# Backup database file
+cp gacha_game_dev.db gacha_game_dev.db.bak
+# Run migrations
+KEMOVERSE_ENV=dev python3 setup_db.py
+```
diff --git a/readme.md b/readme.md
index 40d723d..37b9849 100644
--- a/readme.md
+++ b/readme.md
@@ -1,12 +1,9 @@
# Kemoverse
A gacha-style bot for the Fediverse built with Python. Users can roll for characters, trade, duel, and perhaps engage with popularity-based mechanics. Currently designed for use with Misskey. Name comes from Kemonomimi and Fediverse.
-
-## Installation
-
-## Roadmap
-
-
+
+
+
## ๐ง Features
@@ -57,89 +54,6 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
The bot is meant to feel *light, fun, and competitive*. Mixing social, gacha and duel tactics.
-## ๐งช Installation
-
-### Download and install dependencies
-
-Clone the repo
-
-```sh
-git clone https://git.waifuism.life/waifu/kemoverse.git
-cd kemoverse
-```
-
-Setup a virtual environment (Optional, recommended)
-
-```sh
-python3 -m venv venv
-source venv/bin/activate
-```
-
-Install project dependencies via pip
-
-```sh
-python3 -m pip install -r requirements.txt
-```
-
-### Setup config file
-
-A sample config file is included with the project as a template: `example_config.ini`
-
-Create a copy of this file and replace its' values with your own. Consult the
-template for more information about individual config values and their meaning.
-
-Config files are environment-specific. Use `config_dev.ini` for development and
-`config_prod.ini` for production. Switch between environments using the
-`KEMOVERSE_ENV` environment variable.
-
-```sh
-cp example_config.ini config_dev.ini
-# Edit config_dev.ini
-```
-
-### Setup database
-
-To set up the database, run:
-
-```sh
-KEMOVERSE_ENV=dev python3 setup_db.py
-```
-
-### Run the bot
-
-```sh
-KEMOVERSE_ENV=dev ./startup.sh
-```
-
-If all goes well, you should now be able to interact with the bot.
-
-### Running in production
-
-To run the the in a production environment, use `KEMOVERSE_ENV=prod`. You will
-also need to create a `config_prod.ini` file and run the database setup step
-again if pointing prod to a different database. (you are pointing dev and prod
-to different databases, right? ๐คจ)
-
-### Updating
-
-To update the bot, first pull new changes from upstream:
-
-```sh
-git pull
-```
-
-Then run any database migrations. We recommend testing in dev beforehand to
-make sure nothing breaks in the update process.
-
-**Always backup your prod database before running any migrations!**
-
-```sh
-# Backup database file
-cp gacha_game_dev.db gacha_game_dev.db.bak
-# Run migrations
-KEMOVERSE_ENV=dev python3 setup_db.py
-```
-
```mermaid
flowchart TD
From 62cc80033db95a4df7f7b7e78c8158f0a1c95726 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 10 Jun 2025 00:23:28 -0300
Subject: [PATCH 42/43] docs
---
docs/index.md | 2 +-
readme.md | 9 ++++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/docs/index.md b/docs/index.md
index 3b6683c..292d673 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,4 +1,4 @@
-# ๐ฒ Kemoverse Gacha Game Documentation
+# ๐ฒ Kemoverse Documentation
Welcome to the developer documentation for **Kemoverse**, a gacha trading card game in the Fediverse!
diff --git a/readme.md b/readme.md
index 37b9849..437b09f 100644
--- a/readme.md
+++ b/readme.md
@@ -5,6 +5,12 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
+## ๐ Docs
+
+๐ [**Start reading the docs**](./docs/index.md)
+
+๐ค [**Install instructions for those in a rush**](docs/install.md)
+
## ๐ง Features
### โ Implemented
@@ -12,10 +18,11 @@ A gacha-style bot for the Fediverse built with Python. Users can roll for charac
- ๐ง Core database structure for cards
- ๐ฆ Basic support for storing pulls per player
- โฑ๏ธ Time-based limitations on rolls
+- โ ๏ธ Explicit account creation/deletion
### ๐งฉ In Progress
- ๐ Whitelist system to limit access
-- โ ๏ธ Explicit account creation/deletion
+
## ๐ง Roadmap
From fa21ce201dfed12814bde078025ab78139e07de0 Mon Sep 17 00:00:00 2001
From: w
Date: Tue, 10 Jun 2025 00:32:53 -0300
Subject: [PATCH 43/43] Theme and visual identity
---
docs/theme.md | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 docs/theme.md
diff --git a/docs/theme.md b/docs/theme.md
new file mode 100644
index 0000000..69e9ed3
--- /dev/null
+++ b/docs/theme.md
@@ -0,0 +1,33 @@
+Welcome to the **Visual Identity** guide for the Kemoverse. This page contains the standard colors, logos, and graphic elements used across the game (cards, UI, web presence, bots, etc). Please follow these guidelines to ensure consistency.
+
+---
+
+## ๐ข Primary Color Palette
+
+| Color Name | Hex Code | Usage |
+|----------------|------------|--------------------------------------|
+| Green | `#5aa02c` | Main buttons, links, headers |
+| Midnight Black | `#1A1A1A` | Backgrounds, dark mode |
+| Misty White | `#FAFAFA` | Default backgrounds, light text bg |
+| Soft Gray | `#CCCCCC` | Borders, placeholders, separators |
+| Highlight Green | `#8dd35f` | Alerts, emphasis, icons |
+| Rarity Gold | `#FFD700` | Special rare cards, SSR outlines |
+| Rarity Silver | `#C0C0C0` | Rare card text, stat glow effects |
+
+> โ Use `Green` and `Misty White` for the standard UI. Avoid mixing in extra palettes unless explicitly needed.
+
+---
+
+## ๐ผ Logos
+
+### Main Logo
+
+
+
+
+
+- File: `web/static/logo.png`
+- Usage: Website header, favicon, bot avatar, watermark
+
+
+---
\ No newline at end of file