"""Telegram command handlers for JIGAIDO - Thin wrappers around core services.""" import time from functools import wraps from typing import Optional import dateparser from telegram import Update from telegram.ext import ContextTypes from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage from core.services import BountyService, TrackingService ROOM_STORAGE = JsonFileRoomStorage() TRACKING_STORAGE = JsonFileTrackingStorage() BOUNTY_SERVICE = BountyService(ROOM_STORAGE) TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE) TELEGRAM_BOT_USERNAME = "your_bot_username" def extract_args(text: str) -> list[str]: if not text: return [] tokens = text.strip().split() return tokens[1:] if len(tokens) > 1 else [] def parse_args( args: list[str], ) -> tuple[Optional[str], Optional[str], Optional[int], bool, bool]: text = None link = None due_date_ts = None clear_link = False clear_date = False i = 0 while i < len(args): arg = args[i] 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 i += 1 elif due_date_ts is None: parsed = dateparser.parse(arg) if parsed: due_date_ts = int(parsed.timestamp()) i += 1 else: i += 1 if text is None: text = arg else: text = text + " " + arg else: i += 1 if text is None: text = arg else: text = text + " " + arg return text, link, due_date_ts, clear_link, clear_date def format_bounty(b, show_id: bool = True, slice_length: int = 0) -> str: parts = [] if show_id: parts.append(f"[#{b.id}]") if b.text: text = b.text if slice_length > 0 and len(text) > slice_length: text = text[:slice_length] + "..." parts.append(text) if b.link: parts.append(f"🔗 {b.link}") if b.due_date_ts: due_str = time.strftime("%d %b %Y", time.localtime(b.due_date_ts)) days_left = (b.due_date_ts - int(time.time())) // 86400 if days_left < 0: parts.append(f"⏰ {due_str} (OVERDUE)") elif days_left == 0: parts.append(f"⏰ Today (OVERDUE)") else: parts.append(f"⏰ {due_str} ({days_left}d)") if b.created_by_user_id: parts.append(f"by {b.created_by_user_id}") return " | ".join(parts) def is_group(update: Update) -> bool: return update.effective_chat.type != "private" def get_group_id(update: Update) -> int: return update.effective_chat.id def get_user_id(update: Update) -> int: return update.effective_user.id def get_room_id(update: Update) -> int: """Get room_id for the current context. For groups: negative group_id For DMs: positive user_id """ if is_group(update): return get_group_id(update) return get_user_id(update) async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: room_id = get_room_id(update) args = extract_args(update.message.text) show_all = "all" in args args = [a for a in args if a != "all"] try: limit = int(args[0]) if args else 5 except (ValueError, IndexError): limit = 5 now = int(time.time()) cutoff_24h = now - 86400 all_bounties = BOUNTY_SERVICE.list_bounties(room_id) def is_expired(b) -> bool: return b.due_date_ts is not None and b.due_date_ts < cutoff_24h def sort_key(b): if b.due_date_ts is not None: return (0, b.due_date_ts) return (1, b.created_at) filtered_bounties = [b for b in all_bounties if not is_expired(b) or show_all] filtered_bounties.sort(key=sort_key) total_count = len(filtered_bounties) displayed_bounties = filtered_bounties[:limit] if not displayed_bounties: if show_all: await update.message.reply_text("No bounties yet.") else: await update.message.reply_text( "No active bounties. Use /bounty all to show expired." ) return lines = [] if limit < total_count: lines.append(f"Showing {limit} of {total_count} bounties:") slice_length = 40 elif show_all and total_count > limit: lines.append(f"Showing {limit} of {total_count} bounties (including expired):") slice_length = 40 else: lines.append(f"Showing {total_count} bounties:") slice_length = 0 for b in displayed_bounties: lines.append(format_bounty(b, show_id=True, slice_length=slice_length)) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: user_id = get_user_id(update) 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) if not bounties: msg = ( "You are not tracking any bounties." if is_group(update) else "No personal bounties." ) await update.message.reply_text(msg) return lines = [format_bounty(b, show_id=True) for b in bounties] await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) 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" ) return text, link, due_date_ts, _, _ = parse_args(args) if not text and not link: await update.message.reply_text("A bounty needs at least text or a link.") return 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, ) due_str = "" if due_date_ts: due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" await update.message.reply_text( f"✅ Bounty added (#{bounty.id}){due_str}", disable_web_page_preview=True, ) async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) if len(args) < 1: await update.message.reply_text( "Usage: /update [text] [link] [due_date]\n" " /update -link [] - clear or set link\n" " /update -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 try: bounty_id = int(args[0]) except ValueError: await update.message.reply_text("Invalid bounty ID.") return text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:]) if ( 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.") return user_id = get_user_id(update) room_id = get_room_id(update) try: success = BOUNTY_SERVICE.update_bounty( room_id=room_id, bounty_id=bounty_id, user_id=user_id, text=text, link=link, due_date_ts=due_date_ts, clear_link=clear_link, clear_due=clear_date, ) except PermissionError as e: await update.message.reply_text(f"⛔ {e}") return except ValueError as e: await update.message.reply_text(f"⛔ {e}") return if success: await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") else: await update.message.reply_text("Bounty not found.") 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 ") return try: bounty_id = int(args[0]) except ValueError: await update.message.reply_text("Invalid bounty ID.") 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 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: if not is_group(update): await update.message.reply_text("⛔ /track is only available in groups.") return args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /track ") return try: bounty_id = int(args[0]) except ValueError: await update.message.reply_text("Invalid bounty ID.") return user_id = get_user_id(update) room_id = get_room_id(update) try: if TRACKING_SERVICE.track_bounty(room_id, user_id, bounty_id): await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") else: await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") except ValueError as e: await update.message.reply_text(str(e)) async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if not is_group(update): await update.message.reply_text("⛔ /untrack is only available in groups.") return args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /untrack ") return try: bounty_id = int(args[0]) except ValueError: await update.message.reply_text("Invalid bounty ID.") return user_id = get_user_id(update) room_id = get_room_id(update) if TRACKING_SERVICE.untrack_bounty(room_id, user_id, bounty_id): await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") else: await update.message.reply_text("Not tracking bounty #{bounty_id}.") async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if is_group(update): await update.message.reply_text( "👻 JIGAIDO is watching.\n\n" "This group's bounty list is now active.\n" "/bounty — list bounties\n" "/add — create a bounty\n" "/track — track a bounty\n" "/my — your tracked bounties" ) else: await update.message.reply_text( "👻 JIGAIDO activated.\n\n" "Personal bounty list ready.\n" "/bounty — list your bounties\n" "/add — create a bounty\n" "/my — your tracked bounties" ) 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 ") 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}") if bounty.link: lines.append(f"🔗 {bounty.link}") if bounty.due_date_ts: due_str = time.strftime("%d %B %Y %H:%M", time.localtime(bounty.due_date_ts)) lines.append(f"📅 {due_str} ({timezone})") username = bounty.created_by_username or f"user#{bounty.created_by_user_id}" lines.append(f"👤 @{username}") created_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(bounty.created_at)) lines.append(f"📌 Created: {created_str}") await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) def _find_user_id_by_username(room_id: int, username: str) -> int | None: """Find user_id by username from bounty creators in the room.""" bounties = BOUNTY_SERVICE.list_bounties(room_id) for bounty in bounties: if ( bounty.created_by_username and bounty.created_by_username.lower() == username.lower() ): return bounty.created_by_user_id return None async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: args = extract_args(update.message.text) if not args or args[0] not in ("add", "remove"): await update.message.reply_text( "Usage:\n" "/admin add @username — add admin\n" "/admin remove @username — remove admin" ) return subcommand = args[0] if len(args) < 2: await update.message.reply_text(f"Usage: /admin {subcommand} @username") return raw_username = args[1] if not raw_username.startswith("@"): await update.message.reply_text(f"Usage: /admin {subcommand} @username") return username = raw_username[1:] user_id = get_user_id(update) room_id = get_room_id(update) if not BOUNTY_SERVICE.is_admin(room_id, user_id): await update.message.reply_text("⛔ Only admins can perform this action.") return target_user_id = _find_user_id_by_username(room_id, username) if target_user_id is None: await update.message.reply_text(f"⛔ User @{username} not found.") return try: if subcommand == "add": BOUNTY_SERVICE.add_admin(room_id, target_user_id, user_id) await update.message.reply_text(f"✅ @{username} is now an admin.") elif subcommand == "remove": BOUNTY_SERVICE.remove_admin(room_id, target_user_id, user_id) await update.message.reply_text(f"✅ @{username} is no longer an admin.") except PermissionError as e: await update.message.reply_text(f"⛔ {e}") async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text( "👻 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" "/edit [text] [link] [due] — edit bounty (same as update)\n" " /edit -link [] — clear or set link\n" " /edit -date [] — clear or set date\n" "/delete — delete bounty (admin only)\n" "/track — track a bounty (groups only)\n" "/untrack — stop tracking (groups only)\n" "/show — show bounty details\n" "/start — re-initialize\n" "/help — this message", disable_web_page_preview=True, )