From 8069ed646501b5768a2cc36145ff582c5d86be50 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:39:11 +0000 Subject: [PATCH 1/4] feat: add multi-ID delete support with per-ID results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add delete_bounties method to BountyService that returns individual results per bounty ID (deleted, not_found, permission_denied) - Update cmd_delete to accept multiple IDs and show per-ID messages - Add tests for delete_bounties Example output: /delete 1 2 3 ✅ Bounty #1 deleted. ✅ Bounty #2 deleted. ⛔ Bounty #3 not found. Fixes #47 --- apps/telegram-bot/commands.py | 34 +++++++++++++------------ core/services.py | 25 +++++++++++++++++++ tests/test_services.py | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index b0ae05e..b26f59a 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -210,32 +210,34 @@ cmd_edit = cmd_update async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) if not args: - await update.message.reply_text("Usage: /delete ") + await update.message.reply_text("Usage: /delete [bounty_id ...]") return try: - bounty_id = int(args[0]) + bounty_ids = [int(arg) for arg in args] except ValueError: - await update.message.reply_text("Invalid bounty ID.") + await update.message.reply_text("Invalid bounty ID(s).") return user_id = get_user_id(update) room_id = get_room_id(update) - try: - success = BOUNTY_SERVICE.delete_bounty( - room_id=room_id, - bounty_id=bounty_id, - user_id=user_id, - ) - except PermissionError as e: - await update.message.reply_text(f"⛔ {e}") - return + results = BOUNTY_SERVICE.delete_bounties( + room_id=room_id, + bounty_ids=bounty_ids, + user_id=user_id, + ) - if success: - await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") - else: - await update.message.reply_text("Bounty not found.") + lines = [] + for bounty_id, result in results.items(): + if result == "deleted": + lines.append(f"✅ Bounty #{bounty_id} deleted.") + elif result == "not_found": + lines.append(f"⛔ Bounty #{bounty_id} not found.") + elif result == "permission_denied": + lines.append(f"⛔ Bounty #{bounty_id} - only admins can delete.") + + await update.message.reply_text("\n".join(lines)) async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/core/services.py b/core/services.py index 2f727b4..3ced3e5 100644 --- a/core/services.py +++ b/core/services.py @@ -210,6 +210,31 @@ class BountyService: self._storage.update_bounty(room_id, bounty) return True + def delete_bounties( + self, room_id: int, bounty_ids: list[int], user_id: int + ) -> dict[int, str]: + """Soft delete multiple bounties. Only admins can delete. + + Returns a dict mapping bounty_id to result: + - "deleted": Successfully soft-deleted + - "not_found": Bounty does not exist + - "permission_denied": User is not admin + """ + 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): + results[bounty_id] = "permission_denied" + continue + + bounty.deleted_at = int(time.time()) + self._storage.update_bounty(room_id, bounty) + results[bounty_id] = "deleted" + return results + class TrackingService: """Service for tracking bounty operations.""" diff --git a/tests/test_services.py b/tests/test_services.py index 8a92134..bd7c74b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -210,6 +210,53 @@ class TestBountyService: result = self.service.delete_bounty(-1001, 999, self.admin_user_id) assert result is False + def test_delete_bounties_multiple_success(self): + """Test delete_bounties soft deletes multiple bounties.""" + b1 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="First" + ) + b2 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="Second" + ) + b3 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="Third" + ) + results = self.service.delete_bounties( + -1001, [b1.id, b2.id, b3.id], self.admin_user_id + ) + assert results == {b1.id: "deleted", b2.id: "deleted", b3.id: "deleted"} + assert self.service.get_bounty(-1001, b1.id) is None + assert self.service.get_bounty(-1001, b2.id) is None + assert self.service.get_bounty(-1001, b3.id) is None + + def test_delete_bounties_mixed_results(self): + """Test delete_bounties returns individual results per ID.""" + b1 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="Exists" + ) + results = self.service.delete_bounties( + -1001, [b1.id, 999, 888], self.admin_user_id + ) + assert results == {b1.id: "deleted", 999: "not_found", 888: "not_found"} + + def test_delete_bounties_permission_denied(self): + """Test delete_bounties returns permission_denied for non-admin.""" + b1 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="First" + ) + b2 = self.service.add_bounty( + room_id=-1001, user_id=self.admin_user_id, text="Second" + ) + results = self.service.delete_bounties( + -1001, + [b1.id, b2.id], + 999, # non-admin user + ) + assert results == {b1.id: "permission_denied", b2.id: "permission_denied"} + # Bounties should not be deleted + assert self.service.get_bounty(-1001, b1.id) is not None + assert self.service.get_bounty(-1001, b2.id) is not None + class TestTrackingService: """Unit tests for TrackingService.""" From eed3ab33aed61d39e42bea1c0db161bd37ade682 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 07:12:23 +0000 Subject: [PATCH 2/4] feat: implement /timezone command to get/set room timezone - Add cmd_timezone handler for /timezone command - Validate timezone using IANA format (zoneinfo.ZoneInfo) - Use existing BountyService.get_timezone and set_timezone methods - Admin-only permission via service layer - Update help text and bot command list - Fixes #53 --- apps/telegram-bot/bot.py | 3 +++ apps/telegram-bot/commands.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/telegram-bot/bot.py b/apps/telegram-bot/bot.py index c72df9c..a34453b 100644 --- a/apps/telegram-bot/bot.py +++ b/apps/telegram-bot/bot.py @@ -14,6 +14,7 @@ from commands import ( cmd_help, cmd_my, cmd_start, + cmd_timezone, cmd_track, cmd_untrack, cmd_update, @@ -41,6 +42,7 @@ def build_app() -> Application: app.add_handler(CommandHandler("delete", cmd_delete)) app.add_handler(CommandHandler("track", cmd_track)) app.add_handler(CommandHandler("untrack", cmd_untrack)) + app.add_handler(CommandHandler("timezone", cmd_timezone)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) @@ -56,6 +58,7 @@ async def post_init(app: Application) -> None: ("edit", "Edit a bounty"), ("track", "Track a bounty"), ("untrack", "Stop tracking"), + ("timezone", "Get/set room timezone"), ("help", "Show help"), ] ) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index b26f59a..2f608ad 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -3,6 +3,7 @@ import time from functools import wraps from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import dateparser from telegram import Update @@ -323,7 +324,37 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "/delete — delete bounty\n" "/track — track a bounty (groups only)\n" "/untrack — stop tracking (groups only)\n" + "/timezone [tz] — get/set room timezone (admin only)\n" "/start — re-initialize\n" "/help — this message", 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}.") From f521a682c520307dd5894c2e40968203c418d90d Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 07:19:18 +0000 Subject: [PATCH 3/4] feat: human-readable date format with timezone awareness - Add format_due_date() function that formats dates as '4 April 2026' or '4 April 2026 14:30 (Asia/Jakarta)' with timezone support - Update format_bounty() to use timezone-aware date formatting - Update cmd_bounty, cmd_my, cmd_add to pass room_id for timezone - Dates now display in room's configured timezone - Fixes #54 --- apps/telegram-bot/commands.py | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index 2f608ad..62ccafa 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -1,6 +1,7 @@ """Telegram command handlers for JIGAIDO - Thin wrappers around core services.""" import time +from datetime import datetime from functools import wraps from typing import Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -20,6 +21,34 @@ TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE) 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]: if not text: return [] @@ -49,7 +78,7 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[ return text, link, due_date_ts -def format_bounty(b, show_id: bool = True) -> str: +def format_bounty(b, show_id: bool = True, room_id: int | None = None) -> str: parts = [] if show_id: parts.append(f"[#{b.id}]") @@ -58,7 +87,11 @@ def format_bounty(b, show_id: bool = True) -> str: if b.link: parts.append(f"🔗 {b.link}") if b.due_date_ts: - due_str = time.strftime("%Y-%m-%d", time.localtime(b.due_date_ts)) + timezone_str = "UTC" + 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 if days_left < 0: parts.append(f"⏰ {due_str} (OVERDUE)") @@ -102,7 +135,7 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("No bounties yet.") return - lines = [format_bounty(b, show_id=True) for b in bounties] + lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties] await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) @@ -112,6 +145,7 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if is_group(update): group_id = get_group_id(update) bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id) + room_id = group_id else: room_id = get_room_id(update) bounties = BOUNTY_SERVICE.list_bounties(room_id) @@ -125,7 +159,7 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(msg) return - lines = [format_bounty(b, show_id=True) for b in bounties] + lines = [format_bounty(b, show_id=True, room_id=room_id) for b in bounties] await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) @@ -156,7 +190,8 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: due_str = "" if due_date_ts: - due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" + timezone_str = BOUNTY_SERVICE.get_timezone(room_id) + due_str = f" | Due: {format_due_date(due_date_ts, timezone_str)}" await update.message.reply_text( f"✅ Bounty added (#{bounty.id}){due_str}", From c005ee341af395d47a4f403fe322e925086dc5cd Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 07:24:03 +0000 Subject: [PATCH 4/4] Revert "Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main" This reverts commit bd2627efe9827fd9911d2513767fd5bd570dddae, reversing changes made to 42ed551554a6b4b6e642eb04ad8db39ede44eeea. --- apps/telegram-bot/commands.py | 34 ++++++++++++------------- core/services.py | 25 ------------------- tests/test_services.py | 47 ----------------------------------- 3 files changed, 16 insertions(+), 90 deletions(-) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index 62ccafa..941934c 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -246,34 +246,32 @@ cmd_edit = cmd_update async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) if not args: - await update.message.reply_text("Usage: /delete [bounty_id ...]") + await update.message.reply_text("Usage: /delete ") return try: - bounty_ids = [int(arg) for arg in args] + bounty_id = int(args[0]) except ValueError: - await update.message.reply_text("Invalid bounty ID(s).") + await update.message.reply_text("Invalid bounty ID.") return user_id = get_user_id(update) room_id = get_room_id(update) - results = BOUNTY_SERVICE.delete_bounties( - room_id=room_id, - bounty_ids=bounty_ids, - user_id=user_id, - ) + try: + success = BOUNTY_SERVICE.delete_bounty( + room_id=room_id, + bounty_id=bounty_id, + user_id=user_id, + ) + except PermissionError as e: + await update.message.reply_text(f"⛔ {e}") + return - lines = [] - for bounty_id, result in results.items(): - if result == "deleted": - lines.append(f"✅ Bounty #{bounty_id} deleted.") - elif result == "not_found": - lines.append(f"⛔ Bounty #{bounty_id} not found.") - elif result == "permission_denied": - lines.append(f"⛔ Bounty #{bounty_id} - only admins can delete.") - - await update.message.reply_text("\n".join(lines)) + if success: + await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") + else: + await update.message.reply_text("Bounty not found.") async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/core/services.py b/core/services.py index 3ced3e5..2f727b4 100644 --- a/core/services.py +++ b/core/services.py @@ -210,31 +210,6 @@ class BountyService: self._storage.update_bounty(room_id, bounty) return True - def delete_bounties( - self, room_id: int, bounty_ids: list[int], user_id: int - ) -> dict[int, str]: - """Soft delete multiple bounties. Only admins can delete. - - Returns a dict mapping bounty_id to result: - - "deleted": Successfully soft-deleted - - "not_found": Bounty does not exist - - "permission_denied": User is not admin - """ - 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): - results[bounty_id] = "permission_denied" - continue - - bounty.deleted_at = int(time.time()) - self._storage.update_bounty(room_id, bounty) - results[bounty_id] = "deleted" - return results - class TrackingService: """Service for tracking bounty operations.""" diff --git a/tests/test_services.py b/tests/test_services.py index bd7c74b..8a92134 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -210,53 +210,6 @@ class TestBountyService: result = self.service.delete_bounty(-1001, 999, self.admin_user_id) assert result is False - def test_delete_bounties_multiple_success(self): - """Test delete_bounties soft deletes multiple bounties.""" - b1 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="First" - ) - b2 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="Second" - ) - b3 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="Third" - ) - results = self.service.delete_bounties( - -1001, [b1.id, b2.id, b3.id], self.admin_user_id - ) - assert results == {b1.id: "deleted", b2.id: "deleted", b3.id: "deleted"} - assert self.service.get_bounty(-1001, b1.id) is None - assert self.service.get_bounty(-1001, b2.id) is None - assert self.service.get_bounty(-1001, b3.id) is None - - def test_delete_bounties_mixed_results(self): - """Test delete_bounties returns individual results per ID.""" - b1 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="Exists" - ) - results = self.service.delete_bounties( - -1001, [b1.id, 999, 888], self.admin_user_id - ) - assert results == {b1.id: "deleted", 999: "not_found", 888: "not_found"} - - def test_delete_bounties_permission_denied(self): - """Test delete_bounties returns permission_denied for non-admin.""" - b1 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="First" - ) - b2 = self.service.add_bounty( - room_id=-1001, user_id=self.admin_user_id, text="Second" - ) - results = self.service.delete_bounties( - -1001, - [b1.id, b2.id], - 999, # non-admin user - ) - assert results == {b1.id: "permission_denied", b2.id: "permission_denied"} - # Bounties should not be deleted - assert self.service.get_bounty(-1001, b1.id) is not None - assert self.service.get_bounty(-1001, b2.id) is not None - class TestTrackingService: """Unit tests for TrackingService."""