Support audio, documents, stickers and video notes
Completes TODO #6. The bot accepted photo, animation, video, voice and sticker; music files, documents and round videos were silently dropped. - Replace the five near-identical media handlers with one dispatch table. Animations are matched before documents because Telegram populates both fields for a GIF, so the old ordering would have filed every GIF as a document once document support existed. - Store video notes as "video". Telegram has no cached inline result for video notes, so this is what keeps round videos searchable; it costs the round presentation on playback. - Build inline results from a table keyed by media_type, and cover audio and document. A test asserts every storable type has a builder, so a new media type cannot be added without a way to return it. - Reuse one MEDIA_FILTER for both the idle and mid-keyword handlers; the second list had already drifted from the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ from aiogram.types import (
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
InlineQuery,
|
||||
InlineQueryResultCachedAudio,
|
||||
InlineQueryResultCachedDocument,
|
||||
InlineQueryResultCachedMpeg4Gif,
|
||||
InlineQueryResultCachedPhoto,
|
||||
InlineQueryResultCachedSticker,
|
||||
@@ -93,7 +95,7 @@ class EditMedia(StatesGroup):
|
||||
def menu_text(user_id: int) -> str:
|
||||
if is_admin(user_id):
|
||||
return (
|
||||
"Send me any <b>photo, GIF, video, voice, or sticker</b>.\n"
|
||||
"Send me any <b>photo, GIF, video, voice, music, sticker, or file</b>.\n"
|
||||
"I'll ask you for keywords next.\n\n"
|
||||
"/list — show all saved memes\n"
|
||||
"/edit [search] — edit keywords of a meme\n"
|
||||
@@ -154,40 +156,45 @@ async def _receive_media(msg: Message, state: FSMContext, file_id: str, file_uni
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.photo, StateFilter(None))
|
||||
async def on_photo(msg: Message, state: FSMContext):
|
||||
# Ordered: Telegram sets `document` alongside `animation` for backward
|
||||
# compatibility, so animations must be matched before documents or every GIF
|
||||
# would be filed as a file. Round videos are stored as plain "video" so they
|
||||
# stay reachable from inline search, which has no cached video_note result.
|
||||
MEDIA_EXTRACTORS = (
|
||||
("photo", lambda m: m.photo[-1] if m.photo else None),
|
||||
("gif", lambda m: m.animation),
|
||||
("sticker", lambda m: m.sticker),
|
||||
("video", lambda m: m.video),
|
||||
("video", lambda m: m.video_note),
|
||||
("voice", lambda m: m.voice),
|
||||
("audio", lambda m: m.audio),
|
||||
("document", lambda m: m.document),
|
||||
)
|
||||
|
||||
MEDIA_FILTER = (
|
||||
F.photo | F.animation | F.sticker | F.video
|
||||
| F.video_note | F.voice | F.audio | F.document
|
||||
)
|
||||
|
||||
|
||||
def extract_media(msg: Message) -> tuple[str, str, str] | None:
|
||||
"""Return (media_type, file_id, file_unique_id) for any supported media."""
|
||||
for media_type, getter in MEDIA_EXTRACTORS:
|
||||
obj = getter(msg)
|
||||
if obj is not None:
|
||||
return media_type, obj.file_id, obj.file_unique_id
|
||||
return None
|
||||
|
||||
|
||||
@dp.message(MEDIA_FILTER, StateFilter(None))
|
||||
async def on_media(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
return
|
||||
photo = msg.photo[-1]
|
||||
await _receive_media(msg, state, photo.file_id, photo.file_unique_id, "photo")
|
||||
|
||||
|
||||
@dp.message(F.animation, StateFilter(None))
|
||||
async def on_gif(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
found = extract_media(msg)
|
||||
if found is None:
|
||||
return
|
||||
await _receive_media(msg, state, msg.animation.file_id, msg.animation.file_unique_id, "gif")
|
||||
|
||||
|
||||
@dp.message(F.video, StateFilter(None))
|
||||
async def on_video(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
return
|
||||
await _receive_media(msg, state, msg.video.file_id, msg.video.file_unique_id, "video")
|
||||
|
||||
|
||||
@dp.message(F.voice, StateFilter(None))
|
||||
async def on_voice(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
return
|
||||
await _receive_media(msg, state, msg.voice.file_id, msg.voice.file_unique_id, "voice")
|
||||
|
||||
|
||||
@dp.message(F.sticker, StateFilter(None))
|
||||
async def on_sticker(msg: Message, state: FSMContext):
|
||||
if not is_admin(msg.from_user.id):
|
||||
return
|
||||
await _receive_media(msg, state, msg.sticker.file_id, msg.sticker.file_unique_id, "sticker")
|
||||
media_type, file_id, file_unique_id = found
|
||||
await _receive_media(msg, state, file_id, file_unique_id, media_type)
|
||||
|
||||
|
||||
# ── step 2: collect keywords ─────────────────────────────────────────────────
|
||||
@@ -222,7 +229,7 @@ async def on_keyword(msg: Message, state: FSMContext):
|
||||
await msg.reply(f"Keywords so far: <b>{', '.join(merged)}</b>\nSend more or /done.", parse_mode="HTML")
|
||||
|
||||
|
||||
@dp.message(AddMedia.waiting_for_keywords, F.photo | F.animation | F.video | F.voice | F.sticker)
|
||||
@dp.message(AddMedia.waiting_for_keywords, MEDIA_FILTER)
|
||||
async def on_media_during_keywords(msg: Message):
|
||||
await msg.reply("Still waiting for keywords. Send /done to save the current item, or /cancel to discard.")
|
||||
|
||||
@@ -375,6 +382,26 @@ async def on_edit_keyword(msg: Message, state: FSMContext):
|
||||
await msg.reply(f"Keywords so far: <b>{', '.join(merged)}</b>\nSend more or /done.", parse_mode="HTML")
|
||||
|
||||
|
||||
# Cached inline results, keyed by the media_type stored in the database.
|
||||
# Audio carries its own title metadata; video and document require one.
|
||||
INLINE_BUILDERS = {
|
||||
"photo": lambda rid, fid, kw: InlineQueryResultCachedPhoto(
|
||||
id=rid, photo_file_id=fid, title=kw, description=kw),
|
||||
"gif": lambda rid, fid, kw: InlineQueryResultCachedMpeg4Gif(
|
||||
id=rid, mpeg4_file_id=fid, title=kw),
|
||||
"video": lambda rid, fid, kw: InlineQueryResultCachedVideo(
|
||||
id=rid, video_file_id=fid, title=kw, description=kw),
|
||||
"voice": lambda rid, fid, kw: InlineQueryResultCachedVoice(
|
||||
id=rid, voice_file_id=fid, title=kw),
|
||||
"sticker": lambda rid, fid, kw: InlineQueryResultCachedSticker(
|
||||
id=rid, sticker_file_id=fid),
|
||||
"audio": lambda rid, fid, kw: InlineQueryResultCachedAudio(
|
||||
id=rid, audio_file_id=fid),
|
||||
"document": lambda rid, fid, kw: InlineQueryResultCachedDocument(
|
||||
id=rid, document_file_id=fid, title=kw or "file", description=kw),
|
||||
}
|
||||
|
||||
|
||||
# ── inline mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
@dp.inline_query()
|
||||
@@ -392,28 +419,9 @@ async def inline_handler(query: InlineQuery):
|
||||
kw_display = item["keywords"].replace(",", ", ")
|
||||
mtype = item["media_type"]
|
||||
|
||||
if mtype == "photo":
|
||||
results.append(InlineQueryResultCachedPhoto(
|
||||
id=rid, photo_file_id=item["file_id"],
|
||||
title=kw_display, description=kw_display,
|
||||
))
|
||||
elif mtype == "gif":
|
||||
results.append(InlineQueryResultCachedMpeg4Gif(
|
||||
id=rid, mpeg4_file_id=item["file_id"], title=kw_display,
|
||||
))
|
||||
elif mtype == "video":
|
||||
results.append(InlineQueryResultCachedVideo(
|
||||
id=rid, video_file_id=item["file_id"],
|
||||
title=kw_display, description=kw_display,
|
||||
))
|
||||
elif mtype == "voice":
|
||||
results.append(InlineQueryResultCachedVoice(
|
||||
id=rid, voice_file_id=item["file_id"], title=kw_display,
|
||||
))
|
||||
elif mtype == "sticker":
|
||||
results.append(InlineQueryResultCachedSticker(
|
||||
id=rid, sticker_file_id=item["file_id"],
|
||||
))
|
||||
builder = INLINE_BUILDERS.get(mtype)
|
||||
if builder is not None:
|
||||
results.append(builder(rid, item["file_id"], kw_display))
|
||||
|
||||
if not results:
|
||||
results = [InlineQueryResultArticle(
|
||||
|
||||
Reference in New Issue
Block a user