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 95 additions and 143 deletions

View File

@@ -14,7 +14,6 @@ from commands import (
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_start, cmd_start,
cmd_timezone,
cmd_track, cmd_track,
cmd_untrack, cmd_untrack,
cmd_update, cmd_update,
@@ -42,7 +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(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -58,7 +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"),
("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 []
@@ -62,53 +32,23 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[
due_date_ts = None due_date_ts = None
remaining = [] remaining = []
i = 0 for arg in args:
while i < len(args):
arg = args[i]
if not link and (arg.startswith("http://") or arg.startswith("https://")): if not link and (arg.startswith("http://") or arg.startswith("https://")):
link = arg link = arg
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())
if i + 1 < len(args) and _is_time_format(args[i + 1]):
time_str = args[i + 1]
hour, minute = map(int, time_str.split(":"))
due_date_ts = _set_time_on_timestamp(due_date_ts, hour, minute)
i += 1
else: else:
remaining.append(arg) remaining.append(arg)
else: else:
remaining.append(arg) remaining.append(arg)
i += 1
text = " ".join(remaining) if remaining else None text = " ".join(remaining) if remaining else None
return text, link, due_date_ts return text, link, due_date_ts
def _is_time_format(s: str) -> bool: def format_bounty(b, show_id: bool = True) -> str:
"""Check if string matches HH:MM format."""
if not s or len(s) != 5:
return False
if s[2] != ":":
return False
try:
h, m = map(int, s.split(":"))
return 0 <= h <= 23 and 0 <= m <= 59
except ValueError:
return False
def _set_time_on_timestamp(ts: int, hour: int, minute: int) -> int:
"""Set time (hour:minute) on a Unix timestamp, keeping the date."""
import datetime
dt = datetime.datetime.fromtimestamp(ts)
dt = dt.replace(hour=hour, minute=minute, second=0, microsecond=0)
return int(dt.timestamp())
def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b.id}]") parts.append(f"[#{b.id}]")
@@ -117,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)")
@@ -165,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)
@@ -175,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)
@@ -189,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)
@@ -197,8 +132,8 @@ async def cmd_add(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( await update.message.reply_text(
"Usage: /add <text> [link] [date] [time]\n" "Usage: /add <text> [link] [due_date]\n"
"Example: /add Fix bug https://github.com/foo/bar april 15 14:30" "Example: /add Fix the bug https://github.com/foo/bar tomorrow"
) )
return return
@@ -210,25 +145,17 @@ 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)
try: bounty = BOUNTY_SERVICE.add_bounty(
bounty = BOUNTY_SERVICE.add_bounty( room_id=room_id,
room_id=room_id, user_id=user_id,
user_id=user_id, text=text,
text=text, link=link,
link=link, due_date_ts=due_date_ts,
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:
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}",
@@ -389,42 +316,12 @@ 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] [date] [time] — 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"
"/delete <id> — delete bounty (admin only)\n" "/delete <id> — delete bounty\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"
"/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

@@ -96,24 +96,21 @@ class BountyService:
def check_link_unique( def check_link_unique(
self, room_id: int, link: str | None, exclude_bounty_id: int | None = None self, room_id: int, link: str | None, exclude_bounty_id: int | None = None
) -> int | None: ) -> bool:
"""Check if a link is unique within a room (not used by another bounty). """Check if a link is unique within a room (not used by another bounty)."""
Returns the conflicting bounty ID if found, or None if unique/allowed.
"""
if not link: if not link:
return None return True
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
return None return True
for bounty in room_data.bounties: for bounty in room_data.bounties:
if bounty.deleted_at is not None: if bounty.deleted_at is not None:
continue continue
if bounty.link == link and bounty.id != exclude_bounty_id: if bounty.link == link and bounty.id != exclude_bounty_id:
return bounty.id return False
return None return True
def add_bounty( def add_bounty(
self, self,
@@ -127,11 +124,8 @@ class BountyService:
if not self.is_admin(room_id, user_id): if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can add bounties.") raise PermissionError("Only admins can add bounties.")
conflicting_id = self.check_link_unique(room_id, link) if not self.check_link_unique(room_id, link):
if conflicting_id is not None: raise ValueError("A bounty with this link already exists in this room.")
raise ValueError(
f"A bounty with this link already exists: #{conflicting_id}"
)
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
@@ -184,10 +178,8 @@ class BountyService:
if not self.is_admin(room_id, user_id): if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can edit bounties.") raise PermissionError("Only admins can edit bounties.")
if ( if link and not self.check_link_unique(
link room_id, link, exclude_bounty_id=bounty_id
and self.check_link_unique(room_id, link, exclude_bounty_id=bounty_id)
is not None
): ):
raise ValueError("A bounty with this link already exists in this room.") raise ValueError("A bounty with this link already exists in this room.")
@@ -218,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."""