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:
@@ -3,3 +3,4 @@ bot_db/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
push.sh
|
push.sh
|
||||||
|
TODO
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Memes Bot
|
# Memes Bot
|
||||||
|
|
||||||
ربات شخصی تلگرام برای ذخیره و جستجوی میم (عکس، گیف، ویدیو، صدا) با کلیدواژه.
|
ربات شخصی تلگرام برای ذخیره و جستجوی میم (عکس، گیف، ویدیو، صدا، موزیک، استیکر، فایل) با کلیدواژه.
|
||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
### نحوه استفاده
|
### نحوه استفاده
|
||||||
|
|
||||||
**افزودن میم** — یک عکس، گیف، ویدیو یا صدا برای ربات بفرستید. ربات از شما کلیدواژه میخواهد. کلیدواژهها را یکییکی یا با کاما جدا بفرستید، سپس `/done` بزنید.
|
**افزودن میم** — یک عکس، گیف، ویدیو، صدا، موزیک، استیکر یا هر فایلی برای ربات بفرستید. ربات از شما کلیدواژه میخواهد. کلیدواژهها را یکییکی یا با کاما جدا بفرستید، سپس `/done` بزنید.
|
||||||
|
|
||||||
**جستجوی اینلاین** — در هر چتی بنویسید `@نام_ربات کلیدواژه` تا نتایج نمایش داده شود.
|
**جستجوی اینلاین** — در هر چتی بنویسید `@نام_ربات کلیدواژه` تا نتایج نمایش داده شود.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Memes Bot
|
# Memes Bot
|
||||||
|
|
||||||
A personal Telegram inline bot to store and search media (photos, GIFs, videos, voice) by keywords.
|
A personal Telegram inline bot to store and search media (photos, GIFs, videos, voice, music, stickers, files) by keywords.
|
||||||
|
|
||||||
[فارسی](README.fa.md)
|
[فارسی](README.fa.md)
|
||||||
|
|
||||||
@@ -50,7 +50,9 @@ The database will be saved in `./bot_db/media.db`.
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
**Adding media** — send the bot a photo, GIF, video, or voice message, then type keywords when prompted (comma-separated or one per message), then `/done`.
|
**Adding media** — send the bot a photo, GIF, video, voice message, music file, sticker, or any document, then type keywords when prompted (comma-separated or one per message), then `/done`.
|
||||||
|
|
||||||
|
Round video messages are stored as regular videos: Telegram has no inline result type for video notes, so keeping them as video is what makes them searchable.
|
||||||
|
|
||||||
**Searching inline** — in any chat type `@yourbotusername keyword` to search and send.
|
**Searching inline** — in any chat type `@yourbotusername keyword` to search and send.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from aiogram.types import (
|
|||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
InlineKeyboardMarkup,
|
InlineKeyboardMarkup,
|
||||||
InlineQuery,
|
InlineQuery,
|
||||||
|
InlineQueryResultCachedAudio,
|
||||||
|
InlineQueryResultCachedDocument,
|
||||||
InlineQueryResultCachedMpeg4Gif,
|
InlineQueryResultCachedMpeg4Gif,
|
||||||
InlineQueryResultCachedPhoto,
|
InlineQueryResultCachedPhoto,
|
||||||
InlineQueryResultCachedSticker,
|
InlineQueryResultCachedSticker,
|
||||||
@@ -93,7 +95,7 @@ class EditMedia(StatesGroup):
|
|||||||
def menu_text(user_id: int) -> str:
|
def menu_text(user_id: int) -> str:
|
||||||
if is_admin(user_id):
|
if is_admin(user_id):
|
||||||
return (
|
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"
|
"I'll ask you for keywords next.\n\n"
|
||||||
"/list — show all saved memes\n"
|
"/list — show all saved memes\n"
|
||||||
"/edit [search] — edit keywords of a meme\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))
|
# Ordered: Telegram sets `document` alongside `animation` for backward
|
||||||
async def on_photo(msg: Message, state: FSMContext):
|
# 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):
|
if not is_admin(msg.from_user.id):
|
||||||
return
|
return
|
||||||
photo = msg.photo[-1]
|
found = extract_media(msg)
|
||||||
await _receive_media(msg, state, photo.file_id, photo.file_unique_id, "photo")
|
if found is None:
|
||||||
|
|
||||||
|
|
||||||
@dp.message(F.animation, StateFilter(None))
|
|
||||||
async def on_gif(msg: Message, state: FSMContext):
|
|
||||||
if not is_admin(msg.from_user.id):
|
|
||||||
return
|
return
|
||||||
await _receive_media(msg, state, msg.animation.file_id, msg.animation.file_unique_id, "gif")
|
media_type, file_id, file_unique_id = found
|
||||||
|
await _receive_media(msg, state, file_id, file_unique_id, media_type)
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
|
|
||||||
# ── step 2: collect keywords ─────────────────────────────────────────────────
|
# ── 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")
|
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):
|
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.")
|
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")
|
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 ──────────────────────────────────────────────────────────────
|
# ── inline mode ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dp.inline_query()
|
@dp.inline_query()
|
||||||
@@ -392,28 +419,9 @@ async def inline_handler(query: InlineQuery):
|
|||||||
kw_display = item["keywords"].replace(",", ", ")
|
kw_display = item["keywords"].replace(",", ", ")
|
||||||
mtype = item["media_type"]
|
mtype = item["media_type"]
|
||||||
|
|
||||||
if mtype == "photo":
|
builder = INLINE_BUILDERS.get(mtype)
|
||||||
results.append(InlineQueryResultCachedPhoto(
|
if builder is not None:
|
||||||
id=rid, photo_file_id=item["file_id"],
|
results.append(builder(rid, item["file_id"], kw_display))
|
||||||
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"],
|
|
||||||
))
|
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
results = [InlineQueryResultArticle(
|
results = [InlineQueryResultArticle(
|
||||||
|
|||||||
+95
@@ -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()
|
||||||
Reference in New Issue
Block a user