Compare commits

..

10 Commits

Author SHA1 Message Date
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
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
13 changed files with 688 additions and 415 deletions

View File

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

View File

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

59
SPEC.md
View File

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

View File

@@ -56,6 +56,8 @@ class JsonFileRoomStorage:
due_date_ts=b.get("due_date_ts"), due_date_ts=b.get("due_date_ts"),
created_at=b["created_at"], created_at=b["created_at"],
created_by_user_id=b["created_by_user_id"], created_by_user_id=b["created_by_user_id"],
deleted_at=b.get("deleted_at"),
created_by_username=b.get("created_by_username"),
) )
for b in data.get("bounties", []) for b in data.get("bounties", [])
] ]
@@ -64,6 +66,8 @@ class JsonFileRoomStorage:
room_id=data["room_id"], room_id=data["room_id"],
bounties=bounties, bounties=bounties,
next_id=data["next_id"], next_id=data["next_id"],
timezone=data.get("timezone"),
admin_user_ids=data.get("admin_user_ids", []),
) )
def save(self, room_data: RoomData) -> None: def save(self, room_data: RoomData) -> None:
@@ -71,6 +75,8 @@ class JsonFileRoomStorage:
data = { data = {
"room_id": room_data.room_id, "room_id": room_data.room_id,
"next_id": room_data.next_id, "next_id": room_data.next_id,
"timezone": room_data.timezone,
"admin_user_ids": room_data.admin_user_ids or [],
"bounties": [ "bounties": [
{ {
"id": b.id, "id": b.id,
@@ -79,6 +85,8 @@ class JsonFileRoomStorage:
"due_date_ts": b.due_date_ts, "due_date_ts": b.due_date_ts,
"created_at": b.created_at, "created_at": b.created_at,
"created_by_user_id": b.created_by_user_id, "created_by_user_id": b.created_by_user_id,
"deleted_at": b.deleted_at,
"created_by_username": b.created_by_username,
} }
for b in room_data.bounties for b in room_data.bounties
], ],

View File

@@ -4,15 +4,20 @@ import logging
import os import os
import sys import sys
from telegram import Update from telegram.ext import Application, CommandHandler, MessageHandler, filters
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
)
import commands from commands import (
cmd_add,
cmd_bounty,
cmd_delete,
cmd_edit,
cmd_help,
cmd_my,
cmd_start,
cmd_track,
cmd_untrack,
cmd_update,
)
logging.basicConfig( logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -26,17 +31,18 @@ BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "")
def build_app() -> Application: def build_app() -> Application:
app = Application.builder().token(BOT_TOKEN).build() app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", commands.cmd_start)) app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", commands.cmd_help)) app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("bounty", commands.cmd_bounty)) app.add_handler(CommandHandler("bounty", cmd_bounty))
app.add_handler(CommandHandler("my", commands.cmd_my)) app.add_handler(CommandHandler("my", cmd_my))
app.add_handler(CommandHandler("add", commands.cmd_add)) app.add_handler(CommandHandler("add", cmd_add))
app.add_handler(CommandHandler("update", commands.cmd_update)) app.add_handler(CommandHandler("edit", cmd_edit))
app.add_handler(CommandHandler("delete", commands.cmd_delete)) app.add_handler(CommandHandler("update", cmd_update))
app.add_handler(CommandHandler("track", commands.cmd_track)) app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("untrack", commands.cmd_untrack)) app.add_handler(CommandHandler("track", cmd_track))
app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
return app return app
@@ -47,6 +53,7 @@ async def post_init(app: Application) -> None:
("bounty", "List bounties"), ("bounty", "List bounties"),
("my", "Your tracked bounties"), ("my", "Your tracked bounties"),
("add", "Add a bounty"), ("add", "Add a bounty"),
("edit", "Edit a bounty"),
("track", "Track a bounty"), ("track", "Track a bounty"),
("untrack", "Stop tracking"), ("untrack", "Stop tracking"),
("help", "Show help"), ("help", "Show help"),

View File

@@ -1,8 +1,5 @@
"""Telegram command handlers for JIGAIDO.""" """Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import json
import os
import re
import time import time
from functools import wraps from functools import wraps
from typing import Optional from typing import Optional
@@ -11,7 +8,13 @@ import dateparser
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
import storage from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.services import BountyService, TrackingService
ROOM_STORAGE = JsonFileRoomStorage()
TRACKING_STORAGE = JsonFileTrackingStorage()
BOUNTY_SERVICE = BountyService(ROOM_STORAGE)
TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE)
TELEGRAM_BOT_USERNAME = "your_bot_username" TELEGRAM_BOT_USERNAME = "your_bot_username"
@@ -45,25 +48,25 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[
return text, link, due_date_ts return text, link, due_date_ts
def format_bounty(b: dict, show_id: bool = True) -> str: def format_bounty(b, show_id: bool = True) -> str:
parts = [] parts = []
if show_id: if show_id:
parts.append(f"[#{b['id']}]") parts.append(f"[#{b.id}]")
if b.get("text"): if b.text:
parts.append(b["text"]) parts.append(b.text)
if b.get("link"): if b.link:
parts.append(f"🔗 {b['link']}") parts.append(f"🔗 {b.link}")
if b.get("due_date_ts"): if b.due_date_ts:
due_str = time.strftime("%Y-%m-%d", time.localtime(b["due_date_ts"])) due_str = time.strftime("%Y-%m-%d", time.localtime(b.due_date_ts))
days_left = (b["due_date_ts"] - int(time.time())) // 86400 days_left = (b.due_date_ts - int(time.time())) // 86400
if days_left < 0: if days_left < 0:
parts.append(f"{due_str} (OVERDUE)") parts.append(f"{due_str} (OVERDUE)")
elif days_left == 0: elif days_left == 0:
parts.append(f"{due_str} (TODAY)") parts.append(f"{due_str} (TODAY)")
else: else:
parts.append(f"{due_str} ({days_left}d)") parts.append(f"{due_str} ({days_left}d)")
if b.get("created_by_user_id"): if b.created_by_user_id:
parts.append(f"by {b['created_by_user_id']}") parts.append(f"by {b.created_by_user_id}")
return " | ".join(parts) return " | ".join(parts)
@@ -79,19 +82,26 @@ def get_user_id(update: Update) -> int:
return update.effective_user.id return update.effective_user.id
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: def get_room_id(update: Update) -> int:
"""Get room_id for the current context.
For groups: negative group_id
For DMs: positive user_id
"""
if is_group(update): if is_group(update):
data = storage.load_group_bounties(get_group_id(update)) return get_group_id(update)
bounties = data.get("bounties", []) return get_user_id(update)
else:
data = storage.load_user_personal(get_user_id(update))
bounties = data.get("bounties", []) async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update)
bounties = BOUNTY_SERVICE.list_bounties(room_id)
if not bounties: if not bounties:
await update.message.reply_text("No bounties yet.") await update.message.reply_text("No bounties yet.")
return return
lines = [format_bounty(dict(b), show_id=True) for b in bounties] lines = [format_bounty(b, show_id=True) for b in bounties]
await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
@@ -100,38 +110,22 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if is_group(update): if is_group(update):
group_id = get_group_id(update) group_id = get_group_id(update)
tracking = storage.load_user_tracking(group_id, user_id) bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id)
tracked = tracking.get("tracked", [])
else: else:
data = storage.load_user_personal(user_id) room_id = get_room_id(update)
bounties = data.get("bounties", []) bounties = BOUNTY_SERVICE.list_bounties(room_id)
lines = [format_bounty(dict(b), show_id=True) for b in bounties]
await update.message.reply_text( if not bounties:
"\n".join(lines) if lines else "No personal bounties.", msg = (
disable_web_page_preview=True, "You are not tracking any bounties."
if is_group(update)
else "No personal bounties."
) )
await update.message.reply_text(msg)
return return
if not tracked: lines = [format_bounty(b, show_id=True) for b in bounties]
await update.message.reply_text("You are not tracking any bounties.") await update.message.reply_text("\n".join(lines), disable_web_page_preview=True)
return
group_data = storage.load_group_bounties(group_id)
bounty_map = {b["id"]: b for b in group_data.get("bounties", [])}
bounty_lines = []
for t in tracked:
bounty = bounty_map.get(t["bounty_id"])
if bounty:
bounty_lines.append(format_bounty(bounty, show_id=True))
if not bounty_lines:
await update.message.reply_text("You are not tracking any bounties.")
return
await update.message.reply_text(
"\n".join(bounty_lines), disable_web_page_preview=True
)
async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -149,19 +143,22 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): bounty = BOUNTY_SERVICE.add_bounty(
group_id = get_group_id(update) room_id=room_id,
bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts) user_id=user_id,
else: text=text,
bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts) link=link,
due_date_ts=due_date_ts,
)
due_str = "" due_str = ""
if due_date_ts: if due_date_ts:
due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}"
await update.message.reply_text( await update.message.reply_text(
f"✅ Bounty added (#{bounty['id']}){due_str}", f"✅ Bounty added (#{bounty.id}){due_str}",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
@@ -186,25 +183,28 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) success = BOUNTY_SERVICE.update_bounty(
bounty = storage.get_group_bounty(group_id, bounty_id) room_id=room_id,
if not bounty: bounty_id=bounty_id,
await update.message.reply_text("Bounty not found.") user_id=user_id,
text=text,
link=link,
due_date_ts=due_date_ts,
)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return return
if bounty["created_by_user_id"] != user_id:
await update.message.reply_text("⛔ Only the creator can edit this bounty.")
return
storage.update_group_bounty(group_id, bounty_id, text, link, due_date_ts)
else:
bounty = storage.get_personal_bounty(user_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.update_personal_bounty(user_id, bounty_id, text, link, due_date_ts)
if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
else:
await update.message.reply_text("Bounty not found.")
cmd_edit = cmd_update
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -220,30 +220,29 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) success = BOUNTY_SERVICE.delete_bounty(
bounty = storage.get_group_bounty(group_id, bounty_id) room_id=room_id,
if not bounty: bounty_id=bounty_id,
await update.message.reply_text("Bounty not found.") user_id=user_id,
return
if bounty["created_by_user_id"] != user_id:
await update.message.reply_text(
"⛔ Only the creator can delete this bounty."
) )
except PermissionError as e:
await update.message.reply_text(f"{e}")
return return
storage.delete_group_bounty(group_id, bounty_id)
else:
bounty = storage.get_personal_bounty(user_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
storage.delete_personal_bounty(user_id, bounty_id)
if success:
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
else:
await update.message.reply_text("Bounty not found.")
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /track is only available in groups.")
return
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /track <bounty_id>") await update.message.reply_text("Usage: /track <bounty_id>")
@@ -256,25 +255,22 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): try:
group_id = get_group_id(update) if TRACKING_SERVICE.track_bounty(room_id, user_id, bounty_id):
bounty = storage.get_group_bounty(group_id, bounty_id)
if not bounty:
await update.message.reply_text("Bounty not found.")
return
if storage.track_bounty(group_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
else:
if storage.track_bounty(user_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.")
else: else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
except ValueError as e:
await update.message.reply_text(str(e))
async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not is_group(update):
await update.message.reply_text("⛔ /untrack is only available in groups.")
return
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /untrack <bounty_id>") await update.message.reply_text("Usage: /untrack <bounty_id>")
@@ -287,18 +283,12 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update)
if is_group(update): if TRACKING_SERVICE.untrack_bounty(room_id, user_id, bounty_id):
group_id = get_group_id(update)
if storage.untrack_bounty(group_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else: else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.") await update.message.reply_text("Not tracking bounty #{bounty_id}.")
else:
if storage.untrack_bounty(user_id, user_id, bounty_id):
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
else:
await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -327,10 +317,10 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"/bounty — list all bounties\n" "/bounty — list all bounties\n"
"/my — bounties you're tracking\n" "/my — bounties you're tracking\n"
"/add <text> [link] [due] — add bounty\n" "/add <text> [link] [due] — add bounty\n"
"/update <id> [text] [link] [due] — update bounty\n" "/update <id> [text> [link] [due] — update bounty\n"
"/delete <id> — delete bounty\n" "/delete <id> — delete bounty\n"
"/track <id> — track a bounty\n" "/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking\n" "/untrack <id> — stop tracking (groups only)\n"
"/start — re-initialize\n" "/start — re-initialize\n"
"/help — this message", "/help — this message",
disable_web_page_preview=True, disable_web_page_preview=True,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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