Compare commits

...

118 Commits

Author SHA1 Message Date
shokollm
3743dc6a45 fix: add sort and limit to /my command for consistency with /bounty
- Add same sort_key logic as /bounty (due date first, then created_at)
- Add default limit of 5 with "Showing X of Y" message
- Add "all" flag to show expired bounties
- Add delete button for consistency with /bounty

Fixes #94
2026-04-09 14:08:57 +00:00
7e0bc1f8a3 Merge pull request 'test: Category Feature - Tests (#88)' (#93) from feature/category-tests into main 2026-04-09 13:17:01 +02:00
shokollm
961adf103b test: add category feature tests
Tests for category feature (Issue #88):

Category Management Tests (11 tests):
- test_add_category_requires_admin
- test_add_category_duplicate_slug_fails
- test_add_category_invalid_slug_fails_uppercase
- test_add_category_invalid_slug_fails_with_numbers
- test_add_category_invalid_slug_fails_with_symbols
- test_add_category_invalid_slug_fails_empty
- test_add_category_valid
- test_delete_category_soft_deletes
- test_deleted_category_not_listed
- test_list_categories_empty
- test_list_categories_returns_active
- test_get_category_not_found
- test_get_category_deleted_returns_none
- test_add_category_requires_admin_non_existent_room

Category-to-Bounty Tests (10 tests):
- test_add_category_to_bounty
- test_add_duplicate_category_to_bounty_noop
- test_add_category_to_bounty_invalid_bounty
- test_add_category_to_bounty_invalid_category
- test_remove_category_from_bounty
- test_remove_category_not_on_bounty_returns_false
- test_update_bounty_categories_replace_all
- test_update_bounty_categories_clear_all
- test_update_bounty_categories_validates
- test_add_category_to_bounty_requires_admin
- test_remove_category_from_bounty_requires_admin
- test_update_bounty_categories_requires_admin
- test_delete_category_requires_admin

All 123 tests pass (96 original + 27 new)
2026-04-09 11:08:46 +00:00
e9d7ba0c8e Merge pull request 'feat: Category Feature - Commands (#87)' (#92) from feature/category-commands into main 2026-04-09 13:06:36 +02:00
shokollm
e43c36b84f feat: add category command handlers
Commands layer for category feature (Issue #87):

New Commands:
- /category - list all categories
- /category add <slug> <name> - create category (admin)
- /category delete <slug> - soft delete category (admin)

Updated Commands:
- /add - supports -cat <slug>[,<slug>] flag
- /update - supports -cat, -cat -, and -remove-cat flags
- /bounty - supports -c <slug>[,<slug>] filter
- /show - displays categories for bounty
- /help - includes category commands

Syntax Examples:
- /add Fix bug github.com/repo -cat bug,urgent
- /update 1 -cat feature,docs
- /update 1 -cat - (clear categories)
- /update 1 -remove-cat bug
- /bounty -c bug
- /bounty -c bug,feature
2026-04-09 10:50:50 +00:00
44680dcb4c Merge pull request 'feat: Category Feature - Service Layer (#86)' (#91) from feature/category-service into main 2026-04-09 12:44:18 +02:00
shokollm
7c17bff110 feat: add category service layer methods
Service layer for category feature (Issue #86):

Category Management:
- add_category() - Create category (admin only, validates slug format)
- delete_category() - Soft delete category (admin only)
- list_categories() - List active categories
- get_category() - Get category by slug

Category-to-Bounty Association:
- add_category_to_bounty() - Add category to bounty (admin only)
- remove_category_from_bounty() - Remove category from bounty (admin only)
- update_bounty_categories() - Replace all categories on bounty (admin only)

All methods properly validate permissions, slug format, and existence.
Soft delete preserves category data for bounties that reference it.
2026-04-09 10:38:08 +00:00
43d07eae92 Merge pull request 'feat: Category Feature - Models & Storage (#85)' (#90) from feature/category-models-storage into main 2026-04-09 12:35:50 +02:00
shokollm
66d2a9fb86 feat: add Category model and update storage for categories
Models & Storage layer for category feature (Issue #85):

- Add Category dataclass to core/models.py
  - id (slug): lowercase alphabetic only
  - name: display name
  - created_at: Unix timestamp
  - deleted_at: soft delete timestamp (None if active)

- Add category_ids field to Bounty dataclass
  - list[str] for multiple categories per bounty
  - Default empty list for backward compatibility

- Add categories field to RoomData dataclass
  - list[Category] for room-level categories
  - Default empty list

- Update JsonFileRoomStorage to serialize/deserialize:
  - Category fields (id, name, created_at, deleted_at)
  - Bounty.category_ids
  - RoomData.categories

Backward compatible: existing data without categories works fine.
2026-04-09 10:23:33 +00:00
shokollm
235a89653f test: add pytest.ini for easier test running
- Set testpaths = tests
- Set pythonpath = . (no need for PYTHONPATH=. prefix)
- Set asyncio_default_fixture_loop_scope = function
2026-04-09 10:15:27 +00:00
shokollm
2158f71fd0 feat: normalize URLs without scheme to https://
- Add normalize_url() helper function in commands.py
- Automatically prefix URLs without scheme (e.g. github.com → https://github.com)
- Applies to both -link flag and auto-detected URLs
- Add 5 new tests for URL normalization
- Fix existing tests to handle 5-value return from parse_args()

Examples:
  /add Fix bug github.com/user/repo
  → stored as: https://github.com/user/repo
2026-04-09 10:10:06 +00:00
shokollm
4885be0752 fix: cleanup codebase and sync SPEC with actual permissions
Phase 1: Ruff lint fixes
- Remove unused imports across all files
- Remove unused variables (now_utc, tz, ctx)
- Fix f-string without placeholders
- Fix E402 import order with noqa comments

Phase 2: Remove confusing hard delete from storage
- Removed delete_bounty() from RoomStorage Protocol (never used by app)
- Removed delete_bounty() from JsonFileRoomStorage (was hard delete)
- Removed corresponding tests (hard delete was never used)

Phase 3: Sync SPEC.md with actual code behavior
- Updated overview: admins can add/edit/delete (not 'anyone' + 'creator')
- Updated command table: /add, /edit, /delete are admin only
- Updated error handling messages

Test results: 96 passed (2 hard delete tests removed)
2026-04-09 10:01:02 +00:00
shokollm
75122b3ee2 docs: add audit and category spec from feynman 2026-04-09 09:30:42 +00:00
shokollm
8494b4621c feat: switch admin identification from user_id to username
- Replace admin_user_ids (list[int]) with admin_usernames (list[str])
- Update all service methods to use username for permission checks
- Add delete button to bot responses for message cleanup
- Update tests to match new implementation

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

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

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

Also removed debug logging added during troubleshooting.
2026-04-04 22:59:37 +00:00
shokollm
6e5006b429 debug: add detailed logging to add_admin for troubleshooting 2026-04-04 16:08:46 +00:00
shokollm
cce71e55c2 debug: add logging to cmd_start for troubleshooting admin promotion
Adding logging to understand why group creator admin promotion
may not be working in production.
2026-04-04 16:01:24 +00:00
shokollm
8ac8cd21ec fix: allow self-promotion to first admin in room
Users can now add themselves as the first admin without existing
admin permission. This enables /start in DMs to work correctly.
2026-04-04 15:34:58 +00:00
shokollm
d75f897043 feat: auto-promote group creator AND DM user to admin
- Groups: group creator auto-promoted to admin via /start
- DMs: user automatically becomes admin of their own DM
2026-04-04 15:30:27 +00:00
shokollm
0260cae40b feat: auto-promote group creator to admin
When /start is called in a group, check if user is the group creator
and automatically add them as admin. DMs don't need admin concept.
2026-04-04 15:24:47 +00:00
shokollm
408318d323 feat: bot reads JIGAIDO_BOT_TOKEN from config file
- config.py: Added _resolve_bot_token() to read from config file
- bot.py: Uses config.config.bot_token instead of env var directly
- test_config.py: Added test for config file token reading
2026-04-04 15:16:55 +00:00
5502de96ad Merge pull request 'feat: implement /recover command and fix /admin list' (#83) from fix/issue-49-50-recover-admin-list into main 2026-04-04 16:42:07 +02:00
shokollm
6a933742cb feat: implement /recover command and fix /admin list
- Add /recover command for listing and recovering soft-deleted bounties
  - /recover - list recoverable bounties (admin only)
  - /recover <id> [<id>...] - recover specific bounties (admin only)
- Fix /admin list to show @username instead of admin_id
- Add recover_bounty and recover_bounties methods to BountyService
- Add get_deleted_bounty method to BountyService
- Clean up duplicate cmd_admin functions
- Add /recover to bot command menu
- Fixes #49 and #50
2026-04-04 14:29:19 +00:00
b554a81979 Merge pull request 'feat: human-readable date format with timezone awareness (#54)' (#80) from fix/issue-54-v2 into main 2026-04-04 15:37:20 +02:00
shokollm
350ecbf867 feat: human-readable date format with timezone awareness
Add format_due_date() for human-readable dates like "4 April 2026".
Update cmd_add to use timezone-aware date formatting.

Fixes #54
2026-04-04 20:36:38 +07:00
28241eaf61 Merge pull request 'feat(/admin): add /admin command for admin management' (#78) from fix/issue-52 into main 2026-04-04 15:32:44 +02:00
shokollm
8db5ba0ba4 Merge fix/issue-52 with conflict resolution 2026-04-04 20:32:06 +07:00
a727751978 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#79) from fix/issue-47 into main 2026-04-04 15:28:36 +02:00
shokollm
90b0b564c2 feat: add multi-ID delete support with per-ID results
- Add delete_bounties() method to BountyService that returns individual
  results per bounty ID (deleted, not_found, permission_denied)
- Update cmd_delete to accept multiple IDs and show per-ID messages
- Add 3 tests for delete_bounties method

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

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

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

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

Fixes #53
2026-04-04 08:14:58 +00:00
e3b813661d Merge pull request 'feat(/bounty): add pagination, sorting, and filtering' (#62) from fix/issue-48-bounty-pagination into main 2026-04-04 10:09:29 +02:00
bdb0f3cd8b Merge pull request 'feat: implement /show command to display full bounty details' (#59) from fix/issue-44 into main 2026-04-04 10:09:28 +02:00
shokollm
649b1ffbd3 revert: remove timezone command and revert date format to simple YYYY-MM-DD
This reverts:
- cmd_timezone function (issue #67)
- format_due_date with human-readable dates (issue #68)
- Reverts date display back to time.strftime("%Y-%m-%d")
- Keeps /edit command with -link/-date flags (issue #46)
2026-04-04 15:05:29 +07:00
shokollm
b8f6b98836 Merge pull request #61 from fix/issue-46 2026-04-04 07:40:59 +00:00
shokollm
c005ee341a Revert "Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main"
This reverts commit bd2627efe9, reversing
changes made to 42ed551554.
2026-04-04 07:24:03 +00:00
922858a81a Merge pull request 'feat: human-readable date format with timezone awareness' (#68) from fix/issue-54 into main 2026-04-04 09:20:43 +02:00
shokollm
f521a682c5 feat: human-readable date format with timezone awareness
- Add format_due_date() function that formats dates as '4 April 2026'
  or '4 April 2026 14:30 (Asia/Jakarta)' with timezone support
- Update format_bounty() to use timezone-aware date formatting
- Update cmd_bounty, cmd_my, cmd_add to pass room_id for timezone
- Dates now display in room's configured timezone
- Fixes #54
2026-04-04 07:19:18 +00:00
015df15bd5 Merge pull request 'feat: implement /timezone command to get/set room timezone' (#67) from feat/issue-53-timezone into main 2026-04-04 09:13:41 +02:00
shokollm
eed3ab33ae feat: implement /timezone command to get/set room timezone
- Add cmd_timezone handler for /timezone command
- Validate timezone using IANA format (zoneinfo.ZoneInfo)
- Use existing BountyService.get_timezone and set_timezone methods
- Admin-only permission via service layer
- Update help text and bot command list
- Fixes #53
2026-04-04 07:12:23 +00:00
bd2627efe9 Merge pull request 'feat: add multi-ID delete support with per-ID results' (#63) from fix/issue-47 into main 2026-04-04 08:54:55 +02:00
shokollm
8069ed6465 feat: add multi-ID delete support with per-ID results
- Add delete_bounties method to BountyService that returns individual
  results per bounty ID (deleted, not_found, permission_denied)
- Update cmd_delete to accept multiple IDs and show per-ID messages
- Add tests for delete_bounties

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

Fixes #47
2026-04-04 06:39:11 +00:00
shokollm
d38d47fb79 feat(/bounty): add pagination, sorting, and filtering
- Default shows 5 bounties per page
- /bounty 10 - show 10 bounties
- /bounty all - show all active (exclude overdue >24h)
- /bounty all 10 - show 10 including expired

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

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

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

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

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

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

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

Fixes #42
2026-04-04 05:12:19 +00:00
d413f6ce13 Merge pull request 'feat: Model updates - add deleted_at, timezone, admin_user_ids fields' (#56) from fix/issue-41 into main 2026-04-04 07:08:28 +02:00
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
edbc924b98 Merge pull request 'feat(adapter): implement JSON file storage adapter for issue #9' (#27) from fix/issue-9 into main 2026-04-03 14:24:06 +02:00
2a9795a0c3 Merge pull request 'feat(core): implement services for issue #8' (#26) from fix/issue-8 into main 2026-04-03 14:23:32 +02:00
shokollm
d889d0e8ab fix(adapter): add unit tests + reorganize data directories
- Add tests/test_json_file.py with unit tests for JsonFileRoomStorage and JsonFileTrackingStorage
- Reorganize data directories per han's feedback:
  - Rooms: ~/.jigaido/data/room/<room_id>.json (was ~/.jigaido/data/<room_id>.json)
  - Tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json (was ~/.jigaido/tracking/...)
- Note: duplicate tracking is handled at TrackingService layer (returns False if already tracking), adapter allows duplicates by design
2026-04-03 11:38:42 +00:00
shokollm
3feab1d469 test(services): add unit tests for BountyService and TrackingService
- Test BountyService: add_bounty, list_bounties, get_bounty, update_bounty, delete_bounty
- Test TrackingService: track_bounty, untrack_bounty, get_tracked_bounties
- Test edge cases: permission errors, not found, duplicate tracking
2026-04-03 11:36:07 +00:00
shokollm
e79fbaddc5 feat(adapter): implement JSON file storage adapter for issue #9
Implements RoomStorage and TrackingStorage ports using JSON file persistence:
- JsonFileRoomStorage: Stores room data at ~/.jigaido/data/<room_id>.json
- JsonFileTrackingStorage: Stores tracking data at ~/.jigaido/tracking/<room_id>_<user_id>.json

Features:
- Atomic writes using tempfile + rename for data safety
- Automatic directory creation
- Implements all methods from ports.py protocols
2026-04-03 09:36:31 +00:00
shokollm
920fb70257 feat(core): implement services for issue #8
- Add BountyService for room bounty operations (group and personal)
- Add TrackingService for tracking bounty operations
- Uses RoomStorage and TrackingStorage ports
- PermissionError raised when non-creator edits/deletes
- ValueError raised when bounty not found in tracking
2026-04-03 09:26:48 +00:00
shokollm
e691abce60 Revert "Merge feat/issue-6-storage-ports: JSON file storage adapter for issue #9"
This reverts commit 9e3641a850, reversing
changes made to 8aebb763ee.
2026-04-03 08:36:04 +00:00
shokollm
9e3641a850 Merge feat/issue-6-storage-ports: JSON file storage adapter for issue #9 2026-04-03 08:26:10 +00:00
shokollm
af8eb90563 feat(adapter): implement JSON file storage adapter
Add JsonFileRoomStorage and JsonFileTrackingStorage implementations
that implement the RoomStorage and TrackingStorage ports.

- Stores room data at ~/.jigaido/data/<room_id>.json
- Stores tracking data at ~/.jigaido/tracking/<room_id>_<user_id>.json
- Implements all port methods: load, save, add_bounty, update_bounty,
  delete_bounty, get_bounty for rooms; load, save, track_bounty,
  untrack_bounty for tracking

Fixes #9
2026-04-03 08:06:51 +00:00
8aebb763ee Merge pull request 'Add core/ports.py - Storage interfaces' (#20) from feat/issue-6-storage-ports into main 2026-04-03 08:58:30 +02:00
shokollm
a237810dd2 Remove ensure_room/ensure_tracking from Protocol - tests prove not needed
Tests with SimpleRoomStorage and SimpleTrackingStorage (without ensure_*)
show that add_bounty() and track_bounty() work fine without explicit
ensure methods - they create rooms/tracking internally.

This simplifies the Protocol to only essential methods.
2026-04-03 06:48:52 +00:00
shokollm
43603659de Address PR #20 feedback:
- Removed PersonalStorage (redundant - RoomStorage handles both via room_id)
- Added ensure_room() and ensure_tracking() methods for explicit creation
- Added @runtime_checkable to Protocols for isinstance checks
- Added tests/test_ports.py with 11 unit tests for storage protocols
2026-04-02 23:57:49 +00:00
shokollm
5450d12400 Add core/ports.py - Storage interfaces
Define abstract storage interfaces (Protocols):
- RoomStorage: for room/group bounties (load, save, add/update/delete/get_bounty)
- PersonalStorage: same operations for personal/DM bounties
- TrackingStorage: for tracking data (load, save, track/untrack_bounty)
2026-04-02 22:40:11 +00:00
ddd44cb593 Merge pull request 'feat(core): implement domain dataclasses for issue #5' (#19) from feat/issue-5-core-models into main 2026-04-03 00:37:30 +02:00
shokollm
b2854393ae Address PR #19 review feedback round 3:
- TrackingData.group_id renamed to room_id (works for both group and DM)
- Removed room_id from TrackedBounty (it's just a lightweight pointer)
2026-04-02 22:34:19 +00:00
shokollm
330203e6ef Address PR #19 review feedback round 2:
- Bounty.created_by_user_id is now non-optional (always required)
- Removed is_group from RoomData (negative room_id is self-documenting)
- Added room_id to TrackedBounty to track which room bounty was tracked from
- Added clarifying docstrings explaining TrackingData vs TrackedBounty
- Updated tests to match new model structure
2026-04-02 22:24:12 +00:00
shokollm
f1ef33451c Address PR #19 review feedback: simplify models
- Remove GroupBounty/PersonalBounty subclasses, use Bounty with optional created_by_user_id
- Combine UserData/GroupData into RoomData with room_id and is_group fields
- Add group_id field to TrackingData (supports negative Telegram group IDs)
- Add test_bounty_comparison_not_equal for verifying different bounties are not equal
- Update core/__init__.py exports
2026-04-02 21:47:26 +00:00
5aebb5a814 Merge pull request 'feat(config): implement configuration management' (#18) from feat/issue-7-config into main 2026-04-02 23:43:56 +02:00
shokollm
9b8b15414f feat(config): implement configuration management for issue #7
- Create config.py with Config class
- Config precedence: ENV > config file > defaults
- data_dir: JIGAIDO_DATA_DIR env or ~/.jigaido/config.json or default
- bot_token: JIGAIDO_BOT_TOKEN env var
- ensure_data_dir() method to create data directory
- Add tests/test_config.py with 7 passing tests

Fixes #7
2026-04-02 20:16:41 +00:00
shokollm
db09a518d1 feat(core): implement domain dataclasses for issue #5
- Create core/__init__.py
- Create core/models.py with all domain dataclasses:
  - Bounty (base class)
  - GroupBounty (extends Bounty)
  - PersonalBounty (extends Bounty)
  - TrackedBounty
  - GroupData
  - TrackingData
  - UserData
- Create tests/test_models.py with 15 passing tests

Fixes #5
2026-04-02 20:15:41 +00:00
98a8c4d173 Merge pull request 'feat: Replace SQLite with per-user JSON storage (fixes #2)' (#3) from fix/issue-2-json-storage into main 2026-04-02 17:44:08 +02:00
shokollm
7c2bd09ada feat: implement new storage design per issue #2
- Storage: Change from per-user to per-group JSON files
- Data location: ~/.jigaido/ instead of apps/telegram-bot/data/
- Group bounties: data/{group_id}/group.json
- User tracking: data/{group_id}/{user_id}.json
- Personal bounties: data/{user_id}/user.json
- Update commands.py for new storage model
- Update bot.py to remove admin handlers
- Update tests to reflect created_by_user_id field
- Update SPEC.md with new design

Addresses user feedback from issue #2
2026-04-02 14:56:42 +00:00
shokollm
2e7b20ed81 Update issue #2 storage design with new file structure
Changed from per-user flat files to group/DM directory structure:
- data/{group_id}/group.json — group bounties
- data/{group_id}/{user_id}.json — user tracking in group
- data/{user_id}/user.json — user personal bounties (DM)
- Groups isolated, no cross-group access
- Tracking is per-group-per-user
2026-04-01 21:31:34 +00:00
shokollm
8bb964fdd0 feat: Replace SQLite with per-user JSON storage (fixes #2)
- Add storage.py with load_user(), save_user(), next_bounty_id()
- Rewrite commands.py to use JSON storage (simplified)
- Remove db.py, schema.sql, cron.py, test_db.py
- Update SPEC.md to reflect new architecture
- Admin model removed (anyone can add, creator only can edit/delete)
- No reminders in v1
2026-04-01 10:02:51 +00:00
32 changed files with 5763 additions and 1224 deletions

View File

@@ -5,27 +5,42 @@
```bash
git clone https://git.fbrns.co/shoko/jigaido.git
cd jigaido
# Create virtual environment
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Install dependencies
pip install -r apps/telegram-bot/requirements.txt
# Run tests
pytest
# Run bot
export JIGAIDO_BOT_TOKEN="test:token"
python bot.py
export JIGAIDO_BOT_TOKEN="your_bot_token"
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
- Python (no strict formatter enforced yet)
- Python 3.10+ with type hints
- Async/await for Telegram handlers
- Type hints where obvious
- Docstrings for public functions
- Follow existing code patterns
## Pull Request Workflow
1. Branch from `main`
2. Make changes
3. Test locally
3. Test locally with `pytest`
4. Open PR with description of what changed and why
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/
├── 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/
│ └── telegram-bot/ ← first app (Python)
│ ├── bot.py
── commands.py
│ ├── cron.py
│ ├── db.py
│ └── requirements.txt
└── SPEC.md ← full design specification
│ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py # Bot entry point
── commands.py # Command handlers
├── tests/ # Unit tests
├── config.py # Configuration management
└── 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

221
SPEC.md
View File

@@ -6,23 +6,21 @@
## Overview
JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking/reminders.
JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking.
- **Group mode**: Each Telegram group has its own bounty list. Only group admins can add/update/delete bounties. Any member can track/untrack.
- **DM mode**: Personal bounty list. No admin restrictions — anyone can manage their own bounties.
- **Tracking**: Users can add any bounty (group or DM) to their personal tracking list.
- **Reminders**: Daily cron checks for due dates within 7 days and DMs the user.
- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL` — no reminder.
- **Links**: Optional. If provided, deduplicated per group (no two bounties in the same group can share the same link). Multiple links in one bounty: first link only, user can update later.
- **Informed by**: Every bounty stores the Telegram username of who posted/added it (not who created the record — the person whose message triggered the add).
- **Group mode**: Each Telegram group has its own bounty list. Admins can add/edit/delete bounties. Anyone can track.
- **DM mode**: Personal bounty list. Anyone can manage their own bounties.
- **Tracking**: Users can track any bounty (group or personal) to their tracking list.
- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`.
- **Links**: Optional. If provided, stored with the bounty.
- **Informed by**: Every bounty stores the user ID of who posted/added it.
---
## Architecture
### Stack
- **Bot**: `python-telegram-bot` (pure Python, no C extensions)
- **Database**: SQLite (zero-install, single file)
- **Storage**: Per-group JSON files via `adapters/storage/json_file.py`
- **Date parsing**: `dateparser`
- **Runtime**: Python 3.10+
- **Deployment**: Any $5 VPS with Python 3.10+
@@ -31,81 +29,91 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
```
jigaido/
├── core/ # Domain layer
│ ├── models.py # Domain dataclasses (Bounty, Tracking)
│ ├── ports.py # Port interfaces (abstract base classes)
│ └── services.py # Domain services (BountyService, TrackingService)
├── adapters/ # Infrastructure adapters
│ └── storage/
│ └── json_file.py # JSON file storage implementation
├── apps/
│ └── telegram-bot/ # Telegram bot app
│ ├── bot.py # Entrypoint
── commands.py # Command handlers
├── cron.py # Daily reminder job
│ ├── db.py # SQLite wrapper
│ ├── schema.sql # Database schema
│ ├── requirements.txt
│ └── .env.example
├── SPEC.md # This document
├── README.md
└── CONTRIBUTING.md
│ └── telegram-bot/ # Telegram bot CLI application
│ ├── bot.py # Bot entry point
── commands.py # Command handlers
├── config.py # Configuration management
└── tests/ # Unit tests
```
---
### Hexagonal Architecture
## Database Schema
- **Core** (`core/`): Pure domain logic, no external dependencies
- `models.py`: Domain dataclasses
- `ports.py`: Abstract interfaces for storage
- `services.py`: Business logic
```sql
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_chat_id INTEGER UNIQUE NOT NULL,
creator_user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
- **Adapters** (`adapters/`): Implementations of ports
- `storage/json_file.py`: JSON file-based storage
CREATE TABLE group_admins (
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
);
- **Apps** (`apps/`): CLI applications
- `telegram-bot/`: Telegram bot interface
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_user_id INTEGER UNIQUE NOT NULL,
username TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
### Data Storage
CREATE TABLE bounties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
created_by_user_id INTEGER REFERENCES users(id),
informed_by_username TEXT NOT NULL,
text TEXT,
link TEXT,
due_date_ts INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(group_id, link)
);
Data is stored at `~/.jigaido/` (home directory), NOT inside the repository.
CREATE TABLE user_bounty_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, bounty_id)
);
**File: `~/.jigaido/{group_id}/group.json`**
CREATE TABLE reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE,
reminded_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, bounty_id)
);
```json
{
"bounties": [
{
"id": 1,
"created_by_user_id": 123456,
"text": "Fix login bug",
"link": "https://github.com/example/repo/issues/1",
"due_date_ts": 1735689600,
"created_at": 1735603200
}
],
"next_id": 2
}
```
### Notes
- `group_id = NULL` means a personal/DM bounty.
- `UNIQUE(group_id, link)` — only enforced when `link IS NOT NULL` (SQLite treats NULL as distinct).
- `reminder_log` dedup ensures a user only gets one reminder per bounty.
**File: `~/.jigaido/{group_id}/{user_id}.json`**
```json
{
"tracked": [
{"bounty_id": 1, "created_at": 1735600000}
]
}
```
**File: `~/.jigaido/{user_id}/user.json`**
```json
{
"bounties": [
{
"id": 1,
"text": "Personal task",
"link": null,
"due_date_ts": 1735689600,
"created_at": 1735603200
}
],
"next_id": 2
}
```
**Key design decisions:**
1. **Hexagonal architecture** — Core domain is isolated from infrastructure
2. **Group-isolated storage** — Each group has its own directory. No cross-group access.
3. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter.
4. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
---
## Commands
### In Group
@@ -115,23 +123,21 @@ CREATE TABLE reminder_log (
| `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you in this group |
| `/add <text> [link] [due date]` | admin only | Add a new bounty to the group |
| `/update <bounty_id> [text] [link] [due_date]` | admin only | Update an existing bounty |
| `/edit <bounty_id> [text] [link] [due_date]` | admin only | Edit an existing bounty |
| `/delete <bounty_id>` | admin only | Delete a bounty |
| `/track <bounty_id>` | anyone | Add a group bounty to your tracking |
| `/untrack <bounty_id>` | anyone | Remove a bounty from your tracking |
| `/admin_add <user>` | creator only | Promote a user to admin |
| `/admin_remove <user>` | creator only | Demote an admin |
| `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
### In DM (1:1 with bot)
| Command | Description |
|---|---|
| `/bounty` | List all your personal bounties |
| `/my` | List all your tracked personal bounties |
| `/my` | List all your personal bounties |
| `/add <text> [link] [due date]` | Add a personal bounty |
| `/update <bounty_id> [text] [link] [due_date]` | Update a personal bounty |
| `/delete <bounty_id>` | Delete a personal bounty (owner only) |
| `/track <bounty_id>` | Add a personal bounty to your tracking |
| `/edit <bounty_id> [text] [link] [due_date]` | Edit a personal bounty |
| `/delete <bounty_id>` | Delete a personal bounty |
| `/track <bounty_id>` | Track a personal bounty |
### Add/Update Syntax
@@ -143,19 +149,6 @@ CREATE TABLE reminder_log (
- `link` is optional
- `due_date` is optional, free-form
- If link already exists in group → rejected with error
### Tracking
- `/track <bounty_id>` — works in both group and DM. In group: tracks a group bounty. In DM: tracks a personal bounty.
- Users can track any bounty regardless of who created it.
- A bounty can be tracked by multiple users.
---
## Informed By
When a user triggers `/add`, the bot captures `message.from_user.username` and stores it in `informed_by_username`. This is displayed on bounty listings so the group/DM knows who posted or requested the bounty.
---
@@ -168,40 +161,26 @@ Uses `dateparser` library. Examples:
- `"2026-04-15"`
- `"next friday"`
If parsing fails → `due_date_ts = NULL`. No error is shown to user, reminder just won't fire.
If parsing fails → `due_date_ts = NULL`. No error is shown to user.
Stored as Unix timestamp. User-facing display can be localized/converted to any timezone at render time.
---
## Reminders (Cron)
Runs daily (e.g., 09:00 local). For each user:
1. Find tracked bounties where `due_date_ts - now() < 7 days`
2. Exclude any already in `reminder_log` for that user
3. Send DM: `"Bounty '{title}' is due in {N} days."`
4. Insert into `reminder_log`
Does not re-remind. If a bounty is 2 days away today, you get one message. Tomorrow you don't get another.
---
## Admin Management
- **Creator**: The user who first added the bot to the group. Stored as `creator_user_id` in `groups`. Only the creator can run `/admin_add` and `/admin_remove`.
- **Admins**: Added via `/admin_add <username>`. Can add/update/delete any bounty in the group. Regular members can only track/untrack.
- First admin assignment is automatic when the bot detects a new group.
---
## Error Handling
- Unknown command → help text with available commands
- `/add` with duplicate link in same group → rejection message
- `/update`/`/delete` by non-admin → "Admin only" message
- `/admin_add`/`/admin_remove` by non-creator → "Creator only" message
- `/track` already tracked → "Already tracking" (idempotent, no error)
- `/untrack` not tracked → "Not tracking" (idempotent, no error)
- `/add`/`/edit`/`/delete` by non-admin → "⛔ Only admins can add/edit/delete bounties."
- `/track` already tracked → "Already tracking" (idempotent)
- `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found"
- User not found → "User not found"
---
## When to Revert to SQLite
- Multiple concurrent users with write conflicts
- Complex queries across users
- Reminder system with proper dedup
- Scale > 1,000 users
- Need ACID guarantees on concurrent writes

5
adapters/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Storage adapters for JIGAIDO."""
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"]

View File

@@ -0,0 +1,5 @@
"""Storage adapters for JIGAIDO."""
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"]

View File

@@ -0,0 +1,268 @@
"""JSON file storage adapter for JIGAIDO.
Implements RoomStorage and TrackingStorage ports using JSON file persistence.
Data stored at:
- Rooms: ~/.jigaido/data/room/<room_id>.json
- Tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json
"""
import json
import os
import tempfile
from pathlib import Path
from core.models import Bounty, Category, RoomData, TrackingData, TrackedBounty
class JsonFileRoomStorage:
"""RoomStorage implementation using JSON files.
Stores room data at ~/.jigaido/data/room/<room_id>.json
"""
def __init__(self, data_dir: Path | None = None):
if data_dir is None:
data_dir = Path.home() / ".jigaido" / "data" / "room"
self._data_dir = data_dir
self._data_dir.mkdir(parents=True, exist_ok=True)
def _get_file_path(self, room_id: int) -> Path:
return self._data_dir / f"{room_id}.json"
def _atomic_write(self, path: Path, data: dict) -> None:
"""Write data atomically using tempfile + rename."""
with tempfile.NamedTemporaryFile(
mode="w", dir=self._data_dir, delete=False
) as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, path)
def load(self, room_id: int) -> RoomData | None:
"""Load room data from JSON file. Returns None if not found."""
file_path = self._get_file_path(room_id)
if not file_path.exists():
return None
with open(file_path, "r") as f:
data = json.load(f)
bounties = [
Bounty(
id=b["id"],
text=b.get("text"),
link=b.get("link"),
due_date_ts=b.get("due_date_ts"),
created_at=b["created_at"],
created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"),
category_ids=b.get("category_ids", []),
)
for b in data.get("bounties", [])
]
categories = [
Category(
id=c["id"],
name=c["name"],
created_at=c["created_at"],
deleted_at=c.get("deleted_at"),
)
for c in data.get("categories", [])
]
return RoomData(
room_id=data["room_id"],
bounties=bounties,
next_id=data["next_id"],
timezone=data.get("timezone"),
admin_usernames=data.get("admin_usernames", []),
categories=categories,
)
def save(self, room_data: RoomData) -> None:
"""Save room data to JSON file."""
data = {
"room_id": room_data.room_id,
"next_id": room_data.next_id,
"timezone": room_data.timezone,
"admin_usernames": room_data.admin_usernames or [],
"categories": [
{
"id": c.id,
"name": c.name,
"created_at": c.created_at,
"deleted_at": c.deleted_at,
}
for c in room_data.categories
],
"bounties": [
{
"id": b.id,
"text": b.text,
"link": b.link,
"due_date_ts": b.due_date_ts,
"created_at": b.created_at,
"created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at,
"created_by_username": b.created_by_username,
"category_ids": b.category_ids,
}
for b in room_data.bounties
],
}
self._atomic_write(self._get_file_path(room_data.room_id), data)
def add_bounty(self, room_id: int, bounty: Bounty) -> None:
"""Add a bounty to a room, creating the room if necessary."""
room_data = self.load(room_id)
if room_data is None:
room_data = RoomData(room_id=room_id, bounties=[], next_id=1)
room_data.bounties.append(bounty)
if bounty.id >= room_data.next_id:
room_data.next_id = bounty.id + 1
self.save(room_data)
def update_bounty(self, room_id: int, bounty: Bounty) -> None:
"""Update an existing bounty in a room."""
room_data = self.load(room_id)
if room_data is None:
return
for i, b in enumerate(room_data.bounties):
if b.id == bounty.id:
room_data.bounties[i] = bounty
break
self.save(room_data)
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty from a room by ID."""
room_data = self.load(room_id)
if room_data is None:
return None
for b in room_data.bounties:
if b.id == bounty_id:
return b
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:
"""TrackingStorage implementation using JSON files.
Stores tracking data at ~/.jigaido/data/tracking/<room_id>_<user_id>.json
"""
def __init__(self, tracking_dir: Path | None = None):
if tracking_dir is None:
tracking_dir = Path.home() / ".jigaido" / "data" / "tracking"
self._tracking_dir = tracking_dir
self._tracking_dir.mkdir(parents=True, exist_ok=True)
def _get_file_path(self, room_id: int, user_id: int) -> Path:
return self._tracking_dir / f"{room_id}_{user_id}.json"
def _atomic_write(self, path: Path, data: dict) -> None:
"""Write data atomically using tempfile + rename."""
with tempfile.NamedTemporaryFile(
mode="w", dir=self._tracking_dir, delete=False
) as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, path)
def load(self, room_id: int, user_id: int) -> TrackingData | None:
"""Load tracking data from JSON file. Returns None if not found."""
file_path = self._get_file_path(room_id, user_id)
if not file_path.exists():
return None
with open(file_path, "r") as f:
data = json.load(f)
tracked = [
TrackedBounty(
bounty_id=t["bounty_id"],
created_at=t["created_at"],
)
for t in data.get("tracked", [])
]
return TrackingData(
room_id=data["room_id"],
user_id=data["user_id"],
tracked=tracked,
)
def save(self, tracking_data: TrackingData) -> None:
"""Save tracking data."""
data = {
"room_id": tracking_data.room_id,
"user_id": tracking_data.user_id,
"tracked": [
{
"bounty_id": t.bounty_id,
"created_at": t.created_at,
}
for t in tracking_data.tracked
],
}
self._atomic_write(
self._get_file_path(tracking_data.room_id, tracking_data.user_id), data
)
def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None:
"""Add a bounty to a user's tracking list, creating the tracking entry if needed."""
tracking_data = self.load(room_id, user_id)
if tracking_data is None:
tracking_data = TrackingData(room_id=room_id, user_id=user_id, tracked=[])
tracking_data.tracked.append(tracked)
self.save(tracking_data)
def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None:
"""Remove a bounty from a user's tracking list."""
tracking_data = self.load(room_id, user_id)
if tracking_data is None:
return
tracking_data.tracked = [
t for t in tracking_data.tracked if t.bounty_id != bounty_id
]
self.save(tracking_data)

View File

@@ -1,19 +1,34 @@
"""JIGAIDO Telegram bot entrypoint."""
import logging
import os
import sys
from telegram import Update
sys.path.insert(0, "/home/shoko/repositories/jigaido")
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
CallbackQueryHandler,
)
import db
import commands
from commands import (
cmd_add,
cmd_admin,
cmd_bounty,
cmd_category,
cmd_delete,
cmd_delete_message,
cmd_edit,
cmd_help,
cmd_my,
cmd_recover,
cmd_show,
cmd_start,
cmd_timezone,
cmd_track,
cmd_untrack,
cmd_update,
)
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -21,56 +36,76 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
# Token from environment or config
BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
from config import config # noqa: E402
BOT_TOKEN = config.bot_token or ""
async def error_handler(update, context):
log.error(f"Error: {context.error}")
def build_app() -> Application:
app = Application.builder().token(BOT_TOKEN).build()
# Core commands
app.add_handler(CommandHandler("start", commands.cmd_start))
app.add_handler(CommandHandler("help", commands.cmd_help))
app.add_handler(CommandHandler("bounty", commands.cmd_bounty))
app.add_handler(CommandHandler("my", commands.cmd_my))
app.add_handler(CommandHandler("add", commands.cmd_add))
app.add_handler(CommandHandler("update", commands.cmd_update))
app.add_handler(CommandHandler("delete", commands.cmd_delete))
app.add_handler(CommandHandler("track", commands.cmd_track))
app.add_handler(CommandHandler("untrack", commands.cmd_untrack))
app.add_handler(CommandHandler("admin_add", commands.cmd_admin_add))
app.add_handler(CommandHandler("admin_remove", commands.cmd_admin_remove))
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("bounty", cmd_bounty))
app.add_handler(CommandHandler("my", cmd_my))
app.add_handler(CommandHandler("add", cmd_add))
app.add_handler(CommandHandler("edit", cmd_edit))
app.add_handler(CommandHandler("update", cmd_update))
app.add_handler(CommandHandler("delete", cmd_delete))
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(CommandHandler("category", cmd_category))
# Fallback: unknown commands
app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help))
app.add_handler(CallbackQueryHandler(cmd_delete_message))
app.add_error_handler(error_handler)
return app
async def post_init(app: Application) -> None:
# Set bot commands in menu
await app.bot.set_my_commands([
("bounty", "List bounties"),
("my", "Your tracked bounties"),
("add", "Add a bounty"),
("track", "Track a bounty"),
("untrack", "Stop tracking"),
("help", "Show help"),
])
await app.bot.set_my_commands(
[
("bounty", "List bounties"),
("my", "Your tracked bounties"),
("add", "Add a bounty"),
("edit", "Edit a bounty"),
("track", "Track a bounty"),
("untrack", "Stop tracking"),
("show", "Show bounty details"),
("timezone", "Get/set room timezone"),
("admin", "Manage admins"),
("recover", "Recover deleted bounties"),
("category", "Manage categories"),
("help", "Show help"),
]
)
def main() -> None:
import asyncio
if not BOT_TOKEN:
log.error("JIGAIDO_BOT_TOKEN environment variable not set.")
sys.exit(1)
db.init_db()
log.info("Database initialized.")
app = build_app()
app.post_init = post_init
log.info("JIGAIDO starting...")
# Python 3.14 compatibility: ensure event loop exists
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
app.run_polling(drop_pending_updates=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
"""Daily reminder cron job for JIGAIDO.
Run with: python -m cron
Or schedule via systemd timer / cron.
"""
import asyncio
import logging
import os
import sys
import time
# Add project root to path
sys.path.insert(0, os.path.dirname(__file__))
import db
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
level=logging.INFO,
)
log = logging.getLogger(__name__)
# Token from environment
BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
REMINDER_WINDOW_DAYS = 7
async def send_reminder(user_telegram_id: int, bounty: dict, bot) -> None:
days_left = (bounty["due_date_ts"] - int(time.time())) // 86400
if days_left < 0:
urgency = "OVERDUE"
elif days_left == 0:
urgency = "TODAY"
else:
urgency = f"{days_left} days left"
due_str = time.strftime("%Y-%m-%d", time.localtime(bounty["due_date_ts"]))
text = f"⏰ Reminder: bounty #{bounty['id']}"
if bounty["text"]:
text += f"{bounty['text']}"
text += f"\nDue: {due_str} ({urgency})"
try:
await bot.send_message(chat_id=user_telegram_id, text=text, disable_web_page_preview=True)
log.info(f"Reminder sent to {user_telegram_id} for bounty #{bounty['id']}")
except Exception as e:
log.error(f"Failed to send reminder to {user_telegram_id}: {e}")
async def run_reminders() -> None:
if not BOT_TOKEN:
log.error("JIGAIDO_BOT_TOKEN not set")
return
from telegram import Bot
bot = Bot(BOT_TOKEN)
user_ids = db.get_all_user_ids()
log.info(f"Running reminders for {len(user_ids)} users...")
for user_telegram_id in user_ids:
due_bounties = db.get_bounties_due_soon(user_telegram_id, REMINDER_WINDOW_DAYS)
for bounty in due_bounties:
await send_reminder(user_telegram_id, dict(bounty), bot)
db.log_reminder(user_telegram_id, bounty["id"])
log.info("Reminder run complete.")
def main() -> None:
asyncio.run(run_reminders())
if __name__ == "__main__":
main()

View File

@@ -1,306 +0,0 @@
"""SQLite database wrapper for JIGAIDO."""
import sqlite3
import time
from pathlib import Path
from typing import Optional
DB_PATH = Path(__file__).parent / "jigaido.db"
def get_conn() -> sqlite3.Connection:
# isolation_level=None enables autocommit mode.
# row_factory disables SQLite Python's implicit transaction management,
# so we need explicit autocommit to make writes work correctly.
conn = sqlite3.connect(DB_PATH, detect_types=sqlite3.PARSE_DECLTYPES)
conn.isolation_level = None
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
return conn
def _row_to_dict(row: sqlite3.Row) -> dict:
return dict(row)
def init_db() -> None:
schema = (Path(__file__).parent / "schema.sql").read_text()
with get_conn() as conn:
conn.executescript(schema)
# ── Users ──────────────────────────────────────────────────────────────────
def upsert_user(telegram_user_id: int, username: str | None) -> int:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO users (telegram_user_id, username)
VALUES (?, ?)
ON CONFLICT (telegram_user_id) DO UPDATE SET username = excluded.username
RETURNING id""",
(telegram_user_id, username),
)
return cur.fetchone()["id"]
def get_user_by_telegram_id(telegram_user_id: int) -> Optional[dict]:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE telegram_user_id = ?",
(telegram_user_id,),
).fetchone()
return _row_to_dict(row) if row else None
# ── Groups ─────────────────────────────────────────────────────────────────
def upsert_group(telegram_chat_id: int, creator_user_id: int) -> int:
"""Insert group if not exists. Returns group id."""
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO groups (telegram_chat_id, creator_user_id)
VALUES (?, ?)
ON CONFLICT (telegram_chat_id) DO UPDATE SET creator_user_id = excluded.creator_user_id
WHERE groups.creator_user_id IS NULL OR groups.creator_user_id = excluded.creator_user_id
RETURNING id""",
(telegram_chat_id, creator_user_id),
)
return cur.fetchone()["id"]
def get_group(telegram_chat_id: int) -> Optional[dict]:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM groups WHERE telegram_chat_id = ?",
(telegram_chat_id,),
).fetchone()
return _row_to_dict(row) if row else None
def get_group_creator_user_id(group_id: int) -> Optional[int]:
with get_conn() as conn:
row = conn.execute(
"SELECT creator_user_id FROM groups WHERE id = ?",
(group_id,),
).fetchone()
return row["creator_user_id"] if row else None
# ── Group Admins ────────────────────────────────────────────────────────────
def add_group_admin(group_id: int, user_id: int) -> bool:
"""Add user as admin. Returns True if newly added, False if already admin."""
with get_conn() as conn:
try:
conn.execute(
"INSERT INTO group_admins (group_id, user_id) VALUES (?, ?)",
(group_id, user_id),
)
return True
except sqlite3.IntegrityError:
return False
def remove_group_admin(group_id: int, user_id: int) -> bool:
"""Remove user from admins. Returns True if removed, False if not an admin."""
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM group_admins WHERE group_id = ? AND user_id = ?",
(group_id, user_id),
)
return cur.rowcount > 0
def is_group_admin(group_id: int, user_id: int) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT 1 FROM group_admins WHERE group_id = ? AND user_id = ?",
(group_id, user_id),
).fetchone()
return row is not None
def is_group_creator(group_id: int, user_id: int) -> bool:
return get_group_creator_user_id(group_id) == user_id
def get_user_by_username(username: str) -> Optional[dict]:
"""Look up user by username (without @)."""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?",
(username,),
).fetchone()
return _row_to_dict(row) if row else None
# ── Bounties ────────────────────────────────────────────────────────────────
def add_bounty(
group_id: int | None,
created_by_user_id: int,
informed_by_username: str,
text: str | None,
link: str | None,
due_date_ts: int | None,
) -> int:
"""Add a bounty. Returns bounty id. Raises ValueError on duplicate link."""
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO bounties
(group_id, created_by_user_id, informed_by_username, text, link, due_date_ts)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id""",
(group_id, created_by_user_id, informed_by_username, text, link, due_date_ts),
)
return cur.fetchone()["id"]
except sqlite3.IntegrityError as e:
if "UNIQUE" in str(e) and "link" in str(e):
raise ValueError(f"Link already exists in this group: {link}")
raise
def get_bounty(bounty_id: int) -> Optional[dict]:
with get_conn() as conn:
row = conn.execute("SELECT * FROM bounties WHERE id = ?", (bounty_id,)).fetchone()
return _row_to_dict(row) if row else None
def get_group_bounties(group_id: int) -> list[dict]:
with get_conn() as conn:
return [_row_to_dict(r) for r in conn.execute(
"SELECT * FROM bounties WHERE group_id = ? ORDER BY created_at DESC",
(group_id,),
)]
def get_user_personal_bounties(user_id: int) -> list[dict]:
"""Bounties created by user in DM (group_id IS NULL)."""
with get_conn() as conn:
return [_row_to_dict(r) for r in conn.execute(
"SELECT * FROM bounties WHERE group_id IS NULL AND created_by_user_id = ? ORDER BY created_at DESC",
(user_id,),
)]
def update_bounty(
bounty_id: int,
text: str | None,
link: str | None,
due_date_ts: int | None,
) -> bool:
"""Update bounty fields. Returns True if updated. Raises ValueError on duplicate link."""
with get_conn() as conn:
try:
cur = conn.execute(
"""UPDATE bounties
SET text = COALESCE(?, text),
link = COALESCE(?, link),
due_date_ts = COALESCE(?, due_date_ts)
WHERE id = ?""",
(text, link, due_date_ts, bounty_id),
)
return cur.rowcount > 0
except sqlite3.IntegrityError as e:
if "UNIQUE" in str(e) and "link" in str(e):
raise ValueError(f"Link already exists in this group: {link}")
raise
def delete_bounty(bounty_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM bounties WHERE id = ?", (bounty_id,))
return cur.rowcount > 0
# ── Tracking ────────────────────────────────────────────────────────────────
def track_bounty(user_id: int, bounty_id: int) -> bool:
"""Add bounty to user's tracking. Returns True if newly tracked, False if already tracking."""
with get_conn() as conn:
try:
conn.execute(
"INSERT INTO user_bounty_tracking (user_id, bounty_id) VALUES (?, ?)",
(user_id, bounty_id),
)
return True
except sqlite3.IntegrityError:
return False
def untrack_bounty(user_id: int, bounty_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM user_bounty_tracking WHERE user_id = ? AND bounty_id = ?",
(user_id, bounty_id),
)
return cur.rowcount > 0
def is_tracking(user_id: int, bounty_id: int) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT 1 FROM user_bounty_tracking WHERE user_id = ? AND bounty_id = ?",
(user_id, bounty_id),
).fetchone()
return row is not None
def get_user_tracked_bounties_in_group(user_id: int, group_id: int) -> list[dict]:
with get_conn() as conn:
return [_row_to_dict(r) for r in conn.execute(
"""SELECT b.* FROM bounties b
JOIN user_bounty_tracking t ON t.bounty_id = b.id
WHERE t.user_id = ? AND b.group_id = ?
ORDER BY b.created_at DESC""",
(user_id, group_id),
)]
def get_user_tracked_bounties_personal(user_id: int) -> list[dict]:
"""Tracked bounties where group_id IS NULL (personal)."""
with get_conn() as conn:
return [_row_to_dict(r) for r in conn.execute(
"""SELECT b.* FROM bounties b
JOIN user_bounty_tracking t ON t.bounty_id = b.id
WHERE t.user_id = ? AND b.group_id IS NULL
ORDER BY b.created_at DESC""",
(user_id,),
)]
# ── Reminders ───────────────────────────────────────────────────────────────
def get_bounties_due_soon(user_id: int, days: int = 7) -> list[dict]:
"""Get tracked bounties with due_date within `days` that haven't been reminded yet."""
now = int(time.time())
deadline = now + days * 86400
with get_conn() as conn:
return [_row_to_dict(r) for r in conn.execute(
"""SELECT b.*, u.username, u.telegram_user_id FROM bounties b
JOIN user_bounty_tracking t ON t.bounty_id = b.id
JOIN users u ON u.id = b.created_by_user_id
WHERE t.user_id = ?
AND b.due_date_ts IS NOT NULL
AND b.due_date_ts <= ?
AND b.due_date_ts >= ?
AND b.id NOT IN (
SELECT bounty_id FROM reminder_log WHERE user_id = ?
)
ORDER BY b.due_date_ts ASC""",
(user_id, deadline, now, user_id),
)]
def log_reminder(user_id: int, bounty_id: int) -> None:
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO reminder_log (user_id, bounty_id) VALUES (?, ?)",
(user_id, bounty_id),
)
def get_all_user_ids() -> list[int]:
with get_conn() as conn:
return [row["telegram_user_id"] for row in conn.execute("SELECT telegram_user_id FROM users")]

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

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

View File

@@ -1,49 +0,0 @@
-- JIGAIDO Database Schema
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_chat_id INTEGER UNIQUE NOT NULL,
creator_user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS group_admins (
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_user_id INTEGER UNIQUE NOT NULL,
username TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS bounties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
created_by_user_id INTEGER REFERENCES users(id),
informed_by_username TEXT NOT NULL,
text TEXT,
link TEXT,
due_date_ts INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(group_id, link)
);
CREATE TABLE IF NOT EXISTS user_bounty_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, bounty_id)
);
CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE,
reminded_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, bounty_id)
);

View File

@@ -1,27 +1,8 @@
"""Pytest fixtures for telegram-bot tests."""
import sys
import tempfile
from pathlib import Path
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))
@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
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch, AsyncMock
import pytest
from commands import extract_args, parse_args, format_bounty
from telegram import Update, Message, User, Chat
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:
@@ -30,84 +52,113 @@ class TestExtractArgs:
class TestParseArgs:
def test_text_only(self):
text, link, due = parse_args(["hello", "world"])
text, link, due, _, _ = parse_args(["hello", "world"])
assert text == "hello world"
assert link is None
assert due is None
def test_link_extracted(self):
text, link, due = parse_args(["hello", "https://example.com"])
text, link, due, _, _ = parse_args(["hello", "https://example.com"])
# "hello" is non-link non-date → becomes text; only the URL becomes link
assert text == "hello"
assert link == "https://example.com"
assert due is None
def test_text_and_link(self):
text, link, due = parse_args(["hello", "world", "https://example.com"])
text, link, due, _, _ = parse_args(["hello", "world", "https://example.com"])
assert text == "hello world"
assert link == "https://example.com"
def test_due_date_parsed(self):
text, link, due = parse_args(["hello", "tomorrow"])
text, link, due, _, _ = parse_args(["hello", "tomorrow"])
assert text == "hello"
assert due is not None
# Should be some time in the future
assert due > int(time.time())
def test_all_three(self):
text, link, due = parse_args(["hello", "https://example.com", "tomorrow"])
text, link, due, _, _ = parse_args(["hello", "https://example.com", "tomorrow"])
assert text == "hello"
assert link == "https://example.com"
assert due is not None
def test_http_and_https_both_detected(self):
_, link1, _ = parse_args(["http://example.com"])
_, link2, _ = parse_args(["https://example.com"])
_, link1, _, _, _ = parse_args(["http://example.com"])
_, link2, _, _, _ = parse_args(["https://example.com"])
assert link1 == "http://example.com"
assert link2 == "https://example.com"
def test_non_url_non_date_becomes_text(self):
text, link, due = parse_args(["fix", "the", "bug"])
text, link, due, _, _ = parse_args(["fix", "the", "bug"])
assert text == "fix the bug"
assert link is None
assert due is None
def test_multiple_links_first_only(self):
_, link, _ = parse_args(["text", "https://first.com", "https://second.com"])
_, link, _, _, _ = parse_args(["text", "https://first.com", "https://second.com"])
assert link == "https://first.com"
def test_due_date_after_link(self):
text, link, due = parse_args(["task", "https://example.com", "in 5 days"])
text, link, due, _, _ = parse_args(["task", "https://example.com", "in 5 days"])
assert text == "task"
assert link == "https://example.com"
assert due is not None
def test_empty_args(self):
text, link, due = parse_args([])
text, link, due, _, _ = parse_args([])
assert text is None
assert link is None
assert due is None
def test_date_parser_failure_returns_none(self):
# "asdfjkl" is not parseable → goes to text
text, link, due = parse_args(["hello", "asdfjkl"])
text, link, due, _, _ = parse_args(["hello", "asdfjkl"])
assert text == "hello asdfjkl"
assert due is None
def test_link_takes_first_match(self):
# Even if it's not a valid URL, starts with https://
_, link, _ = parse_args(["skip", "https://not-real.but-still-a-link"])
_, link, _, _, _ = parse_args(["skip", "https://not-real.but-still-a-link"])
assert link == "https://not-real.but-still-a-link"
def test_url_without_scheme_normalized_to_https(self):
"""URLs without scheme should get https:// prefix."""
_, link, _, _, _ = parse_args(["github.com/user/repo"])
assert link == "https://github.com/user/repo"
def test_url_without_scheme_github_normalized(self):
text, link, _, _, _ = parse_args(["Fix bug", "github.com/owner/repo"])
assert text == "Fix bug"
assert link == "https://github.com/owner/repo"
def test_url_with_explicit_https_unchanged(self):
_, link, _, _, _ = parse_args(["task", "https://example.com/page"])
assert link == "https://example.com/page"
def test_url_with_http_unchanged(self):
_, link, _, _, _ = parse_args(["task", "http://example.com/page"])
assert link == "http://example.com/page"
def test_url_link_flag_without_scheme_normalized(self):
_, link, _, _, _ = parse_args(["-link", "example.com/path"])
assert link == "https://example.com/path"
class TestFormatBounty:
def _row(self, id=1, text="Test bounty", link="https://example.com",
due_date_ts=None, informed_by_username="alice"):
def _row(
self,
id=1,
text="Test bounty",
link="https://example.com",
due_date_ts=None,
created_by_user_id=123456,
):
row = MagicMock()
row.__getitem__ = lambda s, k: {
"id": id, "text": text, "link": link,
"due_date_ts": due_date_ts, "informed_by_username": informed_by_username
}[k]
row.id = id
row.text = text
row.link = link
row.due_date_ts = due_date_ts
row.created_by_user_id = created_by_user_id
return row
def test_shows_id(self):
@@ -155,12 +206,381 @@ class TestFormatBounty:
out = format_bounty(b)
assert "OVERDUE" in out
def test_informed_by_shown(self):
b = self._row(informed_by_username="bob")
def test_created_by_shown(self):
b = self._row(created_by_user_id=999)
out = format_bounty(b)
assert "@bob" in out
assert "999" in out
def test_informed_by_unknown_fallback(self):
b = self._row(informed_by_username=None)
out = format_bounty(b)
assert "@unknown" 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")
_, 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

View File

@@ -1,298 +0,0 @@
"""Tests for db.py"""
import time
import pytest
import db as _db
class TestUsers:
def test_upsert_user_creates_new(self):
uid = _db.upsert_user(123, "alice")
assert uid > 0
row = _db.get_user_by_telegram_id(123)
assert row is not None
assert row["telegram_user_id"] == 123
assert row["username"] == "alice"
def test_upsert_user_updates_username(self):
# Two upserts to the same telegram_user_id: second one updates the username.
# Returns the same id both times (idempotent).
uid1 = _db.upsert_user(123, "alice")
uid2 = _db.upsert_user(123, "alice_updated")
assert uid1 == uid2
row = _db.get_user_by_telegram_id(123)
assert row["username"] == "alice_updated"
def test_get_user_by_telegram_id_not_found(self):
row = _db.get_user_by_telegram_id(999999)
assert row is None
class TestGroups:
def test_upsert_group_creates_new(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
assert gid > 0
row = _db.get_group(-100123)
assert row is not None
assert row["telegram_chat_id"] == -100123
assert row["creator_user_id"] == uid
def test_upsert_group_idempotent(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid1 = _db.upsert_group(-100123, uid)
gid2 = _db.upsert_group(-100123, uid)
assert gid1 == gid2
def test_get_group_creator_user_id(self, fresh_db):
uid = _db.upsert_user(1, "creator")
_db.upsert_group(-100123, uid)
assert _db.get_group_creator_user_id(_db.get_group(-100123)["id"]) == uid
def test_get_group_not_found(self, fresh_db):
row = _db.get_group(-999999)
assert row is None
class TestGroupAdmins:
def test_add_remove_is_admin(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
assert not _db.is_group_admin(gid, uid)
added = _db.add_group_admin(gid, uid)
assert added is True
assert _db.is_group_admin(gid, uid) is True
# Adding again returns False (already admin)
assert _db.add_group_admin(gid, uid) is False
removed = _db.remove_group_admin(gid, uid)
assert removed is True
assert _db.is_group_admin(gid, uid) is False
def test_remove_nonexistent_admin_returns_false(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
assert _db.remove_group_admin(gid, uid) is False
def test_is_group_creator(self, fresh_db):
uid = _db.upsert_user(1, "creator")
other = _db.upsert_user(2, "other")
gid = _db.upsert_group(-100123, uid)
_db.add_group_admin(gid, uid)
assert _db.is_group_creator(gid, uid) is True
assert _db.is_group_creator(gid, other) is False
def test_get_user_by_username(self, fresh_db):
uid = _db.upsert_user(1, "alice")
row = _db.get_user_by_username("alice")
assert row is not None
assert row["id"] == uid
assert _db.get_user_by_username("nobody") is None
class TestBounties:
def test_add_bounty_group(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
_db.add_group_admin(gid, uid)
bid = _db.add_bounty(
group_id=gid,
created_by_user_id=uid,
informed_by_username="bob",
text="Fix bug",
link="https://github.com/bob/repo",
due_date_ts=int(time.time()) + 86400,
)
assert bid > 0
bounty = _db.get_bounty(bid)
assert bounty["text"] == "Fix bug"
assert bounty["link"] == "https://github.com/bob/repo"
assert bounty["informed_by_username"] == "bob"
assert bounty["group_id"] == gid
def test_add_bounty_personal(self, fresh_db):
uid = _db.upsert_user(1, "alice")
bid = _db.add_bounty(
group_id=None,
created_by_user_id=uid,
informed_by_username="alice",
text="Personal reminder",
link=None,
due_date_ts=None,
)
assert bid > 0
bounty = _db.get_bounty(bid)
assert bounty["group_id"] is None
assert bounty["text"] == "Personal reminder"
def test_add_bounty_duplicate_link_rejected(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
_db.add_bounty(gid, uid, "user1", "text1", "https://example.com", None)
with pytest.raises(ValueError, match="Link already exists"):
_db.add_bounty(gid, uid, "user2", "text2", "https://example.com", None)
def test_add_bounty_null_link_allows_multiples(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
bid1 = _db.add_bounty(gid, uid, "user1", "text only 1", None, None)
bid2 = _db.add_bounty(gid, uid, "user2", "text only 2", None, None)
assert bid1 != bid2
def test_get_group_bounties(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
_db.add_group_admin(gid, uid)
_db.add_bounty(gid, uid, "user", "bounty1", None, None)
_db.add_bounty(gid, uid, "user", "bounty2", None, None)
bounties = _db.get_group_bounties(gid)
assert len(bounties) == 2
def test_get_user_personal_bounties(self, fresh_db):
uid = _db.upsert_user(1, "alice")
_db.add_bounty(None, uid, "alice", "personal1", None, None)
_db.add_bounty(None, uid, "alice", "personal2", None, None)
# Group bounty should not appear
other = _db.upsert_user(2, "bob")
gid = _db.upsert_group(-100, other)
_db.add_bounty(gid, other, "bob", "group bounty", None, None)
personal = _db.get_user_personal_bounties(uid)
assert len(personal) == 2
def test_update_bounty(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
bid = _db.add_bounty(gid, uid, "user", "old text", None, None)
_db.update_bounty(bid, "new text", None, None)
updated = _db.get_bounty(bid)
assert updated["text"] == "new text"
def test_update_bounty_duplicate_link_rejected(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
_db.add_bounty(gid, uid, "user1", "bounty1", "https://a.com", None)
bid2 = _db.add_bounty(gid, uid, "user2", "bounty2", None, None)
with pytest.raises(ValueError, match="Link already exists"):
_db.update_bounty(bid2, None, "https://a.com", None)
def test_delete_bounty(self, fresh_db):
uid = _db.upsert_user(1, "creator")
gid = _db.upsert_group(-100123, uid)
bid = _db.add_bounty(gid, uid, "user", "to delete", None, None)
assert _db.delete_bounty(bid) is True
assert _db.get_bounty(bid) is None
# Deleting again returns False
assert _db.delete_bounty(bid) is False
class TestTracking:
def test_track_untrack_is_tracking(self, fresh_db):
uid = _db.upsert_user(1, "alice")
uid2 = _db.upsert_user(2, "bob")
gid = _db.upsert_group(-100123, uid)
bid = _db.add_bounty(gid, uid, "alice", "task", None, None)
assert _db.track_bounty(uid, bid) is True
assert _db.is_tracking(uid, bid) is True
# Track again → False (already tracking)
assert _db.track_bounty(uid, bid) is False
# Other user tracking same bounty
assert _db.track_bounty(uid2, bid) is True
assert _db.is_tracking(uid2, bid) is True
# Untrack
assert _db.untrack_bounty(uid, bid) is True
assert _db.is_tracking(uid, bid) is False
assert _db.is_tracking(uid2, bid) is True # other user still tracking
# Untrack again → False
assert _db.untrack_bounty(uid, bid) is False
def test_get_user_tracked_bounties_in_group(self, fresh_db):
uid = _db.upsert_user(1, "alice")
gid = _db.upsert_group(-100123, uid)
bid1 = _db.add_bounty(gid, uid, "alice", "task1", None, None)
bid2 = _db.add_bounty(gid, uid, "alice", "task2", None, None)
# Different group bounty
other_gid = _db.upsert_group(-100124, uid)
bid3 = _db.add_bounty(other_gid, uid, "alice", "other group task", None, None)
_db.track_bounty(uid, bid1)
_db.track_bounty(uid, bid3)
tracked = _db.get_user_tracked_bounties_in_group(uid, gid)
assert len(tracked) == 1
assert tracked[0]["id"] == bid1
def test_get_user_tracked_bounties_personal(self, fresh_db):
uid = _db.upsert_user(1, "alice")
bid1 = _db.add_bounty(None, uid, "alice", "personal1", None, None)
bid2 = _db.add_bounty(None, uid, "alice", "personal2", None, None)
gid = _db.upsert_group(-100123, uid)
bid3 = _db.add_bounty(gid, uid, "alice", "group task", None, None)
_db.track_bounty(uid, bid1)
_db.track_bounty(uid, bid3)
tracked = _db.get_user_tracked_bounties_personal(uid)
assert len(tracked) == 1
assert tracked[0]["id"] == bid1
class TestReminders:
def test_get_bounties_due_soon(self, fresh_db):
uid = _db.upsert_user(1, "alice")
gid = _db.upsert_group(-100123, uid)
now = int(time.time())
# Due in 3 days (< 7 days)
bid_soon = _db.add_bounty(gid, uid, "alice", "soon", None, now + 3 * 86400)
# Due in 10 days (> 7 days)
_db.add_bounty(gid, uid, "alice", "later", None, now + 10 * 86400)
# No due date
bid_no_date = _db.add_bounty(gid, uid, "alice", "no date", None, None)
_db.track_bounty(uid, bid_soon)
_db.track_bounty(uid, bid_no_date)
due = _db.get_bounties_due_soon(uid, days=7)
assert len(due) == 1
assert due[0]["id"] == bid_soon
def test_reminder_log_prevents_duplicate_reminders(self, fresh_db):
uid = _db.upsert_user(1, "alice")
gid = _db.upsert_group(-100123, uid)
now = int(time.time())
bid = _db.add_bounty(gid, uid, "alice", "task", None, now + 2 * 86400)
_db.track_bounty(uid, bid)
due1 = _db.get_bounties_due_soon(uid, days=7)
assert len(due1) == 1
# Log that we reminded
_db.log_reminder(uid, bid)
# Should not appear again
due2 = _db.get_bounties_due_soon(uid, days=7)
assert len(due2) == 0
def test_get_all_user_ids(self, fresh_db):
_db.upsert_user(1, "alice")
_db.upsert_user(2, "bob")
ids = _db.get_all_user_ids()
assert sorted(ids) == [1, 2]

250
cli/main.py Normal file
View File

@@ -0,0 +1,250 @@
"""JIGAIDO CLI - Command line interface for bounty tracking."""
import argparse
import sys
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()

54
config.py Normal file
View File

@@ -0,0 +1,54 @@
"""JIGAIDO Configuration Management."""
import json
import os
from pathlib import Path
from typing import Optional
DEFAULT_DATA_DIR = Path.home() / ".jigaido"
class Config:
"""JIGAIDO configuration with precedence: ENV > config file > defaults."""
def __init__(self):
self.data_dir: Path = self._resolve_data_dir()
self.bot_token: Optional[str] = self._resolve_bot_token()
def _resolve_bot_token(self) -> Optional[str]:
env_token = os.environ.get("JIGAIDO_BOT_TOKEN")
if env_token:
return env_token
config_file = Path("~/.jigaido/config.json").expanduser()
if config_file.exists():
with open(config_file) as f:
config_data = json.load(f)
if "JIGAIDO_BOT_TOKEN" in config_data:
return config_data["JIGAIDO_BOT_TOKEN"]
return None
def _resolve_data_dir(self) -> Path:
env_dir = os.environ.get("JIGAIDO_DATA_DIR")
if env_dir:
return Path(env_dir)
config_file = Path("~/.jigaido/config.json").expanduser()
if config_file.exists():
with open(config_file) as f:
config_data = json.load(f)
if "data_dir" in config_data:
return Path(config_data["data_dir"])
return DEFAULT_DATA_DIR
def ensure_data_dir(self) -> None:
"""Ensure the data directory exists."""
self.data_dir.mkdir(parents=True, exist_ok=True)
config = Config()
config = Config()

21
core/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""Core domain models for JIGAIDO."""
from core.models import (
Bounty,
TrackedBounty,
RoomData,
TrackingData,
)
from core.ports import (
RoomStorage,
TrackingStorage,
)
__all__ = [
"Bounty",
"TrackedBounty",
"RoomData",
"TrackingData",
"RoomStorage",
"TrackingStorage",
]

96
core/models.py Normal file
View File

@@ -0,0 +1,96 @@
"""Domain dataclasses for JIGAIDO bounty tracker."""
from dataclasses import dataclass, field
@dataclass
class Category:
"""A category for organizing bounties in a room.
Categories are per-room and support soft delete.
The id (slug) must be lowercase alphabetic only (e.g., "bug", "feature").
"""
id: str # slug: lowercase alphabetic only, e.g., "bug", "feature"
name: str # display name: e.g., "Bug", "Feature"
created_at: int
deleted_at: int | None = None # soft delete
@dataclass
class Bounty:
"""A bounty created by a user.
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.
The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp.
The category_ids field lists category slugs associated with this bounty.
"""
id: int
text: str | None
link: str | None
due_date_ts: int | None
created_at: int
created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
category_ids: list[str] = field(default_factory=list)
@dataclass
class TrackedBounty:
"""A bounty that a user is tracking.
Lightweight relation/pointer - the actual tracking context (including room)
lives in TrackingData, not here.
"""
bounty_id: int
created_at: int
@dataclass
class RoomData:
"""All data for a room (group or DM).
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 timezone field stores the room's timezone (e.g., "Asia/Jakarta"), default UTC+0.
The admin_usernames field lists usernames who have admin privileges in this room.
The categories field contains all categories for organizing bounties in this room.
"""
room_id: int
bounties: list[Bounty]
next_id: int
timezone: str | None = None
admin_usernames: list[str] | None = None
categories: list[Category] = field(default_factory=list)
def __post_init__(self):
if self.admin_usernames is None:
self.admin_usernames = []
if self.categories is None:
self.categories = []
@dataclass
class TrackingData:
"""User tracking state within a room (group or DM).
TrackingData vs TrackedBounty:
- Use TrackingData to store ALL tracked bounties for a user in a specific room.
It contains the room_id, user_id, and a list of TrackedBounty entries.
- Use TrackedBounty to represent a single tracked bounty entry within that list.
TrackingData is the container, TrackedBounty is the item.
"""
room_id: int
user_id: int
tracked: list[TrackedBounty]

80
core/ports.py Normal file
View File

@@ -0,0 +1,80 @@
"""Abstract storage interfaces (Ports) for JIGAIDO storage adapters."""
from typing import Protocol, runtime_checkable
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
@runtime_checkable
class RoomStorage(Protocol):
"""Storage port for room bounties.
A room is identified by room_id:
- Negative room_id: Telegram group (e.g., -1001)
- Positive room_id: DM/personal context (user's Telegram ID)
This single port handles both group and personal bounties.
"""
def load(self, room_id: int) -> RoomData | None:
"""Load all data for a room. Returns None if room doesn't exist."""
...
def save(self, room_data: RoomData) -> None:
"""Save all data for a room."""
...
def add_bounty(self, room_id: int, bounty: Bounty) -> None:
"""Add a new bounty to a room. Creates room if it doesn't exist."""
...
def update_bounty(self, room_id: int, bounty: Bounty) -> None:
"""Update an existing bounty in a room."""
...
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""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
class TrackingStorage(Protocol):
"""Storage port for tracking data.
Tracks which bounties a user is tracking in a specific room.
"""
def load(self, room_id: int, user_id: int) -> TrackingData | None:
"""Load tracking data for a user in a room. Returns None if not tracking anything."""
...
def save(self, tracking_data: TrackingData) -> None:
"""Save tracking data."""
...
def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None:
"""Add a bounty to a user's tracking list. Creates tracking entry if needed."""
...
def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None:
"""Remove a bounty from a user's tracking list."""
...

573
core/services.py Normal file
View File

@@ -0,0 +1,573 @@
"""Pure business logic services for JIGAIDO."""
import time
from typing import Optional
from core.models import Bounty, Category, RoomData, TrackedBounty, TrackingData
from core.ports import RoomStorage, TrackingStorage
class BountyService:
"""Service for bounty operations in a room.
A room is identified by room_id:
- Negative room_id: Telegram group (e.g., -1001)
- Positive room_id: DM/personal context (user's Telegram 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):
self._storage = storage
def is_admin(self, room_id: int, username: str | None) -> bool:
"""Check if user is admin in a room by username."""
if not username:
return False
room_data = self._storage.load(room_id)
if room_data is None:
return False
return username in (room_data.admin_usernames or [])
def add_admin(
self, room_id: int, username: str, requesting_username: str | None
) -> None:
"""Add an admin to a room. Requires admin permission, or self-promotion if first admin."""
room_data = self._storage.load(room_id)
has_no_admins = room_data is None or not room_data.admin_usernames
is_self_promotion = requesting_username == username
if not self.is_admin(room_id, requesting_username):
if not (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.")
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_usernames=[]
)
admin_usernames = room_data.admin_usernames
if admin_usernames is None:
admin_usernames = []
room_data.admin_usernames = []
if username in admin_usernames:
raise ValueError(f"@{username} is already an admin.")
admin_usernames.append(username)
self._storage.save(room_data)
def remove_admin(
self, room_id: int, username: str, requesting_username: str | None
) -> None:
"""Remove an admin from a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_username):
raise PermissionError("Only admins can remove admins.")
room_data = self._storage.load(room_id)
if room_data is None or not (room_data.admin_usernames or []):
raise ValueError(f"@{username} is not an admin.")
if username not in (room_data.admin_usernames or []):
raise ValueError(f"@{username} is not an admin.")
(room_data.admin_usernames or []).remove(username)
self._storage.save(room_data)
def list_admins(self, room_id: int) -> list[str]:
"""List all admin usernames in a room."""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return list(room_data.admin_usernames or [])
def set_timezone(
self, room_id: int, timezone: str, requesting_username: str | None
) -> None:
"""Set the timezone for a room. Requires admin permission."""
if not self.is_admin(room_id, requesting_username):
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_usernames=[]
)
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(
self,
room_id: int,
user_id: int,
username: str | None,
text: Optional[str] = None,
link: Optional[str] = None,
due_date_ts: Optional[int] = None,
created_by_username: Optional[str] = None,
) -> Bounty:
"""Add a new bounty to the room. Requires admin permission."""
if not self.is_admin(room_id, username):
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)
if room_data is None:
room_data = RoomData(room_id=room_id, bounties=[], next_id=1)
else:
room_data.next_id += 1
bounty = Bounty(
id=room_data.next_id,
created_by_user_id=user_id,
created_by_username=created_by_username,
text=text,
link=link,
due_date_ts=due_date_ts,
created_at=int(time.time()),
)
self._storage.add_bounty(room_id, bounty)
return bounty
def list_bounties(self, room_id: int) -> list[Bounty]:
"""List all non-deleted bounties in a room."""
return self._storage.list_bounties(room_id)
def list_deleted_bounties(self, room_id: int) -> list[Bounty]:
"""List all soft-deleted bounties in a room. For /recover functionality."""
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
return [b for b in all_bounties if b.deleted_at is not None]
def get_deleted_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific soft-deleted bounty by ID."""
all_bounties = self._storage.list_all_bounties(room_id, include_deleted=True)
for b in all_bounties:
if b.id == bounty_id and b.deleted_at is not None:
return b
return None
def recover_bounty(self, room_id: int, bounty_id: int, username: str | None) -> str:
"""Recover a soft-deleted bounty. Admin only.
Returns: 'recovered', 'not_found', 'not_deleted', 'permission_denied'
"""
if not self.is_admin(room_id, username):
return "permission_denied"
bounty = self.get_deleted_bounty(room_id, bounty_id)
if not bounty:
return "not_found"
if bounty.deleted_at is None:
return "not_deleted"
bounty.deleted_at = None
self._storage.update_bounty(room_id, bounty)
return "recovered"
def recover_bounties(
self, room_id: int, bounty_ids: list[int], username: str | None
) -> dict[int, str]:
"""Recover multiple soft-deleted bounties. Admin only.
Returns dict of bounty_id -> result ('recovered', 'not_found', 'not_deleted', 'permission_denied')
"""
results = {}
for bounty_id in bounty_ids:
results[bounty_id] = self.recover_bounty(room_id, bounty_id, username)
return results
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty by ID. Excludes soft-deleted bounties."""
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(
self,
room_id: int,
bounty_id: int,
username: str | None,
text: Optional[str] = None,
link: Optional[str] = None,
due_date_ts: Optional[int] = None,
clear_link: bool = False,
clear_due: bool = False,
) -> bool:
"""Update a bounty. Only admins can update."""
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
return False
if not self.is_admin(room_id, username):
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(
id=bounty.id,
created_by_user_id=bounty.created_by_user_id,
text=text if text is not None else bounty.text,
link=None if clear_link else (link if link is not None else bounty.link),
due_date_ts=None
if clear_due
else (due_date_ts if due_date_ts is not None else bounty.due_date_ts),
created_at=bounty.created_at,
deleted_at=bounty.deleted_at,
created_by_username=bounty.created_by_username,
)
self._storage.update_bounty(room_id, updated)
return True
def delete_bounty(self, room_id: int, bounty_id: int, username: str | None) -> bool:
"""Soft delete a bounty. Only admins can delete."""
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
return False
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can delete bounties.")
bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty)
return True
def delete_bounties(
self, room_id: int, bounty_ids: list[int], username: str | None
) -> dict[int, str]:
"""Soft delete multiple bounties. Returns dict of bounty_id -> result.
Results can be: 'deleted', 'not_found', 'permission_denied'
"""
results = {}
for bounty_id in bounty_ids:
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
results[bounty_id] = "not_found"
continue
if not self.is_admin(room_id, username):
results[bounty_id] = "permission_denied"
continue
bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty)
results[bounty_id] = "deleted"
return results
# --- Category Management ---
def add_category(
self,
room_id: int,
slug: str,
name: str,
username: str | None,
) -> Category:
"""Create a new category. Admin only.
Args:
room_id: Room identifier
slug: Category ID (lowercase alphabetic, e.g., "bug")
name: Display name (e.g., "Bug Report")
username: Requesting admin's username
Returns:
Created Category
Raises:
PermissionError: If not admin
ValueError: If slug already exists or invalid
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can add categories.")
# Validate slug format (lowercase alphabetic only)
if not slug or not slug.isalpha() or not slug.islower():
raise ValueError(
"Category slug must be lowercase alphabetic only (e.g., 'bug', 'feature')."
)
room_data = self._storage.load(room_id)
if room_data is None:
room_data = RoomData(
room_id=room_id, bounties=[], next_id=1, admin_usernames=[], categories=[]
)
# Check for duplicate slug
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
raise ValueError(f"Category '{slug}' already exists.")
category = Category(
id=slug,
name=name,
created_at=int(time.time()),
deleted_at=None,
)
room_data.categories.append(category)
self._storage.save(room_data)
return category
def delete_category(
self,
room_id: int,
slug: str,
username: str | None,
) -> bool:
"""Soft delete a category. Admin only.
Args:
room_id: Room identifier
slug: Category slug to delete
username: Requesting admin's username
Returns:
True if deleted, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can delete categories.")
room_data = self._storage.load(room_id)
if room_data is None:
return False
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
cat.deleted_at = int(time.time())
self._storage.save(room_data)
return True
return False
def list_categories(self, room_id: int) -> list[Category]:
"""List active categories (excludes soft-deleted).
Args:
room_id: Room identifier
Returns:
List of active categories
"""
room_data = self._storage.load(room_id)
if room_data is None:
return []
return [c for c in room_data.categories if c.deleted_at is None]
def get_category(self, room_id: int, slug: str) -> Category | None:
"""Get a category by slug (excludes soft-deleted).
Args:
room_id: Room identifier
slug: Category slug
Returns:
Category or None if not found
"""
room_data = self._storage.load(room_id)
if room_data is None:
return None
for cat in room_data.categories:
if cat.id == slug and cat.deleted_at is None:
return cat
return None
def _validate_category_exists(self, room_id: int, slug: str) -> None:
"""Validate that a category exists (and is not deleted). Raises ValueError if not found."""
if not self.get_category(room_id, slug):
raise ValueError(f"Category '{slug}' not found.")
def add_category_to_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Add category to a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to add
username: Requesting admin's username
Returns:
True if newly added, False if already exists
Raises:
PermissionError: If not admin
ValueError: If bounty or category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
self._validate_category_exists(room_id, category_slug)
if category_slug in bounty.category_ids:
return False # Already exists
bounty.category_ids.append(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def remove_category_from_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None,
) -> bool:
"""Remove category from a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to remove
username: Requesting admin's username
Returns:
True if removed, False if not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
if category_slug not in bounty.category_ids:
return False
bounty.category_ids.remove(category_slug)
self._storage.update_bounty(room_id, bounty)
return True
def update_bounty_categories(
self,
room_id: int,
bounty_id: int,
category_slugs: list[str],
username: str | None,
) -> bool:
"""Replace all categories on a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slugs: New list of category slugs
username: Requesting admin's username
Returns:
True if updated
Raises:
PermissionError: If not admin
ValueError: If bounty or any category not found
"""
if not self.is_admin(room_id, username):
raise PermissionError("Only admins can manage bounty categories.")
bounty = self.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
# Validate all categories exist
for slug in category_slugs:
self._validate_category_exists(room_id, slug)
bounty.category_ids = category_slugs
self._storage.update_bounty(room_id, bounty)
return True
class TrackingService:
"""Service for tracking bounty operations."""
def __init__(self, tracking_storage: TrackingStorage, room_storage: RoomStorage):
self._tracking = tracking_storage
self._room = room_storage
def track_bounty(self, room_id: int, user_id: int, bounty_id: int) -> bool:
"""Start tracking a bounty. Returns True if newly tracked."""
bounty = self._room.get_bounty(room_id, bounty_id)
if not bounty:
raise ValueError("Bounty not found.")
tracking_data = self._tracking.load(room_id, user_id)
if tracking_data is None:
tracking_data = TrackingData(room_id=room_id, user_id=user_id, tracked=[])
for tracked in tracking_data.tracked:
if tracked.bounty_id == bounty_id:
return False
tracked = TrackedBounty(bounty_id=bounty_id, created_at=int(time.time()))
self._tracking.track_bounty(room_id, user_id, tracked)
return True
def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> bool:
"""Stop tracking a bounty. Returns True if was tracking."""
tracking_data = self._tracking.load(room_id, user_id)
if tracking_data is None:
return False
for tracked in tracking_data.tracked:
if tracked.bounty_id == bounty_id:
self._tracking.untrack_bounty(room_id, user_id, bounty_id)
return True
return False
def get_tracked_bounties(self, room_id: int, user_id: int) -> list[Bounty]:
"""Get all bounties tracked by a user in a room."""
tracking_data = self._tracking.load(room_id, user_id)
if tracking_data is None:
return []
room_data = self._room.load(room_id)
if room_data is None:
return []
bounty_map = {b.id: b for b in room_data.bounties if b.deleted_at is None}
return [
bounty_map[t.bounty_id]
for t in tracking_data.tracked
if t.bounty_id in bounty_map
]

544
docs/AUDIT_AND_SPEC.md Normal file
View File

@@ -0,0 +1,544 @@
# JIGAIDO Audit & Feature Specification
> Document created: 2026-04-09
> Purpose: Repository audit findings and category feature specification
---
# Part I: Repository Audit
## 1.1 Current Architecture
JIGAIDO follows **hexagonal architecture** with clear separation:
```
jigaido/
├── core/ # Domain layer (pure Python, no deps)
│ ├── models.py # Domain dataclasses
│ ├── ports.py # Storage interfaces
│ └── services.py # Business logic
├── adapters/
│ └── storage/
│ └── json_file.py # JSON file persistence
├── apps/
│ └── telegram-bot/
│ ├── bot.py # Bot entrypoint
│ └── commands.py # Command handlers
├── tests/ # Unit tests (98 tests passing)
├── config.py # Configuration
├── SPEC.md # Original design spec
└── README.md # Overview
```
## 1.2 Features Implemented
| Feature | Status | Location |
|---------|--------|----------|
| Group bounty management | ✅ Done | `BountyService` |
| Personal DM bounties | ✅ Done | Same service, different room_id |
| Admin management | ✅ Done | `add_admin`, `remove_admin`, `list_admins` |
| Soft delete (recoverable) | ✅ Done | `delete_bounty`, `recover_bounty` |
| Due date with timezone | ✅ Done | `dateparser` + `ZoneInfo` |
| Link deduplication | ✅ Done | `check_link_unique` |
| Tracking/untracking | ✅ Done | `TrackingService` |
| `/track` in groups only | ✅ Done | Command handler |
| Expired bounty filtering | ✅ Done | 24h cutoff logic |
| Timezone per room | ✅ Done | `set_timezone`, `get_timezone` |
## 1.3 Bugs & Issues Found
### Bug 1: Admin Promotion Logic Edge Case
**Location**: `core/services.py` - `add_admin()`
**Issue**: The first admin can self-promote, but if the Telegram group creator joins later, they won't be recognized as admin since they're not in `admin_usernames`.
**Code**:
```python
# core/services.py:44-49
has_no_admins = room_data is None or not room_data.admin_usernames
is_self_promotion = requesting_username == username
if not self.is_admin(room_id, requesting_username):
if not (has_no_admins and is_self_promotion):
raise PermissionError("Only admins can add admins.")
```
**Recommendation**: Document this behavior or enhance to auto-detect Telegram group creator.
---
### Bug 2: Hard Delete Method in Storage
**Location**: `adapters/storage/json_file.py:113-119`
**Issue**: `delete_bounty()` in storage does hard delete, while `BountyService.delete_bounty()` does soft delete. The storage method is unused but confusing.
**Code**:
```python
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
room_data = self.load(room_id)
if room_data is None:
return
room_data.bounties = [b for b in room_data.bounties if b.id != bounty_id]
self.save(room_data)
```
**Recommendation**: Remove or mark as deprecated.
---
### Issue 3: Spec vs Code Inconsistency
**Location**: `SPEC.md` vs `commands.py`
**Issue**: SPEC.md says anyone can `/add` in groups, but code requires admin.
| Command | SPEC.md | Code |
|---------|---------|------|
| `/add` | anyone | admin only |
| `/edit` | creator only | admin only |
| `/delete` | creator only | admin only |
**Recommendation**: Update SPEC.md to reflect actual implementation.
---
### Issue 4: No Input Sanitization
**Location**: `commands.py` - `parse_args()`
**Issue**: Links without proper scheme (e.g., `github.com/user/repo`) are accepted as-is.
**Recommendation**: Consider normalizing URL scheme or validating format.
---
### Issue 5: No Rate Limiting
**Location**: `/add` command
**Issue**: No limit on:
- Number of bounties per room
- Text length
- Request rate
**Recommendation**: Add rate limiting for production use.
---
## 1.4 Test Status
```bash
$ PYTHONPATH=. python -m pytest tests/
======================== 98 passed, 1 warning ========================
```
**Note**: Tests require `PYTHONPATH=.` to run. Consider adding `pytest.ini` or `pyproject.toml`.
---
# Part II: Category Feature Specification
## 2.1 Overview
Add category support to JIGAIDO to allow filtering and organizing bounties.
**Goals**:
- Admin-only category management (create, delete)
- Multiple categories per bounty (no duplicates)
- Filter bounties by category
- Show categories on `/show` command
- Backward compatibility (existing bounties work without categories)
## 2.2 Data Model
### Category
```python
@dataclass
class Category:
"""A category for organizing bounties in a room."""
id: str # slug: lowercase alphabetic only, e.g., "bug", "feature"
name: str # display name: e.g., "Bug", "Feature"
created_at: int
deleted_at: int | None = None # soft delete
```
**Constraints**:
- `id` (slug): lowercase alphabetic only, no symbols, e.g., `^[a-z]+$`
- `name`: human-readable display
- Unique within room (slug must be unique)
- Soft delete preserves data
### Bounty (Modified)
```python
@dataclass
class Bounty:
# ... existing fields ...
id: int
text: str | None
link: str | None
due_date_ts: int | None
created_at: int
created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
category_ids: list[str] = field(default_factory=list) # NEW
```
### RoomData (Modified)
```python
@dataclass
class RoomData:
room_id: int
bounties: list[Bounty]
next_id: int
timezone: str | None = None
admin_usernames: list[str] | None = None
categories: list[Category] = field(default_factory=list) # NEW
```
## 2.3 Category Scope
- **Per room**: Same as bounties, each room (group/DM) has independent categories
- **Admin only**: Only admins can create/delete categories
- **User access**: Regular users can only filter by category
## 2.4 Service Layer API
All methods require admin permission unless specified otherwise.
### Category Management
```python
class BountyService:
# ... existing methods ...
# --- Category Management ---
def add_category(
self,
room_id: int,
slug: str,
name: str,
username: str | None
) -> Category:
"""Create a new category. Admin only.
Args:
room_id: Room identifier
slug: Category ID (lowercase alphabetic, e.g., "bug")
name: Display name (e.g., "Bug Report")
username: Requesting admin's username
Returns:
Created Category
Raises:
PermissionError: If not admin
ValueError: If slug already exists or invalid
"""
...
def delete_category(
self,
room_id: int,
slug: str,
username: str | None
) -> bool:
"""Soft delete a category. Admin only.
Args:
room_id: Room identifier
slug: Category slug to delete
username: Requesting admin's username
Returns:
True if deleted, False if not found
"""
...
def list_categories(self, room_id: int) -> list[Category]:
"""List active categories (excludes soft-deleted).
Args:
room_id: Room identifier
Returns:
List of active categories
"""
...
def get_category(self, room_id: int, slug: str) -> Category | None:
"""Get a category by slug (excludes soft-deleted).
Args:
room_id: Room identifier
slug: Category slug
Returns:
Category or None if not found
"""
...
```
### Category-to-Bounty Association
```python
def add_category_to_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None
) -> bool:
"""Add category to a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to add
username: Requesting admin's username
Returns:
True if newly added, False if already exists
Raises:
PermissionError: If not admin
ValueError: If bounty or category not found
"""
...
def remove_category_from_bounty(
self,
room_id: int,
bounty_id: int,
category_slug: str,
username: str | None
) -> bool:
"""Remove category from a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slug: Category slug to remove
username: Requesting admin's username
Returns:
True if removed, False if not found
"""
...
def update_bounty_categories(
self,
room_id: int,
bounty_id: int,
category_slugs: list[str],
username: str | None
) -> bool:
"""Replace all categories on a bounty. Admin only.
Args:
room_id: Room identifier
bounty_id: Bounty ID
category_slugs: New list of category slugs
username: Requesting admin's username
Returns:
True if updated
Raises:
PermissionError: If not admin
ValueError: If bounty or any category not found
"""
...
```
### Bounty Listing with Category Filter
```python
def list_bounties(
self,
room_id: int,
category_slugs: list[str] | None = None,
include_expired: bool = False
) -> list[Bounty]:
"""List bounties with optional category filtering.
Args:
room_id: Room identifier
category_slugs: If provided, filter by ANY of these categories (OR)
include_expired: If True, include bounties past due date
Returns:
List of non-deleted bounties, sorted by due date
"""
...
```
## 2.5 Filtering Logic
- **Single category**: `/bounty -c bug` → bounties with "bug"
- **Multiple categories (OR)**: `/bounty -c bug,feature` → bounties with "bug" OR "feature"
- **No filter**: `/bounty` → all bounties (current behavior)
## 2.6 Command Syntax
### Category Management
```
/category - list categories
/category add <slug> <name> - create category (admin)
/category delete <slug> - soft delete category (admin)
```
### Bounty with Category
```
/add <text> [link] [date] -cat <slug> - add with category
/add <text> [link] [date] -cat <slug1>,<slug2> - add with multiple categories
/update <id> -cat <slug> - add category to bounty
/update <id> -cat <slug1>,<slug2> - set categories (replace all)
/update <id> -cat - - clear all categories
/update <id> -remove-cat <slug> - remove specific category
```
### Bounty Listing with Filter
```
/bounty - all bounties (current)
/bounty -c <slug> - filter by category
/bounty -c <slug1>,<slug2> - filter by multiple categories (OR)
/bounty all - show expired (current)
/bounty all -c <slug> - show expired + filter by category
```
### Show Bounty
```
/show <id> - show bounty details with categories
```
## 2.7 Display Format
### `/category` output
```
Categories:
- bug → Bug Report
- feature → Feature Request
- docs → Documentation
```
### `/show <id>` output (with categories)
```
[#1] Fix login bug
🔗 https://github.com/...
📅 15 April 2026 14:30 (Asia/Jakarta)
📂 Categories: bug | feature
👤 @username
📌 Created: 2026-04-01 10:00
```
### `/bounty -c bug` output
```
Filtering with 🐛 bug category:
Showing 3 of 10 bounties:
[#5] ...
[#1] ...
[#3] ...
```
### `/bounty -c bug,feature` output
```
Filtering with 🐛 bug, ✨ feature categories:
Showing 5 of 10 bounties:
[#5] ...
[#1] ...
```
## 2.8 Edge Cases
| Scenario | Behavior |
|----------|----------|
| Delete category | Soft delete - existing bounties keep category in data, but filter won't find it |
| Filter by deleted category | Show "No bounties with this category" or error |
| Add duplicate category to bounty | No-op, return False |
| Add invalid slug (uppercase/symbols) | Reject with validation error |
| Category slug conflict | Reject with "Category already exists" |
| Bounty without categories | `category_ids = []` (backward compatible) |
## 2.9 Test Cases to Add
```python
# Category Management
def test_add_category_requires_admin():
def test_add_category_duplicate_slug_fails():
def test_add_category_invalid_slug_fails():
def test_add_category_valid():
def test_delete_category_soft_deletes():
def test_deleted_category_not_listed():
def test_deleted_category_still_in_bounty_data():
def test_list_categories_empty():
def test_list_categories_returns_active():
def test_get_category_not_found():
def test_get_category_deleted_returns_none():
# Category-to-Bounty
def test_add_category_to_bounty():
def test_add_duplicate_category_to_bounty_noop():
def test_add_category_to_bounty_invalid_category():
def test_remove_category_from_bounty():
def test_remove_category_not_on_bounty_returns_false():
def test_update_bounty_categories_replace_all():
def test_update_bounty_categories_validates():
# Bounty Listing with Filter
def test_list_bounties_filter_by_single_category():
def test_list_bounties_filter_by_multiple_categories_or():
def test_list_bounties_no_category_returns_all():
def test_list_bounties_category_excludes_deleted_bounties():
```
---
# Part III: Implementation Checklist
## Models Layer
- [ ] Add `Category` dataclass to `core/models.py`
- [ ] Add `category_ids` field to `Bounty` dataclass
- [ ] Add `categories` field to `RoomData` dataclass
## Storage Layer
- [ ] Update `JsonFileRoomStorage.load()` to deserialize categories
- [ ] Update `JsonFileRoomStorage.save()` to serialize categories
## Service Layer
- [ ] Implement `add_category()`
- [ ] Implement `delete_category()`
- [ ] Implement `list_categories()`
- [ ] Implement `get_category()`
- [ ] Implement `add_category_to_bounty()`
- [ ] Implement `remove_category_from_bounty()`
- [ ] Implement `update_bounty_categories()`
- [ ] Update `list_bounties()` to support category filter
## Command Layer
- [ ] Add `/category` command handler
- [ ] Add `-cat` flag parsing to `/add`
- [ ] Add `-cat` and `-remove-cat` flags to `/update`
- [ ] Add `-c` flag to `/bounty` for category filter
- [ ] Update `/show` to display categories
## Tests
- [ ] Add category management tests
- [ ] Add category-to-bounty tests
- [ ] Add category filter tests
## Documentation
- [ ] Update README.md with category feature
- [ ] Update command help text
---
# Part IV: Open Questions
1. **Category icon/emoji**: Should categories have optional emoji? (Not in initial spec, can add later)
2. **Category reactivation**: Should soft-deleted categories be reactable? (Not in initial spec)
3. **Bulk category operations**: Should we support `/category add bulk`? (Not in initial spec)
---
*End of Audit & Specification Document*

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
pythonpath = .
asyncio_default_fixture_loop_scope = function

0
tests/__init__.py Normal file
View File

375
tests/test_cli.py Normal file
View File

@@ -0,0 +1,375 @@
"""Tests for cli/main.py — CLI commands with mocked dependencies."""
import pytest
from unittest.mock import patch, MagicMock
from io import StringIO
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") is 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") is 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"])

91
tests/test_config.py Normal file
View File

@@ -0,0 +1,91 @@
"""Tests for config.py — configuration management."""
import json
import os
from pathlib import Path
from unittest.mock import patch
from config import Config, DEFAULT_DATA_DIR
class TestConfigDataDir:
def test_default_data_dir(self, tmp_path):
"""Test that default data_dir is ~/.jigaido when no config exists."""
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.exists", return_value=False):
cfg = Config()
assert cfg.data_dir == DEFAULT_DATA_DIR
def test_env_override_data_dir(self, tmp_path):
"""Test that JIGAIDO_DATA_DIR env var overrides config file."""
env_dir = "/custom/env/data/dir"
with patch.dict(os.environ, {"JIGAIDO_DATA_DIR": env_dir}, clear=False):
cfg = Config()
assert cfg.data_dir == Path(env_dir)
def test_config_file_data_dir(self, tmp_path):
"""Test that config file is read when JIGAIDO_DATA_DIR not set."""
config_dir = tmp_path / ".jigaido"
config_dir.mkdir()
config_file = config_dir / "config.json"
config_file.write_text(json.dumps({"data_dir": "/custom/config/data"}))
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.expanduser", return_value=config_file):
cfg = Config()
assert cfg.data_dir == Path("/custom/config/data")
def test_bot_token_from_env(self):
"""Test that bot_token is read from JIGAIDO_BOT_TOKEN env var."""
with patch.dict(
os.environ, {"JIGAIDO_BOT_TOKEN": "test_token_123"}, clear=False
):
cfg = Config()
assert cfg.bot_token == "test_token_123"
def test_bot_token_none_when_not_set(self):
"""Test that bot_token is None when JIGAIDO_BOT_TOKEN not set and no config file."""
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.exists", return_value=False):
cfg = Config()
assert cfg.bot_token is None
def test_bot_token_from_config_file(self):
"""Test that bot_token is read from config file when env var not set."""
config_dir = Path.home() / ".jigaido"
config_file = config_dir / "config.json"
with patch.dict(os.environ, {}, clear=True):
with patch("pathlib.Path.expanduser", return_value=config_file):
with patch("pathlib.Path.exists", return_value=True):
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = lambda *a: None
mock_open.return_value.read = lambda: (
'{"JIGAIDO_BOT_TOKEN": "config_token"}'
)
cfg = Config()
assert cfg.bot_token == "config_token"
class TestConfigEnsureDataDir:
def test_ensure_data_dir_creates_directory(self, tmp_path):
"""Test that ensure_data_dir creates the directory if it doesn't exist."""
data_dir = tmp_path / "test_data_dir"
with patch.object(Config, "__init__", lambda self: None):
cfg = Config()
cfg.data_dir = data_dir
assert not data_dir.exists()
cfg.ensure_data_dir()
assert data_dir.exists()
assert data_dir.is_dir()
def test_ensure_data_dir_does_nothing_if_exists(self, tmp_path):
"""Test that ensure_data_dir doesn't fail if directory already exists."""
data_dir = tmp_path / "existing_dir"
data_dir.mkdir()
with patch.object(Config, "__init__", lambda self: None):
cfg = Config()
cfg.data_dir = data_dir
cfg.ensure_data_dir()
assert data_dir.exists()

258
tests/test_json_file.py Normal file
View File

@@ -0,0 +1,258 @@
"""Tests for adapters/storage/json_file.py — JSON file storage adapter."""
import json
import tempfile
from pathlib import Path
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
class TestJsonFileRoomStorage:
"""Unit tests for JsonFileRoomStorage."""
def setup_method(self):
"""Set up a temporary directory for each test."""
self.temp_dir = tempfile.mkdtemp()
self.storage = JsonFileRoomStorage(data_dir=Path(self.temp_dir))
def teardown_method(self):
"""Clean up temporary directory after each test."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _create_bounty(
self,
id=1,
text="Test bounty",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
):
"""Helper to create a Bounty."""
return Bounty(
id=id,
text=text,
link=link,
due_date_ts=due_date_ts,
created_at=created_at,
created_by_user_id=created_by_user_id,
)
def test_load_returns_none_for_nonexistent_room(self):
"""Test that load returns None for a room that doesn't exist."""
result = self.storage.load(-1001)
assert result is None
def test_save_and_load_room(self):
"""Test that save and load work correctly."""
room = RoomData(room_id=-1001, bounties=[], next_id=1)
self.storage.save(room)
loaded = self.storage.load(-1001)
assert loaded is not None
assert loaded.room_id == -1001
assert loaded.bounties == []
assert loaded.next_id == 1
def test_add_bounty_creates_room(self):
"""Test that add_bounty creates a room if it doesn't exist."""
bounty = self._create_bounty()
self.storage.add_bounty(-1001, bounty)
loaded = self.storage.load(-1001)
assert loaded is not None
assert len(loaded.bounties) == 1
assert loaded.bounties[0].text == "Test bounty"
def test_add_bounty_increments_next_id(self):
"""Test that add_bounty properly handles next_id."""
bounty1 = self._create_bounty(id=1)
bounty2 = self._create_bounty(id=2)
self.storage.add_bounty(-1001, bounty1)
self.storage.add_bounty(-1001, bounty2)
loaded = self.storage.load(-1001)
assert loaded.next_id == 3 # Should be max id + 1
def test_update_bounty(self):
"""Test that update_bounty correctly updates a bounty."""
bounty = self._create_bounty(id=1, text="Original")
self.storage.add_bounty(-1001, bounty)
updated = self._create_bounty(id=1, text="Updated")
self.storage.update_bounty(-1001, updated)
loaded = self.storage.load(-1001)
assert loaded.bounties[0].text == "Updated"
def test_update_bounty_nonexistent_room(self):
"""Test that update_bounty does nothing for nonexistent room."""
updated = self._create_bounty(id=1, text="Updated")
self.storage.update_bounty(-1001, updated) # Should not raise
assert self.storage.load(-1001) is None
def test_get_bounty_found(self):
"""Test that get_bounty returns the bounty when found."""
bounty = self._create_bounty(id=1)
self.storage.add_bounty(-1001, bounty)
result = self.storage.get_bounty(-1001, 1)
assert result is not None
assert result.text == "Test bounty"
def test_get_bounty_not_found(self):
"""Test that get_bounty returns None when not found."""
result = self.storage.get_bounty(-1001, 999)
assert result is None
def test_file_path_format(self):
"""Test that room data is stored in correct location."""
room = RoomData(room_id=-1001, bounties=[], next_id=1)
self.storage.save(room)
expected_path = Path(self.temp_dir) / "-1001.json"
assert expected_path.exists()
def test_atomic_write(self):
"""Test that data is written atomically."""
room = RoomData(room_id=-1001, bounties=[], next_id=1)
self.storage.save(room)
# Check that the file is valid JSON
file_path = Path(self.temp_dir) / "-1001.json"
with open(file_path) as f:
data = json.load(f)
assert data["room_id"] == -1001
class TestJsonFileTrackingStorage:
"""Unit tests for JsonFileTrackingStorage."""
def setup_method(self):
"""Set up a temporary directory for each test."""
self.temp_dir = tempfile.mkdtemp()
self.storage = JsonFileTrackingStorage(tracking_dir=Path(self.temp_dir))
def teardown_method(self):
"""Clean up temporary directory after each test."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _create_tracked(self, bounty_id=1, created_at=0):
"""Helper to create a TrackedBounty."""
return TrackedBounty(bounty_id=bounty_id, created_at=created_at)
def test_load_returns_none_for_nonexistent_tracking(self):
"""Test that load returns None when no tracking exists."""
result = self.storage.load(-1001, 123456)
assert result is None
def test_save_and_load_tracking(self):
"""Test that save and load work correctly."""
tracking = TrackingData(room_id=-1001, user_id=123456, tracked=[])
self.storage.save(tracking)
loaded = self.storage.load(-1001, 123456)
assert loaded is not None
assert loaded.room_id == -1001
assert loaded.user_id == 123456
def test_track_bounty(self):
"""Test that track_bounty adds a bounty to tracking."""
tracked = self._create_tracked(bounty_id=5)
self.storage.track_bounty(-1001, 123456, tracked)
loaded = self.storage.load(-1001, 123456)
assert loaded is not None
assert len(loaded.tracked) == 1
assert loaded.tracked[0].bounty_id == 5
def test_untrack_bounty(self):
"""Test that untrack_bounty removes a bounty from tracking."""
tracked = self._create_tracked(bounty_id=5)
self.storage.track_bounty(-1001, 123456, tracked)
self.storage.untrack_bounty(-1001, 123456, 5)
loaded = self.storage.load(-1001, 123456)
assert loaded is not None
assert len(loaded.tracked) == 0
def test_untrack_bounty_nonexistent(self):
"""Test that untrack_bounty handles nonexistent tracking gracefully."""
self.storage.untrack_bounty(-1001, 123456, 999) # Should not raise
def test_file_path_format(self):
"""Test that tracking data is stored in correct location."""
tracked = self._create_tracked(bounty_id=5)
self.storage.track_bounty(-1001, 123456, tracked)
expected_path = Path(self.temp_dir) / "-1001_123456.json"
assert expected_path.exists()
def test_multiple_tracked_bounties(self):
"""Test tracking multiple bounties."""
self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=1))
self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=2))
self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=3))
loaded = self.storage.load(-1001, 123456)
assert len(loaded.tracked) == 3
def test_different_users_independent_tracking(self):
"""Test that different users have independent tracking."""
self.storage.track_bounty(-1001, 111, self._create_tracked(bounty_id=1))
self.storage.track_bounty(-1001, 222, self._create_tracked(bounty_id=1))
loaded_111 = self.storage.load(-1001, 111)
loaded_222 = self.storage.load(-1001, 222)
assert len(loaded_111.tracked) == 1
assert len(loaded_222.tracked) == 1
class TestDuplicateTrackingBehavior:
"""Test that duplicate tracking is handled correctly.
Note: The deduplication logic is in the TrackingService layer,
not in the adapter. This test verifies the adapter behavior
when the same bounty is tracked multiple times (which would only
happen if the service layer has a bug).
"""
def setup_method(self):
"""Set up a temporary directory for each test."""
self.temp_dir = tempfile.mkdtemp()
self.storage = JsonFileTrackingStorage(tracking_dir=Path(self.temp_dir))
def teardown_method(self):
"""Clean up temporary directory after each test."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_adapter_allows_duplicate_tracking(self):
"""The adapter does NOT prevent duplicate tracking.
This is by design - deduplication should be handled by
TrackingService.track_bounty(), not the storage adapter.
"""
# Add same bounty twice via adapter (bypassing service)
self.storage.track_bounty(
-1001, 123456, TrackedBounty(bounty_id=5, created_at=0)
)
self.storage.track_bounty(
-1001, 123456, TrackedBounty(bounty_id=5, created_at=0)
)
loaded = self.storage.load(-1001, 123456)
# Adapter allows duplicates - service should prevent them
assert len(loaded.tracked) == 2
assert loaded.tracked[0].bounty_id == 5
assert loaded.tracked[1].bounty_id == 5

182
tests/test_models.py Normal file
View File

@@ -0,0 +1,182 @@
"""Tests for core/models.py — domain dataclasses."""
from core.models import (
Bounty,
TrackedBounty,
RoomData,
TrackingData,
)
class TestBounty:
def test_create_bounty(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,
)
assert b.id == 1
assert b.text == "Fix the bug"
assert b.link == "https://github.com/example/repo/issues/1"
assert b.due_date_ts == 1735689600
assert b.created_at == 1735603200
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):
b = Bounty(
id=1,
text=None,
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
assert b.text is None
assert b.link 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):
b1 = Bounty(
id=1,
text="a",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
b2 = Bounty(
id=1,
text="a",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
assert b1 == b2
def test_bounty_comparison_not_equal(self):
b1 = Bounty(
id=1,
text="a",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
b2 = Bounty(
id=2,
text="b",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=456,
)
assert b1 != b2
class TestTrackedBounty:
def test_create_tracked_bounty(self):
tb = TrackedBounty(bounty_id=5, created_at=1735600000)
assert tb.bounty_id == 5
assert tb.created_at == 1735600000
def test_tracked_bounty_comparison(self):
tb1 = TrackedBounty(bounty_id=1, created_at=0)
tb2 = TrackedBounty(bounty_id=1, created_at=0)
assert tb1 == tb2
class TestRoomData:
def test_create_group_room_data(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
)
assert rd.room_id == -1001
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_usernames == []
def test_create_dm_room_data(self):
rd = RoomData(
room_id=123456,
bounties=[],
next_id=1,
)
assert rd.room_id == 123456
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_usernames == []
def test_room_data_with_bounties(self):
b = Bounty(
id=1,
text="Task",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
rd = RoomData(room_id=-1001, bounties=[b], next_id=2)
assert len(rd.bounties) == 1
assert rd.bounties[0].text == "Task"
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_usernames=["alice", "bob"],
)
assert rd.timezone == "Asia/Jakarta"
assert rd.admin_usernames == ["alice", "bob"]
def test_room_data_admin_usernames_defaults_to_empty_list(self):
rd = RoomData(
room_id=-1001,
bounties=[],
next_id=1,
)
assert rd.admin_usernames == []
class TestTrackingData:
def test_create_tracking_data(self):
td = TrackingData(room_id=-1001, user_id=123456, tracked=[])
assert td.room_id == -1001
assert td.user_id == 123456
assert td.tracked == []
def test_tracking_data_with_tracked(self):
tb = TrackedBounty(bounty_id=5, created_at=0)
td = TrackingData(room_id=-1001, user_id=123, tracked=[tb])
assert len(td.tracked) == 1
assert td.tracked[0].bounty_id == 5

270
tests/test_ports.py Normal file
View File

@@ -0,0 +1,270 @@
"""Tests for core/ports.py — storage interfaces."""
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
from core.ports import RoomStorage, TrackingStorage
class SimpleRoomStorage:
"""Minimal mock without ensure_room - tests if add_bounty works without it.
This mock only has the basic CRUD methods. It does NOT implement ensure_room().
If add_bounty() still works with this simple mock, then ensure_room() may not
be needed as a public Protocol method.
"""
def __init__(self):
self._rooms: dict[int, RoomData] = {}
def load(self, room_id: int) -> RoomData | None:
return self._rooms.get(room_id)
def save(self, room_data: RoomData) -> 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] = RoomData(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 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
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:
"""Minimal mock without ensure_tracking - tests if track_bounty works without it.
This mock only has the basic methods. It does NOT implement ensure_tracking().
If track_bounty() still works with this simple mock, then ensure_tracking() may not
be needed as a public Protocol method.
"""
def __init__(self):
self._tracking: dict[tuple[int, int], TrackingData] = {}
def load(self, room_id: int, user_id: int) -> TrackingData | None:
return self._tracking.get((room_id, user_id))
def save(self, tracking_data: TrackingData) -> None:
self._tracking[(tracking_data.room_id, tracking_data.user_id)] = tracking_data
def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None:
key = (room_id, user_id)
if key not in self._tracking:
self._tracking[key] = TrackingData(
room_id=room_id, user_id=user_id, tracked=[]
)
self._tracking[key].tracked.append(tracked)
def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None:
key = (room_id, user_id)
if key in self._tracking:
self._tracking[key].tracked = [
t for t in self._tracking[key].tracked if t.bounty_id != bounty_id
]
class MockRoomStorage:
"""Mock implementation of RoomStorage for testing."""
def __init__(self):
self._rooms: dict[int, RoomData] = {}
def load(self, room_id: int) -> RoomData | None:
return self._rooms.get(room_id)
def save(self, room_data: RoomData) -> 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] = RoomData(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 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
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:
"""Mock implementation of TrackingStorage for testing."""
def __init__(self):
self._tracking: dict[tuple[int, int], TrackingData] = {}
def load(self, room_id: int, user_id: int) -> TrackingData | None:
return self._tracking.get((room_id, user_id))
def save(self, tracking_data: TrackingData) -> None:
self._tracking[(tracking_data.room_id, tracking_data.user_id)] = tracking_data
def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None:
key = (room_id, user_id)
if key not in self._tracking:
self._tracking[key] = TrackingData(
room_id=room_id, user_id=user_id, tracked=[]
)
self._tracking[key].tracked.append(tracked)
def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None:
key = (room_id, user_id)
if key in self._tracking:
self._tracking[key].tracked = [
t for t in self._tracking[key].tracked if t.bounty_id != bounty_id
]
class TestRoomStorage:
def test_simple_storage_without_ensure_room(self):
"""Test that SimpleRoomStorage (no ensure_room) still works.
This verifies that ensure_room() is NOT needed as a public Protocol method,
since add_bounty() can create the room internally without it.
"""
storage = SimpleRoomStorage()
bounty = Bounty(
id=1,
text="Test",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.add_bounty(-1001, bounty)
room = storage.load(-1001)
assert room is not None
assert len(room.bounties) == 1
assert room.bounties[0].text == "Test"
assert room.next_id == 1
def test_mock_implements_room_storage_protocol(self):
storage: RoomStorage = MockRoomStorage()
assert isinstance(storage, RoomStorage)
def test_add_bounty_creates_room_if_not_exists(self):
storage = MockRoomStorage()
bounty = Bounty(
id=1,
text="Test",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.add_bounty(-1001, bounty)
room = storage.load(-1001)
assert room is not None
assert len(room.bounties) == 1
assert room.bounties[0].text == "Test"
def test_update_bounty(self):
storage = MockRoomStorage()
bounty = Bounty(
id=1,
text="Original",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.add_bounty(-1001, bounty)
updated = Bounty(
id=1,
text="Updated",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.update_bounty(-1001, updated)
result = storage.get_bounty(-1001, 1)
assert result is not None
assert result.text == "Updated"
class TestTrackingStorage:
def test_simple_storage_without_ensure_tracking(self):
"""Test that SimpleTrackingStorage (no ensure_tracking) still works.
This verifies that ensure_tracking() is NOT needed as a public Protocol method,
since track_bounty() can create the tracking entry internally without it.
"""
storage = SimpleTrackingStorage()
tracked = TrackedBounty(bounty_id=5, created_at=0)
storage.track_bounty(-1001, 123456, tracked)
result = storage.load(-1001, 123456)
assert result is not None
assert len(result.tracked) == 1
assert result.tracked[0].bounty_id == 5
def test_mock_implements_tracking_storage_protocol(self):
storage: TrackingStorage = MockTrackingStorage()
assert isinstance(storage, TrackingStorage)
def test_track_bounty_creates_tracking_if_not_exists(self):
storage = MockTrackingStorage()
tracked = TrackedBounty(bounty_id=5, created_at=0)
storage.track_bounty(-1001, 123456, tracked)
result = storage.load(-1001, 123456)
assert result is not None
assert len(result.tracked) == 1
assert result.tracked[0].bounty_id == 5
def test_untrack_bounty(self):
storage = MockTrackingStorage()
tracked = TrackedBounty(bounty_id=5, created_at=0)
storage.track_bounty(-1001, 123456, tracked)
storage.untrack_bounty(-1001, 123456, 5)
result = storage.load(-1001, 123456)
assert result is not None
assert len(result.tracked) == 0

686
tests/test_services.py Normal file
View File

@@ -0,0 +1,686 @@
"""Tests for core/services.py — business logic services."""
import pytest
from core.models import RoomData
from core.services import BountyService, TrackingService
from tests.test_ports import MockRoomStorage, MockTrackingStorage
class TestBountyService:
"""Unit tests for BountyService."""
def setup_method(self):
"""Set up fresh storage and service for each test."""
self.storage = MockRoomStorage()
self.service = BountyService(self.storage)
self.admin_username = "admin"
self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, username: str):
"""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_usernames=[]
)
if username not in (room_data.admin_usernames or []):
room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_usernames.append(username)
self.storage.save(room_data)
def test_add_bounty_creates_room_if_not_exists(self):
"""Test that add_bounty creates a new room if it doesn't exist."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
username=self.admin_username,
text="Fix bug",
link="https://github.com/issue/1",
created_by_username=self.admin_username,
)
assert bounty.id == 1
assert bounty.text == "Fix bug"
assert bounty.created_by_user_id == 123
room = self.storage.load(-1001)
assert room is not None
assert len(room.bounties) == 1
def test_add_bounty_increments_id(self):
"""Test that add_bounty increments bounty ID for each new bounty."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="First"
)
b2 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Second"
)
b3 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Third"
)
assert b1.id == 1
assert b2.id == 2
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, username="nonadmin", text="Not admin"
)
def test_list_bounties_empty_room(self):
"""Test list_bounties returns empty list for non-existent room."""
bounties = self.service.list_bounties(-1001)
assert bounties == []
def test_list_bounties_returns_all_bounties(self):
"""Test list_bounties returns all bounties in a room."""
self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="First"
)
self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Second"
)
# Add bounty to different room to verify isolation
self._make_admin(-999, "otheradmin")
self.service.add_bounty(
room_id=-999, user_id=456, username="otheradmin", text="Other room"
)
bounties = self.service.list_bounties(-1001)
assert len(bounties) == 2
assert all(b.text in ["First", "Second"] for b in bounties)
def test_get_bounty_found(self):
"""Test get_bounty returns bounty when it exists."""
created = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Test"
)
found = self.service.get_bounty(-1001, created.id)
assert found is not None
assert found.text == "Test"
def test_get_bounty_not_found(self):
"""Test get_bounty returns None when bounty doesn't exist."""
found = self.service.get_bounty(-1001, 999)
assert found is None
def test_get_bounty_wrong_room(self):
"""Test get_bounty returns None when bounty is in different room."""
self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Test"
)
found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1
assert found is None
def test_update_bounty_success(self):
"""Test update_bounty succeeds when admin updates their bounty."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Original"
)
result = self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
username=self.admin_username,
text="Updated",
)
assert result is True
updated = self.service.get_bounty(-1001, bounty.id)
assert updated.text == "Updated"
def test_update_bounty_not_admin_raises_permission_error(self):
"""Test update_bounty raises PermissionError when non-admin tries to update."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="Original"
)
with pytest.raises(PermissionError, match="Only admins can edit bounties"):
self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
username="nonadmin", # different user, not admin
text="Hacked",
)
def test_update_bounty_not_found(self):
"""Test update_bounty returns False when bounty doesn't exist."""
result = self.service.update_bounty(
room_id=-1001,
bounty_id=999,
username=self.admin_username,
text="Updated",
)
assert result is False
def test_update_bounty_partial_update(self):
"""Test update_bounty only updates provided fields."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
username=self.admin_username,
text="Original",
link="https://original.link",
)
self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
username=self.admin_username,
text="Updated only text",
)
updated = self.service.get_bounty(-1001, bounty.id)
assert updated.text == "Updated only text"
assert updated.link == "https://original.link" # link unchanged
def test_update_bounty_clear_link(self):
"""Test update_bounty can clear link."""
bounty = self.service.add_bounty(
room_id=-1001,
user_id=123,
username=self.admin_username,
text="Test",
link="https://original.link",
)
self.service.update_bounty(
room_id=-1001,
bounty_id=bounty.id,
username=self.admin_username,
clear_link=True,
)
updated = self.service.get_bounty(-1001, bounty.id)
assert updated.link is None
def test_delete_bounty_success(self):
"""Test delete_bounty soft deletes when admin deletes their bounty."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
result = self.service.delete_bounty(-1001, bounty.id, self.admin_username)
assert result is True
# Soft delete - bounty should not be found via get_bounty
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_admin_raises_permission_error(self):
"""Test delete_bounty raises PermissionError when non-admin tries to delete."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
with pytest.raises(PermissionError, match="Only admins can delete bounties"):
self.service.delete_bounty(-1001, bounty.id, "nonadmin")
def test_delete_bounty_not_found(self):
"""Test delete_bounty returns False when bounty doesn't exist."""
result = self.service.delete_bounty(-1001, 999, self.admin_username)
assert result is False
def test_delete_bounties_multi_id_success(self):
"""Test delete_bounties returns individual results for multiple bounties."""
bounty1 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete 1"
)
bounty2 = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete 2"
)
results = self.service.delete_bounties(
-1001, [bounty1.id, bounty2.id], self.admin_username
)
assert results == {bounty1.id: "deleted", bounty2.id: "deleted"}
# Verify both are soft deleted
assert self.service.get_bounty(-1001, bounty1.id) is None
assert self.service.get_bounty(-1001, bounty2.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns not_found for non-existent bounties."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
results = self.service.delete_bounties(
-1001, [bounty.id, 999, 888], self.admin_username
)
assert results == {bounty.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin users."""
bounty = self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text="To delete"
)
results = self.service.delete_bounties(
-1001,
[bounty.id],
"nonadmin", # non-admin user
)
assert results == {bounty.id: "permission_denied"}
# Verify bounty was NOT deleted
assert self.service.get_bounty(-1001, bounty.id) is not None
class TestTrackingService:
"""Unit tests for TrackingService."""
def setup_method(self):
"""Set up fresh storage and service for each test."""
self.room_storage = MockRoomStorage()
self.tracking_storage = MockTrackingStorage()
self.service = TrackingService(self.tracking_storage, self.room_storage)
self.admin_username = "admin"
self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, username: str):
"""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_usernames=[]
)
if username not in (room_data.admin_usernames or []):
room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_usernames.append(username)
self.room_storage.save(room_data)
def _add_bounty(self, room_id=-1001, username="admin", text="Test bounty"):
"""Helper to add a bounty for tracking tests."""
if self.room_storage.load(room_id) is None or username not in (
self.room_storage.load(room_id).admin_usernames or []
):
self._make_admin(room_id, username)
bounty_service = BountyService(self.room_storage)
return bounty_service.add_bounty(
room_id=room_id, user_id=123, username=username, text=text
)
def test_track_bounty_success(self):
"""Test track_bounty successfully tracks a bounty."""
bounty = self._add_bounty()
result = self.service.track_bounty(-1001, 123456, bounty.id)
assert result is True
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 1
assert tracked[0].id == bounty.id
def test_track_bounty_returns_false_if_already_tracking(self):
"""Test track_bounty returns False if bounty is already tracked."""
bounty = self._add_bounty()
self.service.track_bounty(-1001, 123456, bounty.id)
result = self.service.track_bounty(-1001, 123456, bounty.id)
assert result is False
def test_track_bounty_raises_value_error_if_bounty_not_found(self):
"""Test track_bounty raises ValueError if bounty doesn't exist."""
with pytest.raises(ValueError, match="Bounty not found"):
self.service.track_bounty(-1001, 123456, 999)
def test_track_bounty_different_users_can_track_same_bounty(self):
"""Test that different users can track the same bounty."""
bounty = self._add_bounty()
self.service.track_bounty(-1001, 111, bounty.id)
self.service.track_bounty(-1001, 222, bounty.id)
tracked_by_111 = self.service.get_tracked_bounties(-1001, 111)
tracked_by_222 = self.service.get_tracked_bounties(-1001, 222)
assert len(tracked_by_111) == 1
assert len(tracked_by_222) == 1
assert tracked_by_111[0].id == bounty.id
assert tracked_by_222[0].id == bounty.id
def test_untrack_bounty_success(self):
"""Test untrack_bounty successfully untracks a bounty."""
bounty = self._add_bounty()
self.service.track_bounty(-1001, 123456, bounty.id)
result = self.service.untrack_bounty(-1001, 123456, bounty.id)
assert result is True
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 0
def test_untrack_bounty_returns_false_if_not_tracking(self):
"""Test untrack_bounty returns False if bounty was not being tracked."""
bounty = self._add_bounty()
result = self.service.untrack_bounty(-1001, 123456, bounty.id)
assert result is False
def test_untrack_bounty_returns_false_if_tracking_different_bounty(self):
"""Test untrack_bounty returns False if tracking different bounty."""
b1 = self._add_bounty(text="Bounty 1")
b2 = self._add_bounty(text="Bounty 2")
self.service.track_bounty(-1001, 123456, b1.id)
result = self.service.untrack_bounty(-1001, 123456, b2.id)
assert result is False
# b1 should still be tracked
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 1
assert tracked[0].id == b1.id
def test_get_tracked_bounties_empty(self):
"""Test get_tracked_bounties returns empty list when nothing tracked."""
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert tracked == []
def test_get_tracked_bounties_returns_tracked_bounties(self):
"""Test get_tracked_bounties returns all bounties tracked by user."""
b1 = self._add_bounty(text="First")
b2 = self._add_bounty(text="Second")
self.service.track_bounty(-1001, 123456, b1.id)
self.service.track_bounty(-1001, 123456, b2.id)
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 2
assert all(t.text in ["First", "Second"] for t in tracked)
def test_get_tracked_bounties_ignores_deleted_bounties(self):
"""Test get_tracked_bounties ignores bounties that were deleted."""
bounty_service = BountyService(self.room_storage)
bounty = bounty_service.add_bounty(
room_id=-1001, user_id=123, username="admin", text="To delete"
)
self.service.track_bounty(-1001, 123456, bounty.id)
# Delete the bounty
bounty_service.delete_bounty(-1001, bounty.id, "admin")
tracked = self.service.get_tracked_bounties(-1001, 123456)
assert len(tracked) == 0 # deleted bounty not returned
def test_get_tracked_bounties_different_rooms_independent(self):
"""Test that tracking in different rooms is independent."""
b1 = self._add_bounty(room_id=-1001, text="Room 1")
b2 = self._add_bounty(room_id=-999, text="Room 2")
self.service.track_bounty(-1001, 123456, b1.id)
self.service.track_bounty(-999, 123456, b2.id)
tracked_room1 = self.service.get_tracked_bounties(-1001, 123456)
tracked_room2 = self.service.get_tracked_bounties(-999, 123456)
assert len(tracked_room1) == 1
assert len(tracked_room2) == 1
assert tracked_room1[0].text == "Room 1"
assert tracked_room2[0].text == "Room 2"
class TestCategoryService:
"""Unit tests for category management."""
def setup_method(self):
"""Set up fresh storage and service for each test."""
self.storage = MockRoomStorage()
self.service = BountyService(self.storage)
self.admin_username = "admin"
self._make_admin(-1001, self.admin_username)
def _make_admin(self, room_id: int, username: str):
"""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_usernames=[]
)
if username not in (room_data.admin_usernames or []):
room_data.admin_usernames = room_data.admin_usernames or []
room_data.admin_usernames.append(username)
self.storage.save(room_data)
def _add_bounty(self, text="Test bounty"):
"""Helper to add a bounty for category tests."""
return self.service.add_bounty(
room_id=-1001, user_id=123, username=self.admin_username, text=text
)
# Category Management Tests
def test_add_category_requires_admin(self):
"""Test that add_category raises PermissionError for non-admin."""
with pytest.raises(PermissionError, match="Only admins can add categories"):
self.service.add_category(-1001, "bug", "Bug Report", "nonadmin")
def test_add_category_duplicate_slug_fails(self):
"""Test that adding a duplicate category slug raises ValueError."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(ValueError, match="already exists"):
self.service.add_category(-1001, "bug", "Bug Report 2", self.admin_username)
def test_add_category_invalid_slug_fails_uppercase(self):
"""Test that uppercase slug raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "Bug", "Bug Report", self.admin_username)
def test_add_category_invalid_slug_fails_with_numbers(self):
"""Test that slug with numbers raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "bug1", "Bug 1", self.admin_username)
def test_add_category_invalid_slug_fails_with_symbols(self):
"""Test that slug with symbols raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "bug-fix", "Bug Fix", self.admin_username)
def test_add_category_invalid_slug_fails_empty(self):
"""Test that empty slug raises ValueError."""
with pytest.raises(ValueError, match="lowercase alphabetic only"):
self.service.add_category(-1001, "", "Empty", self.admin_username)
def test_add_category_valid(self):
"""Test that valid category can be created."""
category = self.service.add_category(
-1001, "bug", "Bug Report", self.admin_username
)
assert category.id == "bug"
assert category.name == "Bug Report"
assert category.created_at > 0
assert category.deleted_at is None
def test_delete_category_soft_deletes(self):
"""Test that delete_category performs soft delete."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
result = self.service.delete_category(-1001, "bug", self.admin_username)
assert result is True
# Category should not be found via get_category
assert self.service.get_category(-1001, "bug") is None
# But should still be in raw room data (soft delete)
room_data = self.storage.load(-1001)
for cat in room_data.categories:
if cat.id == "bug":
assert cat.deleted_at is not None
return
assert False, "Category should still exist in storage"
def test_deleted_category_not_listed(self):
"""Test that list_categories excludes soft-deleted categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
categories = self.service.list_categories(-1001)
assert len(categories) == 0
assert not any(cat.id == "bug" for cat in categories)
def test_list_categories_empty(self):
"""Test that list_categories returns empty list for room with no categories."""
categories = self.service.list_categories(-1001)
assert categories == []
def test_list_categories_returns_active(self):
"""Test that list_categories returns only active categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.add_category(-1001, "feature", "Feature Request", self.admin_username)
self.service.add_category(-1001, "docs", "Documentation", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
categories = self.service.list_categories(-1001)
assert len(categories) == 2
category_ids = [c.id for c in categories]
assert "feature" in category_ids
assert "docs" in category_ids
assert "bug" not in category_ids
def test_get_category_not_found(self):
"""Test that get_category returns None for non-existent category."""
category = self.service.get_category(-1001, "nonexistent")
assert category is None
def test_get_category_deleted_returns_none(self):
"""Test that get_category returns None for soft-deleted category."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.delete_category(-1001, "bug", self.admin_username)
category = self.service.get_category(-1001, "bug")
assert category is None
def test_add_category_requires_admin_non_existent_room(self):
"""Test that add_category works for non-existent room (creates it)."""
self._make_admin(-9999, self.admin_username)
category = self.service.add_category(
-9999, "bug", "Bug Report", self.admin_username
)
assert category.id == "bug"
# Category-to-Bounty Association Tests
def test_add_category_to_bounty(self):
"""Test adding category to a bounty."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
result = self.service.add_category_to_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is True
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert "bug" in updated_bounty.category_ids
def test_add_duplicate_category_to_bounty_noop(self):
"""Test that adding duplicate category returns False."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
result = self.service.add_category_to_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is False
def test_add_category_to_bounty_invalid_bounty(self):
"""Test that adding category to non-existent bounty raises ValueError."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(ValueError, match="Bounty not found"):
self.service.add_category_to_bounty(-1001, 999, "bug", self.admin_username)
def test_add_category_to_bounty_invalid_category(self):
"""Test that adding non-existent category raises ValueError."""
bounty = self._add_bounty()
with pytest.raises(ValueError, match="not found"):
self.service.add_category_to_bounty(
-1001, bounty.id, "nonexistent", self.admin_username
)
def test_remove_category_from_bounty(self):
"""Test removing category from a bounty."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
result = self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is True
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert "bug" not in updated_bounty.category_ids
def test_remove_category_not_on_bounty_returns_false(self):
"""Test that removing category not on bounty returns False."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
result = self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", self.admin_username
)
assert result is False
def test_update_bounty_categories_replace_all(self):
"""Test that update_bounty_categories replaces all categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
self.service.add_category(-1001, "feature", "Feature Request", self.admin_username)
self.service.add_category(-1001, "docs", "Documentation", self.admin_username)
bounty = self._add_bounty()
# Add initial categories
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
# Replace with different categories
self.service.update_bounty_categories(
-1001, bounty.id, ["feature", "docs"], self.admin_username
)
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert updated_bounty.category_ids == ["feature", "docs"]
def test_update_bounty_categories_clear_all(self):
"""Test that update_bounty_categories can clear all categories."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
self.service.update_bounty_categories(-1001, bounty.id, [], self.admin_username)
updated_bounty = self.service.get_bounty(-1001, bounty.id)
assert updated_bounty.category_ids == []
def test_update_bounty_categories_validates(self):
"""Test that update_bounty_categories validates all slugs."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
with pytest.raises(ValueError, match="not found"):
self.service.update_bounty_categories(
-1001, bounty.id, ["bug", "nonexistent"], self.admin_username
)
def test_add_category_to_bounty_requires_admin(self):
"""Test that adding category to bounty requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
with pytest.raises(PermissionError, match="Only admins"):
self.service.add_category_to_bounty(
-1001, bounty.id, "bug", "nonadmin"
)
def test_remove_category_from_bounty_requires_admin(self):
"""Test that removing category from bounty requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
bounty = self._add_bounty()
self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username)
with pytest.raises(PermissionError, match="Only admins"):
self.service.remove_category_from_bounty(
-1001, bounty.id, "bug", "nonadmin"
)
def test_update_bounty_categories_requires_admin(self):
"""Test that updating bounty categories requires admin."""
bounty = self._add_bounty()
with pytest.raises(PermissionError, match="Only admins"):
self.service.update_bounty_categories(
-1001, bounty.id, ["bug"], "nonadmin"
)
def test_delete_category_requires_admin(self):
"""Test that deleting category requires admin."""
self.service.add_category(-1001, "bug", "Bug Report", self.admin_username)
with pytest.raises(PermissionError, match="Only admins"):
self.service.delete_category(-1001, "bug", "nonadmin")