3 Commits

Author SHA1 Message Date
goyban faf4fee274 Merge pull request 'Add menu command' (#1) from snail/add-menu-command into main
Reviewed-on: #1
2026-07-27 12:02:19 +02:00
snail f14fdc20f7 Register Telegram command menu 2026-07-26 18:14:24 +02:00
snail 53aa9cfda7 Add menu command 2026-07-26 16:56:29 +02:00
3 changed files with 76 additions and 81 deletions
+6 -1
View File
@@ -54,12 +54,17 @@ The database will be saved in `./bot_db/media.db`.
**Searching inline** — in any chat type `@yourbotusername keyword` to search and send. **Searching inline** — in any chat type `@yourbotusername keyword` to search and send.
Telegram suggests these commands as you type `/`, with a short description beside each suggestion.
**Commands** **Commands**
| Command | Description | | Command | Description |
|---|---| |---|---|
| `/start` | Start the bot and show usage instructions |
| `/menu` | Show the available commands |
| `/list` | Show all saved media | | `/list` | Show all saved media |
| `/delete <id>` | Remove an entry (get ID from `/list`) | | `/edit [search]` | Select a meme and edit its keywords (admin only) |
| `/delete <id>` | Remove an entry (get ID from `/list`; admin only) |
| `/cancel` | Cancel current add operation | | `/cancel` | Cancel current add operation |
### Moving to another server ### Moving to another server
+68 -57
View File
@@ -9,6 +9,8 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import ( from aiogram.types import (
BotCommand,
BotCommandScopeChat,
CallbackQuery, CallbackQuery,
InlineKeyboardButton, InlineKeyboardButton,
InlineKeyboardMarkup, InlineKeyboardMarkup,
@@ -40,6 +42,18 @@ ADMIN_USERS: set[int] = {
bot = Bot(token=BOT_TOKEN) bot = Bot(token=BOT_TOKEN)
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
USER_COMMANDS = [
BotCommand(command="start", description="Start the bot"),
BotCommand(command="menu", description="Show available commands"),
BotCommand(command="list", description="Browse saved memes"),
BotCommand(command="cancel", description="Cancel current operation"),
]
ADMIN_COMMANDS = [
*USER_COMMANDS,
BotCommand(command="edit", description="Edit meme keywords"),
BotCommand(command="delete", description="Remove a meme by ID"),
]
def is_allowed(user_id: int) -> bool: def is_allowed(user_id: int) -> bool:
if user_id in ADMIN_USERS: if user_id in ADMIN_USERS:
@@ -54,6 +68,20 @@ def is_admin(user_id: int) -> bool:
return not ALLOWED_USERS or user_id in ALLOWED_USERS return not ALLOWED_USERS or user_id in ALLOWED_USERS
async def register_commands() -> None:
"""Publish Telegram's command menu with role-appropriate suggestions."""
if not ADMIN_USERS:
await bot.set_my_commands(ADMIN_COMMANDS)
return
await bot.set_my_commands(USER_COMMANDS)
for admin_id in ADMIN_USERS:
await bot.set_my_commands(
ADMIN_COMMANDS,
scope=BotCommandScopeChat(chat_id=admin_id),
)
class AddMedia(StatesGroup): class AddMedia(StatesGroup):
waiting_for_keywords = State() waiting_for_keywords = State()
@@ -62,28 +90,41 @@ class EditMedia(StatesGroup):
waiting_for_new_keywords = State() waiting_for_new_keywords = State()
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"
"I'll ask you for keywords next.\n\n"
"/list — show all saved memes\n"
"/edit [search] — edit keywords of a meme\n"
"/delete &lt;id&gt; — remove a meme by ID\n"
"/cancel — cancel current operation"
)
return (
"Use me inline: type <code>@botname keyword</code> in any chat.\n\n"
"/list — browse all saved memes"
)
# ── /start ─────────────────────────────────────────────────────────────────── # ── /start ───────────────────────────────────────────────────────────────────
@dp.message(Command("start")) @dp.message(Command("start"))
async def cmd_start(msg: Message): async def cmd_start(msg: Message):
if not is_allowed(msg.from_user.id): if not is_allowed(msg.from_user.id):
return return
if is_admin(msg.from_user.id): await msg.answer(
await msg.answer( f"{menu_text(msg.from_user.id)}\n/menu — show this list again",
"Send me any <b>photo, GIF, video, voice, or sticker</b>.\n" parse_mode="HTML",
"I'll ask you for keywords next.\n\n" )
"/list — show all saved memes\n"
"/edit [search] — edit keywords of a meme\n"
"/delete &lt;id&gt; — remove a meme by ID\n" # ── /menu ────────────────────────────────────────────────────────────────────
"/cancel — cancel current operation",
parse_mode="HTML", @dp.message(Command("menu"))
) async def cmd_menu(msg: Message):
else: if not is_allowed(msg.from_user.id):
await msg.answer( return
"Use me inline: type <code>@botname keyword</code> in any chat.\n\n" await msg.answer(menu_text(msg.from_user.id), parse_mode="HTML")
"/list — browse all saved memes",
parse_mode="HTML",
)
# ── /cancel ────────────────────────────────────────────────────────────────── # ── /cancel ──────────────────────────────────────────────────────────────────
@@ -188,52 +229,21 @@ async def on_media_during_keywords(msg: Message):
# ── /list ──────────────────────────────────────────────────────────────────── # ── /list ────────────────────────────────────────────────────────────────────
LIST_PAGE_SIZE = 30
async def list_page(page: int) -> tuple[str, InlineKeyboardMarkup | None]:
total = await db.count_media()
if total == 0:
return "No memes saved yet.", None
total_pages = (total + LIST_PAGE_SIZE - 1) // LIST_PAGE_SIZE
page = max(0, min(page, total_pages - 1))
items = await db.list_media(page, LIST_PAGE_SIZE)
lines = [f"<code>{item['id']}</code> [{item['media_type']}] — {item['keywords']}" for item in items]
text = f"Page {page + 1} of {total_pages}\n" + "\n".join(lines)
buttons = []
if page > 0:
buttons.append(InlineKeyboardButton(text=" Previous", callback_data=f"list:{page - 1}"))
if page < total_pages - 1:
buttons.append(InlineKeyboardButton(text="Next ", callback_data=f"list:{page + 1}"))
keyboard = InlineKeyboardMarkup(inline_keyboard=[buttons]) if buttons else None
return text, keyboard
@dp.message(Command("list")) @dp.message(Command("list"))
async def cmd_list(msg: Message): async def cmd_list(msg: Message):
if not is_allowed(msg.from_user.id): if not is_allowed(msg.from_user.id):
return return
text, keyboard = await list_page(0) items = await db.list_media()
await msg.answer(text, parse_mode="HTML", reply_markup=keyboard) if not items:
await msg.answer("No memes saved yet.")
@dp.callback_query(F.data.startswith("list:"))
async def on_list_page(callback: CallbackQuery):
if not is_allowed(callback.from_user.id):
await callback.answer("Not allowed.", show_alert=True)
return return
lines = []
try: for item in items[:30]:
page = int(callback.data.split(":", 1)[1]) lines.append(f"<code>{item['id']}</code> [{item['media_type']}] — {item['keywords']}")
except ValueError: text = "\n".join(lines)
await callback.answer("Invalid page.", show_alert=True) if len(items) > 30:
return text += f"\n…and {len(items) - 30} more."
await msg.answer(text, parse_mode="HTML")
text, keyboard = await list_page(page)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
await callback.answer()
# ── /delete ────────────────────────────────────────────────────────────────── # ── /delete ──────────────────────────────────────────────────────────────────
@@ -392,6 +402,7 @@ async def inline_handler(query: InlineQuery):
async def main(): async def main():
await db.init_db() await db.init_db()
await register_commands()
log.info("Bot starting…") log.info("Bot starting…")
await dp.start_polling(bot) await dp.start_polling(bot)
+2 -23
View File
@@ -59,29 +59,8 @@ async def search_media(query: str) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
async def list_media(page: int, page_size: int) -> list[dict]: async def list_media() -> list[dict]:
"""Return one page of saved media, newest first.""" return await search_media("")
offset = page * page_size
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"""
SELECT id, file_id, file_unique_id, media_type, keywords
FROM media
ORDER BY added_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(page_size, offset),
)
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def count_media() -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT COUNT(*) FROM media")
row = await cursor.fetchone()
return row[0]
async def get_media_by_id(media_id: int) -> dict | None: async def get_media_by_id(media_id: int) -> dict | None: