-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbot.py
More file actions
1208 lines (1048 loc) · 48 KB
/
bot.py
File metadata and controls
1208 lines (1048 loc) · 48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import math
import sys
import discord
from discord.ext import commands, tasks
import asyncio
import os, datetime, signal
import logging, logging.handlers
import urllib
import ssl
import validators
import psutil
from services.health_monitor import HealthMonitor
from services.metadata_monitor import MetadataMonitor
from services.state_manager import StateManager
from pls_parser import parse_pls
import shout_errors
import urllib_hack
from dotenv import load_dotenv
from pathlib import Path
from streamscrobbler import streamscrobbler
from favorites_manager import get_favorites_manager
from permissions import get_permission_manager, can_set_favorites_check, can_remove_favorites_check, can_manage_roles_check
from stream_validator import get_stream_validator
from input_validator import get_input_validator
from ui_components import FavoritesView, create_favorites_embed, create_favorites_list_embed, create_role_setup_embed, ConfirmationView
load_dotenv() # take environment variables from .env.
BOT_TOKEN = os.getenv('BOT_TOKEN')
LOG_FILE_PATH = Path(os.getenv('LOG_FILE_PATH', './')).joinpath('log.txt')
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper()
# TLS VERIFY
TLS_VERIFY = bool(int(os.environ.get('TLS_VERIFY', 1)))
# Technical Configurations - Be Careful!
MAX_ATTEMPTS = int(os.environ.get('MAX_ATTEMPTS', 10))
HEARTBEAT_INTERVAL = int(os.environ.get('HEARTBEAT_INTERVAL', 15))
BUF_SIZE_IN_KB = int(os.environ.get('BUF_SIZE_IN_KB', 1200))
BITRATE_IN_KBPS = int(os.environ.get('BITRATE_IN_KBPS', 320))
# CLUSETERING INFORMATION
CLUSTER_ID = int(os.environ.get('CLUSTER_ID', 0))
TOTAL_CLUSTERS = int(os.environ.get('TOTAL_CLUSTERS', 1))
TOTAL_SHARDS = int(os.environ.get('TOTAL_SHARDS', 1))
NUMBER_OF_SHARDS_PER_CLUSTER = int(TOTAL_SHARDS / TOTAL_CLUSTERS)
# Identify which shards we are, based on our max shards & cluster ID
shard_ids = [
i
for i in range(
CLUSTER_ID * NUMBER_OF_SHARDS_PER_CLUSTER,
(CLUSTER_ID * NUMBER_OF_SHARDS_PER_CLUSTER) + NUMBER_OF_SHARDS_PER_CLUSTER
)
if i < TOTAL_SHARDS
]
# END CLUSTERING
intents = discord.Intents.default()
intents.members = True
intents.guilds = True
intents.voice_states = True
# minimal member cache: only cache members related to events / interactions
member_cache_flags = discord.MemberCacheFlags.from_intents(intents)
bot = commands.AutoShardedBot(command_prefix='/', case_insensitive=True, intents=intents, member_cache_flags=member_cache_flags , shard_ids=shard_ids, shard_count=TOTAL_SHARDS)
bot.cluster_id = CLUSTER_ID
bot.total_shards = TOTAL_SHARDS
# Set up logging
logger = logging.getLogger('discord')
logger.setLevel(LOG_LEVEL) # Set the desired logging level (DEBUG, INFO, etc.)
logging.getLogger('discord.http').setLevel(logging.INFO)
logging.getLogger('discord.client').setLevel(logging.INFO)
logging.getLogger('discord.gateway').setLevel(logging.INFO)
# Create handlers
console_handler = logging.StreamHandler() # Logs to standard output
file_handler = logging.handlers.RotatingFileHandler( # Logs to a file
filename=LOG_FILE_PATH,
encoding='utf-8',
maxBytes=32 * 1024 * 1024, # 32 MiB
backupCount=5, # Rotate through 5 files
)
# Set log format
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
# Add handlers to the logger
logger.addHandler(console_handler)
logger.addHandler(file_handler)
_active_heartbeats = {}
# TODO: Clean this up?
STATE_MANAGER = None
MONITORS = []
async def init():
### Setup various services ###
# Create State Manager to manage the state
global STATE_MANAGER
STATE_MANAGER = await StateManager.create_state_manager(bot=bot)
# Create list of monitors
global MONITORS
MONITORS = [
HealthMonitor(sys.modules[__name__], client=bot, state_manager=STATE_MANAGER, logger=logger),
MetadataMonitor(sys.modules[__name__], client=bot, state_manager=STATE_MANAGER, logger=logger)
]
asyncio.run(init())
@bot.event
async def on_ready():
# Initialize a hack for urllib that replaces `ICY 200 OK` as the status line with `HTTP/1.0 200 OK`
urllib_hack.init_urllib_hack(TLS_VERIFY)
logger.info("Syncing slash commands")
await bot.tree.sync()
logger.info(f"Logged on as {bot.user}")
logger.info(f"Shard IDS: {bot.shard_ids}")
logger.info(f"Cluster ID: {bot.cluster_id}")
# Log all loaded environment variables
logger.info("Loaded configuration variables:")
config_vars = {
'BOT_TOKEN': BOT_TOKEN,
'LOG_FILE_PATH': str(LOG_FILE_PATH),
'LOG_LEVEL': LOG_LEVEL,
'TLS_VERIFY': TLS_VERIFY,
'TLS_Debug_int': int(TLS_VERIFY),
'MAX_ATTEMPTS': MAX_ATTEMPTS,
'HEARTBEAT_INTERVAL': f'{HEARTBEAT_INTERVAL}s',
'BUF_SIZE_IN_KB': f'{BUF_SIZE_IN_KB}KB',
'BITRATE_IN_KBPS': f'{BITRATE_IN_KBPS}kbps',
'EMPTY_CHANNEL_TIMEOUT': f'{int(os.environ.get("EMPTY_CHANNEL_TIMEOUT", 45*60))}s',
'TOTAL_CLUSTERS': TOTAL_CLUSTERS,
'TOTAL_SHARDS': TOTAL_SHARDS,
'NUMBER_OF_SHARDS_PER_CLUSTER': NUMBER_OF_SHARDS_PER_CLUSTER
}
sensitive_keys = {'BOT_TOKEN'}
for key, value in config_vars.items():
if key in sensitive_keys:
logger.info(f" {key}: [REDACTED]")
else:
logger.info(f" {key}: {value}")
### Custom Checks ###
## Make sure initiating channel is not a thread channel
def is_channel():
"""
Custom check to prevent commands from being used in unsupported channels.
Usage:
@is_channel()
async def command(...):
Returns:
Boolean: True if the command is used in a normal text channel, False otherwise
"""
async def _predicate(interaction: discord.Interaction):
ch = getattr(interaction, 'channel', None)
# Detect thread channels
is_thread = isinstance(ch, discord.Thread) or getattr(ch, 'type', None) in (
discord.ChannelType.public_thread,
discord.ChannelType.private_thread,
discord.ChannelType.news_thread,
)
# Detect direct messages (DMs)
is_dm = isinstance(ch, discord.DMChannel) or getattr(ch, 'type', None) == discord.ChannelType.private
if is_thread:
try:
await interaction.response.send_message("⚠️ I can't process commands *properly* in message threads, use a `text-channel` (or `voice-text-channel`) instead.", ephemeral=True)
logger.error(f"{interaction.user} attempted to use a command in a thread")
except Exception as e:
logger.warning(f"Failed to send thread rejection message: {e}")
return False
if is_dm:
try:
await interaction.response.send_message("⚠️ I can't process commands *directly*, add me to a server and use a `text-channel` instead.")
logger.error(f"{interaction.user} attempted to use a command in a DM")
except Exception as e:
logger.warning(f"Failed to send DM rejection message: {e}")
return False
return True
return discord.app_commands.check(_predicate)
# Verify bot permissions in the initiating channel
def bot_has_channel_permissions(permissions: discord.Permissions):
def predicate(interaction: discord.Interaction):
# Get current permissions
bot_permissions = interaction.channel.permissions_for(interaction.guild.me)
# Check if bot_permissions contains all of requested permissions
if bot_permissions >= permissions:
return True
# Figure out which permissions we don't have
missing_permissions = dict((bot_permissions | permissions) ^ bot_permissions)
# Find which permissions are missing & raise it as an errror
missing_permissions = [v for v in missing_permissions.keys() if missing_permissions[v]]
raise discord.app_commands.BotMissingPermissions(missing_permissions=missing_permissions)
return discord.app_commands.checks.check(predicate)
# Verify bot is not in maintenance mode
def bot_not_in_maintenance():
async def predicate(interaction: discord.Interaction):
if STATE_MANAGER.get_maint() and not await bot.is_owner(interaction.user):
await interaction.response.send_message(f"🚧 This bot is currently experiencing maintenance. Check back later.")
return False
return True
return discord.app_commands.checks.check(predicate)
### Bot Commands ###
@bot.tree.command(
name='play',
description="Begin playback of a shoutcast/icecast stream"
)
@discord.app_commands.checks.cooldown(rate=1, per=5, key=None)
@is_channel()
@bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True))
@bot_not_in_maintenance()
async def play(interaction: discord.Interaction, url: str, private_stream: bool = False):
if not is_valid_url(url):
raise commands.BadArgument("🙇 I'm sorry, I don't know what that means!")
if await is_cleaning_up(interaction):
raise shout_errors.CleaningUp('Bot is still cleaning up from last session')
response_message = f"Starting channel {url}" if not private_stream else "Starting channel ***OMINOUSLY***"
await interaction.response.send_message(response_message, ephemeral=True)
STATE_MANAGER.set_state(interaction.guild_id, 'private_stream', private_stream)
await asyncio.sleep(0.5)
await play_stream(interaction, url)
@bot.tree.command(
name='leave',
description="Remove the bot from the current call"
)
@discord.app_commands.checks.cooldown(rate=1, per=5, key=None)
@is_channel()
@bot_not_in_maintenance()
async def leave(interaction: discord.Interaction, force: bool = False):
voice_client = interaction.guild.voice_client
has_state = bool(STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url'))
# Handle normal case - voice client exists
if voice_client:
await interaction.response.send_message("👋 Seeya Later, Gator!")
await stop_playback(interaction.guild)
return
# Handle desync case - AUTOMATIC RECOVERY
if has_state:
if force:
await interaction.response.send_message("🔧 Force clearing stale state...")
else:
await interaction.response.send_message("🔄 Detected state desync - automatically recovering...")
# Automatically clear stale state
STATE_MANAGER.clear_state(interaction.guild.id)
logger.info(f"[{interaction.guild.id}]: Auto-recovered from state desync via /leave")
if force:
await interaction.edit_original_response(content="✅ Force cleared stale bot state. Ready for new streams!")
else:
await interaction.edit_original_response(content="✅ Auto-recovered from state issue. Ready for new streams!")
return
# Normal case - nothing playing
raise shout_errors.NoVoiceClient("😨 I'm not even playing any music! You don't have to be so mean")
@bot.tree.command(
name="song",
description="Send an embed with the current song information to this channel"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def song(interaction: discord.Interaction):
url = STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url')
if url:
await interaction.response.send_message("Fetching song title...")
stationinfo = await get_station_info(url)
if stationinfo['metadata']:
await interaction.edit_original_response(content=f"Now Playing: 🎶 {stationinfo['metadata']['song']} 🎶")
else:
await interaction.edit_original_response(content=f"Could not retrieve song title. This feature may not be supported by the station")
else:
raise shout_errors.NoStreamSelected("🔎 None. There's no song playing. Turn the stream on maybe?")
@bot.tree.command(
name="refresh",
description="Refresh the stream. Bot will leave and come back. Song updates will start displaying in this channel"
)
@discord.app_commands.checks.cooldown(rate=1, per=5, key=None)
@is_channel()
@bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True))
@bot_not_in_maintenance()
async def refresh(interaction: discord.Interaction):
if STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url'):
await interaction.response.send_message("♻️ Refreshing stream, the bot may skip or leave and re-enter")
await refresh_stream(interaction)
else:
raise shout_errors.NoStreamSelected
@bot.tree.command(
name='support',
description="Information on how to get support"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def support(interaction: discord.Interaction):
embed_data = {
'title': "BunBot Support",
'color': 0xF0E9DE,
'description': """
❔ Got a question?
Join us at https://discord.gg/ksZbX723Jn
The team is always happy to help
⚠️ Found an issue?
Please consider creating a ticket at
https://github.com/CGillen/BunBotPython/issues
We'll appreciate it
🛠️ Or contribute your own fix!
BunBot is completely open source and free to use under the GPLv3 license
Just remember to give us a shoutout
📜 ToS: https://github.com/CGillen/BunBotPython/blob/main/COPYING
🫶 Like what we're doing?
Support us on Ko-Fi: https://ko-fi.com/bunbot
""",
}
embed = discord.Embed.from_dict(embed_data)
await interaction.response.send_message(embed=embed)
@bot.tree.command(
name="debug",
description="Show debug stats & info"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def debug(interaction: discord.Interaction, page: int = 0, per_page: int = 10, id: str = ''):
resp = []
resp.append("==\tGlobal Info\t==")
page_count = math.ceil(len(bot.guilds) / per_page)
page = max(0, page-1)
page = min(page, page_count-1)
page_index = page * per_page
if await bot.is_owner(interaction.user):
if id:
resp.append(id)
resp.append("Guild:")
guild = next((x for x in bot.guilds if str(x.id) == id), None)
if guild:
start_time = STATE_MANAGER.get_state(guild.id, 'start_time')
resp.append(f"- {guild.name} ({guild.id}): user count - {guild.member_count}")
resp.append(f"\tState: {STATE_MANAGER.get_state(guild.id)}")
if start_time:
resp.append(f"\tRun time: {datetime.datetime.now(datetime.UTC) - start_time}")
resp.append(f"\tShard: {guild.shard_id}")
else:
resp.append("Guilds:")
for guild in bot.guilds[page_index:page_index+per_page]:
start_time = STATE_MANAGER.get_state(guild.id, 'start_time')
resp.append(f"- {guild.name} ({guild.id}): user count - {guild.member_count}")
resp.append(f"\tStatus: {STATE_MANAGER.get_state(guild.id, 'current_stream_url') or "Not Playing"}")
resp.append(f"Total pages: {page_count}")
resp.append(f"Current page: {math.floor(page_count/per_page) + 1}")
resp.append("Bot:")
resp.append(f"\tCluster ID: {bot.cluster_id}")
resp.append(f"\tShards: {bot.shard_ids}")
else:
resp.append(f"\tGuild count: {len(bot.guilds)}")
start_time = STATE_MANAGER.get_state(interaction.guild.id, 'start_time')
resp.append("==\tServer Info\t==")
resp.append(f"\tStream URL: {STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url') or "Not Playing"}")
resp.append(f"\tCurrent song: {STATE_MANAGER.get_state(interaction.guild.id, 'current_song') or "Not Playing"}")
if start_time:
resp.append(f"\tRun time: {datetime.datetime.now(datetime.UTC) - start_time}")
await interaction.response.send_message("\n".join(resp), ephemeral=True)
@bot.tree.command(
name='maint',
description="Toggle maintenance mode! (Bot maintainer only)"
)
@discord.app_commands.checks.cooldown(rate=1, per=5, key=None)
@is_channel()
@bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True))
async def maint(interaction: discord.Interaction, status: bool = True):
if await bot.is_owner(interaction.user):
if status == STATE_MANAGER.get_maint():
await interaction.response.send_message("❌ Given maintenance status matches bot maintenance status. Nothing interesting happens.")
return
await interaction.response.send_message("🛠️ Toggling maintenance mode... please wait")
await STATE_MANAGER.set_maint(status=status)
await asyncio.sleep(0.5)
active_guild_ids = STATE_MANAGER.all_active_guild_ids()
for guild_id in active_guild_ids:
text_channel = bot.get_channel(STATE_MANAGER.get_state(guild_id, 'text_channel_id'))
is_active = STATE_MANAGER.get_state(guild_id, 'is_active') or False
if status and is_active is True:
embed_data = {
'title': "Maintenance",
'color': 0xfce053,
'description': "The bot is entering maintenance mode. Commands and playback will be unavailable until maintenance is complete",
'timestamp': str(datetime.datetime.now(datetime.UTC)),
}
await stop_playback(bot.get_guild(guild_id))
STATE_MANAGER.set_state(guild_id, 'was_active', True)
embed = discord.Embed.from_dict(embed_data)
await text_channel.send(embed=embed)
await asyncio.sleep(0.5)
else:
was_active = STATE_MANAGER.get_state(guild_id, 'was_active') or False
if was_active is True and status == False:
embed_data = {
'title': "Maintenance",
'color': 0xfce053,
'description': "Maintenance has concluded.",
'timestamp': str(datetime.datetime.now(datetime.UTC)),
}
embed = discord.Embed.from_dict(embed_data)
await text_channel.send(embed=embed)
if status:
await interaction.edit_original_response(content="💾 saving state...")
await STATE_MANAGER.save_state()
await asyncio.sleep(2)
await interaction.edit_original_response(content="👷 Maintenance mode enabled")
else:
await interaction.edit_original_response(content="🧼 Purging State + DB...")
STATE_MANAGER.clear_state(force=True)
await STATE_MANAGER.clear_state_db()
await asyncio.sleep(3)
await STATE_MANAGER.set_maint(status=status)
await interaction.edit_original_response(content="👷 Maintenance mode disabled")
else:
logger.info("😂 Pleb tried to put me in maintenance mode")
await interaction.response.send_message("Awww look at you, how cute")
### FAVORITES COMMANDS ###
@bot.tree.command(
name='set-favorite',
description="Add a radio station to favorites"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def set_favorite(interaction: discord.Interaction, url: str, name: str = None):
# Check permissions
perm_manager = get_permission_manager()
if not perm_manager.can_set_favorites(interaction.guild.id, interaction.user):
await interaction.response.send_message(
"❌ You don't have permission to set favorites. Ask an admin to assign you the appropriate role.",
ephemeral=True
)
return
# Validate URL format first
if not is_valid_url(url):
await interaction.response.send_message("❌ Please provide a valid URL.", ephemeral=True)
return
await interaction.response.send_message("🔍 Validating stream and adding to favorites...")
try:
favorites_manager = get_favorites_manager()
result = await favorites_manager.add_favorite(
guild_id=interaction.guild.id,
url=url,
name=name,
user_id=interaction.user.id
)
if result['success']:
await interaction.edit_original_response(
content=f"✅ Added **{result['station_name']}** as favorite #{result['favorite_number']}"
)
else:
await interaction.edit_original_response(
content=f"❌ Failed to add favorite: {result['error']}"
)
except Exception as e:
logger.error(f"Error in set_favorite command: {e}")
await interaction.edit_original_response(
content="❌ An unexpected error occurred while adding the favorite."
)
@bot.tree.command(
name='play-favorite',
description="Play a favorite radio station by number"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def play_favorite(interaction: discord.Interaction, number: int):
try:
favorites_manager = get_favorites_manager()
favorite = favorites_manager.get_favorite_by_number(interaction.guild.id, number)
if not favorite:
await interaction.response.send_message(f"❌ Favorite #{number} not found.", ephemeral=True)
return
await interaction.response.send_message(
f"🎵 Starting favorite #{number}: **{favorite['station_name']}**"
)
await play_stream(interaction, favorite['stream_url'])
except Exception as e:
logger.error(f"Error in play_favorite command: {e}")
if interaction.response.is_done():
await interaction.followup.send("❌ An error occurred while playing the favorite.", ephemeral=True)
else:
await interaction.response.send_message("❌ An error occurred while playing the favorite.", ephemeral=True)
@bot.tree.command(
name='favorites',
description="Show favorites with clickable buttons"
)
@discord.app_commands.checks.cooldown(rate=1, per=10)
@is_channel()
async def favorites(interaction: discord.Interaction):
try:
favorites_manager = get_favorites_manager()
favorites_list = favorites_manager.get_favorites(interaction.guild.id)
if not favorites_list:
await interaction.response.send_message(
"📻 No favorites set for this server yet! Use `/set-favorite` to add some.",
ephemeral=True
)
return
# Create embed and view with buttons
embed = create_favorites_embed(favorites_list, 0, interaction.guild.name)
view = FavoritesView(favorites_list, 0)
await interaction.response.send_message(embed=embed, view=view)
except Exception as e:
logger.error(f"Error in favorites command: {e}")
await interaction.response.send_message("❌ An error occurred while loading favorites.", ephemeral=True)
@bot.tree.command(
name='list-favorites',
description="List all favorites (text only, mobile-friendly)"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def list_favorites(interaction: discord.Interaction):
try:
favorites_manager = get_favorites_manager()
favorites_list = favorites_manager.get_favorites(interaction.guild.id)
embed = create_favorites_list_embed(favorites_list, interaction.guild.name)
await interaction.response.send_message(embed=embed)
except Exception as e:
logger.error(f"Error in list_favorites command: {e}")
await interaction.response.send_message("❌ An error occurred while listing favorites.", ephemeral=True)
@bot.tree.command(
name='remove-favorite',
description="Remove a favorite radio station"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def remove_favorite(interaction: discord.Interaction, number: int):
# Check permissions
perm_manager = get_permission_manager()
if not perm_manager.can_remove_favorites(interaction.guild.id, interaction.user):
await interaction.response.send_message(
"❌ You don't have permission to remove favorites. Ask an admin to assign you the appropriate role.",
ephemeral=True
)
return
try:
favorites_manager = get_favorites_manager()
# Check if favorite exists first
favorite = favorites_manager.get_favorite_by_number(interaction.guild.id, number)
if not favorite:
await interaction.response.send_message(f"❌ Favorite #{number} not found.", ephemeral=True)
return
# Create confirmation view
view = ConfirmationView("remove", f"favorite #{number}: {favorite['station_name']}")
await interaction.response.send_message(
f"⚠️ Are you sure you want to remove favorite #{number}: **{favorite['station_name']}**?\n"
f"This will reorder all subsequent favorites.",
view=view
)
# Wait for confirmation
await view.wait()
if view.confirmed:
result = favorites_manager.remove_favorite(interaction.guild.id, number)
if result['success']:
await interaction.followup.send(
f"✅ Removed **{result['station_name']}** from favorites. Subsequent favorites have been renumbered."
)
else:
await interaction.followup.send(f"❌ Failed to remove favorite: {result['error']}")
except Exception as e:
logger.error(f"Error in remove_favorite command: {e}")
if interaction.response.is_done():
await interaction.followup.send("❌ An error occurred while removing the favorite.", ephemeral=True)
else:
await interaction.response.send_message("❌ An error occurred while removing the favorite.", ephemeral=True)
@bot.tree.command(
name='setup-roles',
description="Configure which Discord roles can manage favorites"
)
@discord.app_commands.checks.cooldown(rate=1, per=5)
@is_channel()
async def setup_roles(interaction: discord.Interaction, role: discord.Role = None, permission_level: str = None):
# Check permissions
perm_manager = get_permission_manager()
if not perm_manager.can_manage_roles(interaction.guild.id, interaction.user):
await interaction.response.send_message(
"❌ You don't have permission to manage role assignments. Ask an admin to assign you the appropriate role.",
ephemeral=True
)
return
try:
# If no parameters provided, show current setup
if not role and not permission_level:
role_assignments = perm_manager.get_server_role_assignments(interaction.guild.id)
available_roles = perm_manager.get_available_permission_roles()
embed = create_role_setup_embed(role_assignments, available_roles, interaction.guild.name)
await interaction.response.send_message(embed=embed)
return
# Both parameters required for assignment
if not role or not permission_level:
await interaction.response.send_message(
"❌ Please provide both a role and permission level.\n"
"Example: `/setup-roles @DJ dj`\n"
"Available levels: user, dj, radio manager, admin",
ephemeral=True
)
return
# Validate permission level
available_roles = perm_manager.get_available_permission_roles()
valid_levels = [r['role_name'] for r in available_roles]
if permission_level.lower() not in valid_levels:
await interaction.response.send_message(
f"❌ Invalid permission level. Available levels: {', '.join(valid_levels)}",
ephemeral=True
)
return
# Assign the role
success = perm_manager.assign_role_permission(
guild_id=interaction.guild.id,
role_id=role.id,
role_name=permission_level.lower()
)
if success:
await interaction.response.send_message(
f"✅ Assigned role {role.mention} to permission level **{permission_level}**"
)
else:
await interaction.response.send_message(
"❌ Failed to assign role permission. Please check the permission level is valid.",
ephemeral=True
)
except Exception as e:
logger.error(f"Error in setup_roles command: {e}")
if interaction.response.is_done():
await interaction.followup.send("❌ An error occurred while setting up roles.", ephemeral=True)
else:
await interaction.response.send_message("❌ An error occurred while setting up roles.", ephemeral=True)
### END FAVORITES COMMANDS ###
@bot.tree.error
async def on_command_error(interaction: discord.Interaction, error):
original_error = error.original if hasattr(error, 'original') else error
error_message=""
if isinstance(original_error, commands.MissingRequiredArgument):
# Handle missing argument error for this specific command
error_message = "☠️ Please provide a valid Shoutcast v2 stream link Example: `!play [shoutcast v2 stream link]`"
elif isinstance(original_error, commands.BadArgument):
# Handle bad argument error (e.g., type error)
error_message = "☠️ The provided link is not a valid URL. Please provide a valid Shoutcast stream link."
elif isinstance(original_error, commands.CommandNotFound):
pass
elif isinstance(original_error, shout_errors.AlreadyPlaying):
# Steam was found to be offline somewhere
error_message = "😱 I'm already playing music! I can't be in two places at once"
elif isinstance(original_error, shout_errors.StreamOffline):
# Steam was found to be offline somewhere
error_message = "📋 Error fetching stream. Maybe the stream is down?"
elif isinstance(original_error, shout_errors.AuthorNotInVoice):
# The person sending the command isn't in a voice chat
error_message = "😢 You are not in a voice channel. What are you doing? Where am I supposed to go? Don't leave me here"
elif isinstance(original_error, shout_errors.NoStreamSelected):
# A stream hasn't started yet
error_message = "🙄 No stream started, what did you expect me to do?"
elif isinstance(original_error, shout_errors.NoVoiceClient):
# There isn't a voice client to operate on
error_message = "🙇 I'm not playing any music! Please stop harassing me"
elif isinstance(original_error, shout_errors.CleaningUp):
# The client is still cleaning up after itself
error_message = "🗑️ I'm still cleaning up after myself, give me a sec"
elif isinstance(original_error, discord.app_commands.errors.CommandOnCooldown):
# Commands are being sent too quickly
error_message = "🥵 Slow down, I can only handle so much!"
elif isinstance(original_error, discord.app_commands.errors.BotMissingPermissions):
# We don't have permission to send messages here
error_message = f"😶 It looks like I'm missing permissions for this channel:\n{error}"
elif isinstance(original_error, discord.app_commands.CheckFailure):
# Handle these messages in the permissions check function
return
else:
# General error handler for other errors
error_message = f"🤷 An unexpected error occurred while processing your command:\n{error}"
if interaction.response.is_done():
original_response = await interaction.original_response()
original_response_text = original_response.content
error_message = original_response_text + f"\n{error_message}"
await interaction.edit_original_response(content=error_message)
else:
await interaction.response.send_message(error_message)
### Helper methods ###
def is_valid_url(url):
return validators.url(url)
def url_slicer(url: str, max_display: int = 10) -> str:
"""
Return a markdown link for use in embed fields. If the Path is longer than
max_display value, return an ellipsized label that preserves the hostname and
beginning of the path.
The returned string is intended to be placed directly into an embed field
value so it will be clickable in Discord.
"""
if not url:
return ""
sliced_url = urllib.parse.urlparse(url)
url_raw = str(url)
path_raw = sliced_url.path.rstrip('/')
hostname = sliced_url.hostname
port = sliced_url.port
# Slice the path if necessary
if len(path_raw) <= max_display:
path = path_raw
else:
path = "%s..." % (path_raw[:max_display])
# If port is present and is not the default HTTP/HTTPS port, include it
try:
if port and int(port) not in (80, 443):
display = f"{hostname}:{port}{path}"
else:
display = f"{hostname}{path}"
except Exception:
logger.warning(f"an unexpected error occurred while slicing port: {url}")
display = "Error-slicing-URL"
pass
# Keep the full URL (with scheme) as the hyperlink target
return f"[{display}]({url_raw})"
# Find information about the playing station & send that as an embed to the original text channel
async def send_song_info(guild_id: int):
url = STATE_MANAGER.get_state(guild_id, 'current_stream_url')
channel = bot.get_channel(STATE_MANAGER.get_state(guild_id, 'text_channel_id'))
stationinfo = await get_station_info(url)
if not stationinfo['metadata']:
logger.warning("We didn't get metadata back from the station, can't send the station info")
return
# We need to quite now if we can't send messages
guild = bot.get_guild(guild_id)
if not channel.permissions_for(guild.me).send_messages:
logger.warning("we don't have permission to send the song info!")
return False
embed_data = {
'title': "Now Playing",
'color': 0x0099ff,
'description': f"🎶 {stationinfo['metadata']['song']} 🎶",
# 'timestamp': str(datetime.datetime.now(datetime.UTC)),
}
embed = discord.Embed.from_dict(embed_data)
sliced_url = url_slicer(url)
bitrate = stationinfo['metadata'].get('bitrate', None)
now_utc = datetime.datetime.now(datetime.timezone.utc)
discord_time = f"<t:{int(now_utc.timestamp())}:R>"
# Set information about the source in the "footer"
if STATE_MANAGER.get_state(guild_id, 'private_stream'):
# stream is private, do not show URL
try:
if bitrate not in (None, 0):
embed.add_field(name="\u200b", value=f"Source: `Private` • Bitrate: {bitrate}kbps • {discord_time}", inline=True)
else:
embed.add_field(name="\u200b", value=f"Source: `Private` • {discord_time}", inline=True)
except Exception:
# Legacy Footer fallback
logger.warning("Failed to add fields to embed, falling back to legacy footer")
embed.set_footer(text=f"Source: Private")
else:
# stream is public, show URL
try:
if bitrate not in (None, 0):
embed.add_field(name="\u200b", value=f"Source: {sliced_url} • Bitrate: {bitrate}kbps • {discord_time}", inline=True)
else:
embed.add_field(name="\u200b", value=f"Source: {sliced_url} • {discord_time}", inline=True)
except Exception:
# Legacy Footer fallback
logger.warning("Failed to add fields to embed, falling back to legacy footer")
embed.set_footer(text=f"Source: {url}")
return await channel.send(embed=embed)
# Retrieve information about the shoutcast stream
async def get_station_info(url: str):
if not url:
logger.warning("Stream URL not set, can't send song information to channel")
raise shout_errors.NoStreamSelected()
stationinfo = await asyncio.to_thread(streamscrobbler.get_server_info, url, TLS_VERIFY)
if stationinfo['status'] <= 0:
logger.warning("Stream not up, unable to update song title")
raise shout_errors.StreamOffline()
return stationinfo
# Handle stream disconnect with proper state cleanup
async def handle_stream_disconnect(guild: discord.Guild):
"""Handle stream disconnection and clean up state properly"""
try:
_active_heartbeats[guild.id].cancel()
logger.info(f"[{guild.id}]: Heartbeat Destroyed")
logger.info(f"[{guild.id}]: checking for stream disconnected")
# Get current state before clearing
channel = bot.get_channel(STATE_MANAGER.get_state(guild.id, 'text_channel_id'))
# Notify users if possible
if channel:
try:
# Check if we have permission to send messages
if channel.permissions_for(guild.me).send_messages:
await channel.send("🔌 Stream disconnected. Use `/play` to start a new stream!")
except Exception as e:
logger.warning(f"[{guild.id}]: Could not send disconnect notification: {e}")
# Ensure voice client is properly disconnected
voice_client = guild.voice_client
if voice_client:
try:
if voice_client.is_connected():
await voice_client.disconnect()
logger.info(f"[{guild.id}]: Voice client disconnected")
except Exception as e:
logger.warning(f"[{guild.id}]: Error disconnecting voice client: {e}")
# Ensure ffmpeg is not left running
try:
kill_ffmpeg_process(guild.id)
except Exception as e:
logger.debug(f"[{guild.id}]: Error attempting to purge ffmpeg in Handle_stream_disconnect: {e}")
# Clear all state for this guild
STATE_MANAGER.clear_state(guild.id)
logger.info(f"[{guild.id}]: stream cleaned successfully!")
except Exception as e:
logger.error(f"[{guild.id}]: Error in handle_stream_disconnect: {e}")
# Ensure state is cleared even if other operations fail
STATE_MANAGER.clear_state(guild.id)
# Resync the stream by leaving and coming back
async def refresh_stream(interaction: discord.Interaction):
url = STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url') # preserve current stream url
await stop_playback(interaction.guild)
await play_stream(interaction, url)
# Start playing music from the stream
# Check connection/status of stream
# Check if stream link is .pls and parse it first
# Get stream connection to server
# Connect to voice channel
# Start ffmpeg transcoding stream
# Play stream
# Start metadata monitor (will close stream if streaming server goes down)
async def play_stream(interaction, url):
if not url:
logger.warning("No stream currently set, can't play nothing")
raise shout_errors.NoStreamSelected
# Handle .pls playlist files
sliced_url = urllib.parse.urlparse(url)
path = sliced_url.path
pls = path.find('.pls')
if pls != -1:
logger.debug(f"Detected .pls file, attempting to parse: {url}")
await interaction.edit_original_response(content="❓ Looks Like this is a `.pls`, Let's see if I can figure it out...")
stream_url = await parse_pls(url)
if not stream_url:
# catch all
logger.error("Failed to parse .pls or no valid stream URL found")
raise shout_errors.StreamOffline()
url = stream_url
# Connect to voice channel author is currently in
voice_state = getattr(interaction.user, 'voice', None) # voice channel check, explicitly set to None if not found for some reason
voice_channel = voice_state.channel if voice_state and getattr(voice_state, 'channel', None) else None
if voice_channel is None:
raise shout_errors.AuthorNotInVoice
# Find if voice client is already playing music
voice_client = interaction.guild.voice_client
# If a voice client exists but is not connected, purge it and start over
if voice_client and not voice_client.is_connected():
try:
logger.info("Attempting to purge stale client")
await interaction.edit_original_response(content="this is taking a while... don't worry we're still trying to get your stream!")
STATE_MANAGER.set_state(interaction.guild.id, 'cleaning_up', True)
await voice_client.disconnect(force=True)
logger.info("Disconnected stale voice client before starting new stream")
except Exception as e: # Last ditch effort
logger.warning(f"Error disconnecting stale voice client: {e}")
voice_client = None
# If a voice client is already playing, raise error
if voice_client and voice_client.is_playing():
raise shout_errors.AlreadyPlaying
logger.info(f"Starting channel {url}")
stationinfo = await asyncio.to_thread(streamscrobbler.get_server_info, url, TLS_VERIFY)
## metadata is the bitrate and current song
metadata = stationinfo['metadata']
## status is the integer to tell if the server is up or down, 0 is down, 1 is up, 2 is up with metadata
status = stationinfo['status']
logger.info(f"metadata: {metadata}, status: {status}")
# If the stream status isn't >0, it's offline. Exit out early
if status <= 0:
logger.error("Stream is not online")
raise shout_errors.StreamOffline()
# Try to get an http stream connection to the ... stream
try:
ctx = ssl.create_default_context()
if not TLS_VERIFY:
ctx = ssl._create_unverified_context()
ctx.check_hostname = False
ctx.set_ciphers('DEFAULT:@SECLEVEL=1')
urllib.request.urlopen(url, timeout=10, context=ctx)
except Exception as error: # if there is an error, let the user know.
logger.error(f"Failed to connect to stream: {error}")
await interaction.edit_original_response(content="Error fetching stream. Maybe the stream is down?")
return False
# Try to connect to voice chat, and only consider connected if both conditions met
if not voice_client or not voice_client.is_connected():