Compare commits

...

67 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
shokollm
6a933742cb feat: implement /recover command and fix /admin list
- Add /recover command for listing and recovering soft-deleted bounties
  - /recover - list recoverable bounties (admin only)
  - /recover <id> [<id>...] - recover specific bounties (admin only)
- Fix /admin list to show @username instead of admin_id
- Add recover_bounty and recover_bounties methods to BountyService
- Add get_deleted_bounty method to BountyService
- Clean up duplicate cmd_admin functions
- Add /recover to bot command menu
- Fixes #49 and #50
2026-04-04 14:29:19 +00:00
b554a81979 Merge pull request 'feat: human-readable date format with timezone awareness (#54)' (#80) from fix/issue-54-v2 into main 2026-04-04 15:37:20 +02:00
shokollm
350ecbf867 feat: human-readable date format with timezone awareness
Add format_due_date() for human-readable dates like "4 April 2026".
Update cmd_add to use timezone-aware date formatting.

Fixes #54
2026-04-04 20:36:38 +07:00
28241eaf61 Merge pull request 'feat(/admin): add /admin command for admin management' (#78) from fix/issue-52 into main 2026-04-04 15:32:44 +02:00
shokollm
8db5ba0ba4 Merge fix/issue-52 with conflict resolution 2026-04-04 20:32:06 +07:00
a727751978 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#79) from fix/issue-47 into main 2026-04-04 15:28:36 +02:00
shokollm
90b0b564c2 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 3 tests for delete_bounties method

Example output:
/delete 1 2 3
 Bounty #1 deleted.
 Bounty #2 deleted.
 Bounty #3 not found.

Fixes #47
2026-04-04 13:06:56 +00:00
003c570cfb Merge pull request 'feat(/add): add admin-only and link uniqueness handling' (#77) from fix/issue-45-v3 into main 2026-04-04 12:59:48 +02:00
shokollm
bac6830fc3 feat(/add): add admin-only and link uniqueness handling
Wrap add_bounty call in try-except to handle PermissionError
and ValueError from admin check and link uniqueness check.

Fixes #45
2026-04-04 17:59:20 +07:00
2dd11a8b48 Merge pull request 'feat: remove "by user" from bounty list display' (#76) from fix/issue-55 into main 2026-04-04 12:53:36 +02:00
shokollm
2617d17e28 feat: remove "by user" from bounty list display
Removes created_by_user_id from format_bounty() output.
Fixes #55
2026-04-04 17:52:47 +07:00
b091153f10 Merge pull request 'feat: implement /admin add|remove @username command' (#75) from fix/issue-51-v2 into main 2026-04-04 12:18:31 +02:00
shokollm
ce864d9fdc feat: implement /admin add|remove @username command
- Add cmd_admin handler for /admin add|remove @username
- Add _find_user_id_by_username helper to resolve usernames from bounty creators
- Register admin command handler in bot.py
- Add 'admin' to bot command list
- Addresses issue #51
2026-04-04 08:20:35 +00:00
e805a6428a Merge pull request 'feat: implement /timezone command to get/set room timezone' (#72) from fix/issue-53 into main 2026-04-04 10:15:42 +02:00
shokollm
6da16e752b feat: implement /timezone command to get/set room timezone
Re-implement the timezone command that was reverted.

- Add cmd_timezone function with get/set functionality
- Validate timezone using zoneinfo (IANA format)
- Admin-only permission via service layer
- Update help text and bot command list
- Fix indentation bug in cmd_add (duplicate lines)

Fixes #53
2026-04-04 08:14:58 +00:00
e3b813661d Merge pull request 'feat(/bounty): add pagination, sorting, and filtering' (#62) from fix/issue-48-bounty-pagination into main 2026-04-04 10:09:29 +02:00
bdb0f3cd8b Merge pull request 'feat: implement /show command to display full bounty details' (#59) from fix/issue-44 into main 2026-04-04 10:09:28 +02:00
shokollm
649b1ffbd3 revert: remove timezone command and revert date format to simple YYYY-MM-DD
This reverts:
- cmd_timezone function (issue #67)
- format_due_date with human-readable dates (issue #68)
- Reverts date display back to time.strftime("%Y-%m-%d")
- Keeps /edit command with -link/-date flags (issue #46)
2026-04-04 15:05:29 +07:00
shokollm
b8f6b98836 Merge pull request #61 from fix/issue-46 2026-04-04 07:40:59 +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
d38d47fb79 feat(/bounty): add pagination, sorting, and filtering
- Default shows 5 bounties per page
- /bounty 10 - show 10 bounties
- /bounty all - show all active (exclude overdue >24h)
- /bounty all 10 - show 10 including expired

Filtering:
- Overdue >24h filtered out by default
- 'all' flag includes overdue

Sorting:
- Bounties with due date sorted by due_date_ts (earliest first)
- Bounties without due date shown last, sorted by created_at

Output format updated:
- Header shows 'Showing X of Y bounties'
- Description sliced to 40 chars when showing pagination info
- Date format changed to '4 Apr 2026' style

Fixes #48
2026-04-04 05:54:56 +00:00
shokollm
a06e1327fb feat(/edit): per-argument updates + clear syntax + admin-only
- Add -link and -date flags to /edit command for field clearing
- /edit <id> -link - clear link
- /edit <id> -date - clear date
- /edit <id> -link <url> - set link
- /edit <id> -date <date> - set date
- /edit <id> text -link - update text, clear link
- /edit <id> text <url> - update text and set link
- Parse_args now returns (text, link, due_date_ts, clear_link, clear_date)
- Update usage messages and help text
- Fixes #46
2026-04-04 05:51:56 +00:00
shokollm
780cba6301 feat: implement /show command to display full bounty details
- Add cmd_show function to display bounty details including:
  - ID and full text (not sliced)
  - Link if exists
  - Due date formatted with room timezone
  - Created by username
  - Created at timestamp
- Register show command handler in bot.py
- Add show command to help text and bot command list
- Fixes #44
2026-04-04 05:43:50 +00:00
42ed551554 Merge pull request 'feat: implement service layer for Phase 2' (#58) from fix/issue-43 into main 2026-04-04 07:36:09 +02:00
shokollm
af7774ef03 feat: implement service layer for Phase 2 - admin management, timezone, soft delete
BountyService:
- Add is_admin(), add_admin(), remove_admin(), list_admins()
- Add set_timezone(), get_timezone()
- Add check_link_unique(), list_deleted_bounties()
- Modify add_bounty() to check link uniqueness and require admin
- Modify update_bounty() to require admin permission (not creator)
- Modify delete_bounty() to perform soft delete (set deleted_at)
- get_bounty() now filters out soft-deleted bounties
- list_bounties() uses storage.list_bounties() which excludes soft-deleted

TrackingService:
- get_tracked_bounties() now filters out soft-deleted bounties

Tests updated to reflect new admin-only permissions and soft delete behavior.
2026-04-04 05:27:40 +00:00
0a64b4f310 Merge pull request 'feat: add list_bounties and list_all_bounties methods to storage adapter' (#57) from fix/issue-42 into main 2026-04-04 07:17:07 +02:00
shokollm
ed0d31bc04 feat: add list_bounties and list_all_bounties methods to storage adapter
Add filtering methods to JsonFileRoomStorage for Phase 2 soft delete support:
- list_bounties(room_id): returns only non-deleted bounties for normal queries
- list_all_bounties(room_id, include_deleted=True): returns all bounties for /recover

Update RoomStorage protocol to include the new methods.
Update mock classes in tests to pass isinstance checks.

Fixes #42
2026-04-04 05:12:19 +00:00
d413f6ce13 Merge pull request 'feat: Model updates - add deleted_at, timezone, admin_user_ids fields' (#56) from fix/issue-41 into main 2026-04-04 07:08:28 +02:00
10 changed files with 1162 additions and 122 deletions

View File

@@ -140,6 +140,35 @@ class JsonFileRoomStorage:
return None return None
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all non-deleted bounties in a room.
This is the default method for normal queries - soft-deleted bounties
are excluded from results.
"""
room_data = self.load(room_id)
if room_data is None:
return []
return [b for b in room_data.bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
"""List all bounties including or excluding soft-deleted.
Args:
room_id: The room ID
include_deleted: If True, return all bounties including soft-deleted.
If False, return only non-deleted bounties.
Defaults to True for /recover functionality.
"""
room_data = self.load(room_id)
if room_data is None:
return []
if include_deleted:
return room_data.bounties
return [b for b in room_data.bounties if b.deleted_at is None]
class JsonFileTrackingStorage: class JsonFileTrackingStorage:
"""TrackingStorage implementation using JSON files. """TrackingStorage implementation using JSON files.

View File

@@ -4,16 +4,29 @@ 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_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_start, cmd_start,
cmd_timezone,
cmd_track, cmd_track,
cmd_untrack, cmd_untrack,
cmd_update, cmd_update,
@@ -25,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:
@@ -41,8 +60,14 @@ 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("show", cmd_show))
app.add_handler(CommandHandler("timezone", cmd_timezone))
app.add_handler(CommandHandler("admin", cmd_admin))
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
@@ -56,12 +81,18 @@ 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"),
("show", "Show bounty details"),
("timezone", "Get/set room timezone"),
("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)
@@ -70,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

@@ -1,12 +1,16 @@
"""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
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
@@ -19,6 +23,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 []
@@ -26,50 +58,176 @@ def extract_args(text: str) -> list[str]:
return tokens[1:] if len(tokens) > 1 else [] return tokens[1:] if len(tokens) > 1 else []
def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[int]]: def parse_args(
args: list[str],
timezone_str: str = "UTC",
) -> tuple[Optional[str], Optional[str], Optional[int], bool, bool]:
text = None text = None
link = None link = None
due_date_ts = None due_date_ts = None
clear_link = False
clear_date = False
remaining = [] try:
for arg in args: tz = ZoneInfo(timezone_str)
if not link and (arg.startswith("http://") or arg.startswith("https://")): except (KeyError, ZoneInfoNotFoundError):
link = arg tz = ZoneInfo("UTC")
elif due_date_ts is None:
parsed = dateparser.parse(arg) def is_url(s: str) -> bool:
if parsed: if not s:
due_date_ts = int(parsed.timestamp()) 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
while i < len(args):
arg = args[i]
if arg == "-link":
if i + 1 < len(args) and not args[i + 1].startswith("-"):
link = args[i + 1]
i += 2
else: else:
remaining.append(arg) clear_link = True
i += 1
elif arg == "-date":
if i + 1 < len(args) and not args[i + 1].startswith("-"):
due_date_ts = parse_date_with_tz(args[i + 1])
if due_date_ts is not None:
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:
clear_date = True
i += 1
else:
clear_date = True
i += 1
elif not link and is_url(arg):
link = arg
i += 1
elif due_date_ts is None:
due_date_ts = parse_date_with_tz(arg)
if due_date_ts is not None:
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
else:
i += 1
if text is None:
text = arg
else:
text = text + " " + arg
else: else:
remaining.append(arg) i += 1
if text is None:
text = arg
else:
text = text + " " + arg
text = " ".join(remaining) if remaining else None return text, link, due_date_ts, clear_link, clear_date
return text, link, due_date_ts
def format_bounty(b, show_id: bool = True) -> 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}]")
if b.text: if b.text:
parts.append(b.text) text = b.text
if slice_length > 0 and len(text) > slice_length:
text = text[:slice_length] + "..."
parts.append(text)
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)) 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"{due_str} (TODAY)") 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)")
if b.created_by_user_id:
parts.append(f"by {b.created_by_user_id}")
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"
@@ -95,14 +253,69 @@ 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)
bounties = BOUNTY_SERVICE.list_bounties(room_id) user_id = get_user_id(update)
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
args = extract_args(update.message.text)
if not bounties: show_all = "all" in args
await update.message.reply_text("No bounties yet.") 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 return
lines = [format_bounty(b, show_id=True) for b in bounties] lines = []
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) 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, timezone_str=timezone_str
)
)
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:
@@ -111,10 +324,13 @@ 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)
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."
@@ -124,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)
@@ -137,26 +355,38 @@ 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) try:
room_id = get_room_id(update) bounty = BOUNTY_SERVICE.add_bounty(
room_id=room_id,
bounty = BOUNTY_SERVICE.add_bounty( user_id=user_id,
room_id=room_id, text=text,
user_id=user_id, link=link,
text=text, due_date_ts=due_date_ts,
link=link, created_by_username=username,
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}",
disable_web_page_preview=True, disable_web_page_preview=True,
@@ -167,7 +397,14 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if len(args) < 1: if len(args) < 1:
await update.message.reply_text( await update.message.reply_text(
"Usage: /update <bounty_id> [text] [link] [due_date]" "Usage: /update <bounty_id> [text] [link] [due_date]\n"
" /update <bounty_id> -link [<url>] - clear or set link\n"
" /update <bounty_id> -date [<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 return
@@ -177,13 +414,30 @@ 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 = parse_args(args[1:]) user_id = get_user_id(update)
if not text and not link and due_date_ts is None: 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 (
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.") 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(
@@ -193,13 +447,49 @@ async def cmd_update(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,
clear_link=clear_link,
clear_due=clear_date,
) )
except PermissionError as e: except PermissionError as e:
await update.message.reply_text(f"{e}") await update.message.reply_text(f"{e}")
return return
except ValueError as e:
await update.message.reply_text(f"{e}")
return
if success: if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") 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.")
else: else:
await update.message.reply_text("Bounty not found.") await update.message.reply_text("Bounty not found.")
@@ -210,11 +500,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>") await update.message.reply_text("Usage: /delete <bounty_id> [bounty_id ...]")
return return
try: try:
bounty_id = int(args[0]) bounty_ids = [int(arg) for arg in args]
except ValueError: except ValueError:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
@@ -223,19 +513,25 @@ 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:
success = BOUNTY_SERVICE.delete_bounty( results = BOUNTY_SERVICE.delete_bounties(
room_id=room_id, room_id=room_id,
bounty_id=bounty_id, bounty_ids=bounty_ids,
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
if success: response_lines = []
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") for bounty_id, result in results.items():
else: if result == "deleted":
await update.message.reply_text("Bounty not found.") response_lines.append(f"Bounty #{bounty_id} deleted.")
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:
@@ -292,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"
@@ -302,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"
@@ -311,17 +632,278 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: 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 <bounty_id>")
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_str = BOUNTY_SERVICE.get_timezone(room_id)
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
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:
dt_due = datetime.fromtimestamp(bounty.due_date_ts, tz=tz)
due_str = dt_due.strftime("%d %B %Y %H:%M")
lines.append(f"📅 {due_str} ({timezone_str})")
if bounty.created_by_username:
lines.append(
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 = dt_created.strftime("%Y-%m-%d %H:%M")
lines.append(f"📌 Created: {created_str}")
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO Commands:\n\n" "\n".join(lines), disable_web_page_preview=True, parse_mode=ParseMode.HTML
"/bounty — list all bounties\n"
"/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty\n"
"/update <id> [text> [link] [due] — update bounty\n"
"/delete <id> — delete bounty\n"
"/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n"
"/start — re-initialize\n"
"/help — this message",
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}.")
async def cmd_recover(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 BOUNTY_SERVICE.is_admin(room_id, user_id):
await update.message.reply_text("⛔ Only admins can perform this action.")
return
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
def _find_username_by_user_id(room_id: int, user_id: int) -> str | None:
"""Find username by user_id from bounty creators in the room."""
bounties = BOUNTY_SERVICE.list_bounties(room_id)
for bounty in bounties:
if bounty.created_by_user_id == user_id:
return bounty.created_by_username
return None
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text)
if not args:
admins = BOUNTY_SERVICE.list_admins(get_room_id(update))
if not admins:
await update.message.reply_text("No admins in this room.")
return
admin_mentions = []
for admin_id in admins:
username = _find_username_by_user_id(get_room_id(update), admin_id)
if username:
admin_mentions.append(
f'<a href="tg://user?id={admin_id}">@{username}</a>'
)
else:
admin_mentions.append(
f'<a href="tg://user?id={admin_id}">{admin_id}</a>'
)
await update.message.reply_text(
f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions),
parse_mode=ParseMode.HTML,
)
return
if args[0] not in ("add", "remove"):
await update.message.reply_text(
"Usage:\n"
"/admin — list admins\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 = await _find_user_id_by_username(ctx, 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:
user_id = get_user_id(update)
room_id = get_room_id(update)
is_admin = BOUNTY_SERVICE.is_admin(room_id, user_id)
if is_admin:
lines = [
"👻 JIGAIDO Commands (Admin):\n",
"📋 Bounty Management:",
"/bounty — list bounties",
"/add — add bounty",
"/edit — edit bounty",
"/delete — delete bounty",
"/recover — recover deleted bounties",
"",
"🔗 Tracking:",
"/track — track bounty",
"/untrack — stop tracking",
"/my — your tracked bounties",
"/show — show bounty details",
"",
"⚙️ Room Management:",
"/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

@@ -0,0 +1,28 @@
#!/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,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

@@ -40,6 +40,25 @@ class RoomStorage(Protocol):
"""Get a specific bounty from a room by ID.""" """Get a specific bounty from a room by ID."""
... ...
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all non-deleted bounties in a room.
Soft-deleted bounties (where deleted_at is not None) are excluded.
"""
...
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
"""List all bounties including or excluding soft-deleted.
Args:
room_id: The room ID
include_deleted: If True, return all bounties including soft-deleted.
If False, return only non-deleted bounties.
"""
...
@runtime_checkable @runtime_checkable
class TrackingStorage(Protocol): class TrackingStorage(Protocol):

View File

@@ -15,11 +15,108 @@ class BountyService:
- Positive room_id: DM/personal context (user's Telegram ID) - Positive room_id: DM/personal context (user's Telegram ID)
This service handles both group and personal bounties through room_id. This service handles both group and personal bounties through room_id.
Permissions:
- /add, /edit, /delete: admin only
- /admin, /admin add, /admin remove: admin only
- /bounty, /show, /track, /untrack, /my: everyone
""" """
def __init__(self, storage: RoomStorage): def __init__(self, storage: RoomStorage):
self._storage = storage self._storage = storage
def is_admin(self, room_id: int, user_id: int) -> bool:
"""Check if user is admin in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return False
return user_id in (room_data.admin_user_ids or [])
def add_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int
) -> None:
"""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 (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.")
if room_data is None or room_data.admin_user_ids is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[]
)
if admin_user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids.append(admin_user_id)
self._storage.save(room_data)
def remove_admin(
self, room_id: int, admin_user_id: int, requesting_user_id: int
) -> None:
"""Remove an admin from a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can remove admins.")
room_data = self._storage.load(room_id)
if room_data is None:
return
if admin_user_id in (room_data.admin_user_ids or []):
(room_data.admin_user_ids or []).remove(admin_user_id)
self._storage.save(room_data)
def list_admins(self, room_id: int) -> list[int]:
"""List all admin user IDs in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return list(room_data.admin_user_ids or [])
def set_timezone(
self, room_id: int, timezone: str, requesting_user_id: int
) -> None:
"""Set the timezone for a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can set timezone.")
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_user_ids=[]
)
room_data.timezone = timezone
self._storage.save(room_data)
def get_timezone(self, room_id: int) -> str:
"""Get the timezone for a room. Returns UTC+0 if not set."""
room_data = self._storage.load(room_id)
if room_data is None:
return "UTC+0"
return room_data.timezone or "UTC+0"
def check_link_unique(
self, room_id: int, link: str | None, exclude_bounty_id: int | None = None
) -> bool:
"""Check if a link is unique within a room (not used by another bounty)."""
if not link:
return True
room_data = self._storage.load(room_id)
if room_data is None:
return True
for bounty in room_data.bounties:
if bounty.deleted_at is not None:
continue
if bounty.link == link and bounty.id != exclude_bounty_id:
return False
return True
def add_bounty( def add_bounty(
self, self,
room_id: int, room_id: int,
@@ -27,8 +124,15 @@ 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.""" """Add a new bounty to the room. Requires admin permission."""
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can add bounties.")
if not self.check_link_unique(room_id, link):
raise ValueError("A bounty with this link already exists in this room.")
room_data = self._storage.load(room_id) room_data = self._storage.load(room_id)
if room_data is None: if room_data is None:
room_data = RoomData(room_id=room_id, bounties=[], next_id=1) room_data = RoomData(room_id=room_id, bounties=[], next_id=1)
@@ -38,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,
@@ -47,15 +152,58 @@ class BountyService:
return bounty return bounty
def list_bounties(self, room_id: int) -> list[Bounty]: def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all bounties in a room.""" """List all non-deleted bounties in a room."""
room_data = self._storage.load(room_id) return self._storage.list_bounties(room_id)
if room_data is None:
return [] def list_deleted_bounties(self, room_id: int) -> list[Bounty]:
return room_data.bounties """List all soft-deleted bounties in a room. For /recover functionality."""
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]
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.""" """Get a specific bounty by ID. Excludes soft-deleted bounties."""
return self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if bounty and bounty.deleted_at is not None:
return None
return bounty
def update_bounty( def update_bounty(
self, self,
@@ -68,12 +216,17 @@ class BountyService:
clear_link: bool = False, clear_link: bool = False,
clear_due: bool = False, clear_due: bool = False,
) -> bool: ) -> bool:
"""Update a bounty. Only creator can update.""" """Update a bounty. Only admins can update."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty: if not bounty:
return False return False
if bounty.created_by_user_id != user_id: if not self.is_admin(room_id, user_id):
raise PermissionError("Only the creator can edit this bounty.") raise PermissionError("Only admins can edit bounties.")
if link and not self.check_link_unique(
room_id, link, exclude_bounty_id=bounty_id
):
raise ValueError("A bounty with this link already exists in this room.")
updated = Bounty( updated = Bounty(
id=bounty.id, id=bounty.id,
@@ -84,21 +237,46 @@ class BountyService:
if clear_due if clear_due
else (due_date_ts if due_date_ts is not None else bounty.due_date_ts), else (due_date_ts if due_date_ts is not None else bounty.due_date_ts),
created_at=bounty.created_at, created_at=bounty.created_at,
deleted_at=bounty.deleted_at,
created_by_username=bounty.created_by_username,
) )
self._storage.update_bounty(room_id, updated) self._storage.update_bounty(room_id, updated)
return True return True
def delete_bounty(self, room_id: int, bounty_id: int, user_id: int) -> bool: def delete_bounty(self, room_id: int, bounty_id: int, user_id: int) -> bool:
"""Delete a bounty. Only creator can delete.""" """Soft delete a bounty. Only admins can delete."""
bounty = self._storage.get_bounty(room_id, bounty_id) bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty: if not bounty:
return False return False
if bounty.created_by_user_id != user_id: if not self.is_admin(room_id, user_id):
raise PermissionError("Only the creator can delete this bounty.") raise PermissionError("Only admins can delete bounties.")
self._storage.delete_bounty(room_id, bounty_id) bounty.deleted_at = int(time.time())
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."""
@@ -147,7 +325,7 @@ class TrackingService:
if room_data is None: if room_data is None:
return [] return []
bounty_map = {b.id: b for b in room_data.bounties} bounty_map = {b.id: b for b in room_data.bounties if b.deleted_at is None}
return [ return [
bounty_map[t.bounty_id] bounty_map[t.bounty_id]
for t in tracking_data.tracked for t in tracking_data.tracked

View File

@@ -47,10 +47,27 @@ 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):
cfg = Config() with patch("pathlib.Path.exists", return_value=False):
assert cfg.bot_token is None cfg = Config()
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:

View File

@@ -48,6 +48,20 @@ class SimpleRoomStorage:
return b return b
return None return None
def list_bounties(self, room_id: int) -> list[Bounty]:
if room_id not in self._rooms:
return []
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
if room_id not in self._rooms:
return []
if include_deleted:
return self._rooms[room_id].bounties
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
class SimpleTrackingStorage: class SimpleTrackingStorage:
"""Minimal mock without ensure_tracking - tests if track_bounty works without it. """Minimal mock without ensure_tracking - tests if track_bounty works without it.
@@ -119,6 +133,20 @@ class MockRoomStorage:
return b return b
return None return None
def list_bounties(self, room_id: int) -> list[Bounty]:
if room_id not in self._rooms:
return []
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
def list_all_bounties(
self, room_id: int, include_deleted: bool = True
) -> list[Bounty]:
if room_id not in self._rooms:
return []
if include_deleted:
return self._rooms[room_id].bounties
return [b for b in self._rooms[room_id].bounties if b.deleted_at is None]
class MockTrackingStorage: class MockTrackingStorage:
"""Mock implementation of TrackingStorage for testing.""" """Mock implementation of TrackingStorage for testing."""

View File

@@ -15,18 +15,32 @@ class TestBountyService:
"""Set up fresh storage and service for each test.""" """Set up fresh storage and service for each test."""
self.storage = MockRoomStorage() self.storage = MockRoomStorage()
self.service = BountyService(self.storage) self.service = BountyService(self.storage)
self.admin_user_id = 123
self._make_admin(-1001, self.admin_user_id)
def _make_admin(self, room_id: int, user_id: int):
"""Helper to set up a room with an admin user."""
room_data = self.storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[]
)
if user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids = room_data.admin_user_ids or []
room_data.admin_user_ids.append(user_id)
self.storage.save(room_data)
def test_add_bounty_creates_room_if_not_exists(self): def test_add_bounty_creates_room_if_not_exists(self):
"""Test that add_bounty creates a new room if it doesn't exist.""" """Test that add_bounty creates a new room if it doesn't exist."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Fix bug", text="Fix bug",
link="https://github.com/issue/1", link="https://github.com/issue/1",
) )
assert bounty.id == 1 assert bounty.id == 1
assert bounty.text == "Fix bug" assert bounty.text == "Fix bug"
assert bounty.created_by_user_id == 123 assert bounty.created_by_user_id == self.admin_user_id
room = self.storage.load(-1001) room = self.storage.load(-1001)
assert room is not None assert room is not None
@@ -34,14 +48,25 @@ class TestBountyService:
def test_add_bounty_increments_id(self): def test_add_bounty_increments_id(self):
"""Test that add_bounty increments bounty ID for each new bounty.""" """Test that add_bounty increments bounty ID for each new bounty."""
b1 = self.service.add_bounty(room_id=-1001, user_id=123, text="First") b1 = self.service.add_bounty(
b2 = self.service.add_bounty(room_id=-1001, user_id=123, text="Second") room_id=-1001, user_id=self.admin_user_id, text="First"
b3 = self.service.add_bounty(room_id=-1001, user_id=123, text="Third") )
b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second"
)
b3 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Third"
)
assert b1.id == 1 assert b1.id == 1
assert b2.id == 2 assert b2.id == 2
assert b3.id == 3 assert b3.id == 3
def test_add_bounty_requires_admin(self):
"""Test that add_bounty raises PermissionError when non-admin tries to add."""
with pytest.raises(PermissionError, match="Only admins can add bounties"):
self.service.add_bounty(room_id=-1001, user_id=999, text="Not admin")
def test_list_bounties_empty_room(self): def test_list_bounties_empty_room(self):
"""Test list_bounties returns empty list for non-existent room.""" """Test list_bounties returns empty list for non-existent room."""
bounties = self.service.list_bounties(-1001) bounties = self.service.list_bounties(-1001)
@@ -49,9 +74,15 @@ class TestBountyService:
def test_list_bounties_returns_all_bounties(self): def test_list_bounties_returns_all_bounties(self):
"""Test list_bounties returns all bounties in a room.""" """Test list_bounties returns all bounties in a room."""
self.service.add_bounty(room_id=-1001, user_id=123, text="First") self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="First")
self.service.add_bounty(room_id=-1001, user_id=123, text="Second") self.service.add_bounty(
self.service.add_bounty(room_id=-999, user_id=123, text="Other room") room_id=-1001, user_id=self.admin_user_id, text="Second"
)
# Add bounty to different room to verify isolation
self._make_admin(-999, self.admin_user_id)
self.service.add_bounty(
room_id=-999, user_id=self.admin_user_id, text="Other room"
)
bounties = self.service.list_bounties(-1001) bounties = self.service.list_bounties(-1001)
assert len(bounties) == 2 assert len(bounties) == 2
@@ -59,7 +90,9 @@ class TestBountyService:
def test_get_bounty_found(self): def test_get_bounty_found(self):
"""Test get_bounty returns bounty when it exists.""" """Test get_bounty returns bounty when it exists."""
created = self.service.add_bounty(room_id=-1001, user_id=123, text="Test") created = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Test"
)
found = self.service.get_bounty(-1001, created.id) found = self.service.get_bounty(-1001, created.id)
assert found is not None assert found is not None
assert found.text == "Test" assert found.text == "Test"
@@ -71,31 +104,35 @@ class TestBountyService:
def test_get_bounty_wrong_room(self): def test_get_bounty_wrong_room(self):
"""Test get_bounty returns None when bounty is in different room.""" """Test get_bounty returns None when bounty is in different room."""
self.service.add_bounty(room_id=-1001, user_id=123, text="Test") self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="Test")
found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1 found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1
assert found is None assert found is None
def test_update_bounty_success(self): def test_update_bounty_success(self):
"""Test update_bounty succeeds when creator updates their bounty.""" """Test update_bounty succeeds when admin updates their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original") bounty = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Original"
)
result = self.service.update_bounty( result = self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
text="Updated", text="Updated",
) )
assert result is True assert result is True
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
assert updated.text == "Updated" assert updated.text == "Updated"
def test_update_bounty_not_creator_raises_permission_error(self): def test_update_bounty_not_admin_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-creator tries to update.""" """Test update_bounty raises PermissionError when non-admin tries to update."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="Original") bounty = self.service.add_bounty(
with pytest.raises(PermissionError, match="Only the creator can edit"): room_id=-1001, user_id=self.admin_user_id, text="Original"
)
with pytest.raises(PermissionError, match="Only admins can edit bounties"):
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=999, # different user user_id=999, # different user, not admin
text="Hacked", text="Hacked",
) )
@@ -104,7 +141,7 @@ class TestBountyService:
result = self.service.update_bounty( result = self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=999, bounty_id=999,
user_id=123, user_id=self.admin_user_id,
text="Updated", text="Updated",
) )
assert result is False assert result is False
@@ -113,14 +150,14 @@ class TestBountyService:
"""Test update_bounty only updates provided fields.""" """Test update_bounty only updates provided fields."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Original", text="Original",
link="https://original.link", link="https://original.link",
) )
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
text="Updated only text", text="Updated only text",
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
@@ -131,37 +168,88 @@ class TestBountyService:
"""Test update_bounty can clear link.""" """Test update_bounty can clear link."""
bounty = self.service.add_bounty( bounty = self.service.add_bounty(
room_id=-1001, room_id=-1001,
user_id=123, user_id=self.admin_user_id,
text="Test", text="Test",
link="https://original.link", link="https://original.link",
) )
self.service.update_bounty( self.service.update_bounty(
room_id=-1001, room_id=-1001,
bounty_id=bounty.id, bounty_id=bounty.id,
user_id=123, user_id=self.admin_user_id,
clear_link=True, clear_link=True,
) )
updated = self.service.get_bounty(-1001, bounty.id) updated = self.service.get_bounty(-1001, bounty.id)
assert updated.link is None assert updated.link is None
def test_delete_bounty_success(self): def test_delete_bounty_success(self):
"""Test delete_bounty succeeds when creator deletes their bounty.""" """Test delete_bounty soft deletes when admin deletes their bounty."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete") bounty = self.service.add_bounty(
result = self.service.delete_bounty(-1001, bounty.id, 123) room_id=-1001, user_id=self.admin_user_id, text="To delete"
)
result = self.service.delete_bounty(-1001, bounty.id, self.admin_user_id)
assert result is True assert result is True
# Soft delete - bounty should not be found via get_bounty
assert self.service.get_bounty(-1001, bounty.id) is None assert self.service.get_bounty(-1001, bounty.id) is None
# But still exists in list_deleted_bounties
deleted = self.service.list_deleted_bounties(-1001)
assert len(deleted) == 1
assert deleted[0].id == bounty.id
def test_delete_bounty_not_creator_raises_permission_error(self): def test_delete_bounty_not_admin_raises_permission_error(self):
"""Test delete_bounty raises PermissionError when non-creator tries to delete.""" """Test delete_bounty raises PermissionError when non-admin tries to delete."""
bounty = self.service.add_bounty(room_id=-1001, user_id=123, text="To delete") bounty = self.service.add_bounty(
with pytest.raises(PermissionError, match="Only the creator can delete"): room_id=-1001, user_id=self.admin_user_id, text="To delete"
self.service.delete_bounty(-1001, bounty.id, 999) # different user )
with pytest.raises(PermissionError, match="Only admins can delete bounties"):
self.service.delete_bounty(
-1001, bounty.id, 999
) # different user, not admin
def test_delete_bounty_not_found(self): def test_delete_bounty_not_found(self):
"""Test delete_bounty returns False when bounty doesn't exist.""" """Test delete_bounty returns False when bounty doesn't exist."""
result = self.service.delete_bounty(-1001, 999, 123) 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."""
@@ -171,9 +259,27 @@ class TestTrackingService:
self.room_storage = MockRoomStorage() self.room_storage = MockRoomStorage()
self.tracking_storage = MockTrackingStorage() self.tracking_storage = MockTrackingStorage()
self.service = TrackingService(self.tracking_storage, self.room_storage) self.service = TrackingService(self.tracking_storage, self.room_storage)
self.admin_user_id = 123
self._make_admin(-1001, self.admin_user_id)
def _make_admin(self, room_id: int, user_id: int):
"""Helper to set up a room with an admin user."""
room_data = self.room_storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=0, admin_user_ids=[]
)
if user_id not in (room_data.admin_user_ids or []):
room_data.admin_user_ids = room_data.admin_user_ids or []
room_data.admin_user_ids.append(user_id)
self.room_storage.save(room_data)
def _add_bounty(self, room_id=-1001, user_id=123, text="Test bounty"): def _add_bounty(self, room_id=-1001, user_id=123, text="Test bounty"):
"""Helper to add a bounty for tracking tests.""" """Helper to add a bounty for tracking tests."""
if self.room_storage.load(room_id) is None or user_id not in (
self.room_storage.load(room_id).admin_user_ids or []
):
self._make_admin(room_id, user_id)
bounty_service = BountyService(self.room_storage) bounty_service = BountyService(self.room_storage)
return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text) return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text)