Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
6312d94c0b feat: implement /recover command for listing and recovering soft-deleted bounties
- Add recover_bounty method to BountyService for recovering soft-deleted bounties
- Add cmd_recover function to CLI with list and recover modes
- List mode: jigaido-cli recover --group-id <id>
- Recover mode: jigaido-cli recover <id>... --group-id <id>
- Admin-only for recover, everyone for list
- Fixes #49
2026-04-04 07:00:17 +00:00
4 changed files with 86 additions and 193 deletions

View File

@@ -13,9 +13,7 @@ from commands import (
cmd_edit, cmd_edit,
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_show,
cmd_start, cmd_start,
cmd_timezone,
cmd_track, cmd_track,
cmd_untrack, cmd_untrack,
cmd_update, cmd_update,
@@ -43,8 +41,6 @@ def build_app() -> Application:
app.add_handler(CommandHandler("delete", cmd_delete)) app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("track", cmd_track)) app.add_handler(CommandHandler("track", cmd_track))
app.add_handler(CommandHandler("untrack", cmd_untrack)) app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(CommandHandler("timezone", cmd_timezone))
app.add_handler(CommandHandler("show", cmd_show))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -60,8 +56,6 @@ async def post_init(app: Application) -> None:
("edit", "Edit a bounty"), ("edit", "Edit a bounty"),
("track", "Track a bounty"), ("track", "Track a bounty"),
("untrack", "Stop tracking"), ("untrack", "Stop tracking"),
("timezone", "Get/set room timezone"),
("show", "Show bounty details"),
("help", "Show help"), ("help", "Show help"),
] ]
) )

View File

@@ -1,10 +1,8 @@
"""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
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateparser import dateparser
from telegram import Update from telegram import Update
@@ -21,34 +19,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 []
@@ -56,65 +26,29 @@ def extract_args(text: str) -> list[str]:
return tokens[1:] if len(tokens) > 1 else [] return tokens[1:] if len(tokens) > 1 else []
def parse_args( def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[int]]:
args: list[str],
) -> tuple[Optional[str], Optional[str], Optional[int], bool, bool]:
text = None text = None
link = None link = None
due_date_ts = None due_date_ts = None
clear_link = False
clear_date = False
i = 0 remaining = []
while i < len(args): for arg in args:
arg = args[i] if not link and (arg.startswith("http://") or arg.startswith("https://")):
if arg == "-link":
if i + 1 < len(args) and (
args[i + 1].startswith("http://") or args[i + 1].startswith("https://")
):
link = args[i + 1]
i += 2
else:
clear_link = True
i += 1
elif arg == "-date":
if i + 1 < len(args):
parsed = dateparser.parse(args[i + 1])
if parsed:
due_date_ts = int(parsed.timestamp())
i += 2
else:
clear_date = True
i += 1
else:
clear_date = True
i += 1
elif not link and (arg.startswith("http://") or arg.startswith("https://")):
link = arg link = arg
i += 1
elif due_date_ts is None: elif due_date_ts is None:
parsed = dateparser.parse(arg) parsed = dateparser.parse(arg)
if parsed: if parsed:
due_date_ts = int(parsed.timestamp()) due_date_ts = int(parsed.timestamp())
i += 1
else: else:
i += 1 remaining.append(arg)
if text is None:
text = arg
else: else:
text = text + " " + arg remaining.append(arg)
else:
i += 1
if text is None:
text = arg
else:
text = text + " " + arg
return text, link, due_date_ts, clear_link, clear_date text = " ".join(remaining) if remaining else None
return text, link, due_date_ts
def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str: def format_bounty(b, show_id: bool = True) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b.id}]") parts.append(f"[#{b.id}]")
@@ -123,11 +57,7 @@ def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str:
if b.link: if b.link:
parts.append(f"🔗 {b.link}") parts.append(f"🔗 {b.link}")
if b.due_date_ts: if b.due_date_ts:
timezone_str = "UTC" due_str = time.strftime("%Y-%m-%d", time.localtime(b.due_date_ts))
if room_id is not None:
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
due_str = format_due_date(b.due_date_ts, timezone_str)
days_left = (b.due_date_ts - int(time.time())) // 86400 days_left = (b.due_date_ts - int(time.time())) // 86400
if days_left < 0: if days_left < 0:
parts.append(f"{due_str} (OVERDUE)") parts.append(f"{due_str} (OVERDUE)")
@@ -171,7 +101,7 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("No bounties yet.") await update.message.reply_text("No bounties yet.")
return return
lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties] lines = [format_bounty(b, show_id=True) for b in bounties]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -181,7 +111,6 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if is_group(update): if is_group(update):
group_id = get_group_id(update) group_id = get_group_id(update)
bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id) bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id)
room_id = group_id
else: else:
room_id = get_room_id(update) room_id = get_room_id(update)
bounties = BOUNTY_SERVICE.list_bounties(room_id) bounties = BOUNTY_SERVICE.list_bounties(room_id)
@@ -195,7 +124,7 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(msg) await update.message.reply_text(msg)
return return
lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties] lines = [format_bounty(b, show_id=True) for b in bounties]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -208,7 +137,7 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
return return
text, link, due_date_ts, _, _ = parse_args(args) text, link, due_date_ts = parse_args(args)
if not text and not link: if not text and not link:
await update.message.reply_text("A bounty needs at least text or a link.") await update.message.reply_text("A bounty needs at least text or a link.")
return return
@@ -226,8 +155,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}",
@@ -239,14 +167,7 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if len(args) < 1: if len(args) < 1:
await update.message.reply_text( await update.message.reply_text(
"Usage: /update <bounty_id> [text] [link] [due_date]\n" "Usage: /update <bounty_id> [text] [link] [due_date]"
" /update <bounty_id> -link [<url>] - clear or set link\n"
" /update <bounty_id> -date [<date>] - clear or set date\n"
"Examples:\n"
" /update 1 new text - update text only\n"
" /update 1 -link - clear link\n"
" /update 1 -link https://... - set link\n"
" /update 1 -link -date - clear both link and date"
) )
return return
@@ -256,14 +177,8 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:]) text, link, due_date_ts = parse_args(args[1:])
if ( if not text and not link and due_date_ts is None:
not text
and not link
and due_date_ts is None
and not clear_link
and not clear_date
):
await update.message.reply_text("Nothing to update.") await update.message.reply_text("Nothing to update.")
return return
@@ -278,15 +193,10 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
text=text, text=text,
link=link, link=link,
due_date_ts=due_date_ts, due_date_ts=due_date_ts,
clear_link=clear_link,
clear_due=clear_date,
) )
except PermissionError as e: except PermissionError as e:
await update.message.reply_text(f"{e}") await update.message.reply_text(f"{e}")
return return
except ValueError as e:
await update.message.reply_text(f"{e}")
return
if success: if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
@@ -401,94 +311,17 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
async def cmd_show(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text)
if not args:
await update.message.reply_text("Usage: /show <bounty_id>")
return
try:
bounty_id = int(args[0])
except ValueError:
await update.message.reply_text("Invalid bounty ID.")
return
room_id = get_room_id(update)
bounty = BOUNTY_SERVICE.get_bounty(room_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
timezone = BOUNTY_SERVICE.get_timezone(room_id)
lines = []
title = bounty.text or "(no text)"
lines.append(f"[#{bounty.id}] {title}")
due_parts = []
if bounty.due_date_ts:
due_str = time.strftime("%d %B %Y %H:%M", time.localtime(bounty.due_date_ts))
due_parts.append(f"Due: {due_str} ({timezone})")
username = bounty.created_by_username or f"user#{bounty.created_by_user_id}"
if bounty.link:
due_parts.append(f"{bounty.link} by @{username}")
else:
due_parts.append(f"by @{username}")
if due_parts:
lines.extend(due_parts)
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
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"
"/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\n" "/add <text> [link] [due] — add bounty\n"
"/update <id> [text] [link] [due] — update bounty\n" "/update <id> [text> [link] [due] — update bounty\n"
"/edit <id> [text] [link] [due] — edit bounty (same as update)\n" "/delete <id> — delete bounty\n"
" /edit <id> -link [<url>] — clear or set link\n"
" /edit <id> -date [<date>] — clear or set date\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"
"/timezone [tz] — get/set room timezone (admin only)\n"
"/show <id> — show bounty details\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,
) )
async def cmd_timezone(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 args:
current_tz = BOUNTY_SERVICE.get_timezone(room_id)
await update.message.reply_text(f"Current timezone: {current_tz}")
return
timezone_str = args[0]
try:
ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
await update.message.reply_text(
"⛔ Invalid timezone. Use IANA format (e.g., Asia/Jakarta)"
)
return
try:
BOUNTY_SERVICE.set_timezone(room_id, timezone_str, user_id)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
await update.message.reply_text(f"✅ Timezone set to {timezone_str}.")

View File

@@ -127,6 +127,40 @@ def cmd_delete(args):
sys.exit(1) sys.exit(1)
def cmd_recover(args):
"""List or recover soft-deleted bounties."""
bounty_service, _ = create_services()
room_id = args.group_id or args.user_id
user_id = args.user_id or 0
if not args.bounty_ids:
deleted = bounty_service.list_deleted_bounties(room_id)
if not deleted:
print("No recoverable bounties")
return
print("Recoverable bounties:")
for b in deleted:
from datetime import datetime
deleted_str = datetime.fromtimestamp(b.deleted_at).strftime("%d %b %Y")
print(f" [#{b.id}] {b.text or '(no text)'} | Deleted {deleted_str}")
return
if not bounty_service.is_admin(room_id, user_id):
print("Error: Only admins can recover bounties.", file=sys.stderr)
sys.exit(1)
for bounty_id in args.bounty_ids:
try:
success, msg = bounty_service.recover_bounty(
room_id=room_id, bounty_id=bounty_id, user_id=user_id
)
print(msg)
except PermissionError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_track(args): def cmd_track(args):
"""Track a bounty.""" """Track a bounty."""
_, tracking_service = create_services() _, tracking_service = create_services()
@@ -196,6 +230,9 @@ def main():
parser_untrack = subparsers.add_parser("untrack", help="Untrack a bounty") parser_untrack = subparsers.add_parser("untrack", help="Untrack a bounty")
parser_untrack.add_argument("bounty_id", type=int, help="Bounty ID to untrack") parser_untrack.add_argument("bounty_id", type=int, help="Bounty ID to untrack")
parser_recover = subparsers.add_parser("recover", help="List or recover soft-deleted bounties")
parser_recover.add_argument("bounty_ids", nargs="*", type=int, help="Bounty ID(s) to recover (optional)")
for sp in [ for sp in [
parser_add, parser_add,
parser_list, parser_list,
@@ -204,6 +241,7 @@ def main():
parser_delete, parser_delete,
parser_track, parser_track,
parser_untrack, parser_untrack,
parser_recover,
]: ]:
sp.add_argument( sp.add_argument(
"--group-id", type=int, help="Group context (use group room ID)" "--group-id", type=int, help="Group context (use group room ID)"
@@ -222,7 +260,7 @@ def main():
print("Error: either --group-id or --user-id is required", file=sys.stderr) print("Error: either --group-id or --user-id is required", file=sys.stderr)
sys.exit(1) sys.exit(1)
if args.command in ("add", "list", "my", "update", "delete"): if args.command in ("add", "list", "my", "update", "delete", "recover"):
if not (args.group_id or args.user_id): if not (args.group_id or args.user_id):
print("Error: --group-id or --user-id required", file=sys.stderr) print("Error: --group-id or --user-id required", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -238,6 +276,7 @@ def main():
"delete": cmd_delete, "delete": cmd_delete,
"track": cmd_track, "track": cmd_track,
"untrack": cmd_untrack, "untrack": cmd_untrack,
"recover": cmd_recover,
} }
if args.command in command_map: if args.command in command_map:

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.
"""
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."
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can recover bounties.")
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."""