DEV Community

liveavabot
liveavabot

Posted on

Three Aiogram 3 Patterns From Building a Telegram Video Bot

The problem: iPhone videos silently fail as Telegram avatars

Telegram has a feature where your profile picture can be a short looping video instead of a still image. Cool feature. Except when you upload an iPhone video, Telegram either rejects it silently or shows a generic error. Nothing tells you why.

The reason: iPhones since 2016 record video in HEVC (H.265) by default. Telegram's video avatar endpoint accepts H.264 only, and the file has strict constraints most people never see documented in one place.

I got tired of manually converting videos in an ffmpeg terminal every time a friend asked how to set a video avatar. So I built @liveavabot: forward any video or GIF, get back a Telegram-compatible avatar file, tap "set as profile photo", done.

This post covers three aiogram 3 patterns that made the bot maintainable as it grew from a 200-line script to a service handling a few hundred users a day.

What Telegram actually requires

Before any code, the spec you need to hit:

  • Codec: H.264 (libx264)
  • Pixel format: yuv420p
  • Container: mp4 with faststart flag
  • Resolution: 800x800 (square)
  • Duration: 10 seconds or less
  • File size: 2 MB or less
  • Audio: none (strip the audio track entirely)
  • Frame rate: 30 fps is safe, 60 works if size allows

Miss any of these and Telegram gives you an unhelpful error or accepts the file but refuses to display it as an avatar. The 2 MB ceiling is the killer. It forces you to think about bitrate carefully on every encode.

Pattern 1: FSMContext plus inline keyboards for multi-step flows

The first version of the bot was one handler: send video, get result. Then I wanted presets ("Lofi", "B&W", "Vignette"). That meant asking the user which filter to apply after they sent the video. FSMContext turned out to be the cleanest way.

from aiogram import F, Router
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from aiogram.types import (
    Message, CallbackQuery,
    InlineKeyboardMarkup, InlineKeyboardButton,
)

router = Router()

class ConvertFlow(StatesGroup):
    waiting_preset = State()

def preset_kb() -> InlineKeyboardMarkup:
    return InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="Lofi", callback_data="preset:lofi"),
        InlineKeyboardButton(text="B&W", callback_data="preset:bw"),
        InlineKeyboardButton(text="Vignette", callback_data="preset:vignette"),
        InlineKeyboardButton(text="Blur", callback_data="preset:blur"),
    ]])

@router.message(F.video | F.animation)
async def got_video(message: Message, state: FSMContext):
    file_id = (message.video or message.animation).file_id
    await state.update_data(file_id=file_id)
    await state.set_state(ConvertFlow.waiting_preset)
    await message.answer("Pick a preset:", reply_markup=preset_kb())

@router.callback_query(F.data.startswith("preset:"))
async def picked_preset(cq: CallbackQuery, state: FSMContext):
    preset = cq.data.split(":", 1)[1]
    data = await state.update_data(preset=preset)
    await cq.answer()
    await do_conversion(cq.message, data["file_id"], preset)
    await state.clear()
Enter fullscreen mode Exit fullscreen mode

Two things I learned the hard way.

Keep the state small. Store only the file_id (Telegram gives you a persistent handle) plus a couple of user choices. Do not stash raw bytes in state. Redis or MemoryStorage will hate you.

Always set a timeout. Users abandon flows. I added a background task that clears any state older than 15 minutes.

Also worth noting: callback_data is capped at 64 bytes and must be a string. For anything more complex than a fixed enum, use aiogram's CallbackData factory. It gives you typed payloads with validation. Cleaner than parsing colon-separated strings once you have five or more actions.

Pattern 2: A semaphore to keep ffmpeg from eating the VPS

ffmpeg is CPU-bound and hungry. Four concurrent invocations on a 2-core VPS will spike load average past 8 and the bot stops responding to pings. I learned this when a small burst of users made the systemd service look dead to my monitoring.

The fix is one line:

import asyncio

_SEM = asyncio.Semaphore(4)

async def run_ffmpeg(cmd: list[str]) -> tuple[int, bytes, bytes]:
    async with _SEM:
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        return proc.returncode, stdout, stderr
Enter fullscreen mode Exit fullscreen mode

Tune the semaphore value to your host. On a 4-core box I use 4. On a 2-core VPS I use 2. Users see a small queue delay under load but the bot stays responsive on the aiogram event loop, which is what matters. Sending a "typing" chat action covers the wait.

Pattern 3: The actual ffmpeg pipeline

Here is the command the bot runs. It uses cropdetect first to find the largest content region, then re-encodes to Telegram's spec:

# step 1: find the crop box from a 3-second sample
ffmpeg -y -i input.mov \
  -vf "cropdetect=24:16:0,metadata=mode=print" \
  -f null -t 3 - 2>&1 | grep crop=

# step 2: encode using the detected crop
ffmpeg -y -i input.mov \
  -t 10 \
  -vf "crop=1080:1080:420:0,scale=800:800,fps=30,format=yuv420p" \
  -c:v libx264 -preset veryfast -crf 26 \
  -movflags +faststart \
  -an \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Why each flag matters:

  • -t 10 hard-caps duration before encoding, avoids extra work
  • crop=... uses cropdetect output, so a 16:9 iPhone video becomes a centered square instead of a squashed rectangle
  • scale=800:800 matches Telegram avatar dimensions
  • fps=30 normalizes frame rate, some sources have variable fps that breaks looping
  • format=yuv420p is the pixel format Apple hardware decoders expect
  • -crf 26 is a quality setting that usually lands under 2 MB for 10 seconds
  • -movflags +faststart moves the moov atom to the front, needed for streaming
  • -an strips audio, required by the avatar spec

If the output is still over 2 MB, the bot re-runs with -crf 28, then -crf 30. Three attempts, then it gives up and tells the user.

Lessons and edge cases

A few things the tutorials never mention:

  • Animated GIFs come in as message.animation, not message.video. Handle both, or you drop half the "convert my meme" requests.
  • Portrait iPhone videos have rotation metadata. ffmpeg respects it if you do nothing, but if you use raw -map, you can end up sideways. Test with a portrait video before shipping.
  • Some videos have the moov atom at the end. They will not stream. -movflags +faststart fixes it on the encode side.
  • Users forward videos that Telegram itself recompressed twice already. Quality is already gone by the time you get them. Nothing you can do about it, just document it.
  • File size targets are cliffs. 2.00 MB works, 2.01 MB does not. Always encode with a safety margin.

The bot runs on a small VPS handling a few hundred users a day. Total ffmpeg time per conversion is 3 to 8 seconds. aiogram 3's async model keeps interaction snappy while workers grind in the background.

If you want to try it: t.me/LiveAvaBot. Send any video or GIF, get a Telegram-ready avatar back.

Built by me, @liveavabot.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.