Compare commits

...

33 Commits

Author SHA1 Message Date
shokollm
7822e65d6c debug: add logging to _find_user_id_by_username
To see the actual error when user lookup fails.
2026-04-05 02:34:47 +00:00
shokollm
c57b422b6a fix: use Telegram API to lookup users by username
_find_user_id_by_username now uses ctx.bot.get_chat() to lookup
any user by username, not just bounty creators. This allows
/admin add to work for any user in the group.
2026-04-05 02:31:38 +00:00
shokollm
1db8e48414 feat: use HTML links for admin list
Now /admin shows usernames as clickable tg://user?id= links,
same format as /show command. Uses HTML parse mode.
2026-04-05 02:26:10 +00:00
shokollm
1bde18589c fix: unify date format in add and edit confirmations
Now uses format_due_date() for edit confirmation dates,
matching the add confirmation format: "5 April 2026 13:00 (Asia/Jakarta)"
2026-04-05 02:18:45 +00:00
shokollm
22c7d8f4f5 fix: define tz variable in cmd_update before using it
The cmd_update function was missing the tz = ZoneInfo(timezone_str) conversion.
This caused "tz is not defined" error when confirming edits.
2026-04-05 02:08:01 +00:00
shokollm
b85806f3ad fix: use room timezone in edit confirmation dates
The edit confirmation was using time.localtime() (server timezone).
Now uses datetime.fromtimestamp() with room's timezone.
2026-04-05 02:00:13 +00:00
shokollm
442e5279cc fix: properly detect flags after -link and -date
Previously, /edit 28 -link -date would treat -date as the link value.
Now it checks if the next argument starts with "-" to detect flags.
2026-04-05 01:57:56 +00:00
shokollm
6c99751827 fix: remove catch-all handler for unknown commands
Unknown commands like /asdasda no longer show help.
Only known commands (with explicit handlers) respond.
2026-04-05 01:49:58 +00:00
shokollm
e570acff4f fix: add created_by_username parameter to BountyService.add_bounty
The add_bounty method now accepts created_by_username parameter
to store the creator's username for display purposes.
2026-04-05 01:41:02 +00:00
shokollm
8cc7b09716 fix: only show user in /show if username is stored
Now only shows the creator line if created_by_username exists,
no fallback to "User {id}" which could be confusing.
2026-04-05 01:39:29 +00:00
shokollm
a1946e4c4e feat: store username when creating bounty for display
When adding a bounty, capture effective_user's username or first_name
and store it for display in /show. Now shows actual name instead of
just "User".
2026-04-05 01:36:30 +00:00
shokollm
db1369c004 feat: use HTML parse mode for user link in /show
Now using <a href="tg://user?id=XXX">User</a> with HTML parse mode
for clickable user link without pinging.
2026-04-05 01:28:07 +00:00
shokollm
17b022fc6c fix: show creator as clickable user link without pinging
Changed from @user#1663194938 to using tg://user?id=XXX link format.
This creates a clickable link to the user's DM without sending a ping.
2026-04-05 00:53:25 +00:00
shokollm
cecf71da5d fix: parse dates using group's timezone when adding/updating bounties
The parse_args function now accepts timezone_str parameter and uses it
to localize parsed dates. This ensures dates are interpreted in the
group's timezone, not the server's local timezone.
2026-04-05 00:25:59 +00:00
shokollm
58614fbda2 fix: use group timezone in bounty list and show command
1. format_bounty now accepts timezone_str parameter
2. Calculate hours/days remaining using group's timezone
3. Format dates using group's timezone (not server local time)
4. Updated cmd_bounty, cmd_my, cmd_show to pass timezone
2026-04-05 00:06:59 +00:00
shokollm
ec29e4f15f feat: add error handler to bot for better error logging
This will help catch and log errors that were previously
showing as "No error handlers are registered"
2026-04-04 23:59:07 +00:00
shokollm
858305ebac fix: show time in date display and bounty list
1. format_bounty now shows time (HH:MM) if time is set
2. Bounty list shows hours remaining if < 48 hours:
   - < 1 hour: shows minutes (e.g., 45m)
   - < 48 hours: shows hours (e.g., 6h)
   - >= 48 hours: shows days (e.g., 3d)
3. Update message now shows time in date display
2026-04-04 23:54:34 +00:00
shokollm
a76aab657f fix: properly parse time (HH:MM) after date
The parse_args function now:
1. Recognizes time format (HH:MM) after parsing a date
2. Combines date + time into a single timestamp
3. Only text comes before link or date flags

Now /update 6 -date 2026-04-05 12:00 properly sets date+time
2026-04-04 23:48:48 +00:00
shokollm
cfe5f019f2 fix: single delete button per message, use query.message.delete()
- /bounty now shows only ONE delete button (not per-bounty)
- Callback uses query.message.delete() instead of delete_message by ID
- This is more reliable and simpler
2026-04-04 23:45:19 +00:00
shokollm
7b64da7897 fix: -link with any value sets link regardless of URL format
Previously, /update 2 -link s would clear the link and then parse
's' as a date. Now, any value after -link is used as the link,
regardless of whether it looks like a URL.
2026-04-04 23:42:15 +00:00
shokollm
7c238b44c8 fix: accept domain-only URLs like google.com
The is_url() function now accepts any string with a dot and no spaces,
not just URLs with http:// or containing /. This allows /update 2 -link google.com
to properly set the link instead of treating google.com as text.
2026-04-04 23:35:22 +00:00
shokollm
7a4d938c41 fix: /edit command improvements
1. Accept any URL-like string as link (not just http/https)
   - Now detects URLs by pattern: contains "." and "/" (e.g., github.com/foo)

2. Show old -> new changes in update response
   - Now shows exactly what changed for verification
   - Helps user catch mistakes immediately
2026-04-04 23:29:03 +00:00
shokollm
dfafefe071 feat: add inline delete button to /bounty list
- Add inline keyboard with delete button on bounty list messages
- Only the user who triggered the command can delete the message
- Message is actually removed from the chat
- Uses callback query handler for button clicks
2026-04-04 23:25:20 +00:00
shokollm
d01d147a45 refactor: reorganize help command with admin/non-admin split
Non-admin sees minimal commands: bounty, track, untrack, my, show, start, help
Admin sees organized by category: Bounty Management, Tracking, Room Management
2026-04-04 23:11:41 +00:00
shokollm
a667ba216a refactor: simplify help command
- Show only top-level commands without variations
- Show admin-specific commands only to admins
- Reduces cognitive overhead for normal users
2026-04-04 23:07:58 +00:00
shokollm
badb2e3292 fix: handle admin_user_ids=None case in add_admin
When loading room data with admin_user_ids=null in JSON,
the code now properly initializes admin_user_ids to []
instead of incorrectly creating a new RoomData.

Also removed debug logging added during troubleshooting.
2026-04-04 22:59:37 +00:00
shokollm
6e5006b429 debug: add detailed logging to add_admin for troubleshooting 2026-04-04 16:08:46 +00:00
shokollm
cce71e55c2 debug: add logging to cmd_start for troubleshooting admin promotion
Adding logging to understand why group creator admin promotion
may not be working in production.
2026-04-04 16:01:24 +00:00
shokollm
8ac8cd21ec fix: allow self-promotion to first admin in room
Users can now add themselves as the first admin without existing
admin permission. This enables /start in DMs to work correctly.
2026-04-04 15:34:58 +00:00
shokollm
d75f897043 feat: auto-promote group creator AND DM user to admin
- Groups: group creator auto-promoted to admin via /start
- DMs: user automatically becomes admin of their own DM
2026-04-04 15:30:27 +00:00
shokollm
0260cae40b feat: auto-promote group creator to admin
When /start is called in a group, check if user is the group creator
and automatically add them as admin. DMs don't need admin concept.
2026-04-04 15:24:47 +00:00
shokollm
408318d323 feat: bot reads JIGAIDO_BOT_TOKEN from config file
- config.py: Added _resolve_bot_token() to read from config file
- bot.py: Uses config.config.bot_token instead of env var directly
- test_config.py: Added test for config file token reading
2026-04-04 15:16:55 +00:00
5502de96ad Merge pull request 'feat: implement /recover command and fix /admin list' (#83) from fix/issue-49-50-recover-admin-list into main 2026-04-04 16:42:07 +02:00
5 changed files with 364 additions and 86 deletions

View File

@@ -4,13 +4,22 @@ import logging
import os import os
import sys import sys
from telegram.ext import Application, CommandHandler, MessageHandler, filters sys.path.insert(0, "/home/shoko/repositories/jigaido")
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,
@@ -29,7 +38,13 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "") from config import config
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:
@@ -50,7 +65,9 @@ def build_app() -> Application:
app.add_handler(CommandHandler("admin", cmd_admin)) app.add_handler(CommandHandler("admin", cmd_admin))
app.add_handler(CommandHandler("recover", cmd_recover)) app.add_handler(CommandHandler("recover", cmd_recover))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(CallbackQueryHandler(cmd_delete_message))
app.add_error_handler(error_handler)
return app return app
@@ -74,6 +91,8 @@ async def post_init(app: Application) -> None:
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)
@@ -82,6 +101,11 @@ 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

@@ -9,6 +9,8 @@ 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
@@ -58,6 +60,7 @@ 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
@@ -65,38 +68,77 @@ 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 ( if i + 1 < len(args) and not args[i + 1].startswith("-"):
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): if i + 1 < len(args) and not args[i + 1].startswith("-"):
parsed = dateparser.parse(args[i + 1]) due_date_ts = parse_date_with_tz(args[i + 1])
if parsed: if due_date_ts is not None:
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 (arg.startswith("http://") or arg.startswith("https://")): elif not link and is_url(arg):
link = arg link = arg
i += 1 i += 1
elif due_date_ts is None: elif due_date_ts is None:
parsed = dateparser.parse(arg) due_date_ts = parse_date_with_tz(arg)
if parsed: if due_date_ts is not None:
due_date_ts = int(parsed.timestamp()) i += 1
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
@@ -114,7 +156,9 @@ 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(b, show_id: bool = True, slice_length: int = 0) -> str: def format_bounty(
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}]")
@@ -126,17 +170,64 @@ def format_bounty(b, show_id: bool = True, slice_length: int = 0) -> 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("%d %b %Y", time.localtime(b.due_date_ts)) try:
days_left = (b.due_date_ts - int(time.time())) // 86400 tz = ZoneInfo(timezone_str)
if days_left < 0: except (KeyError, ZoneInfoNotFoundError):
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 days_left == 0: elif hours_left < 48:
parts.append(f"⏰ Today (OVERDUE)") if hours_left < 1:
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"
@@ -162,6 +253,8 @@ 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
@@ -212,9 +305,17 @@ 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(format_bounty(b, show_id=True, slice_length=slice_length)) lines.append(
format_bounty(
b, show_id=True, slice_length=slice_length, timezone_str=timezone_str
)
)
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) keyboard = [[InlineKeyboardButton("🗑️ Delete", callback_data=f"del_msg:{user_id}")]]
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:
@@ -228,6 +329,8 @@ 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."
@@ -237,7 +340,9 @@ 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, 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)
@@ -250,14 +355,18 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
return return
text, link, due_date_ts, _, _ = parse_args(args) user_id = get_user_id(update)
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,
@@ -265,6 +374,7 @@ 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}")
@@ -304,7 +414,16 @@ 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
text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:]) user_id = get_user_id(update)
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
@@ -315,8 +434,10 @@ 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
user_id = get_user_id(update) old_bounty = BOUNTY_SERVICE.get_bounty(room_id, bounty_id)
room_id = get_room_id(update) if not old_bounty:
await update.message.reply_text("Bounty not found.")
return
try: try:
success = BOUNTY_SERVICE.update_bounty( success = BOUNTY_SERVICE.update_bounty(
@@ -337,6 +458,37 @@ 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.")
@@ -436,7 +588,29 @@ 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"
@@ -446,6 +620,9 @@ 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"
@@ -474,7 +651,12 @@ 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 = BOUNTY_SERVICE.get_timezone(room_id) timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
lines = [] lines = []
@@ -485,16 +667,22 @@ 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:
due_str = time.strftime("%d %B %Y %H:%M", time.localtime(bounty.due_date_ts)) dt_due = datetime.fromtimestamp(bounty.due_date_ts, tz=tz)
lines.append(f"📅 {due_str} ({timezone})") due_str = dt_due.strftime("%d %B %Y %H:%M")
lines.append(f"📅 {due_str} ({timezone_str})")
username = bounty.created_by_username or f"user#{bounty.created_by_user_id}" if bounty.created_by_username:
lines.append(f"👤 @{username}") lines.append(
f'👤 <a href="tg://user?id={bounty.created_by_user_id}">{bounty.created_by_username}</a>'
)
created_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(bounty.created_at)) dt_created = datetime.fromtimestamp(bounty.created_at, tz=tz)
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("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text(
"\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:
@@ -583,15 +771,19 @@ async def cmd_recover(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("\n".join(response_lines)) await update.message.reply_text("\n".join(response_lines))
def _find_user_id_by_username(room_id: int, username: str) -> int | None: async def _find_user_id_by_username(
"""Find user_id by username from bounty creators in the room.""" ctx: ContextTypes.DEFAULT_TYPE, username: str
bounties = BOUNTY_SERVICE.list_bounties(room_id) ) -> int | None:
for bounty in bounties: """Find user_id by username using Telegram API."""
if ( import logging
bounty.created_by_username
and bounty.created_by_username.lower() == username.lower() log = logging.getLogger(__name__)
): try:
return bounty.created_by_user_id 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
@@ -615,11 +807,16 @@ async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
for admin_id in admins: for admin_id in admins:
username = _find_username_by_user_id(get_room_id(update), admin_id) username = _find_username_by_user_id(get_room_id(update), admin_id)
if username: if username:
admin_mentions.append(f"@{username}") admin_mentions.append(
f'<a href="tg://user?id={admin_id}">@{username}</a>'
)
else: else:
admin_mentions.append(f"user#{admin_id}") admin_mentions.append(
f'<a href="tg://user?id={admin_id}">{admin_id}</a>'
)
await update.message.reply_text( await update.message.reply_text(
f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions) f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions),
parse_mode=ParseMode.HTML,
) )
return return
@@ -652,7 +849,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 = _find_user_id_by_username(room_id, username) target_user_id = await _find_user_id_by_username(ctx, 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.")
@@ -670,27 +867,43 @@ 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:
await update.message.reply_text( user_id = get_user_id(update)
"👻 JIGAIDO Commands:\n\n" room_id = get_room_id(update)
"/bounty — list all bounties\n" is_admin = BOUNTY_SERVICE.is_admin(room_id, user_id)
"/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty (admin only)\n" if is_admin:
"/update <id> [text> [link] [due] — update bounty (admin only)\n" lines = [
"/edit <id> [text> [link] [due] — edit bounty (same as update)\n" "👻 JIGAIDO Commands (Admin):\n",
" /edit <id> -link [<url>] — clear or set link\n" "📋 Bounty Management:",
" /edit <id> -date [<date>] — clear or set date\n" "/bounty — list bounties",
"/delete <id> [<id>...] — delete bounty (admin only)\n" "/add — add bounty",
"/track <id> — track a bounty (groups only)\n" "/edit — edit bounty",
"/untrack <id> — stop tracking (groups only)\n" "/delete — delete bounty",
"/show <id> — show bounty details\n" "/recover — recover deleted bounties",
"/admin — list admins\n" "",
"/admin add @username — add admin (admin only)\n" "🔗 Tracking:",
"/admin remove @username — remove admin (admin only)\n" "/track — track bounty",
"/timezone — get room timezone\n" "/untrack — stop tracking",
"/timezone <tz> — set room timezone (admin only)\n" "/my — your tracked bounties",
"/recover — list recoverable bounties (admin only)\n" "/show — show bounty details",
"/recover <id> [<id>...] — recover bounty (admin only)\n" "",
"/start — re-initialize\n" "⚙️ Room Management:",
"/help — this message", "/admin — manage admins",
disable_web_page_preview=True, "/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

@@ -13,7 +13,21 @@ 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] = os.environ.get("JIGAIDO_BOT_TOKEN") self.bot_token: Optional[str] = self._resolve_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")
@@ -35,3 +49,6 @@ class Config:
config = Config() config = Config()
config = Config()

View File

@@ -35,18 +35,23 @@ 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.""" """Add an admin to a room. Requires admin permission, or self-promotion if first admin."""
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.")
room_data = self._storage.load(room_id) if room_data is None or room_data.admin_user_ids is None:
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 or []).append(admin_user_id) room_data.admin_user_ids.append(admin_user_id)
self._storage.save(room_data) self._storage.save(room_data)
def remove_admin( def remove_admin(
@@ -119,6 +124,7 @@ 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):
@@ -136,6 +142,7 @@ 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,

View File

@@ -47,11 +47,28 @@ 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.""" """Test that bot_token is None when JIGAIDO_BOT_TOKEN not set and no config file."""
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):