Compare commits

..

5 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
9 changed files with 150 additions and 252 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

59
SPEC.md
View File

@@ -20,7 +20,7 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Stack
- **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`
- **Runtime**: 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
```
~/.jigaido/ # Data root (~/.jigaido/)
├── {group_id}/
│ ├── group.json # Group bounties
── {user_id}.json # User tracking within this group
└── {user_id}/
└── user.json # User's personal bounties (DM mode)
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 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
{
@@ -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
{
@@ -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
{
@@ -87,10 +108,10 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
**Key design decisions:**
1. **Group-isolated storage** — Each group has its own directory. No cross-group access.
2. **Bounty IDs are sequential per group.json** — Not global. Each group's file has its own ID counter.
3. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
4. **No reminders in v1** — Dropped for simplicity.
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
@@ -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 |
| `/my` | anyone | List bounties tracked by you in this 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 |
| `/track <bounty_id>` | anyone | Track a group 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 |
| `/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 |
| `/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 |
@@ -149,7 +170,7 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any
## Error Handling
- 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)
- `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found"

View File

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

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

@@ -9,6 +9,9 @@ class 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.
The deleted_at field indicates soft-delete: None means not deleted,
a value means deleted at that Unix timestamp.
"""
id: int
@@ -17,6 +20,8 @@ class Bounty:
due_date_ts: int | None
created_at: int
created_by_user_id: int
deleted_at: int | None = None
created_by_username: str | None = None
@dataclass
@@ -37,11 +42,20 @@ class RoomData:
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_user_ids field lists users who have admin privileges in this room.
"""
room_id: int
bounties: list[Bounty]
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

View File

@@ -28,6 +28,22 @@ class TestBounty:
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(
@@ -41,6 +57,8 @@ class TestBounty:
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(
@@ -103,6 +121,8 @@ class TestRoomData:
assert rd.room_id == -1001
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_create_dm_room_data(self):
rd = RoomData(
@@ -113,6 +133,8 @@ class TestRoomData:
assert rd.room_id == 123456
assert rd.bounties == []
assert rd.next_id == 1
assert rd.timezone is None
assert rd.admin_user_ids == []
def test_room_data_with_bounties(self):
b = Bounty(
@@ -128,6 +150,25 @@ class TestRoomData:
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_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:
def test_create_tracking_data(self):