diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0061016..908cf0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 790bc09..24c73b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SPEC.md b/SPEC.md index a1504b2..bf1ae58 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 [link] [due date]` | anyone | Add a new bounty to the group | -| `/update [text] [link] [due_date]` | creator only | Update an existing bounty | +| `/edit [text] [link] [due_date]` | creator only | Edit an existing bounty | | `/delete ` | creator only | Delete a bounty | | `/track ` | anyone | Track a group bounty | | `/untrack ` | 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 [link] [due date]` | Add a personal bounty | -| `/update [text] [link] [due_date]` | Update a personal bounty | +| `/edit [text] [link] [due_date]` | Edit a personal bounty | | `/delete ` | Delete a personal bounty | | `/track ` | 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" diff --git a/apps/telegram-bot/__init__.py b/apps/telegram-bot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/telegram-bot/requirements-dev.txt b/apps/telegram-bot/requirements-dev.txt deleted file mode 100644 index 970325a..0000000 --- a/apps/telegram-bot/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -python-telegram-bot==21.6 -dateparser==1.2.0 -pytest==8.3.5 -pytest-asyncio==0.25.2 diff --git a/apps/telegram-bot/storage.py b/apps/telegram-bot/storage.py deleted file mode 100644 index 4b931b4..0000000 --- a/apps/telegram-bot/storage.py +++ /dev/null @@ -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