Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
e937cc85b9 feat: implement /recover command for listing and recovering soft-deleted bounties
- Add recover_bounty method to BountyService (admin-only)
- Add cmd_recover handler for listing and recovering deleted bounties
- Register /recover command in bot.py
- Add /recover to bot command list

/recover - list all recoverable bounties (sorted by deleted_at desc)
/recover <id...> - recover specific bounty(ies)

Output formats:
List: [#1] Deleted bounty | 🗑️ Deleted 2 Apr 2026
Recover:  Recovered bounty #1. or  Bounty #5 not found or not deleted.

Fixes #49
2026-04-04 13:12:13 +00:00
4 changed files with 98 additions and 262 deletions

View File

@@ -1,7 +1,6 @@
"""Telegram command handlers for JIGAIDO - Thin wrappers around core services.""" """Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import time import time
from datetime import datetime, timezone
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -21,34 +20,6 @@ TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE)
TELEGRAM_BOT_USERNAME = "your_bot_username" TELEGRAM_BOT_USERNAME = "your_bot_username"
def format_due_date(due_date_ts: int | None, timezone_str: str) -> str:
"""Format due date as human-readable with timezone.
Examples:
No due date: (none shown)
Date only: 4 April 2026
Date + time: 4 April 2026 14:30
With timezone: 4 April 2026 14:30 (Asia/Jakarta)
"""
if not due_date_ts:
return ""
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
dt = datetime.fromtimestamp(due_date_ts, tz=tz)
date_str = dt.strftime("%-d %B %Y")
if dt.hour != 0 or dt.minute != 0:
date_str += f" {dt.strftime('%H:%M')}"
date_str += f" ({timezone_str})"
return date_str
def extract_args(text: str) -> list[str]: def extract_args(text: str) -> list[str]:
if not text: if not text:
return [] return []
@@ -275,8 +246,7 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
due_str = "" due_str = ""
if due_date_ts: if due_date_ts:
timezone_str = BOUNTY_SERVICE.get_timezone(room_id) due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}"
due_str = f" | Due: {format_due_date(due_date_ts, timezone_str)}"
await update.message.reply_text( await update.message.reply_text(
f"✅ Bounty added (#{bounty.id}){due_str}", f"✅ Bounty added (#{bounty.id}){due_str}",
disable_web_page_preview=True, disable_web_page_preview=True,
@@ -348,11 +318,11 @@ cmd_edit = cmd_update
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /delete <bounty_id> [bounty_id ...]") await update.message.reply_text("Usage: /delete <bounty_id>")
return return
try: try:
bounty_ids = [int(arg) for arg in args] bounty_id = int(args[0])
except ValueError: except ValueError:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
@@ -361,25 +331,81 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update) room_id = get_room_id(update)
try: try:
results = BOUNTY_SERVICE.delete_bounties( success = BOUNTY_SERVICE.delete_bounty(
room_id=room_id, room_id=room_id,
bounty_ids=bounty_ids, bounty_id=bounty_id,
user_id=user_id, user_id=user_id,
) )
except PermissionError as e: except PermissionError as e:
await update.message.reply_text(f"{e}") await update.message.reply_text(f"{e}")
return return
response_lines = [] if success:
for bounty_id, result in results.items(): await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
if result == "deleted": else:
response_lines.append(f"Bounty #{bounty_id} deleted.") await update.message.reply_text("Bounty not found.")
elif result == "not_found":
response_lines.append(f"⛔ Bounty #{bounty_id} not found.")
elif result == "permission_denied":
response_lines.append(f"⛔ Bounty #{bounty_id} permission denied.")
await update.message.reply_text("\n".join(response_lines))
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)
try:
if not BOUNTY_SERVICE.is_admin(room_id, user_id):
await update.message.reply_text("⛔ Only admins can recover bounties.")
return
except PermissionError as e:
await update.message.reply_text(f"{e}")
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:
deleted_str = time.strftime("%d %b %Y", time.localtime(b.deleted_at))
text_part = (
b.text[:40] + "..."
if b.text and len(b.text) > 40
else (b.text or "(no text)")
)
lines.append(f"[#{b.id}] {text_part} | 🗑️ Deleted {deleted_str}")
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
return
bounty_ids = []
for arg in args:
try:
bounty_ids.append(int(arg))
except ValueError:
await update.message.reply_text(f"Invalid bounty ID: {arg}")
return
results = []
for bounty_id in bounty_ids:
try:
success, message = BOUNTY_SERVICE.recover_bounty(
room_id=room_id,
bounty_id=bounty_id,
user_id=user_id,
)
except PermissionError as e:
results.append(f"{e}")
continue
if success:
results.append(f"{message}")
else:
results.append(f"{message}")
await update.message.reply_text("\n".join(results))
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -526,63 +552,6 @@ async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(f"✅ Timezone set to {timezone_str}.") 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: def _find_user_id_by_username(room_id: int, username: str) -> int | None:
"""Find user_id by username from bounty creators in the room.""" """Find user_id by username from bounty creators in the room."""
bounties = BOUNTY_SERVICE.list_bounties(room_id) bounties = BOUNTY_SERVICE.list_bounties(room_id)
@@ -595,38 +564,11 @@ def _find_user_id_by_username(room_id: int, username: str) -> int | None:
return 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: async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args or args[0] not in ("add", "remove"):
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( await update.message.reply_text(
"Usage:\n" "Usage:\n"
"/admin — list admins\n"
"/admin add @username — add admin\n" "/admin add @username — add admin\n"
"/admin remove @username — remove admin" "/admin remove @username — remove admin"
) )
@@ -674,22 +616,17 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"👻 JIGAIDO Commands:\n\n" "👻 JIGAIDO Commands:\n\n"
"/bounty — list all bounties\n" "/bounty — list all bounties\n"
"/my — bounties you're tracking\n" "/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty (admin only)\n" "/add <text> [link] [due] — add bounty\n"
"/update <id> [text> [link] [due] — update bounty (admin only)\n" "/update <id> [text] [link] [due] — update bounty\n"
"/edit <id> [text> [link] [due] — edit bounty (same as update)\n" "/edit <id> [text] [link] [due] — edit bounty (same as update)\n"
" /edit <id> -link [<url>] — clear or set link\n" " /edit <id> -link [<url>] — clear or set link\n"
" /edit <id> -date [<date>] — clear or set date\n" " /edit <id> -date [<date>] — clear or set date\n"
"/delete <id> [<id>...] — delete bounty (admin only)\n" "/delete <id> — delete bounty (admin only)\n"
"/track <id> — track a bounty (groups only)\n" "/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n" "/untrack <id> — stop tracking (groups only)\n"
"/show <id> — show bounty details\n" "/show <id> — 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 — get room timezone\n"
"/timezone <tz> — set room timezone (admin only)\n" "/timezone <tz> — set room timezone (admin only)\n"
"/recover — list recoverable bounties (admin only)\n"
"/recover <id> [<id>...] — recover bounty (admin only)\n"
"/start — re-initialize\n" "/start — re-initialize\n"
"/help — this message", "/help — this message",
disable_web_page_preview=True, disable_web_page_preview=True,

View File

@@ -1,28 +0,0 @@
#!/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()

View File

@@ -153,44 +153,6 @@ class BountyService:
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True) 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] 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: def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty by ID. Excludes soft-deleted bounties.""" """Get a specific bounty by ID. Excludes soft-deleted bounties."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
@@ -248,27 +210,32 @@ class BountyService:
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
return True return True
def delete_bounties( def recover_bounty(
self, room_id: int, bounty_ids: list[int], user_id: int self, room_id: int, bounty_id: int, user_id: int
) -> dict[int, str]: ) -> tuple[bool, str]:
"""Soft delete multiple bounties. Returns dict of bounty_id -> result. """Recover a soft-deleted bounty. Only admins can recover.
Results can be: 'deleted', 'not_found', 'permission_denied' Returns (success, message) tuple.
""" """
results = {}
for bounty_id in bounty_ids:
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
results[bounty_id] = "not_found"
continue
if not self.is_admin(room_id, user_id): if not self.is_admin(room_id, user_id):
results[bounty_id] = "permission_denied" raise PermissionError("Only admins can recover bounties.")
continue
bounty.deleted_at = int(time.time()) all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
bounty = None
for b in all_bounties:
if b.id == bounty_id:
bounty = b
break
if not bounty:
return False, f"Bounty #{bounty_id} not found."
if bounty.deleted_at is None:
return False, f"Bounty #{bounty_id} is not deleted."
bounty.deleted_at = None
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
results[bounty_id] = "deleted" return True, f"Recovered bounty #{bounty_id}."
return results
class TrackingService: class TrackingService:

View File

@@ -210,46 +210,6 @@ class TestBountyService:
result = self.service.delete_bounty(-1001, 999, self.admin_user_id) result = self.service.delete_bounty(-1001, 999, self.admin_user_id)
assert result is False assert result is False
def test_delete_bounties_multi_id_success(self):
"""Test delete_bounties returns individual results for multiple bounties."""
bounty1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete 1"
)
bounty2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete 2"
)
results = self.service.delete_bounties(
-1001, [bounty1.id, bounty2.id], self.admin_user_id
)
assert results == {bounty1.id: "deleted", bounty2.id: "deleted"}
# Verify both are soft deleted
assert self.service.get_bounty(-1001, bounty1.id) is None
assert self.service.get_bounty(-1001, bounty2.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns not_found for non-existent bounties."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
results = self.service.delete_bounties(
-1001, [bounty.id, 999, 888], self.admin_user_id
)
assert results == {bounty.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin users."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
results = self.service.delete_bounties(
-1001,
[bounty.id],
999, # non-admin user
)
assert results == {bounty.id: "permission_denied"}
# Verify bounty was NOT deleted
assert self.service.get_bounty(-1001, bounty.id) is not None
class TestTrackingService: class TestTrackingService:
"""Unit tests for TrackingService.""" """Unit tests for TrackingService."""