aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--cogs/jarvis.py2
-rw-r--r--cogs/math_commands.py (renamed from cogs/math.py)0
-rw-r--r--cogs/music/play_commands.py5
-rw-r--r--cogs/search.py73
-rw-r--r--cogs/tts.py25
-rw-r--r--cogs/utils/file_search.py (renamed from cogs/music/search_utils.py)279
-rw-r--r--cogs/vclogs.py55
-rw-r--r--cogs/voice.py140
-rw-r--r--main.py47
-rw-r--r--requirements.txt39
11 files changed, 509 insertions, 157 deletions
diff --git a/.gitignore b/.gitignore
index 0a002ca..b8dd083 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ cogs/__pycache__
5cogs/music/__pycache__ 5cogs/music/__pycache__
6settings.json 6settings.json
7.idea/ 7.idea/
8.venv/
diff --git a/cogs/jarvis.py b/cogs/jarvis.py
index bdb34f1..4f34ea8 100644
--- a/cogs/jarvis.py
+++ b/cogs/jarvis.py
@@ -58,7 +58,7 @@ class Jarvis(commands.Cog):
58 def _build_messages(message): 58 def _build_messages(message):
59 messages = [{ 59 messages = [{
60 "role": "system", 60 "role": "system",
61 "content": "your name is Senator Sperma.\n" 61 "content": "You are an alter ego of Senator Sperma Armstrong.\n"
62 "your task is to tell the program what to do and how to respond after receiving a message.\n" 62 "your task is to tell the program what to do and how to respond after receiving a message.\n"
63 "message field has to be filled out in polish.\n" 63 "message field has to be filled out in polish.\n"
64 "act like jarvis (only behavior wise), be a personal assistant to user, do not use emojis\n" 64 "act like jarvis (only behavior wise), be a personal assistant to user, do not use emojis\n"
diff --git a/cogs/math.py b/cogs/math_commands.py
index bf164b3..bf164b3 100644
--- a/cogs/math.py
+++ b/cogs/math_commands.py
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index b25ce84..e3ca4c1 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -9,7 +9,8 @@ import mutagen
9from typing import List, Tuple, Callable, Awaitable 9from typing import List, Tuple, Callable, Awaitable
10from os import PathLike 10from os import PathLike
11 11
12from cogs.music import search_utils, audio_playback, media_processing 12from cogs.music import audio_playback, media_processing
13from cogs.utils import file_search as search_utils
13from discord.ext import commands 14from discord.ext import commands
14from discord.commands import OptionChoice 15from discord.commands import OptionChoice
15import logging 16import logging
@@ -110,7 +111,7 @@ class PlayCommands(commands.Cog):
110 """Autocomplete handler for search queries.""" 111 """Autocomplete handler for search queries."""
111 query = ctx.value.lower() 112 query = ctx.value.lower()
112 try: 113 try:
113 results = await search_utils.AudioSearcher.search_local_autocomplete(query, max_results=25) 114 results = await search_utils.UniversalSearcher.search_local_autocomplete(query, max_results=25, filetype="Audio")
114 choices = [] 115 choices = []
115 self.path_mapping = {} # Reset the mapping for each new query 116 self.path_mapping = {} # Reset the mapping for each new query
116 117
diff --git a/cogs/search.py b/cogs/search.py
new file mode 100644
index 0000000..2f8373b
--- /dev/null
+++ b/cogs/search.py
@@ -0,0 +1,73 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import urllib.parse
7
8import discord
9from discord.ext import commands
10import logging
11from discord import AutocompleteContext
12from cogs.utils import file_search as search_utils
13
14logger = logging.getLogger(__name__)
15
16class Search(commands.Cog):
17 def __init__(self, bot):
18 self.bot = bot
19 self._cached_results = {}
20
21 async def file_autocomplete(self, ctx: AutocompleteContext):
22 """Autocomplete function for file search."""
23 query = ctx.value.lower() if ctx.value else ""
24 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
25
26 user_id = ctx.interaction.user.id
27 self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
28
29 choices = []
30 for url in results[:25]:
31 filename = urllib.parse.unquote(url.split('/')[-1])
32 display_name = filename[:99] + "…" if len(filename) > 100 else filename
33 choices.append(discord.OptionChoice(name=display_name, value=filename))
34 return choices
35
36 @commands.slash_command(
37 name="search",
38 description="wyszukaj pliku"
39 )
40 @discord.option(
41 name="filename",
42 description="Search for a file to send",
43 autocomplete=file_autocomplete
44 )
45 async def search(self, ctx, filename: str):
46 """Send a file via URL from autocomplete selection."""
47 if not filename:
48 await ctx.reply("Please select a file from the autocomplete options.")
49 return
50
51 await ctx.defer()
52 logger.info(f"Search command called by {ctx.author} for file: {filename}")
53
54 cached = self._cached_results.get(ctx.author.id)
55 if not cached:
56 await ctx.send_followup("Search results expired. Please search again.")
57 return
58
59 matching_url = cached.get(filename)
60 if not matching_url:
61 await ctx.send_followup(f"File '{filename}' not found.")
62 return
63
64 try:
65 await ctx.send_followup(matching_url)
66 logger.info(f"Successfully sent URL: {matching_url}")
67 del self._cached_results[ctx.author.id]
68 except Exception as e:
69 logger.error(f"Error sending URL {matching_url}: {str(e)}")
70 await ctx.send_followup(str(e))
71
72def setup(bot):
73 bot.add_cog(Search(bot))
diff --git a/cogs/tts.py b/cogs/tts.py
index a330b74..46ab637 100644
--- a/cogs/tts.py
+++ b/cogs/tts.py
@@ -40,16 +40,14 @@ class TTS(commands.Cog):
40 logger.error(f"Error sending file: {e}") 40 logger.error(f"Error sending file: {e}")
41 await ctx.respond("eror :c", ephemeral=True) 41 await ctx.respond("eror :c", ephemeral=True)
42 42
43 def _get_voice_manager(self):
44 from cogs.voice import get_voice_manager
45 return get_voice_manager(self.bot)
46
43 async def _get_or_connect_voice_client(self, channel): 47 async def _get_or_connect_voice_client(self, channel):
44 """Get existing voice client or connect to new channel""" 48 """Get existing voice client or connect to new channel"""
45 voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) 49 voice_mgr = self._get_voice_manager()
46 50 return await voice_mgr.join_channel(channel)
47 if not voice_client or not voice_client.is_connected():
48 voice_client = await channel.connect()
49 elif voice_client.channel != channel:
50 await voice_client.move_to(channel)
51
52 return voice_client
53 51
54 async def _handle_voice_output(self, ctx, temp_path, text, volume): 52 async def _handle_voice_output(self, ctx, temp_path, text, volume):
55 """Handle TTS voice channel output""" 53 """Handle TTS voice channel output"""
@@ -65,6 +63,10 @@ class TTS(commands.Cog):
65 try: 63 try:
66 voice_client = await self._get_or_connect_voice_client(channel) 64 voice_client = await self._get_or_connect_voice_client(channel)
67 65
66 if not voice_client:
67 await ctx.respond("Failed to connect to voice channel", ephemeral=True)
68 return
69
68 # Play the TTS audio with volume adjustment 70 # Play the TTS audio with volume adjustment
69 audio_source = discord.FFmpegPCMAudio( 71 audio_source = discord.FFmpegPCMAudio(
70 temp_path, 72 temp_path,
@@ -96,9 +98,10 @@ class TTS(commands.Cog):
96 ): 98 ):
97 await ctx.defer() 99 await ctx.defer()
98 100
99 # Create temporary file path asynchronously 101 # Create temporary file in temp folder
100 temp_fd, temp_path = tempfile.mkstemp(suffix=".mp3") 102 temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp')
101 os.close(temp_fd) # Close the file descriptor, we'll write to it asynchronously 103 os.makedirs(temp_dir, exist_ok=True)
104 temp_path = os.path.join(temp_dir, f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
102 105
103 try: 106 try:
104 # Generate and save TTS to temp file 107 # Generate and save TTS to temp file
diff --git a/cogs/music/search_utils.py b/cogs/utils/file_search.py
index bc9e335..4a2bdc4 100644
--- a/cogs/music/search_utils.py
+++ b/cogs/utils/file_search.py
@@ -3,12 +3,57 @@
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
6"""
7Universal File Searcher
8
9This module provides a universal file searcher that can search for different file types:
10- Audio files (mp3, wav, mp4, opus, m4a, flac, aac, ogg)
11- Video files (mp4, avi, mkv, mov, wmv, flv, webm, m4v, 3gp)
12- Image files (jpg, jpeg, png, gif, bmp, tiff, webp, svg, ico)
13
14Usage Examples:
15 # Search all file types
16 results = await UniversalSearcher.search_local("query")
17
18 # Search only audio files
19 results = await UniversalSearcher.search_local("query", filetype="Audio")
20
21 # Search only video files
22 results = await UniversalSearcher.search_local("query", filetype="Video")
23
24 # Search only image files
25 results = await UniversalSearcher.search_local("query", filetype="Image")
26
27 # Autocomplete search
28 results = await UniversalSearcher.search_local_autocomplete("partial_query", filetype="Video")
29
30Settings.json structure:
31{
32 "file_paths": [
33 "/path/to/your/media/files",
34 "/another/path/with/mixed/content",
35 "relative/path/to/files"
36 ]
37}
38
39Note: file_paths should contain directories that may contain any mix of audio, video,
40and image files. The searcher will filter by file type automatically based on the
41filetype parameter.
42"""
43
6import json 44import json
7import logging 45import logging
8import re 46import re
47import urllib.parse
9from pathlib import Path 48from pathlib import Path
10import aiofiles 49import aiofiles
11from rapidfuzz import fuzz 50from rapidfuzz import fuzz
51import aiohttp
52import asyncio
53import os
54from dotenv import load_dotenv
55
56load_dotenv()
12 57
13logger = logging.getLogger(__name__) 58logger = logging.getLogger(__name__)
14 59
@@ -173,14 +218,34 @@ class SearchUtils:
173 218
174 return score 219 return score
175 220
176class AudioSearcher: 221class UniversalSearcher:
177 """Handles searching for audio files in configured paths.""" 222 """Handles searching for files in configured paths with support for multiple file types."""
223
224 FILE_TYPE_EXTENSIONS = {
225 'Audio': {'.mp4', '.mp3', '.wav', '.opus', '.m4a', '.flac', '.aac', '.ogg'},
226 'Video': {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp', '.gif'},
227 'Image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.ico'}
228 }
178 229
179 @staticmethod 230 @staticmethod
180 def _is_valid_audio_file(file_path: Path) -> bool: 231 def _is_valid_file(file_path: Path, filetype: str = None) -> bool:
181 """Check if file is a valid audio file.""" 232 """Check if file is valid for the specified filetype."""
182 valid_extensions = {'.mp4', '.mp3', '.wav', '.opus', '.m4a'} 233 if not file_path.is_file():
183 return file_path.is_file() and file_path.suffix.lower() in valid_extensions 234 return False
235
236 if filetype is None:
237 # Search all file types
238 all_extensions = set()
239 for extensions in UniversalSearcher.FILE_TYPE_EXTENSIONS.values():
240 all_extensions.update(extensions)
241 return file_path.suffix.lower() in all_extensions
242
243 filetype = filetype.capitalize()
244 if filetype not in UniversalSearcher.FILE_TYPE_EXTENSIONS:
245 return False
246
247 valid_extensions = UniversalSearcher.FILE_TYPE_EXTENSIONS[filetype]
248 return file_path.suffix.lower() in valid_extensions
184 249
185 @staticmethod 250 @staticmethod
186 def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]: 251 def _calculate_match_score(query: str, file_name: str, autocomplete: bool) -> tuple[int, str]:
@@ -201,18 +266,18 @@ class AudioSearcher:
201 return -1, file_name 266 return -1, file_name
202 267
203 @staticmethod 268 @staticmethod
204 async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool) -> list[tuple[int, str]]: 269 async def _search_single_path(path: Path, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> list[tuple[int, str]]:
205 """Search for audio files in a single path.""" 270 """Search for files in a single path."""
206 matches = [] 271 matches = []
207 272
208 logger.debug(f"Searching in directory: {path}") 273 logger.debug(f"Searching in directory: {path} (filetype: {filetype or 'All'})")
209 274
210 for file_path in path.rglob('*'): 275 for file_path in path.rglob('*'):
211 if not AudioSearcher._is_valid_audio_file(file_path): 276 if not UniversalSearcher._is_valid_file(file_path, filetype):
212 continue 277 continue
213 278
214 file_name = file_path.name.lower() 279 file_name = file_path.name.lower()
215 score, _ = AudioSearcher._calculate_match_score(query, file_name, autocomplete) 280 score, _ = UniversalSearcher._calculate_match_score(query, file_name, autocomplete)
216 281
217 if score >= 0: 282 if score >= 0:
218 matches.append((score, str(file_path.absolute()))) 283 matches.append((score, str(file_path.absolute())))
@@ -222,14 +287,41 @@ class AudioSearcher:
222 287
223 return matches 288 return matches
224 289
290 @staticmethod
291 def _get_search_paths(data: dict, filetype: str = None) -> list:
292 """Get the search paths from settings."""
293 return data.get('file_paths', [])
294
225 @classmethod 295 @classmethod
226 async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False): 296 async def _search_paths(cls, search_paths: list, query: str, max_results: int, autocomplete: bool, filetype: str = None) -> tuple[list, int]:
227 """Search for audio files with fuzzy matching and optional autocomplete support. 297 """Search through the provided paths and return matches and file count."""
298 matches = []
299 file_count = 0
300
301 for path_str in search_paths:
302 path = Path(path_str)
303 if not path.exists():
304 logger.warning(f"Path does not exist: {path}")
305 continue
306
307 path_matches = await cls._search_single_path(path, query, max_results, autocomplete, filetype)
308 matches.extend(path_matches)
309 file_count += len(path_matches)
310
311 if len(matches) >= max_results * 5:
312 break
313
314 return matches, file_count
315
316 @classmethod
317 async def search_local(cls, query: str = None, max_results: int = 25, autocomplete: bool = False, filetype: str = None):
318 """Search for files with fuzzy matching and optional autocomplete support.
228 319
229 Args: 320 Args:
230 query: Search query string (can be None for autocomplete) 321 query: Search query string (can be None for autocomplete)
231 max_results: Maximum number of results to return 322 max_results: Maximum number of results to return
232 autocomplete: If True, returns results even with empty query 323 autocomplete: If True, returns results even with empty query
324 filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all)
233 325
234 Returns: 326 Returns:
235 List of matching file paths with scores 327 List of matching file paths with scores
@@ -238,30 +330,17 @@ class AudioSearcher:
238 return [] 330 return []
239 331
240 query = (query or '').lower().strip() 332 query = (query or '').lower().strip()
241 logger.info(f"Starting local search for query: '{query}'") 333 logger.info(f"Starting local search for query: '{query}' (filetype: {filetype or 'All'})")
242 334
243 try: 335 try:
244 async with aiofiles.open('settings.json') as f: 336 async with aiofiles.open('settings.json') as f:
245 content = await f.read() 337 content = await f.read()
246 data = json.loads(content) 338 data = json.loads(content)
247 search_paths = data.get('audio_paths', [])
248 logger.info(f"Searching in paths: {search_paths}")
249 339
250 matches = [] 340 search_paths = cls._get_search_paths(data, filetype)
251 file_count = 0 341 logger.info(f"Searching in paths: {search_paths}")
252 342
253 for path_str in search_paths: 343 matches, file_count = await cls._search_paths(search_paths, query, max_results, autocomplete, filetype)
254 path = Path(path_str)
255 if not path.exists():
256 logger.warning(f"Path does not exist: {path}")
257 continue
258
259 path_matches = await cls._search_single_path(path, query, max_results, autocomplete)
260 matches.extend(path_matches)
261 file_count += len(path_matches)
262
263 if len(matches) >= max_results * 5:
264 break
265 344
266 logger.info(f"Scanned {file_count} files total") 345 logger.info(f"Scanned {file_count} files total")
267 346
@@ -278,10 +357,144 @@ class AudioSearcher:
278 return [] 357 return []
279 358
280 @classmethod 359 @classmethod
281 async def search_local_autocomplete(cls, query: str = None, max_results: int = 10): 360 async def search_local_autocomplete(cls, query: str = None, max_results: int = 10, filetype: str = None):
282 """Wrapper for autocomplete searches that returns more results and allows empty query.""" 361 """Wrapper for autocomplete searches that returns more results and allows empty query.
362
363 Args:
364 query: Search query string (can be None for autocomplete)
365 max_results: Maximum number of results to return
366 filetype: Type of files to search for ('Audio', 'Video', 'Image', or None for all)
367 """
283 # For autocomplete, we want to show more options and allow empty queries 368 # For autocomplete, we want to show more options and allow empty queries
284 if not query: 369 if not query:
285 return [] 370 return []
286 371
287 return await cls.search_local(query, max_results=max_results, autocomplete=True) 372 return await cls.search_local(query, max_results=max_results, autocomplete=True, filetype=filetype)
373
374 @staticmethod
375 async def _load_baseurl():
376 """Load baseurl from settings.json"""
377 try:
378 async with aiofiles.open('settings.json') as f:
379 content = await f.read()
380 data = json.loads(content)
381 return data.get('baseurl', 'http://localhost:8080').rstrip('/')
382 except Exception as e:
383 logger.error(f"Error loading baseurl: {e}")
384 return 'http://localhost:8080'
385
386 @staticmethod
387 async def search_copyparty(query: str, max_results: int = 25):
388 """Search for files using copyparty API (jPOST method).
389
390 Args:
391 query: Search query string
392 max_results: Maximum number of results to return
393
394 Returns:
395 List of matching file URLs
396 """
397
398
399 if not query:
400 return []
401
402 query = query.strip()
403 logger.info(f"Starting copyparty search for query: '{query}'")
404 query = f'name like "*{query}*"'
405
406 try:
407 baseurl = await UniversalSearcher._load_baseurl()
408 password = os.getenv("COPYPARTY_R_PASSWORD")
409
410 async with aiohttp.ClientSession() as session:
411 headers = {"Content-Type": "application/json"}
412 if password:
413 headers["PW"] = password
414 async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response:
415 if response.status == 200:
416 data = await response.json()
417 logger.info(f"Copyparty response: {data}")
418 files = UniversalSearcher._extract_files_from_response(data)
419 results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results)
420
421 logger.info(f"Copyparty search complete. Found {len(results)} matches.")
422 return results
423 else:
424 logger.error(f"Copyparty API returned status {response.status}")
425 return []
426
427 except aiohttp.ClientError as e:
428 logger.error(f"HTTP error during copyparty search: {e}")
429 return []
430 except Exception as e:
431 logger.error(f"Error during copyparty search: {str(e)}", exc_info=True)
432 return []
433
434 @staticmethod
435 def _extract_files_from_response(data):
436 """Extract files list from copyparty API response."""
437 if isinstance(data, dict):
438 if 'hits' in data:
439 return data['hits']
440 elif 'files' in data:
441 return data['files']
442 elif 'results' in data:
443 return data['results']
444 else:
445 return data if isinstance(data, list) else []
446 elif isinstance(data, list):
447 return data
448 else:
449 return []
450
451 @staticmethod
452 def _convert_files_to_urls(files, baseurl, max_results):
453 """Convert file info objects to URLs."""
454 results = []
455
456 for file_info in files[:max_results]:
457 if isinstance(file_info, dict):
458 file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl)
459 if file_url:
460 results.append(file_url)
461 elif isinstance(file_info, str):
462 # If it's just a string path/URL
463 if file_info.startswith('http'):
464 results.append(file_info)
465 else:
466 results.append(f"{baseurl}/{file_info.lstrip('/')}")
467
468 return results
469
470 @staticmethod
471 def _extract_url_from_dict(file_info, baseurl):
472 """Extract URL from file info dictionary."""
473 if 'url' in file_info:
474 return file_info['url']
475 elif 'rp' in file_info:
476 return f"{baseurl}/{file_info['rp']}"
477 elif 'path' in file_info:
478 return f"{baseurl}/{file_info['path'].lstrip('/')}"
479 elif 'name' in file_info:
480 return f"{baseurl}/{file_info['name'].lstrip('/')}"
481 else:
482 return None
483
484 @staticmethod
485 async def search_copyparty_autocomplete(query: str, max_results: int = 10):
486 """Wrapper for autocomplete searches using copyparty.
487
488 Args:
489 query: Search query string
490 max_results: Maximum number of results to return
491
492 Returns:
493 List of matching file URLs
494 """
495 if not query:
496 return []
497
498 return await UniversalSearcher.search_copyparty(query, max_results)
499
500
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
index 901b557..9ed7134 100644
--- a/cogs/vclogs.py
+++ b/cogs/vclogs.py
@@ -7,28 +7,27 @@ import datetime
7import json 7import json
8import logging 8import logging
9import os 9import os
10import asyncio
10import discord 11import discord
11from discord.ext import commands 12from discord.ext import commands
12from gtts import gTTS 13from gtts import gTTS
13import aiofiles 14import aiofiles
15from cogs.voice import get_voice_manager
14 16
15logger = logging.getLogger(__name__) 17logger = logging.getLogger(__name__)
16 18
17 19
18
19class VcLogs(commands.Cog): 20class VcLogs(commands.Cog):
20 def __init__(self, bot): 21 def __init__(self, bot):
21 self.bot = bot 22 self.bot = bot
22 self.settings_check() 23 self._voice_manager = None
23 24
24 settings_file = 'settings.json' 25 def get_voice_manager(self):
26 if self._voice_manager is None:
27 self._voice_manager = get_voice_manager(self.bot)
28 return self._voice_manager
25 29
26 @staticmethod 30 settings_file = 'settings.json'
27 def settings_check():
28 if not os.path.exists(VcLogs.settings_file):
29 with open(VcLogs.settings_file, 'w') as f:
30 json.dump({'volume': '5'}, f, indent=4)
31 logger.info("settings.json created")
32 31
33 @staticmethod 32 @staticmethod
34 async def handle_state_change(member, channel, state_type, is_enabled, current_time, log_channel): 33 async def handle_state_change(member, channel, state_type, is_enabled, current_time, log_channel):
@@ -62,32 +61,34 @@ class VcLogs(commands.Cog):
62 return tts_text, channel 61 return tts_text, channel
63 62
64 async def play_tts(self, channel, text): 63 async def play_tts(self, channel, text):
65 tts_file = "temp_tts.mp3" 64 voice_mgr = self.get_voice_manager()
66 # Generate TTS audio 65
66 temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp')
67 os.makedirs(temp_dir, exist_ok=True)
68 tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3")
67 tts = gTTS(text=text, lang='pl') 69 tts = gTTS(text=text, lang='pl')
70 logger.info("saving file")
68 tts.save(tts_file) 71 tts.save(tts_file)
69 72
70 if os.path.exists(VcLogs.settings_file): 73 if os.path.exists(VcLogs.settings_file):
71 async with aiofiles.open(VcLogs.settings_file) as f: 74 async with aiofiles.open(VcLogs.settings_file) as f:
72 content = await f.read() 75 content = await f.read()
73 data = json.loads(content) 76 data = json.loads(content)
74 volume = data['volume'] 77 volume = data.get('volume', 1.0)
75 else: 78
76 logger.error("settings.json not found") 79 voice_client = await voice_mgr.get_voice_client(channel.guild)
77 80
78 # Connect to the voice channel if not already connected 81 if voice_client is None:
79 voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) 82 voice_client = await voice_mgr.join_channel(channel)
80 if not isinstance(voice_client, discord.VoiceClient): 83
81 voice_client = await channel.connect() 84 logger.info(f"connected: {voice_client}")
82 elif voice_client.channel != channel: 85
83 await voice_client.move_to(channel) 86 if voice_client and not voice_client.is_playing():
84 87 logger.info("preparing to play audio")
85 # Play the TTS audio
86 if not voice_client.is_playing():
87 audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') 88 audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"')
88 voice_client.play(audio_source) 89 voice_client.play(audio_source)
89 while voice_client.is_playing(): 90 while voice_client.is_playing():
90 await discord.utils.sleep_until(datetime.datetime.now() + datetime.timedelta(seconds=1)) 91 await asyncio.sleep(0.5)
91 if os.path.exists(tts_file): 92 if os.path.exists(tts_file):
92 os.remove(tts_file) 93 os.remove(tts_file)
93 94
@@ -96,10 +97,6 @@ class VcLogs(commands.Cog):
96 ) 97 )
97 async def volume(self, ctx: discord.ApplicationContext, volume: int): 98 async def volume(self, ctx: discord.ApplicationContext, volume: int):
98 try: 99 try:
99 if not os.path.exists(VcLogs.settings_file):
100 async with aiofiles.open(VcLogs.settings_file, 'w') as f:
101 json.dump({'volume': '5'}, f, indent=4)
102 logger.info("settings.json created")
103 async with aiofiles.open(VcLogs.settings_file, 'w') as f: 100 async with aiofiles.open(VcLogs.settings_file, 'w') as f:
104 json.dump({"volume": volume}, f, indent=4) 101 json.dump({"volume": volume}, f, indent=4)
105 await ctx.followup.send(f"Volume has been set to: {volume}") 102 await ctx.followup.send(f"Volume has been set to: {volume}")
diff --git a/cogs/voice.py b/cogs/voice.py
index dd5b74a..8ee3699 100644
--- a/cogs/voice.py
+++ b/cogs/voice.py
@@ -10,33 +10,117 @@ import asyncio
10 10
11logger = logging.getLogger(__name__) 11logger = logging.getLogger(__name__)
12 12
13_voice_manager = None
14
15def get_voice_manager(bot):
16 global _voice_manager
17 if _voice_manager is None:
18 _voice_manager = VoiceManager(bot)
19 return _voice_manager
20
21
22class VoiceManager:
23 def __init__(self, bot):
24 self.bot = bot
25 self._voice_lock = asyncio.Lock()
26
27 async def join_channel(self, channel):
28 """Join a voice channel by channel object."""
29 async with self._voice_lock:
30 guild = channel.guild
31 voice_client = discord.utils.get(self.bot.voice_clients, guild=guild)
32
33 if voice_client and voice_client.is_connected():
34 if voice_client.channel == channel:
35 logger.info(f"Already connected to {channel}")
36 return voice_client
37 try:
38 await voice_client.move_to(channel)
39 logger.info(f"Moved to voice channel: {channel}")
40 return voice_client
41 except Exception as e:
42 logger.error(f"Failed to move voice client: {e}")
43 try:
44 await voice_client.disconnect()
45 except Exception:
46 pass
47
48 await asyncio.sleep(0.3)
49 try:
50 voice_client = await channel.connect(timeout=30.0, reconnect=False)
51 logger.info(f"Connected to voice channel: {channel}")
52 return voice_client
53 except Exception as e:
54 logger.error(f"Failed to connect to voice channel: {e}")
55 return None
56
57 async def join_by_channel_id(self, channel_id):
58 """Join a voice channel by channel ID."""
59 channel = self.bot.get_channel(channel_id)
60 if not channel:
61 logger.error(f"Channel {channel_id} not found")
62 return None
63 if not isinstance(channel, discord.VoiceChannel):
64 logger.error(f"Channel {channel_id} is not a voice channel")
65 return None
66 return await self.join_channel(channel)
67
68 async def get_voice_client(self, guild):
69 """Get existing voice client for a guild."""
70 return discord.utils.get(self.bot.voice_clients, guild=guild)
71
72 async def play_audio(self, channel, audio_source, volume=1.0):
73 """Play audio in a voice channel."""
74 voice_client = await self.join_channel(channel)
75 if not voice_client:
76 return False
77
78 if volume != 1.0:
79 audio_source = discord.PCMVolumeTransformer(audio_source)
80 audio_source.volume = volume
81
82 voice_client.play(audio_source)
83
84 while voice_client.is_playing():
85 await asyncio.sleep(0.1)
86 return True
87
88 async def stop(self, guild):
89 """Stop playing and disconnect from voice."""
90 voice_client = await self.get_voice_client(guild)
91 if voice_client:
92 voice_client.stop()
93 await voice_client.disconnect()
94 logger.info(f"Stopped and disconnected from guild {guild}")
95
96
13class Voice(commands.Cog): 97class Voice(commands.Cog):
14 def __init__(self, bot): 98 def __init__(self, bot):
15 self.bot = bot 99 self.bot = bot
100 self._voice_lock = asyncio.Lock()
16 101
17 async def _validate_user_voice_state(self, ctx): 102 async def _validate_user_voice_state(self, ctx):
18 """Validate that user is in a voice channel."""
19 if not ctx.author.voice or not ctx.author.voice.channel: 103 if not ctx.author.voice or not ctx.author.voice.channel:
20 await ctx.send("You need to be in a voice channel first!") 104 await ctx.send("You need to be in a voice channel first!")
21 return False 105 return False
22 return True 106 return True
23 107
24 async def _create_voice_client(self, ctx): 108 async def _create_voice_client(self, ctx):
25 """Create a new voice client connection.""" 109 async with self._voice_lock:
26 try: 110 try:
27 voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=True) 111 await asyncio.sleep(0.5)
28 logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") 112 voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False)
29 return voice_client 113 logger.info(f"Connected to voice channel: {ctx.author.voice.channel}")
30 except asyncio.TimeoutError: 114 return voice_client
31 await ctx.send("Connection to voice channel timed out.") 115 except asyncio.TimeoutError:
32 return None 116 await ctx.send("Connection to voice channel timed out.")
33 except discord.ClientException as e: 117 return None
34 logger.error(f"Failed to connect to voice channel: {e}") 118 except discord.ClientException as e:
35 await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) 119 logger.error(f"Failed to connect to voice channel: {e}")
36 return None 120 await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True)
121 return None
37 122
38 async def _move_voice_client(self, voice_client, ctx): 123 async def _move_voice_client(self, voice_client, ctx):
39 """Move existing voice client to user's channel."""
40 try: 124 try:
41 await voice_client.move_to(ctx.author.voice.channel) 125 await voice_client.move_to(ctx.author.voice.channel)
42 logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") 126 logger.info(f"Moved to voice channel: {ctx.author.voice.channel}")
@@ -50,10 +134,9 @@ class Voice(commands.Cog):
50 return None 134 return None
51 135
52 async def _verify_connection(self, voice_client): 136 async def _verify_connection(self, voice_client):
53 """Verify and repair voice client connection if needed."""
54 if not voice_client.is_connected(): 137 if not voice_client.is_connected():
55 try: 138 try:
56 await voice_client.connect(reconnect=True, timeout=10.0) 139 await voice_client.connect(reconnect=False, timeout=10.0)
57 return voice_client 140 return voice_client
58 except Exception as e: 141 except Exception as e:
59 logger.error(f"Failed to reconnect to voice: {e}") 142 logger.error(f"Failed to reconnect to voice: {e}")
@@ -61,31 +144,30 @@ class Voice(commands.Cog):
61 return voice_client 144 return voice_client
62 145
63 async def ensure_voice_client(self, ctx): 146 async def ensure_voice_client(self, ctx):
64 """Ensure we have a valid voice client connection."""
65 if not await self._validate_user_voice_state(ctx): 147 if not await self._validate_user_voice_state(ctx):
66 return None 148 return None
67 149
68 voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) 150 voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild)
69 151
70 # If no voice client exists, create one 152 if voice_client and not voice_client.is_connected():
153 try:
154 await voice_client.disconnect()
155 except Exception:
156 pass
157 voice_client = None
158
71 if not voice_client or not voice_client.is_connected(): 159 if not voice_client or not voice_client.is_connected():
160 if not await self._validate_user_voice_state(ctx):
161 return None
72 return await self._create_voice_client(ctx) 162 return await self._create_voice_client(ctx)
73 163
74 # If we're in a different channel, move to the user's channel
75 if voice_client.channel != ctx.author.voice.channel: 164 if voice_client.channel != ctx.author.voice.channel:
76 return await self._move_voice_client(voice_client, ctx) 165 return await self._move_voice_client(voice_client, ctx)
77 166
78 # Verify the connection is healthy
79 return await self._verify_connection(voice_client) 167 return await self._verify_connection(voice_client)
80 168
81 async def connect(self, ctx): 169 async def connect(self, ctx):
82 """Connect to the user's voice channel with improved connection handling."""
83 voice_client = await self.ensure_voice_client(ctx) 170 voice_client = await self.ensure_voice_client(ctx)
84 if not voice_client: 171 if not voice_client:
85 return None 172 return None
86 173 return voice_client \ No newline at end of file
87 return voice_client
88
89
90def setup(bot):
91 bot.add_cog(Voice(bot)) \ No newline at end of file
diff --git a/main.py b/main.py
index 96fb03f..73ad94e 100644
--- a/main.py
+++ b/main.py
@@ -13,6 +13,8 @@ import discord
13from discord.ext import commands 13from discord.ext import commands
14from dotenv import load_dotenv 14from dotenv import load_dotenv
15 15
16from cogs.voice import get_voice_manager
17
16# Configure logging 18# Configure logging
17logging.basicConfig( 19logging.basicConfig(
18 level=logging.INFO, 20 level=logging.INFO,
@@ -27,7 +29,11 @@ logger = logging.getLogger(__name__)
27loop = asyncio.new_event_loop() 29loop = asyncio.new_event_loop()
28asyncio.set_event_loop(loop) 30asyncio.set_event_loop(loop)
29 31
30bot = commands.Bot(intents=discord.Intents.all(), loop=loop) 32intents = discord.Intents.default()
33intents.voice_states = True
34intents.guilds = True
35intents.messages = True
36bot = commands.Bot(intents=intents, loop=loop)
31 37
32settings = "settings.json" 38settings = "settings.json"
33 39
@@ -41,27 +47,40 @@ async def on_ready():
41 print(f"One-time invite (expires in 1 s or after 1 use): {invite.url}") 47 print(f"One-time invite (expires in 1 s or after 1 use): {invite.url}")
42 48
43 await channel.send("ŁUKASZ SPERMA AKTYWACJA") 49 await channel.send("ŁUKASZ SPERMA AKTYWACJA")
50
51 # Create settings.json if it doesn't exist
44 if not os.path.exists(settings): 52 if not os.path.exists(settings):
53 default_settings = {
54 'volume': 1.0,
55 'file_paths': ['audio', 'music', 'sounds', 'videos', 'images'],
56 'baseurl': 'http://localhost:8080'
57 }
45 async with aiofiles.open(settings, 'w') as f: 58 async with aiofiles.open(settings, 'w') as f:
46 json.dump({ 59 await f.write(json.dumps(default_settings, indent=2))
47 'volume': 1.0, 60 logger.info("settings.json created")
48 'audio_paths': ['audio', 'music', 'sounds'] 61
49 }, f) 62 # Validate and fix settings.json if it exists
50 if os.path.exists(settings): 63 if os.path.exists(settings):
51 try: 64 try:
52 async with aiofiles.open(settings) as f: 65 async with aiofiles.open(settings, 'r') as f:
53 content = await f.read() 66 content = await f.read()
67 if not content.strip():
68 raise json.JSONDecodeError("Empty file", "", 0)
54 data = json.loads(content) 69 data = json.loads(content)
55 assert 'volume' in data and isinstance(data['volume'], float) 70 assert 'volume' in data and isinstance(data['volume'], float)
56 assert 'audio_paths' in data and isinstance(data['audio_paths'], list) 71 assert 'file_paths' in data and isinstance(data['file_paths'], list)
72 assert 'baseurl' in data and isinstance(data['baseurl'], str)
57 except (json.JSONDecodeError, AssertionError) as error: 73 except (json.JSONDecodeError, AssertionError) as error:
58 logger.error(f"Error loading settings.json: {error}") 74 logger.error(f"Error loading settings.json: {error}")
75 default_settings = {
76 'volume': 1.0,
77 'file_paths': ['audio', 'music', 'sounds', 'videos', 'images'],
78 'baseurl': 'http://localhost:8080'
79 }
59 async with aiofiles.open(settings, 'w') as f: 80 async with aiofiles.open(settings, 'w') as f:
60 json.dump({ 81 await f.write(json.dumps(default_settings, indent=2))
61 'volume': 1.0, 82 logger.info("settings.json recreated")
62 'audio_paths': ['audio', 'music', 'sounds'] 83
63 }, f)
64 logger.info("settings.json created")
65 84
66 85
67if __name__ == "__main__": 86if __name__ == "__main__":
@@ -73,13 +92,14 @@ if __name__ == "__main__":
73 'cogs.web', 92 'cogs.web',
74 'cogs.ai', 93 'cogs.ai',
75 'cogs.conversion', 94 'cogs.conversion',
76 'cogs.math', 95 'cogs.math_commands',
77 'cogs.downloader', 96 'cogs.downloader',
78 'cogs.say', 97 'cogs.say',
79 'cogs.jarvis', 98 'cogs.jarvis',
80 'cogs.music.play_commands', 99 'cogs.music.play_commands',
81 'cogs.music.stop', 100 'cogs.music.stop',
82 'cogs.music.join', 101 'cogs.music.join',
102 'cogs.search',
83 ] 103 ]
84 104
85 try: 105 try:
@@ -93,6 +113,7 @@ if __name__ == "__main__":
93 raise ValueError("No token provided. Please set the TOKEN environment variable.") 113 raise ValueError("No token provided. Please set the TOKEN environment variable.")
94 114
95 bot.run(token) 115 bot.run(token)
116
96 except Exception as e: 117 except Exception as e:
97 logger.error(f"Error starting the bot: {e}") 118 logger.error(f"Error starting the bot: {e}")
98 sys.exit(1) 119 sys.exit(1)
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8000108..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,39 +0,0 @@
1aiohappyeyeballs==2.6.1
2aiohttp==3.13.3
3aiosignal==1.4.0
4anyio==4.12.1
5attrs==25.4.0
6audioop-lts==0.2.2
7beautifulsoup4==4.14.3
8brotli==1.2.0
9certifi==2026.1.4
10cffi==2.0.0
11charset-normalizer==3.4.4
12click==8.1.8
13dotenv==0.9.9
14ffmpeg==1.4
15frozenlist==1.8.0
16gTTS==2.5.4
17h11==0.16.0
18httpcore==1.0.9
19httpx==0.28.1
20idna==3.11
21multidict==6.7.1
22mutagen==1.47.0
23propcache==0.4.1
24py-cord==2.6.1
25pycparser==3.0
26pycryptodomex==3.23.0
27PyNaCl==1.6.2
28python-dotenv==1.2.1
29pytube==15.0.0
30RapidFuzz==3.14.3
31requests==2.32.5
32soupsieve==2.8.3
33typing_extensions==4.15.0
34urllib3==2.6.3
35websockets==16.0
36yarl==1.22.0
37youtube-search-python==1.6.6
38yt-dlp==2025.12.8
39yt-dlp-ejs==0.3.2