Compare commits

...

6 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
003c570cfb Merge pull request 'feat(/add): add admin-only and link uniqueness handling' (#77) from fix/issue-45-v3 into main 2026-04-04 12:59:48 +02:00
shokollm
bac6830fc3 feat(/add): add admin-only and link uniqueness handling
Wrap add_bounty call in try-except to handle PermissionError
and ValueError from admin check and link uniqueness check.

Fixes #45
2026-04-04 17:59:20 +07:00
2dd11a8b48 Merge pull request 'feat: remove "by user" from bounty list display' (#76) from fix/issue-55 into main 2026-04-04 12:53:36 +02:00
shokollm
2617d17e28 feat: remove "by user" from bounty list display
Removes created_by_user_id from format_bounty() output.
Fixes #55
2026-04-04 17:52:47 +07:00
b091153f10 Merge pull request 'feat: implement /admin add|remove @username command' (#75) from fix/issue-51-v2 into main 2026-04-04 12:18:31 +02:00
3 changed files with 106 additions and 9 deletions

View File

@@ -14,6 +14,7 @@ from commands import (
cmd_edit,
cmd_help,
cmd_my,
cmd_recover,
cmd_show,
cmd_start,
cmd_timezone,
@@ -47,6 +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("recover", cmd_recover))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -65,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"),
]
)

View File

@@ -105,8 +105,6 @@ def format_bounty(b, show_id: bool = True, slice_length: int = 0) -> str:
parts.append(f"⏰ Today (OVERDUE)")
else:
parts.append(f"{due_str} ({days_left}d)")
if b.created_by_user_id:
parts.append(f"by {b.created_by_user_id}")
return " | ".join(parts)
@@ -231,13 +229,20 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_id = get_user_id(update)
room_id = get_room_id(update)
bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id,
user_id=user_id,
text=text,
link=link,
due_date_ts=due_date_ts,
)
try:
bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id,
user_id=user_id,
text=text,
link=link,
due_date_ts=due_date_ts,
)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
except ValueError as e:
await update.message.reply_text(f"{e}")
return
due_str = ""
if due_date_ts:
@@ -341,6 +346,68 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Bounty not found.")
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:
if not is_group(update):
await update.message.reply_text("⛔ /track is only available in groups.")

View File

@@ -210,6 +210,33 @@ class BountyService:
self._storage.update_bounty(room_id, bounty)
return True
def recover_bounty(
self, room_id: int, bounty_id: int, user_id: int
) -> tuple[bool, str]:
"""Recover a soft-deleted bounty. Only admins can recover.
Returns (success, message) tuple.
"""
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can recover bounties.")
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)
return True, f"Recovered bounty #{bounty_id}."
class TrackingService:
"""Service for tracking bounty operations."""