From 6a933742cb3215adb0e1777c4bd0ca7d74fd9ea8 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:29:19 +0000 Subject: [PATCH] feat: implement /recover command and fix /admin list - Add /recover command for listing and recovering soft-deleted bounties - /recover - list recoverable bounties (admin only) - /recover [...] - recover specific bounties (admin only) - Fix /admin list to show @username instead of admin_id - Add recover_bounty and recover_bounties methods to BountyService - Add get_deleted_bounty method to BountyService - Clean up duplicate cmd_admin functions - Add /recover to bot command menu - Fixes #49 and #50 --- apps/telegram-bot/bot.py | 4 +- apps/telegram-bot/commands.py | 197 ++++++++++++++++------------------ apps/telegram-bot/run_bot.py | 28 +++++ core/services.py | 38 +++++++ 4 files changed, 163 insertions(+), 104 deletions(-) create mode 100644 apps/telegram-bot/run_bot.py diff --git a/apps/telegram-bot/bot.py b/apps/telegram-bot/bot.py index 3135252..3037453 100644 --- a/apps/telegram-bot/bot.py +++ b/apps/telegram-bot/bot.py @@ -14,6 +14,7 @@ from commands import ( cmd_edit, cmd_help, cmd_my, + cmd_recover, cmd_show, cmd_start, cmd_timezone, @@ -47,7 +48,7 @@ def build_app() -> Application: app.add_handler(CommandHandler("show", cmd_show)) app.add_handler(CommandHandler("timezone", cmd_timezone)) app.add_handler(CommandHandler("admin", cmd_admin)) - app.add_handler(CommandHandler("admin", cmd_admin)) + app.add_handler(CommandHandler("recover", cmd_recover)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) @@ -66,6 +67,7 @@ async def post_init(app: Application) -> None: ("show", "Show bounty details"), ("timezone", "Get/set room timezone"), ("admin", "Manage admins"), + ("recover", "Recover deleted bounties"), ("help", "Show help"), ] ) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index adbd706..47e1690 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -2,7 +2,6 @@ import time from datetime import datetime, timezone -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from functools import wraps from typing import Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -50,8 +49,6 @@ def format_due_date(due_date_ts: int | None, timezone_str: str) -> str: return date_str - - def extract_args(text: str) -> list[str]: if not text: return [] @@ -438,101 +435,6 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Not tracking bounty #{bounty_id}.") -async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - args = extract_args(update.message.text) - if not args: - await update.message.reply_text( - "Usage: /admin [@username]\n" - "/admin list — list admins\n" - "/admin add @username — add admin\n" - "/admin remove @username — remove admin" - ) - return - - subcommand = args[0].lower() - username = args[1] if len(args) > 1 else None - - requesting_user_id = get_user_id(update) - room_id = get_room_id(update) - - if subcommand == "list": - admins = BOUNTY_SERVICE.list_admins(room_id) - if not admins: - await update.message.reply_text("No admins in this room.") - return - admin_mentions = " ".join(f"admin_id:{uid}" for uid in admins) - await update.message.reply_text(f"Admins: {admin_mentions}") - return - - if subcommand == "remove": - if not username: - await update.message.reply_text("Usage: /admin remove @username") - return - - if not username.startswith("@"): - await update.message.reply_text( - f"⛔ {username} is not a valid username (must start with @)." - ) - return - - target_username = username[1:] - try: - chat = await ctx.bot.get_chat(target_username) - target_user_id = chat.id - except Exception: - await update.message.reply_text( - f"⛔ Could not find user @{target_username}." - ) - return - - try: - BOUNTY_SERVICE.remove_admin(room_id, target_user_id, requesting_user_id) - await update.message.reply_text( - f"✅ @{target_username} is no longer an admin." - ) - except PermissionError as e: - await update.message.reply_text(f"⛔ {e}") - except ValueError: - await update.message.reply_text(f"⛔ @{target_username} is not an admin.") - return - - if subcommand == "add": - if not username: - await update.message.reply_text("Usage: /admin add @username") - return - - if not username.startswith("@"): - await update.message.reply_text( - f"⛔ {username} is not a valid username (must start with @)." - ) - return - - target_username = username[1:] - try: - chat = await ctx.bot.get_chat(target_username) - target_user_id = chat.id - except Exception: - await update.message.reply_text( - f"⛔ Could not find user @{target_username}." - ) - return - - try: - BOUNTY_SERVICE.add_admin(room_id, target_user_id, requesting_user_id) - await update.message.reply_text(f"✅ @{target_username} is now an admin.") - except PermissionError as e: - await update.message.reply_text(f"⛔ {e}") - except ValueError: - await update.message.reply_text( - f"⛔ @{target_username} is already an admin." - ) - return - - await update.message.reply_text( - "Unknown subcommand. Use: /admin [@username]" - ) - - async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if is_group(update): await update.message.reply_text( @@ -624,6 +526,63 @@ async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(f"✅ Timezone set to {timezone_str}.") +async def cmd_recover(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + args = extract_args(update.message.text) + room_id = get_room_id(update) + user_id = get_user_id(update) + + if not BOUNTY_SERVICE.is_admin(room_id, user_id): + await update.message.reply_text("⛔ Only admins can perform this action.") + return + + if not args: + deleted_bounties = BOUNTY_SERVICE.list_deleted_bounties(room_id) + if not deleted_bounties: + await update.message.reply_text("No recoverable bounties.") + return + + deleted_bounties.sort(key=lambda b: b.deleted_at or 0, reverse=True) + + lines = ["Recoverable bounties:"] + for b in deleted_bounties[:10]: + text = ( + b.text[:40] + "..." + if b.text and len(b.text) > 40 + else (b.text or "(no text)") + ) + link_str = f" | 🔗 {b.link}" if b.link else "" + deleted_str = ( + time.strftime("%d %b %Y", time.localtime(b.deleted_at)) + if b.deleted_at + else "unknown" + ) + lines.append(f"[#{b.id}] {text}{link_str} | 🗑️ Deleted {deleted_str}") + + await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) + return + + try: + bounty_ids = [int(arg) for arg in args] + except ValueError: + await update.message.reply_text("Invalid bounty ID.") + return + + results = BOUNTY_SERVICE.recover_bounties(room_id, bounty_ids, user_id) + + response_lines = [] + for bounty_id, result in results.items(): + if result == "recovered": + response_lines.append(f"✅ Recovered bounty #{bounty_id}.") + elif result == "not_found": + response_lines.append(f"⛔ Bounty #{bounty_id} not found.") + elif result == "not_deleted": + response_lines.append(f"⛔ Bounty #{bounty_id} is not deleted.") + elif result == "permission_denied": + response_lines.append(f"⛔ Bounty #{bounty_id} permission denied.") + + await update.message.reply_text("\n".join(response_lines)) + + def _find_user_id_by_username(room_id: int, username: str) -> int | None: """Find user_id by username from bounty creators in the room.""" bounties = BOUNTY_SERVICE.list_bounties(room_id) @@ -636,11 +595,38 @@ def _find_user_id_by_username(room_id: int, username: str) -> int | None: return None +def _find_username_by_user_id(room_id: int, user_id: int) -> str | None: + """Find username by user_id from bounty creators in the room.""" + bounties = BOUNTY_SERVICE.list_bounties(room_id) + for bounty in bounties: + if bounty.created_by_user_id == user_id: + return bounty.created_by_username + return None + + async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) - if not args or args[0] not in ("add", "remove"): + if not args: + admins = BOUNTY_SERVICE.list_admins(get_room_id(update)) + if not admins: + await update.message.reply_text("No admins in this room.") + return + admin_mentions = [] + for admin_id in admins: + username = _find_username_by_user_id(get_room_id(update), admin_id) + if username: + admin_mentions.append(f"@{username}") + else: + admin_mentions.append(f"user#{admin_id}") + await update.message.reply_text( + f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions) + ) + return + + if args[0] not in ("add", "remove"): await update.message.reply_text( "Usage:\n" + "/admin — list admins\n" "/admin add @username — add admin\n" "/admin remove @username — remove admin" ) @@ -688,17 +674,22 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "👻 JIGAIDO Commands:\n\n" "/bounty — list all bounties\n" "/my — bounties you're tracking\n" - "/add [link] [due] — add bounty\n" - "/update [text] [link] [due] — update bounty\n" - "/edit [text] [link] [due] — edit bounty (same as update)\n" + "/add [link] [due] — add bounty (admin only)\n" + "/update [text> [link] [due] — update bounty (admin only)\n" + "/edit [text> [link] [due] — edit bounty (same as update)\n" " /edit -link [] — clear or set link\n" " /edit -date [] — clear or set date\n" - "/delete — delete bounty (admin only)\n" + "/delete [...] — delete bounty (admin only)\n" "/track — track a bounty (groups only)\n" "/untrack — stop tracking (groups only)\n" "/show — show bounty details\n" + "/admin — list admins\n" + "/admin add @username — add admin (admin only)\n" + "/admin remove @username — remove admin (admin only)\n" "/timezone — get room timezone\n" "/timezone — set room timezone (admin only)\n" + "/recover — list recoverable bounties (admin only)\n" + "/recover [...] — recover bounty (admin only)\n" "/start — re-initialize\n" "/help — this message", disable_web_page_preview=True, diff --git a/apps/telegram-bot/run_bot.py b/apps/telegram-bot/run_bot.py new file mode 100644 index 0000000..67309c7 --- /dev/null +++ b/apps/telegram-bot/run_bot.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import asyncio +import os +import sys + +# Run from the telegram-bot directory so local imports work +os.chdir("/home/shoko/repositories/jigaido/apps/telegram-bot") +sys.path.insert(0, "/home/shoko/repositories/jigaido") + +# Import main from the local bot module +import bot as bot_module + +if __name__ == "__main__": + if not bot_module.BOT_TOKEN: + bot_module.log.error("JIGAIDO_BOT_TOKEN environment variable not set.") + sys.exit(1) + + app = bot_module.build_app() + app.post_init = bot_module.post_init + bot_module.log.info("JIGAIDO starting...") + + # PTB v20+ app.run_polling() is async - use asyncio.get_event_loop() + run_until_complete + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(app.run_polling(drop_pending_updates=True)) + finally: + loop.close() \ No newline at end of file diff --git a/core/services.py b/core/services.py index 3ddc86f..62e86c9 100644 --- a/core/services.py +++ b/core/services.py @@ -153,6 +153,44 @@ class BountyService: all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True) return [b for b in all_bounties if b.deleted_at is not None] + def get_deleted_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + """Get a specific soft-deleted bounty by ID.""" + all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True) + for b in all_bounties: + if b.id == bounty_id and b.deleted_at is not None: + return b + return None + + def recover_bounty(self, room_id: int, bounty_id: int, user_id: int) -> str: + """Recover a soft-deleted bounty. Admin only. + + Returns: 'recovered', 'not_found', 'not_deleted', 'permission_denied' + """ + if not self.is_admin(room_id, user_id): + return "permission_denied" + + bounty = self.get_deleted_bounty(room_id, bounty_id) + if not bounty: + return "not_found" + if bounty.deleted_at is None: + return "not_deleted" + + bounty.deleted_at = None + self._storage.update_bounty(room_id, bounty) + return "recovered" + + def recover_bounties( + self, room_id: int, bounty_ids: list[int], user_id: int + ) -> dict[int, str]: + """Recover multiple soft-deleted bounties. Admin only. + + Returns dict of bounty_id -> result ('recovered', 'not_found', 'not_deleted', 'permission_denied') + """ + results = {} + for bounty_id in bounty_ids: + results[bounty_id] = self.recover_bounty(room_id, bounty_id, user_id) + return results + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: """Get a specific bounty by ID. Excludes soft-deleted bounties.""" bounty = self._storage.get_bounty(room_id, bounty_id)