aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-04-28 20:05:36 +0200
committersalmonik <raisersoft@proton.me>2026-04-28 20:05:36 +0200
commit5675dfd20b24f1e0eef06b11d9ec41a894d3a5bf (patch)
treecaad1669db6fce506dd0494aeaf2a43f7e291679 /cogs
parentbetter UX for web.py (diff)
downloadsenator-sperma-5675dfd20b24f1e0eef06b11d9ec41a894d3a5bf.tar.gz
senator-sperma-5675dfd20b24f1e0eef06b11d9ec41a894d3a5bf.zip
multitude of changes.
but primarly, use db for all data. Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'cogs')
-rw-r--r--cogs/assistant.py2
-rw-r--r--cogs/compression.py2
-rw-r--r--cogs/conversion.py6
-rw-r--r--cogs/cron.py103
-rw-r--r--cogs/music/play_commands.py8
-rw-r--r--cogs/trigger.py84
-rw-r--r--cogs/uploader.py9
-rw-r--r--cogs/utils/database.py111
-rw-r--r--cogs/utils/file_search.py18
-rw-r--r--cogs/utils/settings.py69
-rw-r--r--cogs/vclogs.py70
-rw-r--r--cogs/voice.py22
-rw-r--r--cogs/web.py4
13 files changed, 342 insertions, 166 deletions
diff --git a/cogs/assistant.py b/cogs/assistant.py
index 21e4989..9926361 100644
--- a/cogs/assistant.py
+++ b/cogs/assistant.py
@@ -100,7 +100,7 @@ class Jarvis(commands.Cog):
100 @staticmethod 100 @staticmethod
101 def _build_payload(messages): 101 def _build_payload(messages):
102 return { 102 return {
103 "model": "arcee-ai/trinity-large-preview:free", 103 "model": os.getenv("OPENROUTER_MODEL"),
104 "messages": messages, 104 "messages": messages,
105 "response_format": Jarvis._get_response_schema(), 105 "response_format": Jarvis._get_response_schema(),
106 "plugins": [{"id": "response-healing"}] 106 "plugins": [{"id": "response-healing"}]
diff --git a/cogs/compression.py b/cogs/compression.py
index acc9eda..7171abb 100644
--- a/cogs/compression.py
+++ b/cogs/compression.py
@@ -38,7 +38,7 @@ class Compression(commands.Cog):
38 self.bot = bot 38 self.bot = bot
39 self.temp_dir = get_temp_dir() 39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {} 40 self._cached_urls = {}
41 self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() 41 self._baseurl = get_baseurl()
42 42
43 async def _file_autocomplete(self, ctx: AutocompleteContext): 43 async def _file_autocomplete(self, ctx: AutocompleteContext):
44 query = ctx.value.lower() if ctx.value else "" 44 query = ctx.value.lower() if ctx.value else ""
diff --git a/cogs/conversion.py b/cogs/conversion.py
index 1117caf..455bfde 100644
--- a/cogs/conversion.py
+++ b/cogs/conversion.py
@@ -35,11 +35,11 @@ class Conversion(commands.Cog):
35 def __init__(self, bot): 35 def __init__(self, bot):
36 self.bot = bot 36 self.bot = bot
37 from cogs.utils.temp import get_temp_dir 37 from cogs.utils.temp import get_temp_dir
38 from cogs.utils.settings import get_baseurl 38 from cogs.utils.settings import get_baseurl, get_uploadurl
39 self.temp_dir = get_temp_dir() 39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {} 40 self._cached_urls = {}
41 self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() 41 self._baseurl = get_baseurl()
42 self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") 42 self._uploadurl = get_uploadurl()
43 43
44 async def _file_autocomplete(self, ctx: AutocompleteContext): 44 async def _file_autocomplete(self, ctx: AutocompleteContext):
45 query = ctx.value.lower() if ctx.value else "" 45 query = ctx.value.lower() if ctx.value else ""
diff --git a/cogs/cron.py b/cogs/cron.py
index 4a63e3a..b4f494a 100644
--- a/cogs/cron.py
+++ b/cogs/cron.py
@@ -4,9 +4,7 @@
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import asyncio 6import asyncio
7import json
8import logging 7import logging
9import os
10from datetime import datetime 8from datetime import datetime
11from venv import logger 9from venv import logger
12 10
@@ -16,37 +14,74 @@ from discord.ext import commands
16 14
17from cogs.utils import file_search as search_utils 15from cogs.utils import file_search as search_utils
18from cogs.voice import get_voice_manager 16from cogs.voice import get_voice_manager
17from cogs.utils.database import get_connection, init_database
19 18
20logger = logging.getLogger(__name__) 19logger = logging.getLogger(__name__)
21 20
21init_database()
22
22 23
23class Cron(commands.Cog): 24class Cron(commands.Cog):
24 def __init__(self, bot): 25 def __init__(self, bot):
25 self.bot = bot 26 self.bot = bot
26 self.scheduled_tasks = [] 27 self.scheduled_tasks = []
27 self.cron_file = "data/cron_jobs.json"
28 self.load_tasks() 28 self.load_tasks()
29 self.task = bot.loop.create_task(self.schedule_checker()) 29 self.task = bot.loop.create_task(self.schedule_checker())
30 30
31 def load_tasks(self): 31 def load_tasks(self):
32 """Load scheduled tasks from file"""
33 try: 32 try:
34 if os.path.exists(self.cron_file): 33 conn = get_connection()
35 with open(self.cron_file, "r") as f: 34 try:
36 self.scheduled_tasks = json.load(f) 35 c = conn.cursor()
37 logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs") 36 c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs")
38 else: 37 rows = c.fetchall()
39 self.scheduled_tasks = [] 38 finally:
39 conn.close()
40
41 self.scheduled_tasks = [
42 {
43 "id": row[0],
44 "cron_expression": row[1],
45 "type": row[2],
46 "channel_id": row[3],
47 "active": bool(row[4]),
48 "audio_filename": row[5],
49 "message": row[6],
50 }
51 for row in rows
52 ]
53 logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database")
40 except Exception as e: 54 except Exception as e:
41 logger.error(f"Error loading cron jobs: {e}") 55 logger.error(f"Error loading cron jobs: {e}")
42 self.scheduled_tasks = [] 56 self.scheduled_tasks = []
43 57
44 def save_tasks(self): 58 def save_tasks(self):
45 """Save scheduled tasks to file"""
46 try: 59 try:
47 os.makedirs(os.path.dirname(self.cron_file), exist_ok=True) 60 conn = get_connection()
48 with open(self.cron_file, "w") as f: 61 try:
49 json.dump(self.scheduled_tasks, f, indent=2) 62 c = conn.cursor()
63 c.execute("PRAGMA foreign_keys = ON")
64
65 c.execute("DELETE FROM cron_jobs")
66
67 for task in self.scheduled_tasks:
68 c.execute(
69 """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message)
70 VALUES (?, ?, ?, ?, ?, ?, ?)""",
71 (
72 task.get("id"),
73 task["cron_expression"],
74 task["type"],
75 task["channel_id"],
76 int(task.get("active", True)),
77 task.get("audio_filename"),
78 task.get("message"),
79 ),
80 )
81
82 conn.commit()
83 finally:
84 conn.close()
50 except Exception as e: 85 except Exception as e:
51 logger.error(f"Error saving cron jobs: {e}") 86 logger.error(f"Error saving cron jobs: {e}")
52 87
@@ -132,11 +167,13 @@ class Cron(commands.Cog):
132 while voice_client.is_playing(): 167 while voice_client.is_playing():
133 await asyncio.sleep(1) 168 await asyncio.sleep(1)
134 169
170 logger.info("Audio playback finished normally - disconnecting")
135 await voice_client.disconnect() 171 await voice_client.disconnect()
136 logger.info(f"Finished playing audio: {audio_url}") 172 logger.info(f"Finished playing audio: {audio_url}")
137 except Exception as e: 173 except Exception as e:
138 logger.error(f"Error playing audio: {e}") 174 logger.error(f"Error playing audio: {e}")
139 if voice_client.is_connected(): 175 if voice_client.is_connected():
176 logger.info("Audio playback failed - disconnecting")
140 await voice_client.disconnect() 177 await voice_client.disconnect()
141 if channel.guild.system_channel: 178 if channel.guild.system_channel:
142 await channel.guild.system_channel.send( 179 await channel.guild.system_channel.send(
@@ -176,8 +213,19 @@ class Cron(commands.Cog):
176 else [discord.OptionChoice(name="Brak wyników", value="")] 213 else [discord.OptionChoice(name="Brak wyników", value="")]
177 ) 214 )
178 215
179 @commands.slash_command( 216 def _get_baseurl(self):
180 name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio" 217 """Get base URL from settings"""
218 try:
219 from cogs.utils.settings import get_baseurl
220
221 return get_baseurl()
222 except Exception:
223 return ""
224
225 trigger_group = discord.SlashCommandGroup("cron", "komendy cron")
226
227 @trigger_group.command(
228 name="dodaj_audio", description="Dodaj nowe zadanie cron audio"
181 ) 229 )
182 async def cron_add_audio( 230 async def cron_add_audio(
183 self, 231 self,
@@ -227,8 +275,8 @@ class Cron(commands.Cog):
227 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" 275 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>"
228 ) 276 )
229 277
230 @commands.slash_command( 278 @trigger_group.command(
231 name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" 279 name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość"
232 ) 280 )
233 async def cron_add_message( 281 async def cron_add_message(
234 self, 282 self,
@@ -272,17 +320,8 @@ class Cron(commands.Cog):
272 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" 320 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>"
273 ) 321 )
274 322
275 def _get_baseurl(self): 323 @trigger_group.command(
276 """Get base URL from settings""" 324 name="pokaż", description="Pokaż wszystkie zadania cron"
277 try:
278 from cogs.utils.settings import get_baseurl
279
280 return get_baseurl()
281 except Exception:
282 return ""
283
284 @commands.slash_command(
285 name="cron_pokaż", description="Pokaż wszystkie zadania cron"
286 ) 325 )
287 async def cron_list(self, ctx): 326 async def cron_list(self, ctx):
288 """List all cron jobs""" 327 """List all cron jobs"""
@@ -313,7 +352,7 @@ class Cron(commands.Cog):
313 352
314 await ctx.respond(embed=embed) 353 await ctx.respond(embed=embed)
315 354
316 @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID") 355 @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID")
317 async def cron_remove( 356 async def cron_remove(
318 self, 357 self,
319 ctx, 358 ctx,
@@ -331,7 +370,7 @@ class Cron(commands.Cog):
331 370
332 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") 371 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
333 372
334 @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID") 373 @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID")
335 async def cron_pause( 374 async def cron_pause(
336 self, 375 self,
337 ctx, 376 ctx,
@@ -349,7 +388,7 @@ class Cron(commands.Cog):
349 388
350 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") 389 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
351 390
352 @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID") 391 @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID")
353 async def cron_resume( 392 async def cron_resume(
354 self, 393 self,
355 ctx, 394 ctx,
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index 5373436..c53dbc7 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -18,6 +18,7 @@ from discord.ext import commands
18 18
19from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
20from cogs.utils.file_search import UniversalSearcher 20from cogs.utils.file_search import UniversalSearcher
21from cogs.utils.settings import get_uploadurl
21 22
22logger = logging.getLogger(__name__) 23logger = logging.getLogger(__name__)
23 24
@@ -229,14 +230,13 @@ class PlayCommands(commands.Cog):
229 230
230 try: 231 try:
231 logger.info(f"Starting async upload: {file_path}") 232 logger.info(f"Starting async upload: {file_path}")
232 baseurl = os.getenv("COPYPARTY_URL_UPLOAD") 233 baseurl = get_uploadurl()
233 password = os.getenv("COPYPARTY_PASSWORD", "") 234 password = os.getenv("COPYPARTY_PASSWORD", "")
234 235
235 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") 236 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}")
236 237
237 if not baseurl: 238 if baseurl == "http://localhost:3939":
238 logger.error("COPYPARTY_URL_UPLOAD not set!") 239 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
239 return
240 240
241 with open(file_path, "rb") as f: 241 with open(file_path, "rb") as f:
242 content = f.read() 242 content = f.read()
diff --git a/cogs/trigger.py b/cogs/trigger.py
index 02e5573..ce80d43 100644
--- a/cogs/trigger.py
+++ b/cogs/trigger.py
@@ -7,13 +7,13 @@ import os
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import random 9import random
10import sqlite3
11import logging 10import logging
12 11
13logger = logging.getLogger(__name__) 12from cogs.utils.database import get_connection, init_database
14 13
14logger = logging.getLogger(__name__)
15 15
16DB_PATH = 'data/triggers.db' 16init_database()
17 17
18_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" 18_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?"
19_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" 19_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?"
@@ -22,88 +22,12 @@ _TRIGGER_NOT_FOUND_MSG = "Trigger not found."
22_TRIGGER_NAME_OPTION = "the name of the trigger" 22_TRIGGER_NAME_OPTION = "the name of the trigger"
23 23
24 24
25def get_connection():
26 return sqlite3.connect(DB_PATH)
27
28
29class Trigger(commands.Cog): 25class Trigger(commands.Cog):
30 def __init__(self, bot): 26 def __init__(self, bot):
31 self.bot = bot 27 self.bot = bot
32 self.db_check()
33 28
34 trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") 29 trigger_group = discord.SlashCommandGroup("trigger", "trigger commands")
35 30
36 @staticmethod
37 def db_check():
38 if not os.path.exists('data'):
39 os.makedirs('data')
40
41 db_exists = os.path.exists(DB_PATH)
42 if not db_exists:
43 logger.warning("Triggers database not found. Creating new database.")
44
45 try:
46 conn = sqlite3.connect(DB_PATH)
47 c = conn.cursor()
48
49 # Enable foreign key constraints
50 c.execute('PRAGMA foreign_keys = ON')
51
52 # Create triggers table
53 c.execute('''
54 CREATE TABLE IF NOT EXISTS triggers (
55 id INTEGER PRIMARY KEY AUTOINCREMENT,
56 name TEXT NOT NULL UNIQUE,
57 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
58 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
59 )''')
60
61 # Create trigger_keywords table with foreign key constraint
62 c.execute('''
63 CREATE TABLE IF NOT EXISTS trigger_keywords (
64 id INTEGER PRIMARY KEY AUTOINCREMENT,
65 trigger_id INTEGER NOT NULL,
66 keyword TEXT NOT NULL,
67 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
68 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE,
69 UNIQUE(trigger_id, keyword)
70 )''')
71
72 # Create trigger_responses table with foreign key constraint
73 c.execute('''
74 CREATE TABLE IF NOT EXISTS trigger_responses (
75 id INTEGER PRIMARY KEY AUTOINCREMENT,
76 trigger_id INTEGER NOT NULL,
77 response_text TEXT NOT NULL,
78 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
79 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
80 )''')
81
82 # Create indexes for better query performance
83 c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)')
84 c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)')
85 c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)')
86
87 # Run integrity check
88 integrity_check = c.execute('PRAGMA integrity_check').fetchone()
89 if integrity_check and integrity_check[0] == 'ok':
90 logger.info("Triggers database integrity check passed")
91 else:
92 logger.warning(f"Triggers database integrity check: {integrity_check}")
93
94 conn.commit()
95
96 except sqlite3.Error as e:
97 logger.error(f"Triggers database error: {e}")
98 raise
99
100 finally:
101 if 'conn' in locals():
102 conn.close()
103
104 if not db_exists:
105 logger.info("Triggers database initialized successfully")
106
107 @commands.Cog.listener() 31 @commands.Cog.listener()
108 async def on_message(self, message): 32 async def on_message(self, message):
109 if message.author == self.bot.user: 33 if message.author == self.bot.user:
@@ -130,7 +54,7 @@ class Trigger(commands.Cog):
130 54
131 @staticmethod 55 @staticmethod
132 def get_type(name): 56 def get_type(name):
133 with sqlite3.connect(DB_PATH) as conn: 57 with get_connection() as conn:
134 c = conn.cursor() 58 c = conn.cursor()
135 c.execute("SELECT name FROM triggers") 59 c.execute("SELECT name FROM triggers")
136 return [row[0] for row in c.fetchall()] 60 return [row[0] for row in c.fetchall()]
diff --git a/cogs/uploader.py b/cogs/uploader.py
index 68a3938..df6908e 100644
--- a/cogs/uploader.py
+++ b/cogs/uploader.py
@@ -7,11 +7,12 @@ import os
7import aiofiles 7import aiofiles
8import requests 8import requests
9import urllib.parse 9import urllib.parse
10from typing import List, Optional 10from typing import Optional
11import discord 11import discord
12from discord.ext import commands 12from discord.ext import commands
13from discord import AutocompleteContext 13from discord import AutocompleteContext
14from cogs.utils import file_search as search_utils 14from cogs.utils import file_search as search_utils
15from cogs.utils.settings import get_uploadurl
15import logging 16import logging
16 17
17logger = logging.getLogger(__name__) 18logger = logging.getLogger(__name__)
@@ -23,7 +24,7 @@ class Uploader(commands.Cog):
23 self.bot = bot 24 self.bot = bot
24 self.temp_dir = get_temp_dir() 25 self.temp_dir = get_temp_dir()
25 self._cached_urls = {} 26 self._cached_urls = {}
26 self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") 27 self._uploadurl = get_uploadurl()
27 28
28 async def _file_autocomplete(self, ctx: AutocompleteContext): 29 async def _file_autocomplete(self, ctx: AutocompleteContext):
29 query = ctx.value.lower() if ctx.value else "" 30 query = ctx.value.lower() if ctx.value else ""
@@ -77,8 +78,8 @@ class Uploader(commands.Cog):
77 78
78 async def _upload_file(self, file_path: str) -> Optional[str]: 79 async def _upload_file(self, file_path: str) -> Optional[str]:
79 """Upload a file and return the URL""" 80 """Upload a file and return the URL"""
80 if not self._uploadurl: 81 if self._uploadurl == "http://localhost:3939":
81 logger.error("COPYPARTY_URL_UPLOAD not set!") 82 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
82 return None 83 return None
83 84
84 return await self._upload_to_copyparty(file_path) 85 return await self._upload_to_copyparty(file_path)
diff --git a/cogs/utils/database.py b/cogs/utils/database.py
new file mode 100644
index 0000000..67cbc9f
--- /dev/null
+++ b/cogs/utils/database.py
@@ -0,0 +1,111 @@
1import os
2import sqlite3
3import logging
4
5logger = logging.getLogger(__name__)
6
7DB_PATH = "data/senator.db"
8
9_initialized = False
10
11
12def get_connection():
13 return sqlite3.connect(get_db_path())
14
15
16def get_db_path():
17 return os.getenv("SENATOR_DB_PATH", DB_PATH)
18
19
20def set_db_path(path):
21 global DB_PATH
22 DB_PATH = path
23
24
25def init_database():
26 global _initialized
27 if _initialized:
28 return
29
30 _initialized = True
31
32 db_path = get_db_path()
33 db_dir = os.path.dirname(db_path)
34 if db_dir and not os.path.exists(db_dir):
35 os.makedirs(db_dir)
36
37 conn = sqlite3.connect(db_path)
38 try:
39 c = conn.cursor()
40
41 c.execute("PRAGMA foreign_keys = ON")
42
43 c.execute("""
44 CREATE TABLE IF NOT EXISTS settings (
45 key TEXT PRIMARY KEY,
46 value TEXT
47 )""")
48
49 c.execute("""
50 CREATE TABLE IF NOT EXISTS cron_jobs (
51 id INTEGER PRIMARY KEY AUTOINCREMENT,
52 cron_expression TEXT NOT NULL,
53 type TEXT NOT NULL,
54 channel_id INTEGER NOT NULL,
55 active INTEGER DEFAULT 1,
56 audio_filename TEXT,
57 message TEXT,
58 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
59 )""")
60
61 c.execute("""
62 CREATE TABLE IF NOT EXISTS voice_activity (
63 user_id TEXT PRIMARY KEY,
64 join_time REAL,
65 total_active REAL DEFAULT 0,
66 total_afk REAL DEFAULT 0,
67 is_afk INTEGER DEFAULT 0,
68 afk_start REAL,
69 active_start REAL,
70 channel_name TEXT,
71 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
72 )""")
73
74 c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)")
75 c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)")
76
77 c.execute("""
78 CREATE TABLE IF NOT EXISTS triggers (
79 id INTEGER PRIMARY KEY AUTOINCREMENT,
80 name TEXT NOT NULL UNIQUE,
81 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
82 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
83 )""")
84
85 c.execute("""
86 CREATE TABLE IF NOT EXISTS trigger_keywords (
87 id INTEGER PRIMARY KEY AUTOINCREMENT,
88 trigger_id INTEGER NOT NULL,
89 keyword TEXT NOT NULL,
90 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
91 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE,
92 UNIQUE(trigger_id, keyword)
93 )""")
94
95 c.execute("""
96 CREATE TABLE IF NOT EXISTS trigger_responses (
97 id INTEGER PRIMARY KEY AUTOINCREMENT,
98 trigger_id INTEGER NOT NULL,
99 response_text TEXT NOT NULL,
100 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
101 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
102 )""")
103
104 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)")
105 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)")
106 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)")
107
108 conn.commit()
109 logger.info("Database initialized successfully")
110 finally:
111 conn.close()
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py
index 8111216..788f50c 100644
--- a/cogs/utils/file_search.py
+++ b/cogs/utils/file_search.py
@@ -34,10 +34,22 @@ class UniversalSearcher:
34 query = query.strip() 34 query = query.strip()
35 logger.info(f"Starting copyparty search for query: '{query}'") 35 logger.info(f"Starting copyparty search for query: '{query}'")
36 36
37 # Build the query with optional extension filtering 37 tokens = query.split()
38 name_query = f'name like "*{query}*"' 38 if not tokens:
39 return []
40
41 token_queries = []
42 for token in tokens:
43 path_query = f'path like "*{token}*"'
44 name_query = f'name like "*{token}*"'
45 token_queries.append(f"({path_query} or {name_query})")
46
47 if len(token_queries) == 1:
48 name_query = token_queries[0]
49 else:
50 name_query = " and ".join(token_queries)
51
39 if file_extensions: 52 if file_extensions:
40 # Create extension filters using copyparty's .ext$ syntax
41 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] 53 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions]
42 ext_query = " or ".join(ext_filters) 54 ext_query = " or ".join(ext_filters)
43 query = f"{name_query} and ({ext_query})" 55 query = f"{name_query} and ({ext_query})"
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py
index d66ce7f..7146b6c 100644
--- a/cogs/utils/settings.py
+++ b/cogs/utils/settings.py
@@ -3,16 +3,18 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import json
7import logging 6import logging
8import aiofiles 7import sqlite3
9import os 8import os
10from dataclasses import dataclass, field 9from dataclasses import dataclass, field
11 10
11from cogs.utils.database import get_connection, init_database
12
12logger = logging.getLogger(__name__) 13logger = logging.getLogger(__name__)
13 14
14settings_file = "data/settings.json" 15DEFAULT_BASEURL = "http://localhost:3939"
15DEFAULT_BASEURL = "http://localhost:8080" 16
17init_database()
16 18
17 19
18@dataclass 20@dataclass
@@ -28,24 +30,22 @@ class Settings:
28 } 30 }
29 31
30 try: 32 try:
31 if os.path.exists(settings_file): 33 conn = get_connection()
32 async with aiofiles.open(settings_file, "r") as f: 34 c = conn.cursor()
33 content = await f.read() 35 c.execute("SELECT key, value FROM settings")
34 if content.strip(): 36 rows = c.fetchall()
35 self._data = json.loads(content) 37 conn.close()
36 self._sync_from_data() 38
37 logger.info("Settings loaded into memory") 39 if rows:
38 else: 40 self._data = {row[0]: row[1] for row in rows}
39 self._data = default_settings.copy() 41 self._sync_from_data()
40 self._sync_from_data() 42 logger.info("Settings loaded from database")
41 await self.save()
42 logger.warning("Empty settings.json, reset to defaults")
43 else: 43 else:
44 self._data = default_settings.copy() 44 self._data = default_settings.copy()
45 self._sync_from_data() 45 self._sync_from_data()
46 await self.save() 46 await self.save()
47 logger.info("Created default settings.json") 47 logger.info("Created default settings in database")
48 except json.JSONDecodeError as e: 48 except Exception as e:
49 logger.error(f"Error loading settings: {e}") 49 logger.error(f"Error loading settings: {e}")
50 self._data = default_settings.copy() 50 self._data = default_settings.copy()
51 self._sync_from_data() 51 self._sync_from_data()
@@ -53,17 +53,28 @@ class Settings:
53 return self 53 return self
54 54
55 def _sync_from_data(self): 55 def _sync_from_data(self):
56 self.volume = self._data.get("volume", 1.0) 56 try:
57 self.volume = float(self._data.get("volume", 1.0))
58 except (ValueError, TypeError):
59 self.volume = 1.0
57 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) 60 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL)
58 61
59 def _sync_to_data(self): 62 def _sync_to_data(self):
60 self._data["volume"] = self.volume 63 self._data["volume"] = str(self.volume)
61 self._data["baseurl"] = self.baseurl 64 self._data["baseurl"] = self.baseurl
62 65
63 async def save(self): 66 async def save(self):
64 self._sync_to_data() 67 self._sync_to_data()
65 async with aiofiles.open(settings_file, "w") as f: 68 conn = get_connection()
66 await f.write(json.dumps(self._data, indent=2)) 69 c = conn.cursor()
70 c.execute("PRAGMA foreign_keys = ON")
71 for key, value in self._data.items():
72 c.execute(
73 "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
74 (key, value),
75 )
76 conn.commit()
77 conn.close()
67 78
68 79
69_settings_instance: Settings | None = None 80_settings_instance: Settings | None = None
@@ -90,4 +101,16 @@ def get_volume() -> float:
90 101
91 102
92def get_baseurl() -> str: 103def get_baseurl() -> str:
93 return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL 104 url = os.getenv("COPYPARTY_URL")
105 if not url:
106 logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939")
107 return DEFAULT_BASEURL
108 return url.rstrip("/")
109
110
111def get_uploadurl() -> str:
112 url = os.getenv("COPYPARTY_URL_UPLOAD")
113 if not url:
114 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939")
115 return DEFAULT_BASEURL
116 return url.rstrip("/")
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
index d0c4825..404ffe0 100644
--- a/cogs/vclogs.py
+++ b/cogs/vclogs.py
@@ -4,7 +4,6 @@
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import datetime 6import datetime
7import json
8import logging 7import logging
9import os 8import os
10import asyncio 9import asyncio
@@ -13,24 +12,69 @@ from discord.ext import commands
13from gtts import gTTS 12from gtts import gTTS
14from cogs.voice import get_voice_manager 13from cogs.voice import get_voice_manager
15from cogs.utils.settings import get_volume, save_settings 14from cogs.utils.settings import get_volume, save_settings
15from cogs.utils.database import get_connection, init_database
16 16
17import tempfile 17import tempfile
18 18
19logger = logging.getLogger(__name__) 19logger = logging.getLogger(__name__)
20 20
21ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') 21init_database()
22 22
23 23
24def load_activity_data(): 24def load_activity_data():
25 if os.path.exists(ACTIVITY_FILE): 25 try:
26 with open(ACTIVITY_FILE, 'r') as f: 26 conn = get_connection()
27 return json.load(f) 27 c = conn.cursor()
28 return {} 28 c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity")
29 rows = c.fetchall()
30 conn.close()
31
32 return {
33 row[0]: {
34 "join_time": row[1],
35 "total_active": row[2],
36 "total_afk": row[3],
37 "is_afk": bool(row[4]),
38 "afk_start": row[5],
39 "active_start": row[6],
40 "channel_name": row[7],
41 }
42 for row in rows
43 }
44 except Exception as e:
45 logger.error(f"Error loading voice activity: {e}")
46 return {}
29 47
30 48
31def save_activity_data(data): 49def save_activity_data(data):
32 with open(ACTIVITY_FILE, 'w') as f: 50 try:
33 json.dump(data, f, indent=4) 51 conn = get_connection()
52 c = conn.cursor()
53 c.execute("PRAGMA foreign_keys = ON")
54
55 c.execute("DELETE FROM voice_activity")
56
57 for user_id, user_data in data.items():
58 c.execute(
59 """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
60 VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
61 (
62 user_id,
63 user_data.get("join_time"),
64 user_data.get("total_active", 0),
65 user_data.get("total_afk", 0),
66 int(user_data.get("is_afk", False)),
67 user_data.get("afk_start"),
68 user_data.get("active_start"),
69 user_data.get("channel_name"),
70 ),
71 )
72
73 conn.commit()
74 conn.close()
75 logger.info(f"Saved voice activity for {len(data)} users")
76 except Exception as e:
77 logger.error(f"Error saving voice activity: {e}")
34 78
35 79
36def format_duration(seconds): 80def format_duration(seconds):
@@ -168,7 +212,7 @@ class VcLogs(commands.Cog):
168 } 212 }
169 save_activity_data(activity_data) 213 save_activity_data(activity_data)
170 214
171 message = f"[{current_time}] {member.name} dołączył na {after.channel.name}" 215 message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>"
172 tts_text = f"{member.display_name} sie zespawnił" 216 tts_text = f"{member.display_name} sie zespawnił"
173 tts_channel = after.channel 217 tts_channel = after.channel
174 await log_channel.send(message) 218 await log_channel.send(message)
@@ -189,7 +233,7 @@ class VcLogs(commands.Cog):
189 afk_str = format_duration(data['total_afk']) 233 afk_str = format_duration(data['total_afk'])
190 234
191 message = ( 235 message = (
192 f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" 236 f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n"
193 f"Aktywny: {active_str} | AFK: {afk_str}" 237 f"Aktywny: {active_str} | AFK: {afk_str}"
194 ) 238 )
195 tts_text = f"{member.display_name} jest chujem" 239 tts_text = f"{member.display_name} jest chujem"
@@ -200,7 +244,7 @@ class VcLogs(commands.Cog):
200 del activity_data[user_id] 244 del activity_data[user_id]
201 save_activity_data(activity_data) 245 save_activity_data(activity_data)
202 else: 246 else:
203 message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name}" 247 message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>"
204 tts_text = f"{member.display_name} jest chujem" 248 tts_text = f"{member.display_name} jest chujem"
205 tts_channel = before.channel 249 tts_channel = before.channel
206 await log_channel.send(message) 250 await log_channel.send(message)
@@ -224,7 +268,7 @@ class VcLogs(commands.Cog):
224 else: 268 else:
225 activity = "odgłuchnął" 269 activity = "odgłuchnął"
226 270
227 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" 271 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
228 f"AFK: {afk_str}" ) 272 f"AFK: {afk_str}" )
229 await log_channel.send(message) 273 await log_channel.send(message)
230 logger.info(f"User returned from AFK: {message}") 274 logger.info(f"User returned from AFK: {message}")
@@ -244,7 +288,7 @@ class VcLogs(commands.Cog):
244 activity = "ogłuchnął" 288 activity = "ogłuchnął"
245 289
246 active_str = format_duration(active_since_start) 290 active_str = format_duration(active_since_start)
247 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" 291 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
248 f"Aktywny: {active_str}" ) 292 f"Aktywny: {active_str}" )
249 await log_channel.send(message) 293 await log_channel.send(message)
250 logger.info(f"User went AFK: {message}") 294 logger.info(f"User went AFK: {message}")
diff --git a/cogs/voice.py b/cogs/voice.py
index 319bb16..046c2a8 100644
--- a/cogs/voice.py
+++ b/cogs/voice.py
@@ -45,6 +45,7 @@ class VoiceManager:
45 return voice_client 45 return voice_client
46 except Exception as e: 46 except Exception as e:
47 logger.error(f"Failed to move voice client: {e}") 47 logger.error(f"Failed to move voice client: {e}")
48 logger.info("Disconnecting after failed move attempt")
48 try: 49 try:
49 await voice_client.disconnect() 50 await voice_client.disconnect()
50 except Exception: 51 except Exception:
@@ -55,6 +56,25 @@ class VoiceManager:
55 voice_client = await channel.connect(timeout=30.0, reconnect=False) 56 voice_client = await channel.connect(timeout=30.0, reconnect=False)
56 logger.info(f"Connected to voice channel: {channel}") 57 logger.info(f"Connected to voice channel: {channel}")
57 return voice_client 58 return voice_client
59 except discord.ClientException as e:
60 if "Already connected" in str(e):
61 logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}")
62 voice_client = discord.utils.get(self.bot.voice_clients, guild=guild)
63 if voice_client:
64 try:
65 await voice_client.disconnect()
66 except Exception:
67 pass
68 await asyncio.sleep(0.3)
69 try:
70 voice_client = await channel.connect(timeout=30.0, reconnect=False)
71 logger.info(f"Reconnected to voice channel: {channel}")
72 return voice_client
73 except Exception as e2:
74 logger.error(f"Failed to reconnect after stale cleanup: {e2}")
75 return None
76 logger.error(f"Failed to connect to voice channel: {e}")
77 return None
58 except Exception as e: 78 except Exception as e:
59 logger.error(f"Failed to connect to voice channel: {e}") 79 logger.error(f"Failed to connect to voice channel: {e}")
60 return None 80 return None
@@ -113,6 +133,7 @@ class VoiceManager:
113 voice_client = await self.get_voice_client(guild) 133 voice_client = await self.get_voice_client(guild)
114 if voice_client: 134 if voice_client:
115 voice_client.stop() 135 voice_client.stop()
136 logger.info("stop() called - disconnecting from voice")
116 await voice_client.disconnect() 137 await voice_client.disconnect()
117 logger.info(f"Stopped and disconnected from guild {guild}") 138 logger.info(f"Stopped and disconnected from guild {guild}")
118 139
@@ -174,6 +195,7 @@ class Voice(commands.Cog):
174 195
175 if voice_client and not voice_client.is_connected(): 196 if voice_client and not voice_client.is_connected():
176 try: 197 try:
198 logger.info("Voice client exists but not connected - disconnecting stale client")
177 await voice_client.disconnect() 199 await voice_client.disconnect()
178 except Exception: 200 except Exception:
179 pass 201 pass
diff --git a/cogs/web.py b/cogs/web.py
index 607d917..eea13b1 100644
--- a/cogs/web.py
+++ b/cogs/web.py
@@ -67,7 +67,7 @@ class Web(commands.Cog):
67 logger.info(f"Query: {query}") 67 logger.info(f"Query: {query}")
68 68
69 if response.status_code != 200: 69 if response.status_code != 200:
70 await ctx.respond(content=f"Error: {response.status_code}") 70 await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True)
71 logger.error(f"Error: {response.status_code}") 71 logger.error(f"Error: {response.status_code}")
72 return 72 return
73 73
@@ -75,7 +75,7 @@ class Web(commands.Cog):
75 links = soup.find_all("a", class_="result__a") 75 links = soup.find_all("a", class_="result__a")
76 76
77 if not links: 77 if not links:
78 await ctx.respond(content="No results found.") 78 await ctx.respond(content="No results found.", ephemeral=True)
79 logger.warning("No results found") 79 logger.warning("No results found")
80 return 80 return
81 81