Compare commits

...

8 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
shokollm
ce864d9fdc feat: implement /admin add|remove @username command
- Add cmd_admin handler for /admin add|remove @username
- Add _find_user_id_by_username helper to resolve usernames from bounty creators
- Register admin command handler in bot.py
- Add 'admin' to bot command list
- Addresses issue #51
2026-04-04 08:20:35 +00:00
e805a6428a Merge pull request 'feat: implement /timezone command to get/set room timezone' (#72) from fix/issue-53 into main 2026-04-04 10:15:42 +02:00
3 changed files with 168 additions and 9 deletions

View File

@@ -8,11 +8,13 @@ from telegram.ext import Application, CommandHandler, MessageHandler, filters
from commands import ( from commands import (
cmd_add, cmd_add,
cmd_admin,
cmd_bounty, cmd_bounty,
cmd_delete, cmd_delete,
cmd_edit, cmd_edit,
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_recover,
cmd_show, cmd_show,
cmd_start, cmd_start,
cmd_timezone, cmd_timezone,
@@ -45,6 +47,8 @@ def build_app() -> Application:
app.add_handler(CommandHandler("untrack", cmd_untrack)) app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(CommandHandler("show", cmd_show)) app.add_handler(CommandHandler("show", cmd_show))
app.add_handler(CommandHandler("timezone", cmd_timezone)) 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)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -62,6 +66,8 @@ async def post_init(app: Application) -> None:
("untrack", "Stop tracking"), ("untrack", "Stop tracking"),
("show", "Show bounty details"), ("show", "Show bounty details"),
("timezone", "Get/set room timezone"), ("timezone", "Get/set room timezone"),
("admin", "Manage admins"),
("recover", "Recover deleted bounties"),
("help", "Show help"), ("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)") parts.append(f"⏰ Today (OVERDUE)")
else: else:
parts.append(f"{due_str} ({days_left}d)") 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) return " | ".join(parts)
@@ -231,13 +229,20 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update) room_id = get_room_id(update)
bounty = BOUNTY_SERVICE.add_bounty( try:
room_id=room_id, bounty = BOUNTY_SERVICE.add_bounty(
user_id=user_id, room_id=room_id,
text=text, user_id=user_id,
link=link, text=text,
due_date_ts=due_date_ts, 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 = "" due_str = ""
if due_date_ts: 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.") 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: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update): if not is_group(update):
await update.message.reply_text("⛔ /track is only available in groups.") await update.message.reply_text("⛔ /track is only available in groups.")
@@ -485,6 +552,65 @@ 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}.")
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)
for bounty in bounties:
if (
bounty.created_by_username
and bounty.created_by_username.lower() == username.lower()
):
return bounty.created_by_user_id
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"):
await update.message.reply_text(
"Usage:\n"
"/admin add @username — add admin\n"
"/admin remove @username — remove admin"
)
return
subcommand = args[0]
if len(args) < 2:
await update.message.reply_text(f"Usage: /admin {subcommand} @username")
return
raw_username = args[1]
if not raw_username.startswith("@"):
await update.message.reply_text(f"Usage: /admin {subcommand} @username")
return
username = raw_username[1:]
user_id = get_user_id(update)
room_id = get_room_id(update)
if not BOUNTY_SERVICE.is_admin(room_id, user_id):
await update.message.reply_text("⛔ Only admins can perform this action.")
return
target_user_id = _find_user_id_by_username(room_id, username)
if target_user_id is None:
await update.message.reply_text(f"⛔ User @{username} not found.")
return
try:
if subcommand == "add":
BOUNTY_SERVICE.add_admin(room_id, target_user_id, user_id)
await update.message.reply_text(f"✅ @{username} is now an admin.")
elif subcommand == "remove":
BOUNTY_SERVICE.remove_admin(room_id, target_user_id, user_id)
await update.message.reply_text(f"✅ @{username} is no longer an admin.")
except PermissionError as e:
await update.message.reply_text(f"{e}")
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO Commands:\n\n" "👻 JIGAIDO Commands:\n\n"

View File

@@ -210,6 +210,33 @@ class BountyService:
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
return True 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: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""