Merge pull request 'fix #16: cleanup - remove old/dead code and update docs' (#35) from fix/issue-16-cleanup into main
This commit was merged in pull request #35.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -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
59
SPEC.md
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
python-telegram-bot==21.6
|
|
||||||
dateparser==1.2.0
|
|
||||||
pytest==8.3.5
|
|
||||||
pytest-asyncio==0.25.2
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user