Compare commits

...

9 Commits

Author SHA1 Message Date
shokollm
2689ddfa8b Merge main into fix/issue-45-add-command-update: resolve conflict with room_id param 2026-04-04 07:39:36 +00:00
shokollm
c005ee341a Revert "Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main"
This reverts commit bd2627efe9, reversing
changes made to 42ed551554.
2026-04-04 07:24:03 +00:00
922858a81a Merge pull request 'feat: human-readable date format with timezone awareness' (#68) from fix/issue-54 into main 2026-04-04 09:20:43 +02:00
shokollm
f521a682c5 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
2026-04-04 07:19:18 +00:00
015df15bd5 Merge pull request 'feat: implement /timezone command to get/set room timezone' (#67) from feat/issue-53-timezone into main 2026-04-04 09:13:41 +02:00
shokollm
eed3ab33ae 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
2026-04-04 07:12:23 +00:00
bd2627efe9 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main 2026-04-04 08:54:55 +02:00
shokollm
8069ed6465 feat: add multi-ID delete support with per-ID results
- 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
2026-04-04 06:39:11 +00:00
shokollm
6fb4b38c66 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
2026-04-04 05:44:27 +00:00
3 changed files with 142 additions and 28 deletions

View File

@@ -14,6 +14,7 @@ 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,
@@ -41,6 +42,7 @@ 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))
@@ -56,6 +58,7 @@ 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,8 +1,10 @@
"""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
@@ -19,6 +21,34 @@ 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 []
@@ -32,23 +62,53 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[
due_date_ts = None due_date_ts = None
remaining = [] 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://")): 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 format_bounty(b, show_id: bool = True) -> str: 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, room_id: int | None = None) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b.id}]") parts.append(f"[#{b.id}]")
@@ -57,7 +117,11 @@ def format_bounty(b, show_id: bool = True) -> 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:
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 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)")
@@ -101,7 +165,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) 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) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -111,6 +175,7 @@ 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)
@@ -124,7 +189,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) 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) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -132,8 +197,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] [due_date]\n" "Usage: /add <text> [link] [date] [time]\n"
"Example: /add Fix the bug https://github.com/foo/bar tomorrow" "Example: /add Fix bug https://github.com/foo/bar april 15 14:30"
) )
return return
@@ -145,6 +210,7 @@ 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,
@@ -152,10 +218,17 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
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:
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( await update.message.reply_text(
f"✅ Bounty added (#{bounty.id}){due_str}", f"✅ Bounty added (#{bounty.id}){due_str}",
@@ -316,12 +389,42 @@ 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] [due] — add bounty\n" "/add <text> [link] [date] [time] — add bounty (admin only)\n"
"/update <id> [text> [link] [due] — update bounty\n" "/update <id> [text] [link] [due] — update bounty (admin only)\n"
"/delete <id> — delete bounty\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"
"/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

@@ -96,21 +96,24 @@ 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
) -> bool: ) -> int | None:
"""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 True return None
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 True return None
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 False return bounty.id
return True return None
def add_bounty( def add_bounty(
self, self,
@@ -124,8 +127,11 @@ 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.")
if not self.check_link_unique(room_id, link): conflicting_id = self.check_link_unique(room_id, link)
raise ValueError("A bounty with this link already exists in this room.") 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) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
@@ -178,8 +184,10 @@ 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 link and not self.check_link_unique( if (
room_id, link, exclude_bounty_id=bounty_id 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.") raise ValueError("A bounty with this link already exists in this room.")