├── .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 |
--------------------------------------------------------------------------------