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:
goyban
2026-09-01 21:25:11 +00:00
parent b55cee1650
commit efc2be5f8a
6 changed files with 166 additions and 60 deletions
+95
View File
@@ -0,0 +1,95 @@
"""Media handling tests. Run with: python test_bot.py
Covers the dispatch table that decides how an incoming message is stored and
how it comes back through inline search.
"""
import datetime
import os
import sys
os.environ.setdefault("BOT_TOKEN", "123456789:AAHtestTokenPlaceholder_ABCDEFGHIJKLMNOP")
os.environ.setdefault("DB_PATH", ":memory:")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from aiogram.types import (Animation, Audio, Chat, Document, Message, PhotoSize,
Sticker, Video, VideoNote, Voice)
import bot as B
CHAT = Chat(id=1, type="private")
NOW = datetime.datetime.now()
def msg(**kw) -> Message:
return Message(message_id=1, date=NOW, chat=CHAT, **kw)
def sample_messages():
photo = PhotoSize(file_id="F_photo", file_unique_id="U_photo", width=1, height=1)
anim = Animation(file_id="F_gif", file_unique_id="U_gif", width=1, height=1, duration=1)
sticker = Sticker(file_id="F_st", file_unique_id="U_st", width=1, height=1,
type="regular", is_animated=False, is_video=False)
return [
("photo", msg(photo=[photo])),
# Telegram sets document alongside animation for backward compatibility.
("gif", msg(animation=anim, document=Document(file_id="F_gif", file_unique_id="U_gif"))),
("sticker", msg(sticker=sticker)),
("video", msg(video=Video(file_id="F_v", file_unique_id="U_v", width=1, height=1, duration=1))),
("video", msg(video_note=VideoNote(file_id="F_vn", file_unique_id="U_vn", length=1, duration=1))),
("voice", msg(voice=Voice(file_id="F_vo", file_unique_id="U_vo", duration=1))),
("audio", msg(audio=Audio(file_id="F_a", file_unique_id="U_a", duration=1))),
("document", msg(document=Document(file_id="F_doc", file_unique_id="U_doc"))),
]
def test_every_type_extracts():
for expected, message in sample_messages():
found = B.extract_media(message)
assert found is not None, f"{expected}: nothing extracted"
assert found[0] == expected, f"expected {expected}, got {found[0]}"
print(" every supported media type extracts to the right media_type")
def test_animation_beats_document():
gif = sample_messages()[1][1]
assert gif.document is not None, "test fixture should mimic Telegram's dual field"
assert B.extract_media(gif)[0] == "gif", "a GIF must not be filed as a document"
print(" animation wins over the backward-compat document field")
def test_video_note_stored_as_video():
note = sample_messages()[4][1]
assert B.extract_media(note) == ("video", "F_vn", "U_vn")
print(" video notes are stored as video so inline search can return them")
def test_non_media_ignored():
assert B.extract_media(msg(text="hello")) is None
print(" plain text messages are ignored")
def test_every_stored_type_renders_inline():
stored = {t for t, _ in B.MEDIA_EXTRACTORS}
missing = stored - set(B.INLINE_BUILDERS)
assert not missing, f"stored but not renderable inline: {sorted(missing)}"
print(" every stored media_type has a matching inline builder")
def test_builders_construct():
for media_type, build in B.INLINE_BUILDERS.items():
build("rid", f"FID_{media_type}", "cat, funny")
# Documents and videos require a non-empty title; keywords can be blank.
build("rid", f"FID_{media_type}", "")
print(" inline builders construct, including with empty keywords")
def main():
for test in (test_every_type_extracts, test_animation_beats_document,
test_video_note_stored_as_video, test_non_media_ignored,
test_every_stored_type_renders_inline, test_builders_construct):
test()
print("\nAll media tests passed.")
if __name__ == "__main__":
main()