From 6fb4b38c66b84f71ef5751094247a86081319aac Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sat, 4 Apr 2026 05:44:27 +0000 Subject: [PATCH] feat(/add): time parsing, link uniqueness, admin-only - Add time parsing (HH:MM format) after date Example: /add Fix bug https://github.com/foo/bar april 15 14:30 - Update check_link_unique to return conflicting bounty ID - Add_bounty now includes bounty ID in duplicate link error - cmd_add now catches PermissionError and displays admin-only message - Update usage text and help message - Fixes #45 --- apps/telegram-bot/commands.py | 63 +++++++++++++++++++++++++++-------- core/services.py | 28 ++++++++++------ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index b0ae05e..e1579b3 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -32,22 +32,52 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[ due_date_ts = None remaining = [] - for arg in args: + i = 0 + while i < len(args): + arg = args[i] if not link and (arg.startswith("http://") or arg.startswith("https://")): link = arg elif due_date_ts is None: parsed = dateparser.parse(arg) if parsed: 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: remaining.append(arg) else: remaining.append(arg) + i += 1 text = " ".join(remaining) if remaining else None return text, link, due_date_ts +def _is_time_format(s: str) -> bool: + """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) -> str: parts = [] if show_id: @@ -132,8 +162,8 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) if not args: await update.message.reply_text( - "Usage: /add [link] [due_date]\n" - "Example: /add Fix the bug https://github.com/foo/bar tomorrow" + "Usage: /add [link] [date] [time]\n" + "Example: /add Fix bug https://github.com/foo/bar april 15 14:30" ) return @@ -145,13 +175,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: @@ -316,9 +353,9 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "👻 JIGAIDO Commands:\n\n" "/bounty — list all bounties\n" "/my — bounties you're tracking\n" - "/add [link] [due] — add bounty\n" - "/update [text> [link] [due] — update bounty\n" - "/delete — delete bounty\n" + "/add [link] [date] [time] — add bounty (admin only)\n" + "/update [text] [link] [due] — update bounty (admin only)\n" + "/delete — delete bounty (admin only)\n" "/track — track a bounty (groups only)\n" "/untrack — stop tracking (groups only)\n" "/start — re-initialize\n" diff --git a/core/services.py b/core/services.py index 2f727b4..2f625af 100644 --- a/core/services.py +++ b/core/services.py @@ -96,21 +96,24 @@ class BountyService: def check_link_unique( self, room_id: int, link: str | None, exclude_bounty_id: int | None = None - ) -> bool: - """Check if a link is unique within a room (not used by another bounty).""" + ) -> int | None: + """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: - return True + return None room_data = self._storage.load(room_id) if room_data is None: - return True + return None for bounty in room_data.bounties: if bounty.deleted_at is not None: continue if bounty.link == link and bounty.id != exclude_bounty_id: - return False - return True + return bounty.id + return None def add_bounty( self, @@ -124,8 +127,11 @@ class BountyService: if not self.is_admin(room_id, user_id): raise PermissionError("Only admins can add bounties.") - if not self.check_link_unique(room_id, link): - raise ValueError("A bounty with this link already exists in this room.") + conflicting_id = self.check_link_unique(room_id, link) + if conflicting_id is not None: + raise ValueError( + f"A bounty with this link already exists: #{conflicting_id}" + ) room_data = self._storage.load(room_id) if room_data is None: @@ -178,8 +184,10 @@ class BountyService: if not self.is_admin(room_id, user_id): raise PermissionError("Only admins can edit bounties.") - if link and not self.check_link_unique( - room_id, link, exclude_bounty_id=bounty_id + if ( + link + 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.")