Compare commits

..

41 Commits

Author SHA1 Message Date
shokollm
e937cc85b9 feat: implement /recover command for listing and recovering soft-deleted bounties
- Add recover_bounty method to BountyService (admin-only)
- Add cmd_recover handler for listing and recovering deleted bounties
- Register /recover command in bot.py
- Add /recover to bot command list

/recover - list all recoverable bounties (sorted by deleted_at desc)
/recover <id...> - recover specific bounty(ies)

Output formats:
List: [#1] Deleted bounty | 🗑️ Deleted 2 Apr 2026
Recover:  Recovered bounty #1. or  Bounty #5 not found or not deleted.

Fixes #49
2026-04-04 13:12:13 +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
shokollm
fee8504813 feat: add deleted_at, created_by_username to Bounty; timezone, admin_user_ids to RoomData
Issue #41: Model updates for Phase 2 features

Bounty model:
- Add deleted_at: int | None - timestamp when deleted (soft-delete)
- Add created_by_username: str | None - username for display purposes

RoomData model:
- Add timezone: str | None - room's timezone (e.g., "Asia/Jakarta")
- Add admin_user_ids: list[int] - list of admin user IDs

Storage adapter updated to handle new fields in load/save operations.
Tests added for new fields.
2026-04-04 04:59:58 +00:00
411e19e5d7 Merge pull request 'test: ensure tests package is importable' (#36) from fix/issue-15 into main 2026-04-03 21:10:27 +02:00
6dc3307e23 Merge pull request 'fix #16: cleanup - remove old/dead code and update docs' (#35) from fix/issue-16-cleanup into main 2026-04-03 21:09:59 +02:00
shokollm
1c55fe26b9 test: ensure tests package is importable
Add tests/__init__.py to make tests/ a proper Python package,
enabling cross-imports between test modules (e.g., test_services
importing from test_ports).

All 145 tests pass:
- tests/: 90 tests
- apps/telegram-bot/tests/: 55 tests
2026-04-03 15:12:59 +00:00
shokollm
99a80b0c62 fix #16: cleanup - remove old/dead code and update docs
- Delete apps/telegram-bot/storage.py (replaced by adapters/storage/json_file.py)
- Delete apps/telegram-bot/__init__.py (empty file)
- Delete apps/telegram-bot/requirements-dev.txt (dev deps in pyproject.toml)
- Update SPEC.md with new hexagonal architecture (core, adapters, apps)
- Update SPEC.md command reference: /update -> /edit
- Update README.md with new project structure and quick start
- Update CONTRIBUTING.md with new architecture and dev setup
2026-04-03 15:12:31 +00:00
50b09ef721 Merge pull request 'refactor(telegram-bot): add /edit command and make bot.py minimal entrypoint' (#34) from fix/issue-14 into main 2026-04-03 16:36:27 +02:00
shokollm
67d801d9de refactor(telegram-bot): add /edit command and make bot.py minimal entrypoint
- Add cmd_edit as alias for cmd_update
- Update bot.py to import commands directly instead of via module
- Register /edit command in bot and post_init commands list
- Clean up unused imports in bot.py

Fixes #14
2026-04-03 14:27:01 +00:00
f5cb28d45c Merge pull request 'refactor(commands): use core services instead of storage module' (#33) from fix/issue-13 into main 2026-04-03 16:14:24 +02:00
44bd0488c4 Merge pull request 'feat(cli): implement CLI for jigaido bounty tracker' (#32) from fix/issue-11 into main 2026-04-03 16:14:07 +02:00
shokollm
5e6a5f16b1 Add CLI unit tests for jigaido issue #11
- Tests mock dependencies and verify command existence, flag processing, and output format
- Tests use --group-id=-1001 format (equals sign) to avoid argparse treating -1001 as a flag
- All 17 tests passing
2026-04-03 13:18:48 +00:00
shokollm
0c36aa7b88 test(commands): add unit tests for command handlers
Add comprehensive unit tests for all command handlers:
- TestHelperFunctions: is_group, get_group_id, get_user_id, get_room_id
- TestCmdBounty: lists bounties, handles empty
- TestCmdMy: shows tracked in groups, personal in DM
- TestCmdAdd: add bounty success, validation
- TestCmdUpdate: update bounty, permission denied, invalid ID
- TestCmdDelete: delete bounty, invalid ID
- TestCmdTrack: track in group, reject in DM
- TestCmdUntrack: untrack in group, reject in DM
- TestCmdStart: group vs DM behavior
- TestCmdHelp: shows all commands

Also fix conftest.py to remove obsolete fresh_db fixture
that referenced non-existent db module.

All 55 tests pass.

Addresses han's feedback on PR #33
2026-04-03 13:11:18 +00:00
shokollm
5b1634ebca refactor(commands): use core services instead of storage module
Refactor commands.py to be thin Telegram wrappers around core services.

Changes:
- Replace 'import storage' with imports from core.services and adapters.storage
- Create module-level service instances (BountyService, TrackingService)
- Update format_bounty() to work with Bounty dataclass instead of dict
- Add get_room_id() helper for unified group/DM handling
- Each command handler is now a thin wrapper that:
  1. Extracts Telegram types (update, user_id, room_id)
  2. Calls appropriate core service
  3. Formats and sends response

Kept from original:
- parse_args()
- format_bounty()
- extract_args()

Commands now use services:
- cmd_bounty: BOUNTY_SERVICE.list_bounties()
- cmd_my: BOUNTY_SERVICE.list_bounties() or TRACKING_SERVICE.get_tracked_bounties()
- cmd_add: BOUNTY_SERVICE.add_bounty()
- cmd_update: BOUNTY_SERVICE.update_bounty()
- cmd_delete: BOUNTY_SERVICE.delete_bounty()
- cmd_track: TRACKING_SERVICE.track_bounty() (groups only)
- cmd_untrack: TRACKING_SERVICE.untrack_bounty() (groups only)

Fixes #13
2026-04-03 12:39:23 +00:00
shokollm
7202eeb1d2 feat(cli): implement CLI for jigaido bounty tracker
Create cli/main.py with the following commands:
- add <text> [--link url] [--due date] - Add a new bounty
- list - List all bounties in room
- my - List tracked bounties for user
- update <id> [text] [--link url] [--due date] [--clear-link] [--clear-due]
- delete <id> - Delete a bounty
- track <id> - Track a bounty
- untrack <id> - Untrack a bounty

Context flags:
- --group-id <id> - Group context
- --user-id <id> - User context

Requires either --group-id or --user-id for all commands.

Fixes #11
2026-04-03 12:37:55 +00:00
20 changed files with 1977 additions and 479 deletions

View File

@@ -5,27 +5,42 @@
```bash ```bash
git clone https://git.fbrns.co/shoko/jigaido.git git clone https://git.fbrns.co/shoko/jigaido.git
cd jigaido cd jigaido
# Create virtual environment
python -m venv venv python -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt
# Install dependencies
pip install -r apps/telegram-bot/requirements.txt
# Run tests
pytest
# Run bot # Run bot
export JIGAIDO_BOT_TOKEN="test:token" export JIGAIDO_BOT_TOKEN="your_bot_token"
python bot.py python -m apps.telegram-bot.bot
``` ```
## Architecture
JIGAIDO follows hexagonal architecture:
- **Core** (`core/`): Pure domain logic - models, ports (interfaces), and services
- **Adapters** (`adapters/`): Infrastructure implementations - storage adapters
- **Apps** (`apps/`): CLI applications - Telegram bot
## Code Style ## Code Style
- Python (no strict formatter enforced yet) - Python 3.10+ with type hints
- Async/await for Telegram handlers - Async/await for Telegram handlers
- Type hints where obvious
- Docstrings for public functions - Docstrings for public functions
- Follow existing code patterns
## Pull Request Workflow ## Pull Request Workflow
1. Branch from `main` 1. Branch from `main`
2. Make changes 2. Make changes
3. Test locally 3. Test locally with `pytest`
4. Open PR with description of what changed and why 4. Open PR with description of what changed and why
5. Someone reviews and merges 5. Someone reviews and merges

View File

@@ -22,14 +22,33 @@ A bounty tracking platform. Currently ships with a Telegram bot for managing and
``` ```
jigaido/ jigaido/
├── core/ # Domain layer (pure Python, no external deps)
│ ├── models.py # Domain dataclasses (Bounty, Tracking)
│ ├── ports.py # Port interfaces
│ └── services.py # Domain services
├── adapters/ # Infrastructure adapters
│ └── storage/
│ └── json_file.py # JSON file storage implementation
├── apps/ ├── apps/
│ └── telegram-bot/ ← first app (Python) │ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py │ ├── bot.py # Bot entry point
── commands.py ── commands.py # Command handlers
│ ├── cron.py ├── tests/ # Unit tests
│ ├── db.py ├── config.py # Configuration management
│ └── requirements.txt └── SPEC.md # Full design specification
└── SPEC.md ← full design specification ```
## Quick Start
```bash
# Install dependencies
pip install -r apps/telegram-bot/requirements.txt
# Set bot token
export JIGAIDO_BOT_TOKEN="your_bot_token"
# Run bot
python -m apps.telegram-bot.bot
``` ```
## License ## License

59
SPEC.md
View File

@@ -20,7 +20,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Stack ### Stack
- **Bot**: `python-telegram-bot` (pure Python, no C extensions) - **Bot**: `python-telegram-bot` (pure Python, no C extensions)
- **Storage**: Per-group JSON files (zero-setup, no DB server) - **Storage**: Per-group JSON files via `adapters/storage/json_file.py`
- **Date parsing**: `dateparser` - **Date parsing**: `dateparser`
- **Runtime**: Python 3.10+ - **Runtime**: Python 3.10+
- **Deployment**: Any $5 VPS with Python 3.10+ - **Deployment**: Any $5 VPS with Python 3.10+
@@ -28,19 +28,40 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Directory Structure ### Directory Structure
``` ```
~/.jigaido/ # Data root (~/.jigaido/) jigaido/
├── {group_id}/ ├── core/ # Domain layer
│ ├── group.json # Group bounties │ ├── models.py # Domain dataclasses (Bounty, Tracking)
── {user_id}.json # User tracking within this group ── ports.py # Port interfaces (abstract base classes)
└── {user_id}/ │ └── services.py # Domain services (BountyService, TrackingService)
└── user.json # User's personal bounties (DM mode) ├── adapters/ # Infrastructure adapters
│ └── storage/
│ └── json_file.py # JSON file storage implementation
├── apps/
│ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py # Bot entry point
│ └── commands.py # Command handlers
├── config.py # Configuration management
└── tests/ # Unit tests
``` ```
**Note:** Data directory is at `~/.jigaido/` (home directory), NOT inside the repository or app directory. ### Hexagonal Architecture
### Storage Design - **Core** (`core/`): Pure domain logic, no external dependencies
- `models.py`: Domain dataclasses
- `ports.py`: Abstract interfaces for storage
- `services.py`: Business logic
**File: `data/{group_id}/group.json`** - **Adapters** (`adapters/`): Implementations of ports
- `storage/json_file.py`: JSON file-based storage
- **Apps** (`apps/`): CLI applications
- `telegram-bot/`: Telegram bot interface
### Data Storage
Data is stored at `~/.jigaido/` (home directory), NOT inside the repository.
**File: `~/.jigaido/{group_id}/group.json`**
```json ```json
{ {
@@ -58,7 +79,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
} }
``` ```
**File: `data/{group_id}/{user_id}.json`** **File: `~/.jigaido/{group_id}/{user_id}.json`**
```json ```json
{ {
@@ -68,7 +89,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
} }
``` ```
**File: `data/{user_id}/user.json`** **File: `~/.jigaido/{user_id}/user.json`**
```json ```json
{ {
@@ -87,10 +108,10 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
**Key design decisions:** **Key design decisions:**
1. **Group-isolated storage** — Each group has its own directory. No cross-group access. 1. **Hexagonal architecture** — Core domain is isolated from infrastructure
2. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter. 2. **Group-isolated storage** Each group has its own directory. No cross-group access.
3. **Atomic writes** — Uses `tempfile` + `rename` for safe writes. 3. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter.
4. **No reminders in v1** — Dropped for simplicity. 4. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
--- ---
## Commands ## Commands
@@ -102,7 +123,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
| `/bounty` | anyone | List all bounties in this group | | `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you in this group | | `/my` | anyone | List bounties tracked by you in this group |
| `/add <text> [link] [due date]` | anyone | Add a new bounty to the group | | `/add <text> [link] [due date]` | anyone | Add a new bounty to the group |
| `/update <bounty_id> [text] [link] [due_date]` | creator only | Update an existing bounty | | `/edit <bounty_id> [text] [link] [due_date]` | creator only | Edit an existing bounty |
| `/delete <bounty_id>` | creator only | Delete a bounty | | `/delete <bounty_id>` | creator only | Delete a bounty |
| `/track <bounty_id>` | anyone | Track a group bounty | | `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Stop tracking a bounty | | `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
@@ -114,7 +135,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
| `/bounty` | List all your personal bounties | | `/bounty` | List all your personal bounties |
| `/my` | List all your personal bounties | | `/my` | List all your personal bounties |
| `/add <text> [link] [due date]` | Add a personal bounty | | `/add <text> [link] [due date]` | Add a personal bounty |
| `/update <bounty_id> [text] [link] [due_date]` | Update a personal bounty | | `/edit <bounty_id> [text] [link] [due_date]` | Edit a personal bounty |
| `/delete <bounty_id>` | Delete a personal bounty | | `/delete <bounty_id>` | Delete a personal bounty |
| `/track <bounty_id>` | Track a personal bounty | | `/track <bounty_id>` | Track a personal bounty |
@@ -149,7 +170,7 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any
## Error Handling ## Error Handling
- Unknown command → help text with available commands - Unknown command → help text with available commands
- `/update`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty." - `/edit`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty."
- `/track` already tracked → "Already tracking" (idempotent) - `/track` already tracked → "Already tracking" (idempotent)
- `/untrack` not tracked → "Not tracking" (idempotent) - `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found" - Bounty not found → "Bounty not found"

View File

@@ -56,6 +56,8 @@ class JsonFileRoomStorage:
due_date_ts=b.get("due_date_ts"), due_date_ts=b.get("due_date_ts"),
created_at=b["created_at"], created_at=b["created_at"],
created_by_user_id=b["created_by_user_id"], created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"),
) )
for b in data.get("bounties", []) for b in data.get("bounties", [])
] ]
@@ -64,6 +66,8 @@ class JsonFileRoomStorage:
room_id=data["room_id"], room_id=data["room_id"],
bounties=bounties, bounties=bounties,
next_id=data["next_id"], next_id=data["next_id"],
timezone=data.get("timezone"),
admin_user_ids=data.get("admin_user_ids", []),
) )
def save(self, room_data: RoomData) -> None: def save(self, room_data: RoomData) -> None:
@@ -71,6 +75,8 @@ class JsonFileRoomStorage:
data = { data = {
"room_id": room_data.room_id, "room_id": room_data.room_id,
"next_id": room_data.next_id, "next_id": room_data.next_id,
"timezone": room_data.timezone,
"admin_user_ids": room_data.admin_user_ids or [],
"bounties": [ "bounties": [
{ {
"id": b.id, "id": b.id,
@@ -79,6 +85,8 @@ class JsonFileRoomStorage:
"due_date_ts": b.due_date_ts, "due_date_ts": b.due_date_ts,
"created_at": b.created_at, "created_at": b.created_at,
"created_by_user_id": b.created_by_user_id, "created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at,
"created_by_username": b.created_by_username,
} }
for b in room_data.bounties for b in room_data.bounties
], ],
@@ -132,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,15 +4,24 @@ import logging
import os import os
import sys import sys
from telegram import Update from telegram.ext import Application, CommandHandler, MessageHandler, filters
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
)
import commands from commands import (
cmd_add,
cmd_admin,
cmd_bounty,
cmd_delete,
cmd_edit,
cmd_help,
cmd_my,
cmd_recover,
cmd_show,
cmd_start,
cmd_timezone,
cmd_track,
cmd_untrack,
cmd_update,
)
logging.basicConfig( logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -26,17 +35,22 @@ BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
def build_app() -> Application: def build_app() -> Application:
app = Application.builder().token(BOT_TOKEN).build() app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", commands.cmd_start)) app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", commands.cmd_help)) app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("bounty", commands.cmd_bounty)) app.add_handler(CommandHandler("bounty", cmd_bounty))
app.add_handler(CommandHandler("my", commands.cmd_my)) app.add_handler(CommandHandler("my", cmd_my))
app.add_handler(CommandHandler("add", commands.cmd_add)) app.add_handler(CommandHandler("add", cmd_add))
app.add_handler(CommandHandler("update", commands.cmd_update)) app.add_handler(CommandHandler("edit", cmd_edit))
app.add_handler(CommandHandler("delete", commands.cmd_delete)) app.add_handler(CommandHandler("update", cmd_update))
app.add_handler(CommandHandler("track", commands.cmd_track)) app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("untrack", commands.cmd_untrack)) app.add_handler(CommandHandler("track", cmd_track))
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, commands.cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
return app return app
@@ -47,8 +61,13 @@ async def post_init(app: Application) -> None:
("bounty", "List bounties"), ("bounty", "List bounties"),
("my", "Your tracked bounties"), ("my", "Your tracked bounties"),
("add", "Add a bounty"), ("add", "Add 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"),
] ]
) )

View File

@@ -1,17 +1,21 @@
"""Telegram command handlers for JIGAIDO.""" """Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import json
import os
import re
import time import time
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
import storage from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.services import BountyService, TrackingService
ROOM_STORAGE = JsonFileRoomStorage()
TRACKING_STORAGE = JsonFileTrackingStorage()
BOUNTY_SERVICE = BountyService(ROOM_STORAGE)
TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE)
TELEGRAM_BOT_USERNAME = "your_bot_username" TELEGRAM_BOT_USERNAME = "your_bot_username"
@@ -23,47 +27,84 @@ 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],
) -> 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 = [] i = 0
for arg in args: while i < len(args):
if not link and (arg.startswith("http://") or arg.startswith("https://")): arg = args[i]
if arg == "-link":
if i + 1 < len(args) and (
args[i + 1].startswith("http://") or args[i + 1].startswith("https://")
):
link = args[i + 1]
i += 2
else:
clear_link = True
i += 1
elif arg == "-date":
if i + 1 < len(args):
parsed = dateparser.parse(args[i + 1])
if parsed:
due_date_ts = int(parsed.timestamp())
i += 2
else:
clear_date = True
i += 1
else:
clear_date = True
i += 1
elif not link and (arg.startswith("http://") or arg.startswith("https://")):
link = arg link = arg
i += 1
elif due_date_ts is None: elif due_date_ts is None:
parsed = dateparser.parse(arg) parsed = dateparser.parse(arg)
if parsed: if parsed:
due_date_ts = int(parsed.timestamp()) due_date_ts = int(parsed.timestamp())
i += 1
else: else:
remaining.append(arg) 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: dict, show_id: bool = True) -> str: def format_bounty(b, show_id: bool = True, slice_length: int = 0) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b['id']}]") parts.append(f"[#{b.id}]")
if b.get("text"): if b.text:
parts.append(b["text"]) text = b.text
if b.get("link"): if slice_length > 0 and len(text) > slice_length:
parts.append(f"🔗 {b['link']}") text = text[:slice_length] + "..."
if b.get("due_date_ts"): parts.append(text)
due_str = time.strftime("%Y-%m-%d", time.localtime(b["due_date_ts"])) if b.link:
days_left = (b["due_date_ts"] - int(time.time())) // 86400 parts.append(f"🔗 {b.link}")
if b.due_date_ts:
due_str = time.strftime("%d %b %Y", time.localtime(b.due_date_ts))
days_left = (b.due_date_ts - int(time.time())) // 86400
if days_left < 0: if days_left < 0:
parts.append(f"{due_str} (OVERDUE)") parts.append(f"{due_str} (OVERDUE)")
elif days_left == 0: elif days_left == 0:
parts.append(f"{due_str} (TODAY)") parts.append(f"Today (OVERDUE)")
else: else:
parts.append(f"{due_str} ({days_left}d)") parts.append(f"{due_str} ({days_left}d)")
if b.get("created_by_user_id"):
parts.append(f"by {b['created_by_user_id']}")
return " | ".join(parts) return " | ".join(parts)
@@ -79,19 +120,71 @@ def get_user_id(update: Update) -> int:
return update.effective_user.id return update.effective_user.id
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: def get_room_id(update: Update) -> int:
if is_group(update): """Get room_id for the current context.
data = storage.load_group_bounties(get_group_id(update))
bounties = data.get("bounties", [])
else:
data = storage.load_user_personal(get_user_id(update))
bounties = data.get("bounties", [])
if not bounties: For groups: negative group_id
await update.message.reply_text("No bounties yet.") For DMs: positive user_id
"""
if is_group(update):
return get_group_id(update)
return get_user_id(update)
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update)
args = extract_args(update.message.text)
show_all = "all" in args
args = [a for a in args if a != "all"]
try:
limit = int(args[0]) if args else 5
except (ValueError, IndexError):
limit = 5
now = int(time.time())
cutoff_24h = now - 86400
all_bounties = BOUNTY_SERVICE.list_bounties(room_id)
def is_expired(b) -> bool:
return b.due_date_ts is not None and b.due_date_ts < cutoff_24h
def sort_key(b):
if b.due_date_ts is not None:
return (0, b.due_date_ts)
return (1, b.created_at)
filtered_bounties = [b for b in all_bounties if not is_expired(b) or show_all]
filtered_bounties.sort(key=sort_key)
total_count = len(filtered_bounties)
displayed_bounties = filtered_bounties[:limit]
if not displayed_bounties:
if show_all:
await update.message.reply_text("No bounties yet.")
else:
await update.message.reply_text(
"No active bounties. Use /bounty all to show expired."
)
return return
lines = [format_bounty(dict(b), show_id=True) for b in bounties] lines = []
if limit < total_count:
lines.append(f"Showing {limit} of {total_count} bounties:")
slice_length = 40
elif show_all and total_count > limit:
lines.append(f"Showing {limit} of {total_count} bounties (including expired):")
slice_length = 40
else:
lines.append(f"Showing {total_count} bounties:")
slice_length = 0
for b in displayed_bounties:
lines.append(format_bounty(b, show_id=True, slice_length=slice_length))
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -100,38 +193,23 @@ 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)
tracking = storage.load_user_tracking(group_id, user_id) bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id)
tracked = tracking.get("tracked", []) room_id = group_id
else: else:
data = storage.load_user_personal(user_id) room_id = get_room_id(update)
bounties = data.get("bounties", []) bounties = BOUNTY_SERVICE.list_bounties(room_id)
lines = [format_bounty(dict(b), show_id=True) for b in bounties]
await update.message.reply_text( if not bounties:
"\n".join(lines) if lines else "No personal bounties.", msg = (
disable_web_page_preview=True, "You are not tracking any bounties."
if is_group(update)
else "No personal bounties."
) )
await update.message.reply_text(msg)
return return
if not tracked: lines = [format_bounty(b, show_id=True) for b in bounties]
await update.message.reply_text("You are not tracking any bounties.") await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
return
group_data = storage.load_group_bounties(group_id)
bounty_map = {b["id"]: b for b in group_data.get("bounties", [])}
bounty_lines = []
for t in tracked:
bounty = bounty_map.get(t["bounty_id"])
if bounty:
bounty_lines.append(format_bounty(bounty, show_id=True))
if not bounty_lines:
await update.message.reply_text("You are not tracking any bounties.")
return
await update.message.reply_text(
"\n".join(bounty_lines), disable_web_page_preview=True
)
async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -143,25 +221,34 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
) )
return return
text, link, due_date_ts = parse_args(args) text, link, due_date_ts, _, _ = parse_args(args)
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) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) bounty = BOUNTY_SERVICE.add_bounty(
bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts) room_id=room_id,
else: user_id=user_id,
bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts) text=text,
link=link,
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))}" due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}"
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,
) )
@@ -170,7 +257,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
@@ -180,31 +274,45 @@ 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:]) text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:])
if not text and not link and due_date_ts is None: 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) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) success = BOUNTY_SERVICE.update_bounty(
bounty = storage.get_group_bounty(group_id, bounty_id) room_id=room_id,
if not bounty: bounty_id=bounty_id,
await update.message.reply_text("Bounty not found.") user_id=user_id,
return text=text,
if bounty["created_by_user_id"] != user_id: link=link,
await update.message.reply_text("⛔ Only the creator can edit this bounty.") due_date_ts=due_date_ts,
return clear_link=clear_link,
storage.update_group_bounty(group_id, bounty_id, text, link, due_date_ts) clear_due=clear_date,
)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
except ValueError as e:
await update.message.reply_text(f"{e}")
return
if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
else: else:
bounty = storage.get_personal_bounty(user_id, bounty_id) await update.message.reply_text("Bounty not found.")
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.update_personal_bounty(user_id, bounty_id, text, link, due_date_ts)
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
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:
@@ -220,30 +328,91 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) success = BOUNTY_SERVICE.delete_bounty(
bounty = storage.get_group_bounty(group_id, bounty_id) room_id=room_id,
if not bounty: bounty_id=bounty_id,
await update.message.reply_text("Bounty not found.") user_id=user_id,
return )
if bounty["created_by_user_id"] != user_id: except PermissionError as e:
await update.message.reply_text( await update.message.reply_text(f"{e}")
"⛔ Only the creator can delete this bounty." return
)
return if success:
storage.delete_group_bounty(group_id, bounty_id) await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
else: else:
bounty = storage.get_personal_bounty(user_id, bounty_id) await update.message.reply_text("Bounty not found.")
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.delete_personal_bounty(user_id, bounty_id)
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
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)
try:
if not BOUNTY_SERVICE.is_admin(room_id, user_id):
await update.message.reply_text("⛔ Only admins can recover bounties.")
return
except PermissionError as e:
await update.message.reply_text(f"{e}")
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:
deleted_str = time.strftime("%d %b %Y", time.localtime(b.deleted_at))
text_part = (
b.text[:40] + "..."
if b.text and len(b.text) > 40
else (b.text or "(no text)")
)
lines.append(f"[#{b.id}] {text_part} | 🗑️ Deleted {deleted_str}")
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
return
bounty_ids = []
for arg in args:
try:
bounty_ids.append(int(arg))
except ValueError:
await update.message.reply_text(f"Invalid bounty ID: {arg}")
return
results = []
for bounty_id in bounty_ids:
try:
success, message = BOUNTY_SERVICE.recover_bounty(
room_id=room_id,
bounty_id=bounty_id,
user_id=user_id,
)
except PermissionError as e:
results.append(f"{e}")
continue
if success:
results.append(f"{message}")
else:
results.append(f"{message}")
await update.message.reply_text("\n".join(results))
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /track is only available in groups.")
return
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /track <bounty_id>") await update.message.reply_text("Usage: /track <bounty_id>")
@@ -256,25 +425,22 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) if TRACKING_SERVICE.track_bounty(room_id, user_id, bounty_id):
bounty = storage.get_group_bounty(group_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
if storage.track_bounty(group_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
else:
if storage.track_bounty(user_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else: else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
except ValueError as e:
await update.message.reply_text(str(e))
async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /untrack is only available in groups.")
return
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /untrack <bounty_id>") await update.message.reply_text("Usage: /untrack <bounty_id>")
@@ -287,18 +453,12 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): if TRACKING_SERVICE.untrack_bounty(room_id, user_id, bounty_id):
group_id = get_group_id(update) await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
if storage.untrack_bounty(group_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
else: else:
if storage.untrack_bounty(user_id, user_id, bounty_id): await update.message.reply_text("Not tracking bounty #{bounty_id}.")
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -321,6 +481,136 @@ async def cmd_start(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 = BOUNTY_SERVICE.get_timezone(room_id)
lines = []
title = bounty.text or "(no text)"
lines.append(f"[#{bounty.id}] {title}")
if bounty.link:
lines.append(f"🔗 {bounty.link}")
if bounty.due_date_ts:
due_str = time.strftime("%d %B %Y %H:%M", time.localtime(bounty.due_date_ts))
lines.append(f"📅 {due_str} ({timezone})")
username = bounty.created_by_username or f"user#{bounty.created_by_user_id}"
lines.append(f"👤 @{username}")
created_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(bounty.created_at))
lines.append(f"📌 Created: {created_str}")
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
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}.")
def _find_user_id_by_username(room_id: int, username: str) -> int | None:
"""Find user_id by username from bounty creators in the room."""
bounties = BOUNTY_SERVICE.list_bounties(room_id)
for bounty in bounties:
if (
bounty.created_by_username
and bounty.created_by_username.lower() == username.lower()
):
return bounty.created_by_user_id
return None
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text)
if not args or args[0] not in ("add", "remove"):
await update.message.reply_text(
"Usage:\n"
"/admin add @username — add admin\n"
"/admin remove @username — remove admin"
)
return
subcommand = args[0]
if len(args) < 2:
await update.message.reply_text(f"Usage: /admin {subcommand} @username")
return
raw_username = args[1]
if not raw_username.startswith("@"):
await update.message.reply_text(f"Usage: /admin {subcommand} @username")
return
username = raw_username[1:]
user_id = get_user_id(update)
room_id = get_room_id(update)
if not BOUNTY_SERVICE.is_admin(room_id, user_id):
await update.message.reply_text("⛔ Only admins can perform this action.")
return
target_user_id = _find_user_id_by_username(room_id, username)
if target_user_id is None:
await update.message.reply_text(f"⛔ User @{username} not found.")
return
try:
if subcommand == "add":
BOUNTY_SERVICE.add_admin(room_id, target_user_id, user_id)
await update.message.reply_text(f"✅ @{username} is now an admin.")
elif subcommand == "remove":
BOUNTY_SERVICE.remove_admin(room_id, target_user_id, user_id)
await update.message.reply_text(f"✅ @{username} is no longer an admin.")
except PermissionError as e:
await update.message.reply_text(f"{e}")
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO Commands:\n\n" "👻 JIGAIDO Commands:\n\n"
@@ -328,9 +618,15 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"/my — bounties you're tracking\n" "/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty\n" "/add <text> [link] [due] — add bounty\n"
"/update <id> [text] [link] [due] — update bounty\n" "/update <id> [text] [link] [due] — update bounty\n"
"/delete <id> — delete bounty\n" "/edit <id> [text] [link] [due] — edit bounty (same as update)\n"
"/track <id> — track a bounty\n" " /edit <id> -link [<url>] — clear or set link\n"
"/untrack <id> — stop tracking\n" " /edit <id> -date [<date>] — clear or set date\n"
"/delete <id> — delete bounty (admin only)\n"
"/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n"
"/show <id> — show bounty details\n"
"/timezone — get room timezone\n"
"/timezone <tz> — set room timezone (admin only)\n"
"/start — re-initialize\n" "/start — re-initialize\n"
"/help — this message", "/help — this message",
disable_web_page_preview=True, disable_web_page_preview=True,

View File

@@ -1,4 +0,0 @@
python-telegram-bot==21.6
dateparser==1.2.0
pytest==8.3.5
pytest-asyncio==0.25.2

View File

@@ -1,216 +0,0 @@
"""Per-group JSON file storage for JIGAIDO."""
import json
import os
import tempfile
from pathlib import Path
from typing import Optional
DATA_DIR = Path.home() / ".jigaido"
def _ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _group_dir(group_id: int) -> Path:
return DATA_DIR / str(group_id)
def _user_personal_dir(user_id: int) -> Path:
return DATA_DIR / str(user_id)
def _group_file(group_id: int) -> Path:
return _group_dir(group_id) / "group.json"
def _user_tracking_file(group_id: int, user_id: int) -> Path:
return _group_dir(group_id) / f"{user_id}.json"
def _user_personal_file(user_id: int) -> Path:
return _user_personal_dir(user_id) / "user.json"
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, path)
def load_group_bounties(group_id: int) -> dict:
_ensure_dirs()
path = _group_file(group_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_group_bounties(group_id: int, data: dict) -> None:
_atomic_write(_group_file(group_id), data)
def add_group_bounty(
group_id: int,
created_by_user_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> dict:
data = load_group_bounties(group_id)
bounty = {
"id": data["next_id"],
"created_by_user_id": created_by_user_id,
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": int(os.path.getctime(_group_file(group_id)))
if _group_file(group_id).exists()
else 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_group_bounties(group_id, data)
return bounty
def update_group_bounty(
group_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_group_bounties(group_id, data)
return True
return False
def delete_group_bounty(group_id: int, bounty_id: int) -> bool:
data = load_group_bounties(group_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_group_bounties(group_id, data)
return True
return False
def get_group_bounty(group_id: int, bounty_id: int) -> Optional[dict]:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None
def load_user_tracking(group_id: int, user_id: int) -> dict:
path = _user_tracking_file(group_id, user_id)
if not path.exists():
return {"tracked": []}
with open(path) as f:
return json.load(f)
def save_user_tracking(group_id: int, user_id: int, data: dict) -> None:
_atomic_write(_user_tracking_file(group_id, user_id), data)
def track_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
if any(t["bounty_id"] == bounty_id for t in data["tracked"]):
return False
data["tracked"].append({"bounty_id": bounty_id})
save_user_tracking(group_id, user_id, data)
return True
def untrack_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
for i, t in enumerate(data["tracked"]):
if t["bounty_id"] == bounty_id:
data["tracked"].pop(i)
save_user_tracking(group_id, user_id, data)
return True
return False
def load_user_personal(user_id: int) -> dict:
path = _user_personal_file(user_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_user_personal(user_id: int, data: dict) -> None:
_atomic_write(_user_personal_file(user_id), data)
def add_personal_bounty(
user_id: int, text: Optional[str], link: Optional[str], due_date_ts: Optional[int]
) -> dict:
data = load_user_personal(user_id)
bounty = {
"id": data["next_id"],
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_user_personal(user_id, data)
return bounty
def update_personal_bounty(
user_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_user_personal(user_id, data)
return True
return False
def delete_personal_bounty(user_id: int, bounty_id: int) -> bool:
data = load_user_personal(user_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_user_personal(user_id, data)
return True
return False
def get_personal_bounty(user_id: int, bounty_id: int) -> Optional[dict]:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None

View File

@@ -6,22 +6,5 @@ from pathlib import Path
import pytest import pytest
# Add the app directory to path so `import db` works when running pytest # Add the app directory to path so imports work when running pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
"""Replace DB_PATH with a temp file before any test runs."""
import db as _db
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
monkeypatch.setattr(_db, "DB_PATH", tmp_path)
_db.init_db()
yield tmp_path
tmp_path.unlink(missing_ok=True)

View File

@@ -1,11 +1,33 @@
"""Tests for commands.py — parsing and formatting functions only.""" """Tests for commands.py — parsing, formatting, and command handlers."""
import time import time
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch, AsyncMock, sentinel
import pytest import pytest
from commands import extract_args, parse_args, format_bounty from telegram import Update, Message, User, Chat, CallbackQuery
from telegram.ext import ContextTypes
from commands import (
extract_args,
parse_args,
format_bounty,
cmd_bounty,
cmd_my,
cmd_add,
cmd_update,
cmd_delete,
cmd_track,
cmd_untrack,
cmd_start,
cmd_help,
is_group,
get_group_id,
get_user_id,
get_room_id,
BOUNTY_SERVICE,
TRACKING_SERVICE,
)
class TestExtractArgs: class TestExtractArgs:
@@ -110,13 +132,11 @@ class TestFormatBounty:
created_by_user_id=123456, created_by_user_id=123456,
): ):
row = MagicMock() row = MagicMock()
row.__getitem__ = lambda s, k: { row.id = id
"id": id, row.text = text
"text": text, row.link = link
"link": link, row.due_date_ts = due_date_ts
"due_date_ts": due_date_ts, row.created_by_user_id = created_by_user_id
"created_by_user_id": created_by_user_id,
}[k]
return row return row
def test_shows_id(self): def test_shows_id(self):
@@ -168,3 +188,378 @@ class TestFormatBounty:
b = self._row(created_by_user_id=999) b = self._row(created_by_user_id=999)
out = format_bounty(b) out = format_bounty(b)
assert "999" in out assert "999" in out
def create_mock_update(
user_id=123,
chat_id=-456,
chat_type="group",
message_text="/bounty",
):
"""Create a mock Telegram Update with common values."""
user = MagicMock(spec=User)
user.id = user_id
chat = MagicMock(spec=Chat)
chat.id = chat_id
chat.type = chat_type
message = MagicMock(spec=Message)
message.text = message_text
message.reply_text = AsyncMock()
message.user = user
update = MagicMock(spec=Update)
update.effective_user = user
update.effective_chat = chat
update.message = message
return update
class TestHelperFunctions:
"""Test helper functions."""
def test_is_group_true(self):
update = create_mock_update(chat_type="group")
assert is_group(update) is True
def test_is_group_false_for_private(self):
update = create_mock_update(chat_type="private")
assert is_group(update) is False
def test_get_group_id(self):
update = create_mock_update(chat_id=-789)
assert get_group_id(update) == -789
def test_get_user_id(self):
update = create_mock_update(user_id=999)
assert get_user_id(update) == 999
def test_get_room_id_group(self):
update = create_mock_update(chat_id=-456, chat_type="group", user_id=123)
assert get_room_id(update) == -456
def test_get_room_id_private(self):
update = create_mock_update(chat_id=123, chat_type="private", user_id=123)
assert get_room_id(update) == 123
class TestCmdBounty:
"""Test cmd_bounty command."""
@pytest.mark.asyncio
async def test_lists_bounties(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Test"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
) as mock_list:
await cmd_bounty(update, ctx)
mock_list.assert_called_once_with(-456)
update.message.reply_text.assert_called_once()
call_args = update.message.reply_text.call_args[0][0]
assert "[#1]" in call_args
assert "Test" in call_args
@pytest.mark.asyncio
async def test_no_bounties(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(BOUNTY_SERVICE, "list_bounties", return_value=[]):
await cmd_bounty(update, ctx)
update.message.reply_text.assert_called_once_with("No bounties yet.")
class TestCmdMy:
"""Test cmd_my command."""
@pytest.mark.asyncio
async def test_in_group_shows_tracked(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Tracked"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
TRACKING_SERVICE, "get_tracked_bounties", return_value=[mock_bounty]
) as mock_track:
await cmd_my(update, ctx)
mock_track.assert_called_once_with(-456, 123)
update.message.reply_text.assert_called_once()
@pytest.mark.asyncio
async def test_in_private_shows_personal(self):
update = create_mock_update(chat_type="private", chat_id=123)
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 2
mock_bounty.text = "Personal"
mock_bounty.link = None
mock_bounty.due_date_ts = None
mock_bounty.created_by_user_id = 123
with patch.object(
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
) as mock_list:
await cmd_my(update, ctx)
mock_list.assert_called_once_with(123)
update.message.reply_text.assert_called_once()
@pytest.mark.asyncio
async def test_no_bounties_tracked(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(TRACKING_SERVICE, "get_tracked_bounties", return_value=[]):
await cmd_my(update, ctx)
update.message.reply_text.assert_called_once_with(
"You are not tracking any bounties."
)
class TestCmdAdd:
"""Test cmd_add command."""
@pytest.mark.asyncio
async def test_add_bounty_success(self):
update = create_mock_update(message_text="/add Fix the bug")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
mock_bounty = MagicMock()
mock_bounty.id = 42
with patch.object(
BOUNTY_SERVICE, "add_bounty", return_value=mock_bounty
) as mock_add:
await cmd_add(update, ctx)
mock_add.assert_called_once()
call_kwargs = mock_add.call_args[1]
assert call_kwargs["room_id"] == -456
assert call_kwargs["user_id"] == 123
assert call_kwargs["text"] == "Fix the bug"
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_add_without_args(self):
update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_add(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_add_needs_text_or_link(self):
update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
_, link, _ = parse_args([])
if not "test" and not link:
await update.message.reply_text("A bounty needs at least text or a link.")
update.message.reply_text.assert_called_once()
class TestCmdUpdate:
"""Test cmd_update command."""
@pytest.mark.asyncio
async def test_update_bounty_success(self):
update = create_mock_update(message_text="/update 1 New text")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE, "update_bounty", return_value=True
) as mock_update:
await cmd_update(update, ctx)
mock_update.assert_called_once()
call_kwargs = mock_update.call_args[1]
assert call_kwargs["room_id"] == -456
assert call_kwargs["bounty_id"] == 1
assert call_kwargs["text"] == "New text"
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_update_without_args(self):
update = create_mock_update(message_text="/update")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_update_invalid_id(self):
update = create_mock_update(message_text="/update abc")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
@pytest.mark.asyncio
async def test_update_permission_denied(self):
update = create_mock_update(message_text="/update 1 new text")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE,
"update_bounty",
side_effect=PermissionError("Not your bounty"),
):
await cmd_update(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
class TestCmdDelete:
"""Test cmd_delete command."""
@pytest.mark.asyncio
async def test_delete_bounty_success(self):
update = create_mock_update(message_text="/delete 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
BOUNTY_SERVICE, "delete_bounty", return_value=True
) as mock_delete:
await cmd_delete(update, ctx)
mock_delete.assert_called_once_with(room_id=-456, bounty_id=1, user_id=123)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_delete_without_args(self):
update = create_mock_update(message_text="/delete")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_delete(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_delete_invalid_id(self):
update = create_mock_update(message_text="/delete abc")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_delete(update, ctx)
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
class TestCmdTrack:
"""Test cmd_track command."""
@pytest.mark.asyncio
async def test_track_in_group(self):
update = create_mock_update(chat_type="group", message_text="/track 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
TRACKING_SERVICE, "track_bounty", return_value=True
) as mock_track:
await cmd_track(update, ctx)
mock_track.assert_called_once_with(-456, 123, 1)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_track_not_in_group(self):
update = create_mock_update(chat_type="private", message_text="/track 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_track(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_track_without_args(self):
update = create_mock_update(chat_type="group", message_text="/track")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_track(update, ctx)
update.message.reply_text.assert_called_once()
assert "Usage" in update.message.reply_text.call_args[0][0]
class TestCmdUntrack:
"""Test cmd_untrack command."""
@pytest.mark.asyncio
async def test_untrack_in_group(self):
update = create_mock_update(chat_type="group", message_text="/untrack 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
with patch.object(
TRACKING_SERVICE, "untrack_bounty", return_value=True
) as mock_untrack:
await cmd_untrack(update, ctx)
mock_untrack.assert_called_once_with(-456, 123, 1)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
@pytest.mark.asyncio
async def test_untrack_not_in_group(self):
update = create_mock_update(chat_type="private", message_text="/untrack 1")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_untrack(update, ctx)
update.message.reply_text.assert_called_once()
assert "" in update.message.reply_text.call_args[0][0]
class TestCmdStart:
"""Test cmd_start command."""
@pytest.mark.asyncio
async def test_start_in_group(self):
update = create_mock_update(chat_type="group")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_start(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "👻" in text
assert "/bounty" in text
@pytest.mark.asyncio
async def test_start_in_private(self):
update = create_mock_update(chat_type="private")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_start(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "👻" in text
assert "/my" in text
class TestCmdHelp:
"""Test cmd_help command."""
@pytest.mark.asyncio
async def test_help_shows_commands(self):
update = create_mock_update()
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
await cmd_help(update, ctx)
update.message.reply_text.assert_called_once()
text = update.message.reply_text.call_args[0][0]
assert "/bounty" in text
assert "/add" in text
assert "/help" in text

251
cli/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""JIGAIDO CLI - Command line interface for bounty tracking."""
import argparse
import sys
from pathlib import Path
import dateparser
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from config import Config
from core.services import BountyService, TrackingService
def parse_due_date(due_str: str | None) -> int | None:
"""Parse due date string to Unix timestamp."""
if not due_str:
return None
parsed = dateparser.parse(due_str)
if parsed:
return int(parsed.timestamp())
return None
def create_services():
"""Create service instances with storage."""
config = Config()
config.ensure_data_dir()
room_storage = JsonFileRoomStorage(config.data_dir / "room")
tracking_storage = JsonFileTrackingStorage(config.data_dir / "tracking")
bounty_service = BountyService(room_storage)
tracking_service = TrackingService(tracking_storage, room_storage)
return bounty_service, tracking_service
def cmd_add(args):
"""Add a new bounty."""
bounty_service, _ = create_services()
due_ts = parse_due_date(args.due)
bounty = bounty_service.add_bounty(
room_id=args.group_id or args.user_id,
user_id=args.user_id or 0,
text=args.text,
link=args.link,
due_date_ts=due_ts,
)
print(f"Added bounty #{bounty.id}")
def cmd_list(args):
"""List all bounties in a room."""
bounty_service, _ = create_services()
bounties = bounty_service.list_bounties(room_id=args.group_id or args.user_id)
if not bounties:
print("No bounties")
return
for b in bounties:
due_str = (
f", due: {dateparser.parse(str(b.due_date_ts)).strftime('%Y-%m-%d')}"
if b.due_date_ts
else ""
)
link_str = f", link: {b.link}" if b.link else ""
print(f"#{b.id}: {b.text or '(no text)'}{link_str}{due_str}")
def cmd_my(args):
"""List tracked bounties for a user."""
_, tracking_service = create_services()
room_id = args.group_id or args.user_id
tracked = tracking_service.get_tracked_bounties(
room_id=room_id, user_id=args.user_id
)
if not tracked:
print("Not tracking any bounties")
return
for b in tracked:
due_str = (
f", due: {dateparser.parse(str(b.due_date_ts)).strftime('%Y-%m-%d')}"
if b.due_date_ts
else ""
)
link_str = f", link: {b.link}" if b.link else ""
print(f"#{b.id}: {b.text or '(no text)'}{link_str}{due_str}")
def cmd_update(args):
"""Update a bounty."""
bounty_service, _ = create_services()
due_ts = parse_due_date(args.due)
try:
success = bounty_service.update_bounty(
room_id=args.group_id or args.user_id,
bounty_id=args.bounty_id,
user_id=args.user_id or 0,
text=args.text,
link=args.link,
due_date_ts=due_ts,
clear_link=args.clear_link,
clear_due=args.clear_due,
)
if success:
print(f"Updated bounty #{args.bounty_id}")
else:
print(f"Bounty #{args.bounty_id} not found", file=sys.stderr)
sys.exit(1)
except PermissionError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_delete(args):
"""Delete a bounty."""
bounty_service, _ = create_services()
try:
success = bounty_service.delete_bounty(
room_id=args.group_id or args.user_id,
bounty_id=args.bounty_id,
user_id=args.user_id or 0,
)
if success:
print(f"Deleted bounty #{args.bounty_id}")
else:
print(f"Bounty #{args.bounty_id} not found", file=sys.stderr)
sys.exit(1)
except PermissionError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_track(args):
"""Track a bounty."""
_, tracking_service = create_services()
try:
success = tracking_service.track_bounty(
room_id=args.group_id,
user_id=args.user_id,
bounty_id=args.bounty_id,
)
if success:
print(f"Tracking bounty #{args.bounty_id}")
else:
print(f"Already tracking bounty #{args.bounty_id}")
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_untrack(args):
"""Untrack a bounty."""
_, tracking_service = create_services()
success = tracking_service.untrack_bounty(
room_id=args.group_id,
user_id=args.user_id,
bounty_id=args.bounty_id,
)
if success:
print(f"Untracked bounty #{args.bounty_id}")
else:
print(f"Not tracking bounty #{args.bounty_id}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="jigaido-cli", description="JIGAIDO bounty tracker CLI"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
parser_add = subparsers.add_parser("add", help="Add a new bounty")
parser_add.add_argument("text", help="Bounty description")
parser_add.add_argument("--link", help="Optional link")
parser_add.add_argument(
"--due", help="Optional due date (e.g., 'tomorrow', 'in 3 days')"
)
parser_list = subparsers.add_parser("list", help="List all bounties")
parser_my = subparsers.add_parser("my", help="List tracked bounties")
parser_update = subparsers.add_parser("update", help="Update a bounty")
parser_update.add_argument("bounty_id", type=int, help="Bounty ID to update")
parser_update.add_argument("text", nargs="?", help="New description")
parser_update.add_argument("--link", help="New link")
parser_update.add_argument("--due", help="New due date")
parser_update.add_argument("--clear-link", action="store_true", help="Clear link")
parser_update.add_argument(
"--clear-due", action="store_true", help="Clear due date"
)
parser_delete = subparsers.add_parser("delete", help="Delete a bounty")
parser_delete.add_argument("bounty_id", type=int, help="Bounty ID to delete")
parser_track = subparsers.add_parser("track", help="Track a bounty")
parser_track.add_argument("bounty_id", type=int, help="Bounty ID to track")
parser_untrack = subparsers.add_parser("untrack", help="Untrack a bounty")
parser_untrack.add_argument("bounty_id", type=int, help="Bounty ID to untrack")
for sp in [
parser_add,
parser_list,
parser_my,
parser_update,
parser_delete,
parser_track,
parser_untrack,
]:
sp.add_argument(
"--group-id", type=int, help="Group context (use group room ID)"
)
sp.add_argument(
"--user-id", type=int, help="User context (use Telegram user ID)"
)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if not args.group_id and not args.user_id:
print("Error: either --group-id or --user-id is required", file=sys.stderr)
sys.exit(1)
if args.command in ("add", "list", "my", "update", "delete"):
if not (args.group_id or args.user_id):
print("Error: --group-id or --user-id required", file=sys.stderr)
sys.exit(1)
if args.command == "add" and not args.text:
print("Error: text is required for add", file=sys.stderr)
sys.exit(1)
command_map = {
"add": cmd_add,
"list": cmd_list,
"my": cmd_my,
"update": cmd_update,
"delete": cmd_delete,
"track": cmd_track,
"untrack": cmd_untrack,
}
if args.command in command_map:
command_map[args.command](args)
else:
print(f"Unknown command: {args.command}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -9,6 +9,9 @@ class Bounty:
The created_by_user_id field always refers to the user who created the bounty. The created_by_user_id field always refers to the user who created the bounty.
It does NOT indicate whether the bounty is a group or personal bounty. It does NOT indicate whether the bounty is a group or personal bounty.
The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp.
""" """
id: int id: int
@@ -17,6 +20,8 @@ class Bounty:
due_date_ts: int | None due_date_ts: int | None
created_at: int created_at: int
created_by_user_id: int created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
@dataclass @dataclass
@@ -37,11 +42,20 @@ class RoomData:
The room_id can be negative for Telegram groups or positive for DMs. The room_id can be negative for Telegram groups or positive for DMs.
The next_id field is used to generate unique bounty IDs within this room. The next_id field is used to generate unique bounty IDs within this room.
The timezone field stores the room's timezone (e.g., "Asia/Jakarta"), default UTC+0.
The admin_user_ids field lists users who have admin privileges in this room.
""" """
room_id: int room_id: int
bounties: list[Bounty] bounties: list[Bounty]
next_id: int next_id: int
timezone: str | None = None
admin_user_ids: list[int] | None = None
def __post_init__(self):
if self.admin_user_ids is None:
self.admin_user_ids = []
@dataclass @dataclass

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,103 @@ 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."""
if not self.is_admin(room_id, requesting_user_id):
raise PermissionError("Only admins can add admins.")
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=[]
)
if admin_user_id not in (room_data.admin_user_ids or []):
(room_data.admin_user_ids or []).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,
@@ -28,7 +120,13 @@ class BountyService:
link: Optional[str] = None, link: Optional[str] = None,
due_date_ts: Optional[int] = None, due_date_ts: Optional[int] = 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)
@@ -47,15 +145,20 @@ 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_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 +171,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 +192,51 @@ 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 recover_bounty(
self, room_id: int, bounty_id: int, user_id: int
) -> tuple[bool, str]:
"""Recover a soft-deleted bounty. Only admins can recover.
Returns (success, message) tuple.
"""
if not self.is_admin(room_id, user_id):
raise PermissionError("Only admins can recover bounties.")
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
bounty = None
for b in all_bounties:
if b.id == bounty_id:
bounty = b
break
if not bounty:
return False, f"Bounty #{bounty_id} not found."
if bounty.deleted_at is None:
return False, f"Bounty #{bounty_id} is not deleted."
bounty.deleted_at = None
self._storage.update_bounty(room_id, bounty)
return True, f"Recovered bounty #{bounty_id}."
class TrackingService: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""
@@ -147,7 +285,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

0
tests/__init__.py Normal file
View File

376
tests/test_cli.py Normal file
View File

@@ -0,0 +1,376 @@
"""Tests for cli/main.py — CLI commands with mocked dependencies."""
import pytest
from unittest.mock import patch, MagicMock
from io import StringIO
import sys
from core.models import Bounty
from core.ports import RoomStorage, TrackingStorage
class MockRoomStorage(RoomStorage):
"""Mock RoomStorage for testing."""
def __init__(self):
self._rooms: dict[int, dict] = {}
def load(self, room_id: int) -> dict | None:
return self._rooms.get(room_id)
def save(self, room_data: dict) -> None:
self._rooms[room_data["room_id"]] = room_data
def add_bounty(self, room_id: int, bounty: Bounty) -> None:
if room_id not in self._rooms:
self._rooms[room_id] = {"room_id": room_id, "bounties": [], "next_id": 1}
self._rooms[room_id]["bounties"].append(bounty)
def update_bounty(self, room_id: int, bounty: Bounty) -> None:
if room_id in self._rooms:
for i, b in enumerate(self._rooms[room_id]["bounties"]):
if b.id == bounty.id:
self._rooms[room_id]["bounties"][i] = bounty
break
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
if room_id in self._rooms:
self._rooms[room_id]["bounties"] = [
b for b in self._rooms[room_id]["bounties"] if b.id != bounty_id
]
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms:
for b in self._rooms[room_id]["bounties"]:
if b.id == bounty_id:
return b
return None
class MockTrackingStorage(TrackingStorage):
"""Mock TrackingStorage for testing."""
def __init__(self):
self._tracking: dict[int, dict] = {}
def load(self, user_id: int) -> dict | None:
return self._tracking.get(user_id)
def save(self, tracking_data: dict) -> None:
self._tracking[tracking_data["user_id"]] = tracking_data
def track_bounty(self, user_id: int, bounty_id: int, room_id: int) -> bool:
if user_id not in self._tracking:
self._tracking[user_id] = {"user_id": user_id, "bounty_ids": []}
if bounty_id not in self._tracking[user_id]["bounty_ids"]:
self._tracking[user_id]["bounty_ids"].append(bounty_id)
return True
return False
def untrack_bounty(self, user_id: int, bounty_id: int) -> bool:
if user_id in self._tracking:
if bounty_id in self._tracking[user_id]["bounty_ids"]:
self._tracking[user_id]["bounty_ids"].remove(bounty_id)
return True
return False
def get_tracked_bounty_ids(self, user_id: int) -> list[int]:
if user_id in self._tracking:
return self._tracking[user_id]["bounty_ids"]
return []
@pytest.fixture
def mock_services():
"""Create mock services for CLI testing."""
room_storage = MockRoomStorage()
tracking_storage = MockTrackingStorage()
return room_storage, tracking_storage
class TestCLIParsing:
"""Test CLI argument parsing through main()."""
def test_add_command_requires_text(self):
"""Test that 'add' command requires text argument."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "add", "--group-id=-1001"]):
with patch("cli.main.create_services"):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "arguments are required: text" in stderr.getvalue()
def test_list_command(self):
"""Test 'list' command parsing."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_create.return_value = (MagicMock(), MagicMock())
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = []
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "No bounties" in stdout.getvalue()
def test_my_command(self):
"""Test 'my' command parsing."""
from cli.main import main
with patch(
"sys.argv", ["jigaido-cli", "my", "--group-id=-1001", "--user-id=123"]
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.get_tracked_bounties.return_value = []
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Not tracking any bounties" in stdout.getvalue()
def test_update_command(self):
"""Test 'update' command parsing."""
from cli.main import main
with patch(
"sys.argv", ["jigaido-cli", "update", "1", "--group-id=-1001", "new text"]
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Updated bounty #1" in stdout.getvalue()
def test_delete_command(self):
"""Test 'delete' command parsing."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "delete", "1", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.delete_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Deleted bounty #1" in stdout.getvalue()
def test_track_command(self):
"""Test 'track' command parsing."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.track_bounty.return_value = True
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Tracking bounty #1" in stdout.getvalue()
def test_untrack_command(self):
"""Test 'untrack' command parsing."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "untrack", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.untrack_bounty.return_value = True
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Untracked bounty #1" in stdout.getvalue()
class TestCLIValidation:
"""Test CLI input validation."""
def test_requires_group_id_or_user_id(self):
"""Test that commands require either --group-id or --user-id."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "add", "some text"]):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "group-id or --user-id is required" in stderr.getvalue()
def test_unknown_command(self):
"""Test handling of unknown commands."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "unknown_cmd", "--group-id=-1001"]):
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "invalid choice" in stderr.getvalue()
def test_update_clear_link_flag(self):
"""Test update with --clear-link flag."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "--clear-link", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_link") == True
def test_update_clear_due_flag(self):
"""Test update with --clear-due flag."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "--clear-due", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.return_value = True
mock_create.return_value = (mock_bounty_service, MagicMock())
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_due") == True
class TestCLIOutput:
"""Test CLI output formatting."""
def test_list_shows_bounties(self):
"""Test that 'list' shows bounty details correctly."""
from cli.main import main
mock_bounty = MagicMock()
mock_bounty.id = 1
mock_bounty.text = "Fix bug"
mock_bounty.link = "https://github.com/issue/1"
mock_bounty.due_date_ts = None
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = [mock_bounty]
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
output = stdout.getvalue()
assert "#1:" in output
assert "Fix bug" in output
assert "https://github.com/issue/1" in output
def test_list_empty(self):
"""Test that 'list' shows 'No bounties' when empty."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.list_bounties.return_value = []
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "No bounties" in stdout.getvalue()
def test_add_output(self):
"""Test that 'add' outputs the new bounty ID."""
from cli.main import main
mock_bounty = MagicMock()
mock_bounty.id = 42
with patch("sys.argv", ["jigaido-cli", "add", "New task", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.add_bounty.return_value = mock_bounty
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Added bounty #42" in stdout.getvalue()
class TestCLIErrorHandling:
"""Test CLI error handling."""
def test_delete_nonexistent_bounty(self):
"""Test deleting a non-existent bounty."""
from cli.main import main
with patch("sys.argv", ["jigaido-cli", "delete", "999", "--group-id=-1001"]):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.delete_bounty.return_value = False
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "not found" in stderr.getvalue()
def test_update_permission_error(self):
"""Test update with permission error."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "update", "1", "new text", "--group-id=-1001"],
):
with patch("cli.main.create_services") as mock_create:
mock_bounty_service = MagicMock()
mock_bounty_service.update_bounty.side_effect = PermissionError(
"Not owner"
)
mock_create.return_value = (mock_bounty_service, MagicMock())
with patch("sys.stderr", new=StringIO()) as stderr:
with pytest.raises(SystemExit):
main()
assert "Not owner" in stderr.getvalue()
def test_track_already_tracking(self):
"""Test tracking a bounty that's already tracked."""
from cli.main import main
with patch(
"sys.argv",
["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"],
):
with patch("cli.main.create_services") as mock_create:
mock_tracking_service = MagicMock()
mock_tracking_service.track_bounty.return_value = False
mock_create.return_value = (MagicMock(), mock_tracking_service)
with patch("sys.stdout", new=StringIO()) as stdout:
main()
assert "Already tracking" in stdout.getvalue()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -28,6 +28,22 @@ class TestBounty:
assert b.due_date_ts == 1735689600 assert b.due_date_ts == 1735689600
assert b.created_at == 1735603200 assert b.created_at == 1735603200
assert b.created_by_user_id == 123 assert b.created_by_user_id == 123
assert b.deleted_at is None
assert b.created_by_username is None
def test_bounty_with_new_fields(self):
b = Bounty(
id=1,
text="Fix the bug",
link="https://github.com/example/repo/issues/1",
due_date_ts=1735689600,
created_at=1735603200,
created_by_user_id=123,
deleted_at=1736200000,
created_by_username="johndoe",
)
assert b.deleted_at == 1736200000
assert b.created_by_username == "johndoe"
def test_bounty_optional_fields_can_be_none(self): def test_bounty_optional_fields_can_be_none(self):
b = Bounty( b = Bounty(
@@ -41,6 +57,8 @@ class TestBounty:
assert b.text is None assert b.text is None
assert b.link is None assert b.link is None
assert b.due_date_ts is None assert b.due_date_ts is None
assert b.deleted_at is None
assert b.created_by_username is None
def test_bounty_comparison_equal(self): def test_bounty_comparison_equal(self):
b1 = Bounty( b1 = Bounty(
@@ -103,6 +121,8 @@ class TestRoomData:
assert rd.room_id == -1001 assert rd.room_id == -1001
assert rd.bounties == [] assert rd.bounties == []
assert rd.next_id == 1 assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_create_dm_room_data(self): def test_create_dm_room_data(self):
rd = RoomData( rd = RoomData(
@@ -113,6 +133,8 @@ class TestRoomData:
assert rd.room_id == 123456 assert rd.room_id == 123456
assert rd.bounties == [] assert rd.bounties == []
assert rd.next_id == 1 assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_room_data_with_bounties(self): def test_room_data_with_bounties(self):
b = Bounty( b = Bounty(
@@ -128,6 +150,25 @@ class TestRoomData:
assert rd.bounties[0].text == "Task" assert rd.bounties[0].text == "Task"
assert rd.bounties[0].created_by_user_id == 123 assert rd.bounties[0].created_by_user_id == 123
def test_room_data_with_new_fields(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
timezone="Asia/Jakarta",
admin_user_ids=[123, 456],
)
assert rd.timezone == "Asia/Jakarta"
assert rd.admin_user_ids == [123, 456]
def test_room_data_admin_user_ids_defaults_to_empty_list(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
)
assert rd.admin_user_ids == []
class TestTrackingData: class TestTrackingData:
def test_create_tracking_data(self): def test_create_tracking_data(self):

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,35 +168,46 @@ 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
@@ -171,9 +219,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)