Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
5e9ec3a5ec feat: add /admin command to list room admins
Implement /admin command as specified in issue #50:
- Lists all admin user IDs for the current room
- Output format: 'Room Admins:\n- @user1\n- @user2'
- Shows 'No admins configured for this room.' if none exist
- Available to everyone (no permission check needed)

Changes:
- Add _find_username_by_user_id helper function
- Update cmd_admin to support listing when called with no args
- Update /help to include /admin command

Closes #50
2026-04-04 13:07:52 +00:00
7 changed files with 105 additions and 605 deletions

View File

@@ -4,26 +4,16 @@ import logging
import os import os
import sys import sys
sys.path.insert(0, "/home/shoko/repositories/jigaido") from telegram.ext import Application, CommandHandler, MessageHandler, filters
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
filters,
)
from commands import ( from commands import (
cmd_add, cmd_add,
cmd_admin, cmd_admin,
cmd_bounty, cmd_bounty,
cmd_delete, cmd_delete,
cmd_delete_message,
cmd_edit, cmd_edit,
cmd_help, cmd_help,
cmd_my, cmd_my,
cmd_recover,
cmd_show, cmd_show,
cmd_start, cmd_start,
cmd_timezone, cmd_timezone,
@@ -38,13 +28,7 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from config import config BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
BOT_TOKEN = config.bot_token or ""
async def error_handler(update, context):
log.error(f"Error: {context.error}")
def build_app() -> Application: def build_app() -> Application:
@@ -63,11 +47,8 @@ def build_app() -> Application:
app.add_handler(CommandHandler("show", cmd_show)) app.add_handler(CommandHandler("show", cmd_show))
app.add_handler(CommandHandler("timezone", cmd_timezone)) app.add_handler(CommandHandler("timezone", cmd_timezone))
app.add_handler(CommandHandler("admin", cmd_admin)) app.add_handler(CommandHandler("admin", cmd_admin))
app.add_handler(CommandHandler("recover", cmd_recover))
app.add_handler(CallbackQueryHandler(cmd_delete_message)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
app.add_error_handler(error_handler)
return app return app
@@ -84,15 +65,12 @@ async def post_init(app: Application) -> None:
("show", "Show bounty details"), ("show", "Show bounty details"),
("timezone", "Get/set room timezone"), ("timezone", "Get/set room timezone"),
("admin", "Manage admins"), ("admin", "Manage admins"),
("recover", "Recover deleted bounties"),
("help", "Show help"), ("help", "Show help"),
] ]
) )
def main() -> None: def main() -> None:
import asyncio
if not BOT_TOKEN: if not BOT_TOKEN:
log.error("JIGAIDO_BOT_TOKEN environment variable not set.") log.error("JIGAIDO_BOT_TOKEN environment variable not set.")
sys.exit(1) sys.exit(1)
@@ -101,11 +79,6 @@ def main() -> None:
app.post_init = post_init app.post_init = post_init
log.info("JIGAIDO starting...") log.info("JIGAIDO starting...")
# Python 3.14 compatibility: ensure event loop exists
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
app.run_polling(drop_pending_updates=True) app.run_polling(drop_pending_updates=True)

View File

@@ -1,7 +1,6 @@
"""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, timezone
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -9,8 +8,6 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateparser import dateparser
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ParseMode
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.services import BountyService, TrackingService from core.services import BountyService, TrackingService
@@ -23,34 +20,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 []
@@ -60,7 +29,6 @@ def extract_args(text: str) -> list[str]:
def parse_args( def parse_args(
args: list[str], args: list[str],
timezone_str: str = "UTC",
) -> tuple[Optional[str], Optional[str], Optional[int], bool, bool]: ) -> tuple[Optional[str], Optional[str], Optional[int], bool, bool]:
text = None text = None
link = None link = None
@@ -68,77 +36,38 @@ def parse_args(
clear_link = False clear_link = False
clear_date = False clear_date = False
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
def is_url(s: str) -> bool:
if not s:
return False
if s.startswith("http://") or s.startswith("https://"):
return True
if "." in s and " " not in s:
return True
return False
def is_time(s: str) -> bool:
if not s or ":" not in s:
return False
parts = s.split(":")
if len(parts) != 2:
return False
try:
h, m = int(parts[0]), int(parts[1])
return 0 <= h <= 23 and 0 <= m <= 59
except ValueError:
return False
def parse_date_with_tz(date_str: str) -> int | None:
parsed = dateparser.parse(date_str)
if parsed:
localized = parsed.replace(tzinfo=tz)
return int(localized.timestamp())
return None
i = 0 i = 0
while i < len(args): while i < len(args):
arg = args[i] arg = args[i]
if arg == "-link": if arg == "-link":
if i + 1 < len(args) and not args[i + 1].startswith("-"): if i + 1 < len(args) and (
args[i + 1].startswith("http://") or args[i + 1].startswith("https://")
):
link = args[i + 1] link = args[i + 1]
i += 2 i += 2
else: else:
clear_link = True clear_link = True
i += 1 i += 1
elif arg == "-date": elif arg == "-date":
if i + 1 < len(args) and not args[i + 1].startswith("-"): if i + 1 < len(args):
due_date_ts = parse_date_with_tz(args[i + 1]) parsed = dateparser.parse(args[i + 1])
if due_date_ts is not None: if parsed:
due_date_ts = int(parsed.timestamp())
i += 2 i += 2
if i < len(args) and is_time(args[i]):
time_parts = args[i].split(":")
due_date_ts += (
int(time_parts[0]) * 3600 + int(time_parts[1]) * 60
)
i += 1
else: else:
clear_date = True clear_date = True
i += 1 i += 1
else: else:
clear_date = True clear_date = True
i += 1 i += 1
elif not link and is_url(arg): elif not link and (arg.startswith("http://") or arg.startswith("https://")):
link = arg link = arg
i += 1 i += 1
elif due_date_ts is None: elif due_date_ts is None:
due_date_ts = parse_date_with_tz(arg) parsed = dateparser.parse(arg)
if due_date_ts is not None: if parsed:
i += 1 due_date_ts = int(parsed.timestamp())
if i < len(args) and is_time(args[i]):
time_parts = args[i].split(":")
due_date_ts += int(time_parts[0]) * 3600 + int(time_parts[1]) * 60
i += 1 i += 1
else: else:
i += 1 i += 1
@@ -156,9 +85,7 @@ def parse_args(
return text, link, due_date_ts, clear_link, clear_date return text, link, due_date_ts, clear_link, clear_date
def format_bounty( def format_bounty(b, show_id: bool = True, slice_length: int = 0) -> str:
b, show_id: bool = True, slice_length: int = 0, timezone_str: str = "UTC"
) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b.id}]") parts.append(f"[#{b.id}]")
@@ -170,64 +97,17 @@ def format_bounty(
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:
try: due_str = time.strftime("%d %b %Y", time.localtime(b.due_date_ts))
tz = ZoneInfo(timezone_str) days_left = (b.due_date_ts - int(time.time())) // 86400
except (KeyError, ZoneInfoNotFoundError): if days_left < 0:
tz = ZoneInfo("UTC")
now_utc = int(time.time())
dt_now = datetime.now(tz)
dt_due = datetime.fromtimestamp(b.due_date_ts, tz=tz)
seconds_left = int((dt_due - dt_now).total_seconds())
hours_left = seconds_left // 3600
days_left = seconds_left // 86400
due_str = dt_due.strftime("%d %b %Y %H:%M")
if seconds_left < 0:
parts.append(f"{due_str} (OVERDUE)") parts.append(f"{due_str} (OVERDUE)")
elif hours_left < 48: elif days_left == 0:
if hours_left < 1: parts.append(f"⏰ Today (OVERDUE)")
minutes_left = seconds_left // 60
parts.append(f"{due_str} ({minutes_left}m)")
else:
parts.append(f"{due_str} ({hours_left}h)")
else: else:
parts.append(f"{due_str} ({days_left}d)") parts.append(f"{due_str} ({days_left}d)")
return " | ".join(parts) return " | ".join(parts)
async def cmd_delete_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if not query:
return
data = query.data
if not data.startswith("del_msg:"):
return
parts = data.split(":")
if len(parts) != 2:
return
try:
expected_user_id = int(parts[1])
except ValueError:
return
user_id = get_user_id(update)
if user_id != expected_user_id:
await query.answer("You can't delete this message", show_alert=True)
return
try:
await query.message.delete()
await query.answer("Deleted")
except Exception as e:
await query.answer(f"Could not delete: {e}", show_alert=True)
def is_group(update: Update) -> bool: def is_group(update: Update) -> bool:
return update.effective_chat.type != "private" return update.effective_chat.type != "private"
@@ -253,8 +133,6 @@ def get_room_id(update: Update) -> int:
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update) room_id = get_room_id(update)
user_id = get_user_id(update)
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
args = extract_args(update.message.text) args = extract_args(update.message.text)
show_all = "all" in args show_all = "all" in args
@@ -305,17 +183,9 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
slice_length = 0 slice_length = 0
for b in displayed_bounties: for b in displayed_bounties:
lines.append( lines.append(format_bounty(b, show_id=True, slice_length=slice_length))
format_bounty(
b, show_id=True, slice_length=slice_length, timezone_str=timezone_str
)
)
keyboard = [[InlineKeyboardButton("🗑️ Delete", callback_data=f"del_msg:{user_id}")]] await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
"\n".join(lines), disable_web_page_preview=True, reply_markup=reply_markup
)
async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -329,8 +199,6 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
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)
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
if not bounties: if not bounties:
msg = ( msg = (
"You are not tracking any bounties." "You are not tracking any bounties."
@@ -340,9 +208,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 = [ lines = [format_bounty(b, show_id=True) for b in bounties]
format_bounty(b, show_id=True, timezone_str=timezone_str) 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)
@@ -355,18 +221,14 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
return return
user_id = get_user_id(update) text, link, due_date_ts, _, _ = parse_args(args)
room_id = get_room_id(update)
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
effective_user = update.effective_user
username = effective_user.username or effective_user.first_name or None
text, link, due_date_ts, _, _ = parse_args(args, timezone_str)
if not text and not link: if not text and not link:
await update.message.reply_text("A bounty needs at least text or a link.") await update.message.reply_text("A bounty needs at least text or a link.")
return return
user_id = get_user_id(update)
room_id = get_room_id(update)
try: try:
bounty = BOUNTY_SERVICE.add_bounty( bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id, room_id=room_id,
@@ -374,7 +236,6 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
text=text, text=text,
link=link, link=link,
due_date_ts=due_date_ts, due_date_ts=due_date_ts,
created_by_username=username,
) )
except PermissionError as e: except PermissionError as e:
await update.message.reply_text(f"{e}") await update.message.reply_text(f"{e}")
@@ -385,8 +246,7 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
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}",
disable_web_page_preview=True, disable_web_page_preview=True,
@@ -414,16 +274,7 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
user_id = get_user_id(update) text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:])
room_id = get_room_id(update)
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:], timezone_str)
if ( if (
not text not text
and not link and not link
@@ -434,10 +285,8 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Nothing to update.") await update.message.reply_text("Nothing to update.")
return return
old_bounty = BOUNTY_SERVICE.get_bounty(room_id, bounty_id) user_id = get_user_id(update)
if not old_bounty: room_id = get_room_id(update)
await update.message.reply_text("Bounty not found.")
return
try: try:
success = BOUNTY_SERVICE.update_bounty( success = BOUNTY_SERVICE.update_bounty(
@@ -458,37 +307,6 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
if success: if success:
changes = []
if text is not None:
old_text = old_bounty.text or "(none)"
changes.append(f"Text: {old_text}{text}")
if link is not None:
old_link = old_bounty.link or "(none)"
changes.append(f"Link: {old_link}{link}")
if due_date_ts is not None:
old_date = (
format_due_date(old_bounty.due_date_ts, timezone_str)
if old_bounty.due_date_ts
else "(none)"
)
new_date = format_due_date(due_date_ts, timezone_str)
changes.append(f"Date: {old_date}{new_date}")
if clear_link:
old_link = old_bounty.link or "(none)"
changes.append(f"Link: {old_link} → (cleared)")
if clear_date:
old_date = (
format_due_date(old_bounty.due_date_ts, timezone_str)
if old_bounty.due_date_ts
else "(none)"
)
changes.append(f"Date: {old_date} → (cleared)")
if changes:
await update.message.reply_text(
f"✅ Bounty #{bounty_id} updated:\n" + "\n".join(changes)
)
else:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
else: else:
await update.message.reply_text("Bounty not found.") await update.message.reply_text("Bounty not found.")
@@ -500,11 +318,11 @@ cmd_edit = cmd_update
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_delete(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("Usage: /delete <bounty_id> [bounty_id ...]") await update.message.reply_text("Usage: /delete <bounty_id>")
return return
try: try:
bounty_ids = [int(arg) for arg in args] bounty_id = int(args[0])
except ValueError: except ValueError:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
@@ -513,25 +331,19 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update) room_id = get_room_id(update)
try: try:
results = BOUNTY_SERVICE.delete_bounties( success = BOUNTY_SERVICE.delete_bounty(
room_id=room_id, room_id=room_id,
bounty_ids=bounty_ids, bounty_id=bounty_id,
user_id=user_id, user_id=user_id,
) )
except PermissionError as e: except PermissionError as e:
await update.message.reply_text(f"{e}") await update.message.reply_text(f"{e}")
return return
response_lines = [] if success:
for bounty_id, result in results.items(): await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
if result == "deleted": else:
response_lines.append(f"Bounty #{bounty_id} deleted.") await update.message.reply_text("Bounty not found.")
elif result == "not_found":
response_lines.append(f"⛔ Bounty #{bounty_id} not found.")
elif result == "permission_denied":
response_lines.append(f"⛔ Bounty #{bounty_id} permission denied.")
await update.message.reply_text("\n".join(response_lines))
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -588,29 +400,7 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): if is_group(update):
try:
chat_member = await ctx.bot.get_chat_member(room_id, user_id)
if chat_member.status == "creator" and not BOUNTY_SERVICE.is_admin(
room_id, user_id
):
BOUNTY_SERVICE.add_admin(room_id, user_id, user_id)
await update.message.reply_text(
"👻 JIGAIDO is watching.\n\n"
"This group's bounty list is now active.\n"
"You are now an admin (group creator).\n"
"/bounty — list bounties\n"
"/add — create a bounty\n"
"/track — track a bounty\n"
"/my — your tracked bounties"
)
return
except Exception:
pass
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO is watching.\n\n" "👻 JIGAIDO is watching.\n\n"
"This group's bounty list is now active.\n" "This group's bounty list is now active.\n"
@@ -620,9 +410,6 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"/my — your tracked bounties" "/my — your tracked bounties"
) )
else: else:
if not BOUNTY_SERVICE.is_admin(room_id, user_id):
BOUNTY_SERVICE.add_admin(room_id, user_id, user_id)
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO activated.\n\n" "👻 JIGAIDO activated.\n\n"
"Personal bounty list ready.\n" "Personal bounty list ready.\n"
@@ -651,12 +438,7 @@ async def cmd_show(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Bounty not found.") await update.message.reply_text("Bounty not found.")
return return
timezone_str = BOUNTY_SERVICE.get_timezone(room_id) timezone = BOUNTY_SERVICE.get_timezone(room_id)
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
lines = [] lines = []
@@ -667,22 +449,16 @@ async def cmd_show(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
lines.append(f"🔗 {bounty.link}") lines.append(f"🔗 {bounty.link}")
if bounty.due_date_ts: if bounty.due_date_ts:
dt_due = datetime.fromtimestamp(bounty.due_date_ts, tz=tz) due_str = time.strftime("%d %B %Y %H:%M", time.localtime(bounty.due_date_ts))
due_str = dt_due.strftime("%d %B %Y %H:%M") lines.append(f"📅 {due_str} ({timezone})")
lines.append(f"📅 {due_str} ({timezone_str})")
if bounty.created_by_username: username = bounty.created_by_username or f"user#{bounty.created_by_user_id}"
lines.append( lines.append(f"👤 @{username}")
f'👤 <a href="tg://user?id={bounty.created_by_user_id}">{bounty.created_by_username}</a>'
)
dt_created = datetime.fromtimestamp(bounty.created_at, tz=tz) created_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(bounty.created_at))
created_str = dt_created.strftime("%Y-%m-%d %H:%M")
lines.append(f"📌 Created: {created_str}") lines.append(f"📌 Created: {created_str}")
await update.message.reply_text( await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
"\n".join(lines), disable_web_page_preview=True, parse_mode=ParseMode.HTML
)
async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -714,76 +490,15 @@ async def cmd_timezone(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(f"✅ Timezone set to {timezone_str}.") await update.message.reply_text(f"✅ Timezone set to {timezone_str}.")
async def cmd_recover(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: def _find_user_id_by_username(room_id: int, username: str) -> int | None:
args = extract_args(update.message.text) """Find user_id by username from bounty creators in the room."""
room_id = get_room_id(update) bounties = BOUNTY_SERVICE.list_bounties(room_id)
user_id = get_user_id(update) for bounty in bounties:
if (
if not BOUNTY_SERVICE.is_admin(room_id, user_id): bounty.created_by_username
await update.message.reply_text("⛔ Only admins can perform this action.") and bounty.created_by_username.lower() == username.lower()
return ):
return bounty.created_by_user_id
if not args:
deleted_bounties = BOUNTY_SERVICE.list_deleted_bounties(room_id)
if not deleted_bounties:
await update.message.reply_text("No recoverable bounties.")
return
deleted_bounties.sort(key=lambda b: b.deleted_at or 0, reverse=True)
lines = ["Recoverable bounties:"]
for b in deleted_bounties[:10]:
text = (
b.text[:40] + "..."
if b.text and len(b.text) > 40
else (b.text or "(no text)")
)
link_str = f" | 🔗 {b.link}" if b.link else ""
deleted_str = (
time.strftime("%d %b %Y", time.localtime(b.deleted_at))
if b.deleted_at
else "unknown"
)
lines.append(f"[#{b.id}] {text}{link_str} | 🗑️ Deleted {deleted_str}")
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
return
try:
bounty_ids = [int(arg) for arg in args]
except ValueError:
await update.message.reply_text("Invalid bounty ID.")
return
results = BOUNTY_SERVICE.recover_bounties(room_id, bounty_ids, user_id)
response_lines = []
for bounty_id, result in results.items():
if result == "recovered":
response_lines.append(f"✅ Recovered bounty #{bounty_id}.")
elif result == "not_found":
response_lines.append(f"⛔ Bounty #{bounty_id} not found.")
elif result == "not_deleted":
response_lines.append(f"⛔ Bounty #{bounty_id} is not deleted.")
elif result == "permission_denied":
response_lines.append(f"⛔ Bounty #{bounty_id} permission denied.")
await update.message.reply_text("\n".join(response_lines))
async def _find_user_id_by_username(
ctx: ContextTypes.DEFAULT_TYPE, username: str
) -> int | None:
"""Find user_id by username using Telegram API."""
import logging
log = logging.getLogger(__name__)
try:
chat = await ctx.bot.get_chat(f"@{username}")
log.info(f"Found user {username}: {chat.id}")
return chat.id
except Exception as e:
log.error(f"Failed to find user @{username}: {e}")
return None return None
@@ -798,26 +513,25 @@ def _find_username_by_user_id(room_id: int, user_id: int) -> str | None:
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args:
admins = BOUNTY_SERVICE.list_admins(get_room_id(update)) # Handle /admin with no subcommand (list admins)
if not admins: if not args or args[0] == "list":
await update.message.reply_text("No admins in this room.") room_id = get_room_id(update)
admin_ids = BOUNTY_SERVICE.list_admins(room_id)
if not admin_ids:
await update.message.reply_text("No admins configured for this room.")
return return
admin_mentions = []
for admin_id in admins: lines = ["Room Admins:"]
username = _find_username_by_user_id(get_room_id(update), admin_id) for admin_id in admin_ids:
username = _find_username_by_user_id(room_id, admin_id)
if username: if username:
admin_mentions.append( lines.append(f"- @{username}")
f'<a href="tg://user?id={admin_id}">@{username}</a>'
)
else: else:
admin_mentions.append( lines.append(f"- user#{admin_id}")
f'<a href="tg://user?id={admin_id}">{admin_id}</a>'
) await update.message.reply_text("\n".join(lines))
await update.message.reply_text(
f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions),
parse_mode=ParseMode.HTML,
)
return return
if args[0] not in ("add", "remove"): if args[0] not in ("add", "remove"):
@@ -849,7 +563,7 @@ async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("⛔ Only admins can perform this action.") await update.message.reply_text("⛔ Only admins can perform this action.")
return return
target_user_id = await _find_user_id_by_username(ctx, username) target_user_id = _find_user_id_by_username(room_id, username)
if target_user_id is None: if target_user_id is None:
await update.message.reply_text(f"⛔ User @{username} not found.") await update.message.reply_text(f"⛔ User @{username} not found.")
@@ -867,43 +581,25 @@ async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_id = get_user_id(update) await update.message.reply_text(
room_id = get_room_id(update) "👻 JIGAIDO Commands:\n\n"
is_admin = BOUNTY_SERVICE.is_admin(room_id, user_id) "/bounty — list all bounties\n"
"/my — bounties you're tracking\n"
if is_admin: "/add <text> [link] [due] — add bounty\n"
lines = [ "/update <id> [text] [link] [due] — update bounty\n"
"👻 JIGAIDO Commands (Admin):\n", "/edit <id> [text] [link] [due] — edit bounty (same as update)\n"
"📋 Bounty Management:", " /edit <id> -link [<url>] — clear or set link\n"
"/bounty — list bounties", " /edit <id> -date [<date>] — clear or set date\n"
"/add — add bounty", "/delete <id> — delete bounty (admin only)\n"
"/edit — edit bounty", "/track <id> — track a bounty (groups only)\n"
"/delete — delete bounty", "/untrack <id> — stop tracking (groups only)\n"
"/recover — recover deleted bounties", "/show <id> — show bounty details\n"
"", "/timezone — get room timezone\n"
"🔗 Tracking:", "/timezone <tz> — set room timezone (admin only)\n"
"/track — track bounty", "/admin — list room admins\n"
"/untrack — stop tracking", "/admin add @username — add admin (admin only)\n"
"/my — your tracked bounties", "/admin remove @username — remove admin (admin only)\n"
"/show — show bounty details", "/start — re-initialize\n"
"", "/help — this message",
"⚙️ Room Management:", disable_web_page_preview=True,
"/admin — manage admins", )
"/timezone — get/set timezone",
"",
"/start — re-initialize",
"/help — show this message",
]
else:
lines = [
"👻 JIGAIDO Commands:\n",
"/bounty — list bounties",
"/track — track bounty",
"/untrack — stop tracking",
"/my — your tracked bounties",
"/show — show bounty details",
"/start — re-initialize",
"/help — show this message",
]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env python3
import asyncio
import os
import sys
# Run from the telegram-bot directory so local imports work
os.chdir("/home/shoko/repositories/jigaido/apps/telegram-bot")
sys.path.insert(0, "/home/shoko/repositories/jigaido")
# Import main from the local bot module
import bot as bot_module
if __name__ == "__main__":
if not bot_module.BOT_TOKEN:
bot_module.log.error("JIGAIDO_BOT_TOKEN environment variable not set.")
sys.exit(1)
app = bot_module.build_app()
app.post_init = bot_module.post_init
bot_module.log.info("JIGAIDO starting...")
# PTB v20+ app.run_polling() is async - use asyncio.get_event_loop() + run_until_complete
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.run_polling(drop_pending_updates=True))
finally:
loop.close()

View File

@@ -13,21 +13,7 @@ class Config:
def __init__(self): def __init__(self):
self.data_dir: Path = self._resolve_data_dir() self.data_dir: Path = self._resolve_data_dir()
self.bot_token: Optional[str] = self._resolve_bot_token() self.bot_token: Optional[str] = os.environ.get("JIGAIDO_BOT_TOKEN")
def _resolve_bot_token(self) -> Optional[str]:
env_token = os.environ.get("JIGAIDO_BOT_TOKEN")
if env_token:
return env_token
config_file = Path("~/.jigaido/config.json").expanduser()
if config_file.exists():
with open(config_file) as f:
config_data = json.load(f)
if "JIGAIDO_BOT_TOKEN" in config_data:
return config_data["JIGAIDO_BOT_TOKEN"]
return None
def _resolve_data_dir(self) -> Path: def _resolve_data_dir(self) -> Path:
env_dir = os.environ.get("JIGAIDO_DATA_DIR") env_dir = os.environ.get("JIGAIDO_DATA_DIR")
@@ -49,6 +35,3 @@ class Config:
config = Config() config = Config()
config = Config()

View File

@@ -35,23 +35,18 @@ class BountyService:
def add_admin( def add_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int self, room_id: int, admin_user_id: int, requesting_user_id: int
) -> None: ) -> None:
"""Add an admin to a room. Requires admin permission, or self-promotion if first admin.""" """Add an admin to a room. Requires admin permission."""
room_data = self._storage.load(room_id)
has_no_admins = room_data is None or not room_data.admin_user_ids
is_self_promotion = requesting_user_id == admin_user_id
if not self.is_admin(room_id, requesting_user_id): if not self.is_admin(room_id, requesting_user_id):
if not (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.") raise PermissionError("Only admins can add admins.")
if room_data is None or room_data.admin_user_ids is None: room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData( room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[] room_id=room_id, bounties=[], next_id=1, admin_user_ids=[]
) )
if admin_user_id not in (room_data.admin_user_ids or []): if admin_user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids.append(admin_user_id) (room_data.admin_user_ids or []).append(admin_user_id)
self._storage.save(room_data) self._storage.save(room_data)
def remove_admin( def remove_admin(
@@ -124,7 +119,6 @@ class BountyService:
text: Optional[str] = None, text: Optional[str] = None,
link: Optional[str] = None, link: Optional[str] = None,
due_date_ts: Optional[int] = None, due_date_ts: Optional[int] = None,
created_by_username: Optional[str] = None,
) -> Bounty: ) -> Bounty:
"""Add a new bounty to the room. Requires admin permission.""" """Add a new bounty to the room. Requires admin permission."""
if not self.is_admin(room_id, user_id): if not self.is_admin(room_id, user_id):
@@ -142,7 +136,6 @@ class BountyService:
bounty = Bounty( bounty = Bounty(
id=room_data.next_id, id=room_data.next_id,
created_by_user_id=user_id, created_by_user_id=user_id,
created_by_username=created_by_username,
text=text, text=text,
link=link, link=link,
due_date_ts=due_date_ts, due_date_ts=due_date_ts,
@@ -160,44 +153,6 @@ class BountyService:
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True) all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
return [b for b in all_bounties if b.deleted_at is not None] return [b for b in all_bounties if b.deleted_at is not None]
def get_deleted_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific soft-deleted bounty by ID."""
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
for b in all_bounties:
if b.id == bounty_id and b.deleted_at is not None:
return b
return None
def recover_bounty(self, room_id: int, bounty_id: int, user_id: int) -> str:
"""Recover a soft-deleted bounty. Admin only.
Returns: 'recovered', 'not_found', 'not_deleted', 'permission_denied'
"""
if not self.is_admin(room_id, user_id):
return "permission_denied"
bounty = self.get_deleted_bounty(room_id, bounty_id)
if not bounty:
return "not_found"
if bounty.deleted_at is None:
return "not_deleted"
bounty.deleted_at = None
self._storage.update_bounty(room_id, bounty)
return "recovered"
def recover_bounties(
self, room_id: int, bounty_ids: list[int], user_id: int
) -> dict[int, str]:
"""Recover multiple soft-deleted bounties. Admin only.
Returns dict of bounty_id -> result ('recovered', 'not_found', 'not_deleted', 'permission_denied')
"""
results = {}
for bounty_id in bounty_ids:
results[bounty_id] = self.recover_bounty(room_id, bounty_id, user_id)
return results
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty by ID. Excludes soft-deleted bounties.""" """Get a specific bounty by ID. Excludes soft-deleted bounties."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
@@ -255,28 +210,6 @@ class BountyService:
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
return True return True
def delete_bounties(
self, room_id: int, bounty_ids: list[int], user_id: int
) -> dict[int, str]:
"""Soft delete multiple bounties. Returns dict of bounty_id -> result.
Results can be: 'deleted', 'not_found', 'permission_denied'
"""
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: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""

View File

@@ -47,28 +47,11 @@ class TestConfigDataDir:
assert cfg.bot_token == "test_token_123" assert cfg.bot_token == "test_token_123"
def test_bot_token_none_when_not_set(self): def test_bot_token_none_when_not_set(self):
"""Test that bot_token is None when JIGAIDO_BOT_TOKEN not set and no config file.""" """Test that bot_token is None when JIGAIDO_BOT_TOKEN not set."""
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.exists", return_value=False):
cfg = Config() cfg = Config()
assert cfg.bot_token is None assert cfg.bot_token is None
def test_bot_token_from_config_file(self):
"""Test that bot_token is read from config file when env var not set."""
config_dir = Path.home() / ".jigaido"
config_file = config_dir / "config.json"
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.expanduser", return_value=config_file):
with patch("pathlib.Path.exists", return_value=True):
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = lambda *a: None
mock_open.return_value.read = lambda: (
'{"JIGAIDO_BOT_TOKEN": "config_token"}'
)
cfg = Config()
assert cfg.bot_token == "config_token"
class TestConfigEnsureDataDir: class TestConfigEnsureDataDir:
def test_ensure_data_dir_creates_directory(self, tmp_path): def test_ensure_data_dir_creates_directory(self, tmp_path):

View File

@@ -210,46 +210,6 @@ class TestBountyService:
result = self.service.delete_bounty(-1001, 999, self.admin_user_id) result = self.service.delete_bounty(-1001, 999, self.admin_user_id)
assert result is False assert result is False
def test_delete_bounties_multi_id_success(self):
"""Test delete_bounties returns individual results for multiple bounties."""
bounty1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete 1"
)
bounty2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete 2"
)
results = self.service.delete_bounties(
-1001, [bounty1.id, bounty2.id], self.admin_user_id
)
assert results == {bounty1.id: "deleted", bounty2.id: "deleted"}
# Verify both are soft deleted
assert self.service.get_bounty(-1001, bounty1.id) is None
assert self.service.get_bounty(-1001, bounty2.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns not_found for non-existent bounties."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
results = self.service.delete_bounties(
-1001, [bounty.id, 999, 888], self.admin_user_id
)
assert results == {bounty.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin users."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
results = self.service.delete_bounties(
-1001,
[bounty.id],
999, # non-admin user
)
assert results == {bounty.id: "permission_denied"}
# Verify bounty was NOT deleted
assert self.service.get_bounty(-1001, bounty.id) is not None
class TestTrackingService: class TestTrackingService:
"""Unit tests for TrackingService.""" """Unit tests for TrackingService."""