├── requirements.txt
├── README.md
├── docker-compose.yml
├── Dockerfile
├── src
├── utils.py
├── text.py
└── bot.py
├── LICENSE
└── .gitignore
/requirements.txt:
--------------------------------------------------------------------------------
1 | setuptools
2 | wheel
3 | pyTelegramBotAPI
4 | requests
5 | hurry.filesize
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # webm2mp4 Telegram bot
2 |
3 | [This bot](https://t.me/webm2mp4bot) converts .webm and .webp links/files to .mp4 videos and .jpg images respectively via ffmpeg.
4 |
5 | ## License
6 |
7 | MIT
8 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | webm2mp4bot:
3 | build: .
4 | container_name: webm2mp4bot
5 | environment:
6 | - TELEGRAM_BOT_TOKEN=
7 | - FFMPEG_THREADS=2
8 | restart: always
9 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:11
2 | RUN apt update && apt install -y --no-install-recommends python3 python3-pip ffmpeg
3 | RUN adduser bot
4 |
5 | COPY src /opt
6 | COPY requirements.txt /opt
7 | RUN pip3 install --upgrade -r /opt/requirements.txt
8 |
9 | USER bot
10 | WORKDIR /opt
11 | ENTRYPOINT ["python3", "./bot.py"]
12 |
--------------------------------------------------------------------------------
/src/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import random
3 | import string
4 |
5 | from hurry.filesize import alternative, size
6 |
7 |
8 | def bytes2human(raw):
9 | return size(raw, system=alternative)
10 |
11 |
12 | def filesize(filename):
13 | return os.stat(filename).st_size
14 |
15 |
16 | def rm(filename):
17 | """Delete file"""
18 | try:
19 | os.remove(filename)
20 | except Exception as e:
21 | print(f"Unable to rm {filename}: {e}")
22 |
23 |
24 | def random_string(length=12):
25 | """Random string of uppercase ASCII and digits"""
26 | return "".join(
27 | random.choice(string.ascii_uppercase + string.digits) for _ in range(length)
28 | )
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Mike_Went
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/text.py:
--------------------------------------------------------------------------------
1 | start = """Hello! I am WebM to MP4 (H.264) and WebP to PNG converter bot 📺
2 |
3 | I can convert:
4 | 🎥 webm and other ffmpeg supported video format → mp4
5 | 🖼 webp and stickers → png & jpg"""
6 | help = "Send me a link (http://...) or a document (including stickers)"
7 | starting = "🚀 Starting..."
8 | downloading = "📥 Downloading..."
9 | converting = "☕️ Converting... {}"
10 | generating_thumbnail = "🖼 Generating thumbnail.."
11 | uploading = "☁️ Uploading to Telegram..."
12 |
13 | class error:
14 | contact_hint = "Contat @Mike_Went if you think it's a bot-side error."
15 |
16 | downloading = "⚠️ Unable to download this file. " + contact_hint
17 | converting = "⚠️ Sorry, ffmpeg seems unable to convert this file. " + contact_hint
18 | generating_thumbnail = "⚠️ Sorry, ffmpeg seems unable to generate a thumbnail image for this file. " + contact_hint
19 | huge_file = "🍉 File is bigger than 50 MB. Telegram does not allow bots to upload huge files, sorry."
20 | animated_sticker = "🎬 Animated stickers are unsupported yet, submit a pull-request if you implement it!"
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | .ipynb_checkpoints
72 |
73 | # pyenv
74 | .python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | *.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 | .spyproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 | # mkdocs documentation
98 | /site
99 |
100 | # mypy
101 | .mypy_cache/
102 |
103 | # Bot config
104 | config.json
105 |
106 | # Generated service file
107 | *.service
108 |
--------------------------------------------------------------------------------
/src/bot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # https://github.com/MikeWent/webm2mp4
4 | # https://t.me/webm2mp4bot
5 |
6 | import re
7 | import subprocess
8 | import threading
9 | import time
10 | from os import getenv
11 |
12 | import requests
13 | import telebot
14 |
15 | import text
16 | import utils
17 |
18 | MAXIMUM_FILESIZE_ALLOWED = 50 * 1024 * 1024 # ~50 MB
19 | TEMP_PATH = "/tmp/"
20 | TELEGRAM_BOT_TOKEN = getenv("TELEGRAM_BOT_TOKEN")
21 | FFMPEG_THREADS = getenv("FFMPEG_THREADS")
22 |
23 |
24 | def convert_worker(target_format, message, url, bot):
25 | """Generic process spawned every time user sends a link or a file"""
26 | input_filename = "".join([TEMP_PATH, utils.random_string()])
27 | output_filename = "".join([TEMP_PATH, utils.random_string(), ".", target_format])
28 |
29 | # Tell user that we are working
30 | status_message = bot.reply_to(message, text.starting, parse_mode="HTML")
31 |
32 | def update_status_message(new_text):
33 | bot.edit_message_text(
34 | chat_id=status_message.chat.id,
35 | message_id=status_message.message_id,
36 | text=new_text,
37 | parse_mode="HTML",
38 | )
39 |
40 | # Try to download URL
41 | try:
42 | r = requests.get(url, stream=True)
43 | except:
44 | update_status_message(text.error.downloading)
45 | return
46 |
47 | # Check file size
48 | if int(r.headers.get("Content-Length", "0")) >= MAXIMUM_FILESIZE_ALLOWED:
49 | update_status_message(text.error.huge_file)
50 | return
51 |
52 | # Download the file
53 | update_status_message(text.downloading)
54 | chunk_size = 4096
55 | raw_input_size = 0
56 | try:
57 | with open(input_filename, "wb") as f:
58 | for chunk in r.iter_content(chunk_size=chunk_size):
59 | f.write(chunk)
60 | raw_input_size += chunk_size
61 | # Download files without Content-Length, but apply standard limit to them
62 | if raw_input_size >= MAXIMUM_FILESIZE_ALLOWED:
63 | update_status_message(text.error.huge_file)
64 | utils.rm(input_filename)
65 | return
66 | except:
67 | update_status_message(text.error.downloading)
68 | bot.reply_to(message, f"HTTP {r.status_code}")
69 | return
70 |
71 | # Start ffmpeg
72 | ffmpeg_process = None
73 | if target_format == "mp4":
74 | ffmpeg_process = subprocess.Popen(
75 | [
76 | "ffmpeg",
77 | "-v",
78 | "error",
79 | "-threads",
80 | FFMPEG_THREADS,
81 | "-i",
82 | input_filename,
83 | "-map",
84 | "V:0?", # select video stream
85 | "-map",
86 | "0:a?", # ignore audio if doesn't exist
87 | "-c:v",
88 | "libx264", # specify video encoder
89 | "-max_muxing_queue_size",
90 | "9999", # https://trac.ffmpeg.org/ticket/6375
91 | "-movflags",
92 | "+faststart", # optimize for streaming
93 | "-preset",
94 | "veryslow", # https://trac.ffmpeg.org/wiki/Encode/H.264#a2.Chooseapresetandtune
95 | "-timelimit",
96 | "900", # prevent DoS (exit after 15 min)
97 | "-vf",
98 | "pad=ceil(iw/2)*2:ceil(ih/2)*2", # https://stackoverflow.com/questions/20847674/ffmpeg-libx264-height-not-divisible-by-2#20848224
99 | output_filename,
100 | ]
101 | )
102 | elif target_format == "png":
103 | ffmpeg_process = subprocess.Popen(
104 | [
105 | "ffmpeg",
106 | "-v",
107 | "error",
108 | "-threads",
109 | FFMPEG_THREADS,
110 | "-thread_type",
111 | "slice",
112 | "-i",
113 | input_filename,
114 | "-timelimit",
115 | "60", # prevent DoS (exit after 15 min)
116 | output_filename,
117 | ]
118 | )
119 |
120 | # Update progress while ffmpeg is alive
121 | old_progress = ""
122 | while ffmpeg_process.poll() == None:
123 | try:
124 | raw_output_size = utils.filesize(output_filename)
125 | except FileNotFoundError:
126 | raw_output_size = 0
127 |
128 | if raw_output_size >= MAXIMUM_FILESIZE_ALLOWED:
129 | update_status_message(text.error.huge_file)
130 | ffmpeg_process.kill()
131 | utils.rm(output_filename)
132 |
133 | input_size = utils.bytes2human(raw_input_size)
134 | output_size = utils.bytes2human(raw_output_size)
135 |
136 | progress = f"{output_size} / {input_size}"
137 | # Update progress only if it changed
138 | if progress != old_progress:
139 | update_status_message(text.converting.format(progress))
140 | old_progress = progress
141 | time.sleep(2)
142 |
143 | # Exit in case of error with ffmpeg
144 | if ffmpeg_process.returncode != 0:
145 | update_status_message(text.error.converting)
146 | # Clean up and close pipe explicitly
147 | utils.rm(output_filename)
148 | return
149 |
150 | # Check output file size
151 | output_size = utils.filesize(output_filename)
152 | if output_size >= MAXIMUM_FILESIZE_ALLOWED:
153 | update_status_message(text.error.huge_file)
154 | # Clean up and close pipe explicitly
155 | utils.rm(output_filename)
156 | return
157 |
158 | # Default params for sending operation
159 | data = {"chat_id": message.chat.id, "reply_to_message_id": message.message_id}
160 |
161 | if target_format == "mp4":
162 | data.update({"supports_streaming": True})
163 | # 1. Get video duration in seconds
164 | video_duration = (
165 | subprocess.run(
166 | [
167 | "ffprobe",
168 | "-v",
169 | "error",
170 | "-select_streams",
171 | "v:0",
172 | "-show_entries",
173 | "format=duration",
174 | "-of",
175 | "default=noprint_wrappers=1:nokey=1",
176 | output_filename,
177 | ],
178 | stdout=subprocess.PIPE,
179 | )
180 | .stdout.decode("utf-8")
181 | .strip()
182 | )
183 |
184 | video_duration = round(float(video_duration))
185 | data.update({"duration": video_duration})
186 |
187 | # 2. Get video height and width
188 | video_props = (
189 | subprocess.run(
190 | [
191 | "ffprobe",
192 | "-v",
193 | "error",
194 | "-select_streams",
195 | "v:0",
196 | "-show_entries",
197 | "stream=width,height",
198 | "-of",
199 | "csv=s=x:p=0",
200 | output_filename,
201 | ],
202 | stdout=subprocess.PIPE,
203 | )
204 | .stdout.decode("utf-8")
205 | .strip()
206 | )
207 |
208 | video_width, video_height = video_props.split("x")
209 | data.update({"width": video_width, "height": video_height})
210 |
211 | # 3. Take one frame from the middle of the video
212 | update_status_message(text.generating_thumbnail)
213 | thumbnail = "".join([TEMP_PATH, utils.random_string(), ".jpg"])
214 | generate_thumbnail_process = subprocess.Popen(
215 | [
216 | "ffmpeg",
217 | "-v",
218 | "error",
219 | "-i",
220 | output_filename,
221 | "-vcodec",
222 | "mjpeg",
223 | "-vframes",
224 | "1",
225 | "-an",
226 | "-f",
227 | "rawvideo",
228 | "-ss",
229 | str(int(video_duration / 2)),
230 | # keep the limit of 90px height/width (Telegram API) while preserving the aspect ratio
231 | "-vf",
232 | "scale='if(gt(iw,ih),90,trunc(oh*a/2)*2)':'if(gt(iw,ih),trunc(ow/a/2)*2,90)'",
233 | thumbnail,
234 | ]
235 | )
236 |
237 | # While process is alive (i.e. is working)
238 | while generate_thumbnail_process.poll() == None:
239 | time.sleep(1)
240 |
241 | # Exit in case of error with ffmpeg
242 | if generate_thumbnail_process.returncode != 0:
243 | update_status_message(text.error.generating_thumbnail)
244 | return
245 |
246 | update_status_message(text.uploading)
247 | requests.post(
248 | "https://api.telegram.org/bot{}/sendVideo".format(TELEGRAM_BOT_TOKEN),
249 | data=data,
250 | files=[
251 | (
252 | "video",
253 | (
254 | utils.random_string() + ".mp4",
255 | open(output_filename, "rb"),
256 | "video/mp4",
257 | ),
258 | ),
259 | (
260 | "thumb",
261 | (
262 | utils.random_string() + ".jpg",
263 | open(thumbnail, "rb"),
264 | "image/jpeg",
265 | ),
266 | ),
267 | ],
268 | )
269 | utils.rm(input_filename)
270 | utils.rm(output_filename)
271 | utils.rm(thumbnail)
272 |
273 | elif target_format == "png":
274 | # Upload to Telegram
275 | update_status_message(text.uploading)
276 | requests.post(
277 | "https://api.telegram.org/bot{}/sendPhoto".format(TELEGRAM_BOT_TOKEN),
278 | data=data,
279 | files=[
280 | (
281 | "photo",
282 | (
283 | utils.random_string() + ".png",
284 | open(output_filename, "rb"),
285 | "image/png",
286 | ),
287 | )
288 | ],
289 | )
290 | requests.post(
291 | "https://api.telegram.org/bot{}/sendDocument".format(TELEGRAM_BOT_TOKEN),
292 | data=data,
293 | files=[
294 | (
295 | "document",
296 | (
297 | utils.random_string() + ".png",
298 | open(output_filename, "rb"),
299 | "image/png",
300 | ),
301 | )
302 | ],
303 | )
304 | utils.rm(input_filename)
305 | utils.rm(output_filename)
306 |
307 | bot.delete_message(message.chat.id, status_message.message_id)
308 |
309 |
310 | bot = telebot.TeleBot(TELEGRAM_BOT_TOKEN)
311 |
312 |
313 | @bot.message_handler(commands=["start", "help"])
314 | def start_help(message):
315 | if message.chat.type != "private":
316 | try:
317 | bot.leave_chat(message.chat.id)
318 | except:
319 | pass
320 | return
321 |
322 | bot.send_message(message.chat.id, text.start, parse_mode="HTML")
323 | bot.send_message(message.chat.id, text.help, parse_mode="HTML")
324 |
325 |
326 | # Handle URLs
327 | URL_REGEXP = r"(http.?:\/\/.*\.(webm|webp|mp4))"
328 |
329 |
330 | @bot.message_handler(regexp=URL_REGEXP)
331 | def handle_urls(message):
332 | if message.chat.type != "private":
333 | try:
334 | bot.leave_chat(message.chat.id)
335 | except:
336 | pass
337 | return
338 |
339 | # Get first url in message
340 | match = re.findall(URL_REGEXP, message.text)[0]
341 | url = match[0]
342 | extension = match[1]
343 | if extension == "webp":
344 | target_format = "png"
345 | else:
346 | target_format = "mp4"
347 |
348 | threading.Thread(
349 | target=convert_worker,
350 | kwargs={
351 | "target_format": target_format,
352 | "message": message,
353 | "url": url,
354 | "bot": bot,
355 | },
356 | ).run()
357 |
358 |
359 | # Handle files
360 | @bot.message_handler(content_types=["document", "video", "sticker"])
361 | def handle_files(message):
362 | if message.chat.type != "private":
363 | try:
364 | bot.leave_chat(message.chat.id)
365 | except:
366 | pass
367 | return
368 |
369 | # Get file url
370 | target = None
371 | if message.document:
372 | target = message.document.file_id
373 | if message.video:
374 | target = message.video.file_id
375 | if message.sticker:
376 | # Ignore animated stickers
377 | if message.sticker.is_animated:
378 | bot.reply_to(message, text.error.animated_sticker, parse_mode="HTML")
379 | return
380 | target = message.sticker.file_id
381 |
382 | url = "https://api.telegram.org/file/bot{0}/{1}".format(
383 | TELEGRAM_BOT_TOKEN, bot.get_file(target).file_path
384 | )
385 | if url.endswith("webp"):
386 | target_format = "png"
387 | else:
388 | target_format = "mp4"
389 |
390 | threading.Thread(
391 | target=convert_worker,
392 | kwargs={
393 | "target_format": target_format,
394 | "message": message,
395 | "url": url,
396 | "bot": bot,
397 | },
398 | ).run()
399 |
400 |
401 | bot.polling(none_stop=True)
402 |
--------------------------------------------------------------------------------