diff --git a/.env.example b/.env.example index b946e54..d613a52 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ BOT_TOKEN=your_telegram_bot_token_here ALLOWED_USERS=123456789,987654321 +ADMIN_USERS=123456789 diff --git a/.gitignore b/.gitignore index 4c6f3e9..65a26f9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bot_db/ __pycache__/ *.pyc +push.sh diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/bot.py b/bot.py index e223ec5..6f0f9e0 100644 --- a/bot.py +++ b/bot.py @@ -9,9 +9,13 @@ from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, InlineQuery, InlineQueryResultCachedMpeg4Gif, InlineQueryResultCachedPhoto, + InlineQueryResultCachedSticker, InlineQueryResultCachedVideo, InlineQueryResultCachedVoice, InlineQueryResultArticle, @@ -25,15 +29,28 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(mess log = logging.getLogger(__name__) BOT_TOKEN = os.environ["BOT_TOKEN"] + ALLOWED_USERS: set[int] = { int(uid.strip()) for uid in os.environ.get("ALLOWED_USERS", "").split(",") if uid.strip() } +ADMIN_USERS: set[int] = { + int(uid.strip()) for uid in os.environ.get("ADMIN_USERS", "").split(",") if uid.strip() +} bot = Bot(token=BOT_TOKEN) dp = Dispatcher(storage=MemoryStorage()) def is_allowed(user_id: int) -> bool: + if user_id in ADMIN_USERS: + return True + return not ALLOWED_USERS or user_id in ALLOWED_USERS + + +def is_admin(user_id: int) -> bool: + # If no ADMIN_USERS set, fall back to ALLOWED_USERS behaviour (backward compat) + if ADMIN_USERS: + return user_id in ADMIN_USERS return not ALLOWED_USERS or user_id in ALLOWED_USERS @@ -41,20 +58,32 @@ class AddMedia(StatesGroup): waiting_for_keywords = State() +class EditMedia(StatesGroup): + waiting_for_new_keywords = State() + + # ── /start ─────────────────────────────────────────────────────────────────── @dp.message(Command("start")) async def cmd_start(msg: Message): if not is_allowed(msg.from_user.id): return - await msg.answer( - "Send me any photo, GIF, video, or voice.\n" - "I'll ask you for keywords next.\n\n" - "/list — show all saved media\n" - "/delete <file_unique_id> — remove an entry\n" - "/cancel — cancel current operation", - parse_mode="HTML", - ) + if is_admin(msg.from_user.id): + await msg.answer( + "Send me any photo, GIF, video, voice, or sticker.\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 <id> — remove a meme by ID\n" + "/cancel — cancel current operation", + parse_mode="HTML", + ) + else: + await msg.answer( + "Use me inline: type @botname keyword in any chat.\n\n" + "/list — browse all saved memes", + parse_mode="HTML", + ) # ── /cancel ────────────────────────────────────────────────────────────────── @@ -67,7 +96,7 @@ async def cmd_cancel(msg: Message, state: FSMContext): await msg.answer("Cancelled.") -# ── step 1: receive media ──────────────────────────────────────────────────── +# ── step 1: receive media (admin only) ─────────────────────────────────────── async def _receive_media(msg: Message, state: FSMContext, file_id: str, file_unique_id: str, media_type: str): await state.set_state(AddMedia.waiting_for_keywords) @@ -86,7 +115,7 @@ 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): - if not is_allowed(msg.from_user.id): + 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") @@ -94,25 +123,32 @@ async def on_photo(msg: Message, state: FSMContext): @dp.message(F.animation, StateFilter(None)) async def on_gif(msg: Message, state: FSMContext): - if not is_allowed(msg.from_user.id): + if not is_admin(msg.from_user.id): 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_allowed(msg.from_user.id): + 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_allowed(msg.from_user.id): + 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 ───────────────────────────────────────────────── @dp.message(AddMedia.waiting_for_keywords, Command("done")) @@ -130,8 +166,7 @@ async def cmd_done(msg: Message, state: FSMContext): keywords, ) await state.clear() - kw_display = ", ".join(keywords) - await msg.answer(f"Saved with keywords: {kw_display}", parse_mode="HTML") + await msg.answer(f"Saved with keywords: {', '.join(keywords)}", parse_mode="HTML") @dp.message(AddMedia.waiting_for_keywords, F.text) @@ -146,41 +181,126 @@ async def on_keyword(msg: Message, state: FSMContext): await msg.reply(f"Keywords so far: {', '.join(merged)}\nSend more or /done.", parse_mode="HTML") -# handle media sent while already in keyword-collection state -@dp.message(AddMedia.waiting_for_keywords, F.photo | F.animation | F.video | F.voice) +@dp.message(AddMedia.waiting_for_keywords, F.photo | F.animation | F.video | F.voice | F.sticker) async def on_media_during_keywords(msg: Message): - await msg.reply("Still waiting for keywords. Send /done first 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.") -# ── /list & /delete ────────────────────────────────────────────────────────── +# ── /list ──────────────────────────────────────────────────────────────────── @dp.message(Command("list")) async def cmd_list(msg: Message): if not is_allowed(msg.from_user.id): return - items = await db.list_media(msg.from_user.id) + items = await db.list_media() if not items: - await msg.answer("No media saved yet.") + await msg.answer("No memes saved yet.") return lines = [] for item in items[:30]: - lines.append(f"{item['file_unique_id']} [{item['media_type']}] — {item['keywords']}") + lines.append(f"{item['id']} [{item['media_type']}] — {item['keywords']}") text = "\n".join(lines) if len(items) > 30: text += f"\n…and {len(items) - 30} more." await msg.answer(text, parse_mode="HTML") +# ── /delete ────────────────────────────────────────────────────────────────── + @dp.message(Command("delete")) async def cmd_delete(msg: Message): - if not is_allowed(msg.from_user.id): + if not is_admin(msg.from_user.id): + await msg.answer("Only admins can delete memes.") return parts = msg.text.split(maxsplit=1) if len(parts) < 2: - await msg.answer("Usage: /delete <file_unique_id>", parse_mode="HTML") + await msg.answer("Usage: /delete <id> — use the numeric ID from /list", parse_mode="HTML") return - removed = await db.delete_media(msg.from_user.id, parts[1].strip()) - await msg.answer("Deleted." if removed else "Not found or not yours.") + try: + media_id = int(parts[1].strip()) + except ValueError: + await msg.answer("Invalid ID. Use the numeric ID from /list.") + return + removed = await db.delete_media(media_id) + await msg.answer("Deleted." if removed else "Not found.") + + +# ── /edit ──────────────────────────────────────────────────────────────────── + +@dp.message(Command("edit")) +async def cmd_edit(msg: Message): + if not is_admin(msg.from_user.id): + await msg.answer("Only admins can edit memes.") + return + parts = msg.text.split(maxsplit=1) + query = parts[1].strip() if len(parts) > 1 else "" + items = await db.search_media(query) + if not items: + no_match = f" matching '{query}'" if query else "" + await msg.answer(f"No memes found{no_match}.") + return + buttons = [] + for item in items[:10]: + label = f"[{item['media_type']}] {item['keywords'][:45]}" + buttons.append([InlineKeyboardButton(text=label, callback_data=f"edit:{item['id']}")]) + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + await msg.answer("Select a meme to edit its keywords:", reply_markup=keyboard) + + +@dp.callback_query(F.data.startswith("edit:")) +async def on_edit_select(callback: CallbackQuery, state: FSMContext): + if not is_admin(callback.from_user.id): + await callback.answer("Not allowed.", show_alert=True) + return + media_id = int(callback.data.split(":", 1)[1]) + item = await db.get_media_by_id(media_id) + if not item: + await callback.answer("Meme not found.", show_alert=True) + return + await state.set_state(EditMedia.waiting_for_new_keywords) + await state.update_data(media_id=media_id, keywords=[]) + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer( + f"Editing #{item['id']} [{item['media_type']}]\n" + f"Current keywords: {item['keywords']}\n\n" + "Send the new keywords (comma-separated or one per message).\n" + "Send /done when finished, or /cancel to abort.", + parse_mode="HTML", + ) + await callback.answer() + + +@dp.message(EditMedia.waiting_for_new_keywords, Command("done")) +async def cmd_edit_done(msg: Message, state: FSMContext): + data = await state.get_data() + keywords: list[str] = data.get("keywords", []) + if not keywords: + await msg.answer("You haven't entered any keywords yet. Send some, then /done.") + return + updated = await db.update_media_keywords(data["media_id"], keywords) + await state.clear() + if updated: + await msg.answer(f"Updated keywords: {', '.join(keywords)}", parse_mode="HTML") + else: + await msg.answer("Failed to update (meme may have been deleted).") + + +@dp.message(EditMedia.waiting_for_new_keywords, Command("cancel")) +async def cmd_edit_cancel(msg: Message, state: FSMContext): + await state.clear() + await msg.answer("Edit cancelled.") + + +@dp.message(EditMedia.waiting_for_new_keywords, F.text) +async def on_edit_keyword(msg: Message, state: FSMContext): + new_kws = [k.strip().lower() for k in msg.text.split(",") if k.strip()] + if not new_kws: + return + data = await state.get_data() + existing: list[str] = data.get("keywords", []) + merged = existing + [k for k in new_kws if k not in existing] + await state.update_data(keywords=merged) + await msg.reply(f"Keywords so far: {', '.join(merged)}\nSend more or /done.", parse_mode="HTML") # ── inline mode ────────────────────────────────────────────────────────────── @@ -192,7 +312,7 @@ async def inline_handler(query: InlineQuery): return try: - results_raw = await db.search_media(query.from_user.id, query.query) + results_raw = await db.search_media(query.query) results = [] for item in results_raw: @@ -206,7 +326,6 @@ async def inline_handler(query: InlineQuery): title=kw_display, description=kw_display, )) elif mtype == "gif": - # Telegram animations are MPEG4, not raw GIF results.append(InlineQueryResultCachedMpeg4Gif( id=rid, mpeg4_file_id=item["file_id"], title=kw_display, )) @@ -219,12 +338,16 @@ async def inline_handler(query: InlineQuery): 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: results = [InlineQueryResultArticle( id="noop", title="No results found", input_message_content=InputTextMessageContent(message_text="—"), - description=f"No media matching '{query.query}'", + description=f"No memes matching '{query.query}'", )] await query.answer(results, cache_time=5, is_personal=True) diff --git a/db.py b/db.py index 7f39351..05d3101 100644 --- a/db.py +++ b/db.py @@ -17,9 +17,7 @@ async def init_db(): added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_owner ON media(owner_id)" - ) + await db.execute("CREATE INDEX IF NOT EXISTS idx_owner ON media(owner_id)") await db.commit() @@ -33,7 +31,7 @@ async def add_media(owner_id: int, file_id: str, file_unique_id: str, media_type await db.commit() -async def search_media(user_id: int, query: str) -> list[dict]: +async def search_media(query: str) -> list[dict]: query = query.strip().lower() async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row @@ -42,36 +40,53 @@ async def search_media(user_id: int, query: str) -> list[dict]: """ SELECT id, file_id, file_unique_id, media_type, keywords FROM media - WHERE owner_id = ? AND (',' || keywords || ',') LIKE ? + WHERE (',' || keywords || ',') LIKE ? ORDER BY added_at DESC LIMIT 50 """, - (user_id, f"%,{query}%"), + (f"%,{query}%",), ) else: cursor = await db.execute( """ SELECT id, file_id, file_unique_id, media_type, keywords FROM media - WHERE owner_id = ? ORDER BY added_at DESC LIMIT 50 """, - (user_id,), ) rows = await cursor.fetchall() return [dict(r) for r in rows] -async def delete_media(user_id: int, file_unique_id: str) -> bool: +async def list_media() -> list[dict]: + return await search_media("") + + +async def get_media_by_id(media_id: int) -> dict | None: + 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 WHERE id = ?", + (media_id,), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def update_media_keywords(media_id: int, keywords: list[str]) -> bool: + kw = ",".join(k.strip().lower() for k in keywords if k.strip()) async with aiosqlite.connect(DB_PATH) as db: cur = await db.execute( - "DELETE FROM media WHERE owner_id = ? AND file_unique_id = ?", - (user_id, file_unique_id), + "UPDATE media SET keywords = ? WHERE id = ?", + (kw, media_id), ) await db.commit() return cur.rowcount > 0 -async def list_media(user_id: int) -> list[dict]: - return await search_media(user_id, "") +async def delete_media(media_id: int) -> bool: + async with aiosqlite.connect(DB_PATH) as db: + cur = await db.execute("DELETE FROM media WHERE id = ?", (media_id,)) + await db.commit() + return cur.rowcount > 0 diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml new file mode 100644 index 0000000..9b8d191 --- /dev/null +++ b/docker-compose.build.yaml @@ -0,0 +1,10 @@ +services: + gif-bot: + container_name: memes-bot + build: . + restart: unless-stopped + env_file: .env + environment: + DB_PATH: /data/media.db + volumes: + - ./bot_db:/data diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8d1c662 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + gif-bot: + container_name: memes-bot + image: git.goyban.com/goyban/bot_meme:latest + restart: unless-stopped + env_file: .env + environment: + DB_PATH: /data/media.db + volumes: + - ./bot_db:/data diff --git a/rerun.sh b/rerun.sh index e8d0033..e906f1c 100755 --- a/rerun.sh +++ b/rerun.sh @@ -4,13 +4,13 @@ set -e cd "$(dirname "$0")" echo "⏹ Stopping..." -docker compose down +docker compose -f docker-compose.build.yaml down echo "🔨 Rebuilding..." -docker compose build +docker compose -f docker-compose.build.yaml build echo "▶ Starting..." -docker compose up -d +docker compose -f docker-compose.build.yaml up -d echo "📋 Logs (Ctrl-C to exit):" -docker compose logs -f +docker compose -f docker-compose.build.yaml logs -f