├── .gitignore ├── README.md ├── player ├── __init__.py ├── __main__.py ├── helpers │ ├── ffmpeg_handler.py │ └── retry_deco.py ├── telegram │ ├── __init__.py │ ├── audio_handler.py │ └── plugins │ │ └── controls.py └── working_dir │ └── config.ini.sample └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | venv2/ 3 | .idea/ 4 | .env 5 | *.ini 6 | *.cfg 7 | *.session 8 | *.json 9 | */__pycache__/ 10 | /auditor/working_dir/tmp/ 11 | *.code-workspace -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MvEargasmDJ: 2 | This is my submission for the Telegram Radio Project of Baivaru. Which required a userbot to continiously play random audio files from the famous telegram music channel @mveargasm in a voice chat. 3 | 4 | ## Available Commands to Control the Playlist: 5 | Only members with access to amend/change voice chat options are allowed to use the following commands. 6 | 1. **!vnext** - skips the current playing song and play a new one. 7 | 2. **!vpause** - pause the current playing song. 8 | 3. **!resume** - resume a paused song. 9 | 4. **!restart** - restart the current playing song. 10 | 11 | ## Cloning and Run: 12 | 1. `git clone https://github.com/eyaadh/mveargasmdj.git`, to clone the repository. 13 | 2. `cd mveargasmdj`, to enter the directory. 14 | 3. `pip3 install -r requirements.txt`, to install rest of the dependencies/requirements. 15 | 4. Create a new `config.ini` using the sample available at `config.ini.sample`. 16 | 5. Insall ffmpeg `apt install ffmpeg` 17 | 5. Run with `python3.8 -m player`, stop with CTRL+C. 18 | > It is recommended to use [virtual environments](https://docs.python-guide.org/dev/virtualenvs/) while running the app, this is a good practice you can use at any of your python projects as virtualenv creates an isolated Python environment which is specific to your project. 19 | -------------------------------------------------------------------------------- /player/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig( 4 | level=logging.INFO, 5 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 6 | ) 7 | logging.getLogger(__name__) -------------------------------------------------------------------------------- /player/__main__.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import logging 3 | import asyncio 4 | import player.telegram 5 | from pyrogram import idle 6 | from player.telegram.audio_handler import start_player 7 | 8 | 9 | async def main(): 10 | await player.telegram.Audio_Master.start() 11 | 12 | while not player.telegram.Audio_Master.is_connected: 13 | await asyncio.sleep(1) 14 | 15 | await start_player() 16 | await idle() 17 | 18 | if __name__ == "__main__": 19 | try: 20 | loop = asyncio.get_event_loop() 21 | loop.run_until_complete(main()) 22 | except KeyboardInterrupt as e: 23 | loop.stop() 24 | finally: 25 | if player.telegram.raw_file_path: 26 | logging.info("Removing temporary files and closing the loop!") 27 | if player.telegram.raw_file_path.exists(): 28 | shutil.rmtree(player.telegram.raw_file_path.parent) 29 | -------------------------------------------------------------------------------- /player/helpers/ffmpeg_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import logging 4 | import secrets 5 | import asyncio 6 | from pathlib import Path 7 | 8 | 9 | async def convert_audio_to_raw(audio_file: str) -> dict: 10 | """ 11 | :what it does: 12 | 1. create a temporary directory to save the output of the converted raw file 13 | 2. run ffmpeg externally to convert given mp3 audio_file to a raw file: 14 | Note: since ffmpeg is run externally we took the abspath of the audio file at the very 15 | beginning of the function 16 | 3. remove the given audio file and its directory since it is not needed any more 17 | :params: 18 | audio_file: a string value expected 19 | path of the mp3 audio file that requires to be converted to a raw file 20 | :return: 21 | a dict with the key 'raw_file' for the path of the raw file that was generated. 22 | """ 23 | audio_file_abspath = os.path.abspath(audio_file) 24 | 25 | audio_covertion_directory = f"player/working_dir/{secrets.token_hex(2)}" 26 | logging.info(f"Creating temp directory {audio_covertion_directory} for audio converting process") 27 | if not os.path.exists(audio_covertion_directory): 28 | os.mkdir(audio_covertion_directory) 29 | 30 | raw_file = os.path.join(audio_covertion_directory, f"{secrets.token_hex(2)}.raw") 31 | logging.info(f"Converting {audio_file} to raw: {raw_file}") 32 | 33 | process_cmd = [ 34 | "ffmpeg", 35 | "-v", 36 | "quiet", 37 | '-i', 38 | audio_file_abspath, 39 | "-f", 40 | "s16le", 41 | "-ac", 42 | "2", 43 | "-ar", 44 | "48000", 45 | "-acodec", 46 | "pcm_s16le", 47 | raw_file 48 | ] 49 | 50 | process = await asyncio.create_subprocess_exec(*process_cmd, stdout=asyncio.subprocess.PIPE, 51 | stderr=asyncio.subprocess.PIPE) 52 | 53 | _, _ = await process.communicate() 54 | 55 | audio_file_path = Path(audio_file) 56 | if os.path.exists(audio_file): 57 | logging.info(f"Finished converting and Removing {audio_file}") 58 | shutil.rmtree(audio_file_path.parent) 59 | 60 | return {'raw_file': raw_file} 61 | -------------------------------------------------------------------------------- /player/helpers/retry_deco.py: -------------------------------------------------------------------------------- 1 | import time 2 | from functools import wraps 3 | 4 | 5 | def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None): 6 | """ 7 | :what it does: 8 | Retry calling the decorated function using an exponential backoff. 9 | 10 | http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/ 11 | original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry 12 | 13 | :param 14 | ExceptionToCheck: Exception or tuple 15 | the exception to check. may be a tuple of exceptions to check 16 | :param 17 | tries: int expected 18 | number of times to try (not retry) before giving up 19 | :param 20 | delay: int expected 21 | initial delay between retries in seconds 22 | :param 23 | backoff: int expected 24 | backoff multiplier e.g. value of 2 will double the delay 25 | each retry 26 | :param 27 | logger: logging.Logger instance 28 | logger to use. If None, print 29 | """ 30 | def deco_retry(f): 31 | 32 | @wraps(f) 33 | async def f_retry(*args, **kwargs): 34 | mtries, mdelay = tries, delay 35 | while mtries > 1: 36 | try: 37 | return await f(*args, **kwargs) 38 | except ExceptionToCheck as e: 39 | msg = "%s, Retrying in %d seconds..." % (str(e), mdelay) 40 | if logger: 41 | logger.warning(msg) 42 | else: 43 | print(msg) 44 | time.sleep(mdelay) 45 | mtries -= 1 46 | mdelay *= backoff 47 | return await f(*args, **kwargs) 48 | 49 | return f_retry # true decorator 50 | 51 | return deco_retry -------------------------------------------------------------------------------- /player/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | import pytgcalls 2 | import configparser 3 | from pyrogram import Client 4 | 5 | Audio_Master = Client( 6 | session_name="audio_bot", 7 | workers=200, 8 | workdir="player/working_dir", 9 | config_file="player/working_dir/config.ini", 10 | plugins=dict(root="player/telegram/plugins") 11 | ) 12 | 13 | app_config = configparser.ConfigParser() 14 | app_config.read("player/working_dir/config.ini") 15 | audio_channel = int(app_config.get("audio-master", "audio_channel")) 16 | voice_chat = int(app_config.get("audio-master", "voice_chat")) 17 | 18 | raw_file_path = None 19 | 20 | group_call = pytgcalls.GroupCall(None, path_to_log_file='') 21 | -------------------------------------------------------------------------------- /player/telegram/audio_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import random 4 | import shutil 5 | import secrets 6 | import asyncio 7 | import logging 8 | import datetime 9 | import pytgcalls 10 | import player.telegram 11 | from pathlib import Path 12 | from random import randrange 13 | from pyrogram.raw import functions 14 | from player.helpers.retry_deco import retry 15 | from player.helpers.ffmpeg_handler import convert_audio_to_raw 16 | 17 | group_call = pytgcalls.GroupCall(None, path_to_log_file='') 18 | 19 | 20 | @retry(Exception, tries=4) 21 | async def download_random_message() -> dict: 22 | """ 23 | :what it does: 24 | 1. get the history count of the intended chat and select a random message id 25 | 2. get the associated message for the selected random message id at step.1 26 | 3. until the selected message contains an audio file or the audio file doesn't have the extension mp3 27 | get a different message by either increasing the random message id by one or decreasing it by one 28 | 4. create a temp directory and download the audio file in the message selected to this directory 29 | :return: 30 | a dict with the keys: 31 | audio_file: the mp3 file that was downloaded 32 | title: the title of the mp3 file (special charectors are removed from the tile) 33 | duration: of the mp3 file 34 | """ 35 | try: 36 | history_count = await player.telegram.Audio_Master.get_history_count(player.telegram.audio_channel) 37 | random_msg_id = randrange(1, history_count) 38 | 39 | 40 | msg = await player.telegram.Audio_Master.get_messages(player.telegram.audio_channel, random_msg_id) 41 | 42 | while (not msg.audio) or (not msg.audio.file_name.endswith('.mp3')): 43 | await asyncio.sleep(3) 44 | msg = await player.telegram.Audio_Master.get_messages( 45 | chat_id=player.telegram.audio_channel, 46 | message_ids=random_msg_id - 1 if random_msg_id >= history_count else random_msg_id + 1 47 | ) 48 | 49 | logging.info(f"About to start downloading the song: {msg.audio.title} from message: {msg.message_id}") 50 | new_directory = f"player/working_dir/{secrets.token_hex(2)}" 51 | audio_file = f"{new_directory}/{secrets.token_hex(2)}.mp3" 52 | logging.info(f"Creating the temporary directory {new_directory} to save the audio file") 53 | 54 | if not os.path.exists(new_directory): 55 | os.mkdir(new_directory) 56 | 57 | logging.info(f"Downloading the file: {msg.audio.file_name} from message: {msg.message_id} to {new_directory}") 58 | await msg.download(file_name=audio_file.replace('player/', '')) 59 | logging.info(f"Finished with Downloading process!") 60 | except Exception as e: 61 | logging.error(e) 62 | raise 63 | try: 64 | title = re.sub(r"[^a-zA-Z0-9]+", ' ', msg.audio.title) 65 | except Exception as e: 66 | title = secrets.token_hex(2) 67 | 68 | return {'audio_file': audio_file, 'title': title, 'duration': msg.audio.duration if msg.audio.duration else 1} 69 | 70 | 71 | async def start_player(): 72 | """ 73 | :what it does: 74 | 1. check if the chat has a voice chat already started, if not keep trying 75 | until a voice chat can be started 76 | 2. prepare and get the audio file for the initial song by calling the 77 | function prepare_audio_files() 78 | 3. start playing the audio file within the voice chat/call 79 | 4. update the title of the voice chat with the title of audio file that we got 80 | at step.2, by calling the function change_voice_chat_title(*args) 81 | 5. add change_player_song() as a handler to group call, to trigger at playout_end action 82 | """ 83 | peer = await player.telegram.Audio_Master.resolve_peer(player.telegram.voice_chat) 84 | chat = await player.telegram.Audio_Master.send(functions.channels.GetFullChannel(channel=peer)) 85 | voice_chat_details = await player.telegram.Audio_Master.get_chat(player.telegram.voice_chat) 86 | 87 | while not chat.full_chat.call: 88 | await asyncio.sleep(3) 89 | logging.info(f"Trying to starting a group voice chat at {voice_chat_details.title} call since there isn't") 90 | await player.telegram.Audio_Master.send( 91 | functions.phone.CreateGroupCall( 92 | peer=peer, 93 | random_id=random.randint(1,99) 94 | ) 95 | ) 96 | 97 | chat = await player.telegram.Audio_Master.send(functions.channels.GetFullChannel(channel=peer)) 98 | 99 | audio_file_details = await prepare_audio_files() 100 | 101 | group_call = pytgcalls.GroupCall( 102 | client=player.telegram.Audio_Master, 103 | input_filename=audio_file_details['audio_file'], 104 | play_on_repeat=False 105 | ) 106 | 107 | player.telegram.group_call = group_call 108 | 109 | await group_call.start(player.telegram.voice_chat) 110 | await change_voice_chat_title(audio_file_details['title']) 111 | logging.info( 112 | f"Playing {audio_file_details['title']} of duration {str(datetime.timedelta(seconds=audio_file_details['duration']))}" 113 | ) 114 | 115 | group_call.add_handler( 116 | change_player_song, 117 | pytgcalls.GroupCallAction.PLAYOUT_ENDED 118 | ) 119 | 120 | 121 | async def prepare_audio_files(): 122 | """ 123 | :what it does: 124 | 1. download a mp3 file from a random message by calling the function download_random_message() 125 | 2. convert the downloaded mp3 file to a raw file by calling the function 126 | convert_audio_to_raw(*args) from player.helpers.ffmpeg_handler 127 | :returns: 128 | a dict with the keys: 129 | audio_file: raw file that was generated 130 | title: title of the audio file that was downloaded 131 | duration: duration of the audio file in seconds 132 | """ 133 | audio_download_proc = await download_random_message() 134 | audio_file = audio_download_proc['audio_file'] 135 | 136 | converted_audio_file = await convert_audio_to_raw(audio_file) 137 | 138 | raw_file = converted_audio_file['raw_file'] 139 | player.telegram.raw_file_path = Path(raw_file) 140 | 141 | return {'audio_file': raw_file, 'title': audio_download_proc['title'], 'duration': audio_download_proc['duration']} 142 | 143 | 144 | async def change_player_song(group_call, raw_file): 145 | """ 146 | :what it does: 147 | 1. prepare a new audio file to be played at the voice chat by 148 | calling prepare_audio_files() 149 | 2. change the current playing audio file with the new file that we got at step.1 150 | 3. update the title of the voice chat with the title of the audio file that we got 151 | from the dict at step.1 152 | 4. remove the old raw audio file from disk as it is not required anymore 153 | """ 154 | audio_file_details = await prepare_audio_files() 155 | group_call.input_filename = audio_file_details['audio_file'] 156 | await change_voice_chat_title(audio_file_details['title']) 157 | logging.info( 158 | f"Playing {audio_file_details['title']} of duration {str(datetime.timedelta(seconds=audio_file_details['duration']))}" 159 | ) 160 | 161 | old_raw_file_path = Path(raw_file) 162 | try: 163 | if old_raw_file_path.exists(): 164 | logging.info(f"Removing old raw file {raw_file}") 165 | shutil.rmtree(old_raw_file_path.parent) 166 | except Exception as e: 167 | logging.error(e) 168 | 169 | 170 | async def change_voice_chat_title(title: str): 171 | """ 172 | :what it does: 173 | this function basically updates the title of the voice chat with the value of argument 174 | title that was provided 175 | :params: 176 | title: String value expected 177 | this is the title of the file that is being played at the voice chat 178 | """ 179 | peer = await player.telegram.Audio_Master.resolve_peer(player.telegram.voice_chat) 180 | chat = await player.telegram.Audio_Master.send(functions.channels.GetFullChannel(channel=peer)) 181 | 182 | await player.telegram.Audio_Master.send( 183 | functions.phone.EditGroupCallTitle( 184 | call = chat.full_chat.call, 185 | title = f"Playing: 🎙️ {title if title else secrets.token_hex(2)}" 186 | ) 187 | ) 188 | -------------------------------------------------------------------------------- /player/telegram/plugins/controls.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import logging 3 | import datetime 4 | import player.telegram 5 | from pathlib import Path 6 | from pyrogram.types import Message 7 | from pyrogram import Client, filters 8 | from player.telegram.audio_handler import prepare_audio_files, change_voice_chat_title 9 | 10 | 11 | async def vc_admins_filter_func(_, c: Client, m: Message): 12 | member_details = await c.get_chat_member( 13 | player.telegram.voice_chat, m.from_user.id) 14 | return member_details.can_manage_voice_chats 15 | 16 | vc_admins_filter = filters.create(vc_admins_filter_func) 17 | 18 | 19 | @Client.on_message(vc_admins_filter & 20 | filters.command("vnext", prefixes="!") & 21 | (filters.chat(player.telegram.voice_chat) | filters.private)) 22 | async def next_song_handler(_, m: Message): 23 | """ 24 | :what it does: 25 | this filter is used to skip the current song and play a new one. 26 | """ 27 | group_call = player.telegram.group_call 28 | raw_file = group_call.input_filename 29 | 30 | await m.delete() 31 | 32 | logging.info("Skipping the current song at playout on request!") 33 | audio_file_details = await prepare_audio_files() 34 | group_call.input_filename = audio_file_details['audio_file'] 35 | await change_voice_chat_title(audio_file_details['title']) 36 | logging.info( 37 | f"Playing {audio_file_details['title']} of duration {str(datetime.timedelta(seconds=audio_file_details['duration']))}" 38 | ) 39 | 40 | if str(raw_file).endswith(".raw"): 41 | old_raw_file_path = Path(raw_file) 42 | try: 43 | if old_raw_file_path.exists(): 44 | logging.info(f"Removing old raw file {raw_file}") 45 | shutil.rmtree(old_raw_file_path.parent) 46 | except Exception as e: 47 | logging.error(e) 48 | 49 | 50 | @Client.on_message(vc_admins_filter & 51 | filters.command("vpause", prefixes="!") & 52 | (filters.chat(player.telegram.voice_chat) | filters.private)) 53 | async def pause_song_handler(_, m: Message): 54 | """ 55 | :what it does: 56 | this filter is used to pause the current playing song. 57 | """ 58 | group_call = player.telegram.group_call 59 | 60 | await m.delete() 61 | 62 | logging.info("Pausing the current song at playout on request!") 63 | group_call.pause_playout() 64 | 65 | 66 | @Client.on_message(vc_admins_filter & 67 | filters.command("vresume", prefixes="!") & 68 | (filters.chat(player.telegram.voice_chat) | filters.private)) 69 | async def resume_song_handler(_, m: Message): 70 | """ 71 | :what it does: 72 | this filter is used to resume a paused song. 73 | """ 74 | group_call = player.telegram.group_call 75 | 76 | await m.delete() 77 | 78 | logging.info("Resuming the current song at playout on request!") 79 | group_call.resume_playout() 80 | 81 | 82 | @Client.on_message(vc_admins_filter & 83 | filters.command("vrestart", prefixes="!") & 84 | (filters.chat(player.telegram.voice_chat) | filters.private)) 85 | async def restart_song_handler(_, m: Message): 86 | """ 87 | :what it does: 88 | this filter is used to restart the current playing song. 89 | """ 90 | group_call = player.telegram.group_call 91 | 92 | await m.delete() 93 | 94 | logging.info("Restarting the current song at playout on request!") 95 | group_call.restart_playout() 96 | -------------------------------------------------------------------------------- /player/working_dir/config.ini.sample: -------------------------------------------------------------------------------- 1 | [pyrogram] 2 | api_id = 3 | api_hash = 4 | [audio-master] 5 | audio_channel = -1001317576439 6 | voice_chat = -1001175411707 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | async-lru==1.0.2 2 | cmake==3.20.5 3 | pyaes==1.6.1 4 | Pyrogram==1.2.9 5 | PySocks==1.7.1 6 | pytgcalls==0.0.23 7 | tgcalls==0.0.16 8 | TgCrypto==1.2.2 9 | --------------------------------------------------------------------------------