aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.env.example1
-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
-rw-r--r--main.py3
-rw-r--r--pyrightconfig.json2
-rw-r--r--tests/cogs/test_cron.py88
-rw-r--r--tests/cogs/test_uploader.py226
-rw-r--r--tests/cogs/test_vclogs.py31
-rw-r--r--tests/conftest.py19
20 files changed, 642 insertions, 236 deletions
diff --git a/.env.example b/.env.example
index 7a52a26..9623155 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,4 @@ COPYPARTY_PASSWORD=
12 12
13# Optional - AI commands 13# Optional - AI commands
14OPENROUTER_API_KEY= 14OPENROUTER_API_KEY=
15OPENROUTER_MODEL=
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
diff --git a/main.py b/main.py
index 37a11c6..8f75ef4 100644
--- a/main.py
+++ b/main.py
@@ -26,7 +26,8 @@ def setup_logging(debug: bool = False):
26 26
27 logging.basicConfig( 27 logging.basicConfig(
28 level=level, 28 level=level,
29 format="%(levelname)s - %(filename)s - %(name)s - %(message)s", 29 datefmt="%m-%d %H:%M:%S",
30 format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s",
30 handlers=[logging.StreamHandler(sys.stdout)], 31 handlers=[logging.StreamHandler(sys.stdout)],
31 ) 32 )
32 logging.getLogger().addFilter(DecryptionFilter()) 33 logging.getLogger().addFilter(DecryptionFilter())
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 7722ae1..38f1ed5 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,5 +1,5 @@
1{ 1{
2 "include": ["cogs", "main.py", "cogs/utils"], 2 "include": ["cogs", "main.py", "cogs/utils", "tests"],
3 "exclude": ["pycord", ".venv", "temp", "data"], 3 "exclude": ["pycord", ".venv", "temp", "data"],
4 "typeCheckingMode": "basic", 4 "typeCheckingMode": "basic",
5 "reportMissingTypeStubs": false, 5 "reportMissingTypeStubs": false,
diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py
index 93031f8..91fcb82 100644
--- a/tests/cogs/test_cron.py
+++ b/tests/cogs/test_cron.py
@@ -1,5 +1,3 @@
1import json
2import os
3import pytest 1import pytest
4from datetime import datetime 2from datetime import datetime
5from unittest.mock import MagicMock, patch 3from unittest.mock import MagicMock, patch
@@ -19,68 +17,48 @@ def mock_bot():
19 17
20def create_cog(mock_bot, tmp_path): 18def create_cog(mock_bot, tmp_path):
21 with patch.object(Cron, "__init__", lambda self, bot: None): 19 with patch.object(Cron, "__init__", lambda self, bot: None):
22 cog = Cron.__new__(Cron) 20 with patch("cogs.cron.init_database"):
23 cog.bot = mock_bot 21 mock_conn = MagicMock()
24 cog.scheduled_tasks = [] 22 mock_cursor = MagicMock()
25 cog.cron_file = str(tmp_path / "cron_jobs.json") 23 mock_cursor.fetchall.return_value = []
26 cog.task = MagicMock() 24 mock_conn.cursor.return_value = mock_cursor
27 return cog 25 mock_conn.close.return_value = None
26
27 with patch("cogs.cron.get_connection", return_value=mock_conn):
28 with patch("cogs.utils.database.get_db_path", return_value=":memory:"):
29 cog = Cron.__new__(Cron)
30 cog.bot = mock_bot
31 cog.scheduled_tasks = []
32 cog.task = MagicMock()
33 return cog
28 34
29 35
30class TestLoadTasks: 36class TestLoadTasks:
31 def test_load_tasks_file_not_exists(self, mock_bot, tmp_path): 37 def test_load_tasks_with_data(self, mock_bot, tmp_path):
32 cog = create_cog(mock_bot, tmp_path) 38 mock_conn = MagicMock()
33 cog.cron_file = str(tmp_path / "nonexistent.json") 39 mock_cursor = MagicMock()
34 cog.load_tasks() 40 mock_cursor.fetchall.return_value = [
35 assert cog.scheduled_tasks == [] 41 (1, "* * * * *", "audio", 123, 1, None, "test message")
36 42 ]
37 def test_load_tasks_empty_file(self, mock_bot, tmp_path): 43 mock_conn.cursor.return_value = mock_cursor
38 cron_file = tmp_path / "empty.json" 44 mock_conn.close.return_value = None
39 cron_file.write_text("")
40 cog = create_cog(mock_bot, tmp_path)
41 cog.cron_file = str(cron_file)
42 cog.load_tasks()
43 assert cog.scheduled_tasks == []
44
45 def test_load_tasks_valid_file(self, mock_bot, tmp_path):
46 cron_file = tmp_path / "valid.json"
47 tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}]
48 cron_file.write_text(json.dumps(tasks))
49 cog = create_cog(mock_bot, tmp_path)
50 cog.cron_file = str(cron_file)
51 cog.load_tasks()
52 assert len(cog.scheduled_tasks) == 1
53 assert cog.scheduled_tasks[0]["id"] == 1
54 45
55 def test_load_tasks_io_error(self, mock_bot, tmp_path): 46 with patch("cogs.cron.get_connection", return_value=mock_conn):
56 cog = create_cog(mock_bot, tmp_path) 47 cog = Cron.__new__(Cron)
57 with patch("builtins.open", side_effect=IOError("test error")): 48 cog.bot = mock_bot
49 cog.scheduled_tasks = []
50 cog.task = MagicMock()
58 cog.load_tasks() 51 cog.load_tasks()
59 assert cog.scheduled_tasks == [] 52 assert len(cog.scheduled_tasks) == 1
53 assert cog.scheduled_tasks[0]["id"] == 1
60 54
61 55
62class TestSaveTasks: 56class TestSaveTasks:
63 def test_save_tasks_with_real_file(self, mock_bot, tmp_path): 57 def test_save_tasks(self, mock_bot, tmp_path):
64 cron_file = tmp_path / "cron_jobs.json"
65 cog = create_cog(mock_bot, tmp_path)
66 cog.cron_file = str(cron_file)
67 cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *"}]
68 cog.save_tasks()
69 assert os.path.exists(cron_file)
70 with open(cron_file) as f:
71 loaded = json.load(f)
72 assert len(loaded) == 1
73
74 def test_save_tasks_creates_directory_structure(self, mock_bot, tmp_path):
75 cog = create_cog(mock_bot, tmp_path) 58 cog = create_cog(mock_bot, tmp_path)
76 new_dir = tmp_path / "new_dir" 59 cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}]
77 cron_file = new_dir / "cron_jobs.json"
78 cog.cron_file = str(cron_file)
79 cog.scheduled_tasks = [{"id": 1}]
80
81 cog.save_tasks() 60 cog.save_tasks()
82 61 assert len(cog.scheduled_tasks) == 1
83 assert os.path.exists(new_dir)
84 62
85 63
86class TestShouldRunTask: 64class TestShouldRunTask:
@@ -163,4 +141,4 @@ class TestTaskTypes:
163 def test_task_type_message_aliases(self, mock_bot, tmp_path): 141 def test_task_type_message_aliases(self, mock_bot, tmp_path):
164 message_types = ("wyślij_wiadomość", "send_message") 142 message_types = ("wyślij_wiadomość", "send_message")
165 for task_type in message_types: 143 for task_type in message_types:
166 assert task_type in ("wyślij_wiadomość", "send_message") 144 assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file
diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py
new file mode 100644
index 0000000..addf87e
--- /dev/null
+++ b/tests/cogs/test_uploader.py
@@ -0,0 +1,226 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4import discord
5from tests.conftest import MockApplicationContext, MockMessage, MockUser
6
7
8@pytest.fixture
9def uploader_cog(bot):
10 with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'):
11 from cogs.uploader import Uploader
12 cog = Uploader(bot)
13 return cog
14
15
16@pytest.mark.asyncio
17async def test_file_autocomplete(uploader_cog):
18 mock_autocomplete_ctx = MagicMock()
19 mock_autocomplete_ctx.value = "test"
20 mock_interaction = MagicMock()
21 mock_user = MockUser(id=12345)
22 mock_interaction.user = mock_user
23 mock_autocomplete_ctx.interaction = mock_interaction
24
25 with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search:
26 mock_search.return_value = [
27 "https://copyparty.local/testfile1.mp3",
28 "https://copyparty.local/testfile2.mp3"
29 ]
30 result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx)
31 assert len(result) == 2
32 assert result[0].name == "testfile1.mp3"
33
34
35@pytest.mark.asyncio
36async def test_file_autocomplete_empty_query(uploader_cog):
37 mock_autocomplete_ctx = MagicMock()
38 mock_autocomplete_ctx.value = ""
39 mock_interaction = MagicMock()
40 mock_interaction.user = MockUser(id=12345)
41 mock_autocomplete_ctx.interaction = mock_interaction
42
43 with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search:
44 result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx)
45 mock_search.assert_called_once_with("", max_results=25)
46
47
48@pytest.mark.asyncio
49async def test_download_from_url_success(uploader_cog):
50 with patch('requests.get') as mock_get:
51 mock_response = MagicMock()
52 mock_response.status_code = 200
53 mock_response.content = b"test file content"
54 mock_get.return_value = mock_response
55
56 mock_file = MagicMock()
57 mock_file.write = AsyncMock()
58
59 mock_cm = AsyncMock()
60 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
61 mock_cm.__aexit__ = AsyncMock(return_value=None)
62
63 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
64 result = await uploader_cog._download_from_url("https://copyparty.local/file.txt")
65
66 assert result is not None
67 assert "file.txt" in result
68 mock_file.write.assert_called_once_with(b"test file content")
69
70
71@pytest.mark.asyncio
72async def test_download_from_url_failure(uploader_cog):
73 with patch('requests.get') as mock_get:
74 mock_response = MagicMock()
75 mock_response.status_code = 404
76 mock_get.return_value = mock_response
77
78 result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt")
79
80 assert result is None
81
82
83@pytest.mark.asyncio
84async def test_upload_to_copyparty_success(uploader_cog):
85 mock_file = MagicMock()
86 mock_file.read = AsyncMock(return_value=b"test content")
87
88 mock_cm = AsyncMock()
89 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
90 mock_cm.__aexit__ = AsyncMock(return_value=None)
91
92 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
93 with patch('requests.post') as mock_post:
94 mock_response = MagicMock()
95 mock_response.status_code = 200
96 mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"}
97 mock_post.return_value = mock_response
98
99 with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}):
100 result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt")
101
102 assert result == "https://copyparty.local/uploaded.txt"
103
104
105@pytest.mark.asyncio
106async def test_upload_to_copyparty_failure(uploader_cog):
107 mock_file = MagicMock()
108 mock_file.read = AsyncMock(return_value=b"test content")
109
110 mock_cm = AsyncMock()
111 mock_cm.__aenter__ = AsyncMock(return_value=mock_file)
112 mock_cm.__aexit__ = AsyncMock(return_value=None)
113
114 with patch('cogs.uploader.aiofiles.open', return_value=mock_cm):
115 with patch('requests.post') as mock_post:
116 mock_response = MagicMock()
117 mock_response.status_code = 500
118 mock_post.return_value = mock_response
119
120 result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt")
121
122 assert result is None
123
124
125@pytest.mark.asyncio
126async def test_upload_file_no_url(uploader_cog):
127 uploader_cog._uploadurl = None
128
129 result = await uploader_cog._upload_file("/tmp/test.txt")
130
131 assert result is None
132
133
134@pytest.mark.asyncio
135async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot):
136 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
137
138 mock_message = MagicMock()
139 mock_message.attachments = []
140
141 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
142 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
143 mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True)
144
145
146@pytest.mark.asyncio
147async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot):
148 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
149
150 mock_attachment = MagicMock()
151 mock_attachment.filename = "test.txt"
152 mock_attachment.save = AsyncMock()
153
154 mock_message = MagicMock()
155 mock_message.attachments = [mock_attachment]
156
157 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
158 mock_upload.return_value = "https://copyparty.local/uploaded.txt"
159
160 with patch('os.path.exists', return_value=True):
161 with patch('os.remove'):
162 with patch.object(ctx, 'defer', new_callable=AsyncMock):
163 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
164 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
165 mock_respond.assert_called_once()
166 assert "Wysłano" in str(mock_respond.call_args)
167
168
169@pytest.mark.asyncio
170async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot):
171 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
172
173 mock_attachment = MagicMock()
174 mock_attachment.filename = "test.txt"
175 mock_attachment.save = AsyncMock()
176
177 mock_message = MagicMock()
178 mock_message.attachments = [mock_attachment]
179
180 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
181 mock_upload.return_value = None
182
183 with patch('os.path.exists', return_value=True):
184 with patch('os.remove'):
185 with patch.object(ctx, 'defer', new_callable=AsyncMock):
186 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
187 await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message)
188 mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True)
189
190
191@pytest.mark.asyncio
192async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot):
193 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
194
195 mock_attachment = MagicMock()
196 mock_attachment.filename = "test.txt"
197 mock_attachment.save = AsyncMock()
198
199 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
200 mock_upload.return_value = "https://copyparty.local/uploaded.txt"
201
202 with patch('os.path.exists', return_value=True):
203 with patch('os.remove'):
204 with patch.object(ctx, 'defer', new_callable=AsyncMock):
205 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
206 await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment)
207 mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt")
208
209
210@pytest.mark.asyncio
211async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot):
212 ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild)
213
214 mock_attachment = MagicMock()
215 mock_attachment.filename = "test.txt"
216 mock_attachment.save = AsyncMock()
217
218 with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload:
219 mock_upload.return_value = None
220
221 with patch('os.path.exists', return_value=True):
222 with patch('os.remove'):
223 with patch.object(ctx, 'defer', new_callable=AsyncMock):
224 with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond:
225 await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment)
226 mock_respond.assert_called_once_with("Nie udało się wysłać pliku", ephemeral=True) \ No newline at end of file
diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py
index 2003478..da41482 100644
--- a/tests/cogs/test_vclogs.py
+++ b/tests/cogs/test_vclogs.py
@@ -1,6 +1,5 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import json
4from tests.conftest import MockApplicationContext 3from tests.conftest import MockApplicationContext
5 4
6 5
@@ -13,20 +12,26 @@ def test_format_duration():
13 12
14 13
15def test_load_activity_data(): 14def test_load_activity_data():
16 with patch('builtins.open', MagicMock()): 15 with patch("cogs.vclogs.get_connection") as mock_conn:
17 with patch('os.path.exists', return_value=True): 16 mock_cursor = MagicMock()
18 with patch('json.load', return_value={"123": {"active": True}}): 17 mock_conn.return_value.cursor.return_value = mock_cursor
19 from cogs.vclogs import load_activity_data 18 mock_conn.return_value.close.return_value = None
20 data = load_activity_data() 19 mock_cursor.fetchall.return_value = []
21 assert isinstance(data, dict) 20
21 from cogs.vclogs import load_activity_data
22 data = load_activity_data()
23 assert isinstance(data, dict)
22 24
23 25
24def test_save_activity_data(): 26def test_save_activity_data():
25 with patch('builtins.open', MagicMock()) as mock_open: 27 with patch("cogs.vclogs.get_connection") as mock_conn:
26 with patch('json.dump'): 28 mock_cursor = MagicMock()
27 from cogs.vclogs import save_activity_data 29 mock_conn.return_value.cursor.return_value = mock_cursor
28 save_activity_data({"123": {"active": True}}) 30 mock_conn.return_value.commit.return_value = None
29 mock_open.assert_called_once() 31 mock_conn.return_value.close.return_value = None
32
33 from cogs.vclogs import save_activity_data
34 save_activity_data({"123": {"active": True}})
30 35
31 36
32@pytest.fixture 37@pytest.fixture
@@ -80,4 +85,4 @@ async def test_handle_state_change_mute_false(vclogs_cog):
80 85
81 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) 86 tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None)
82 87
83 assert "zrobił jajecznice" in tts_text 88 assert "zrobił jajecznice" in tts_text \ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index e9f0f6f..01ece10 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,6 @@
1import asyncio 1import asyncio
2import os
3import tempfile
2from unittest.mock import AsyncMock, MagicMock 4from unittest.mock import AsyncMock, MagicMock
3import pytest 5import pytest
4 6
@@ -6,6 +8,23 @@ import discord
6from discord.ext import commands 8from discord.ext import commands
7 9
8 10
11@pytest.fixture(autouse=True, scope="function")
12def setup_test_env(monkeypatch):
13 test_db = os.path.join(tempfile.gettempdir(), "test_senator.db")
14 if os.path.exists(test_db):
15 os.remove(test_db)
16 monkeypatch.setenv("SENATOR_DB_PATH", test_db)
17 reload_database()
18 yield
19 if os.path.exists(test_db):
20 os.remove(test_db)
21
22
23def reload_database():
24 import cogs.utils.database as db_module
25 db_module._initialized = False
26
27
9class MockUser: 28class MockUser:
10 def __init__(self, id=12345, name="testuser", discriminator="0001"): 29 def __init__(self, id=12345, name="testuser", discriminator="0001"):
11 self.id = id 30 self.id = id