feat: Replace SQLite with per-user JSON storage (fixes #2) #3

Merged
shoko merged 3 commits from fix/issue-2-json-storage into main 2026-04-02 17:44:08 +02:00
5 changed files with 362 additions and 249 deletions
Showing only changes of commit 7c2bd09ada - Show all commits

89
SPEC.md
View File

@@ -13,15 +13,14 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
- **Tracking**: Users can track any bounty (group or personal) to their tracking list. - **Tracking**: Users can track any bounty (group or personal) to their tracking list.
- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`. - **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`.
- **Links**: Optional. If provided, stored with the bounty. - **Links**: Optional. If provided, stored with the bounty.
- **Informed by**: Every bounty stores the Telegram username of who posted/added it. - **Informed by**: Every bounty stores the user ID of who posted/added it.
--- ---
## Architecture ## Architecture
### Stack ### Stack
- **Bot**: `python-telegram-bot` (pure Python, no C extensions) - **Bot**: `python-telegram-bot` (pure Python, no C extensions)
- **Storage**: Per-user JSON files (zero-setup, no DB server) - **Storage**: Per-group JSON files (zero-setup, no DB server)
- **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+
@@ -29,57 +28,71 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — ta
### Directory Structure ### Directory Structure
``` ```
jigaido/ ~/.jigaido/ # Data root (~/.jigaido/)
├── apps/ ├── {group_id}/
── telegram-bot/ # Telegram bot app ── group.json # Group bounties
├── bot.py # Entrypoint └── {user_id}.json # User tracking within this group
│ ├── commands.py # Command handlers └── {user_id}/
├── storage.py # JSON file storage └── user.json # User's personal bounties (DM mode)
│ ├── data/
│ │ └── users/ # Per-user JSON files
│ │ └── {telegram_user_id}.json
│ ├── requirements.txt
│ └── .env.example
├── SPEC.md # This document
├── README.md
└── CONTRIBUTING.md
``` ```
**Note:** Data directory is at `~/.jigaido/` (home directory), NOT inside the repository or app directory.
### Storage Design ### Storage Design
**File structure (`data/users/{telegram_user_id}.json`):** **File: `data/{group_id}/group.json`**
```json ```json
{ {
"user_id": 123, "bounties": [
"username": "alice",
"personal_bounties": [
{ {
"id": 1, "id": 1,
"created_by_user_id": 123456,
"text": "Fix login bug", "text": "Fix login bug",
"link": "https://github.com/example/repo/issues/1", "link": "https://github.com/example/repo/issues/1",
"due_date_ts": 1735689600, "due_date_ts": 1735689600,
"group_id": null,
"informed_by_username": "alice",
"created_at": 1735603200 "created_at": 1735603200
} }
], ],
"tracked_bounties": [ "next_id": 2
{"bounty_id": 5, "group_id": -1001, "created_at": 1735600000}, }
{"bounty_id": 3, "group_id": null, "created_at": 1735590000} ```
**File: `data/{group_id}/{user_id}.json`**
```json
{
"tracked": [
{"bounty_id": 1, "created_at": 1735600000}
] ]
} }
``` ```
**File: `data/{user_id}/user.json`**
```json
{
"bounties": [
{
"id": 1,
"text": "Personal task",
"link": null,
"due_date_ts": 1735689600,
"created_at": 1735603200
}
],
"next_id": 2
}
```
**Key design decisions:** **Key design decisions:**
1. **Single file per user** — Personal bounties live in the creator's file. Group bounties also live in creator's file with `group_id` set. 1. **Group-isolated storage** — Each group has its own directory. No cross-group access.
2. **Bounty IDs are sequential integers per file** — Not global. Each user's file has its own ID counter. 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. 3. **Atomic writes** — Uses `tempfile` + `rename` for safe writes.
4. **No reminders in v1** — Dropped for simplicity. 4. **No reminders in v1** — Dropped for simplicity.
--- ---
## Commands ## Commands
### In Group ### In Group
@@ -87,23 +100,23 @@ jigaido/
| Command | Who | Description | | Command | Who | Description |
|---|---|---| |---|---|---|
| `/bounty` | anyone | List all bounties in this group | | `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you | | `/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 | | `/update <bounty_id> [text] [link] [due_date]` | creator only | Update an existing bounty |
| `/delete <bounty_id>` | creator only | Delete a bounty | | `/delete <bounty_id>` | creator only | Delete a bounty |
| `/track <bounty_id>` | anyone | Add a group bounty to your tracking | | `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Remove a bounty from your tracking | | `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
### In DM (1:1 with bot) ### In DM (1:1 with bot)
| Command | Description | | Command | Description |
|---|---| |---|---|
| `/bounty` | List all your personal bounties | | `/bounty` | List all your personal bounties |
| `/my` | List bounties you're tracking | | `/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 | | `/update <bounty_id> [text] [link] [due_date]` | Update a personal bounty |
| `/delete <bounty_id>` | Delete a personal bounty | | `/delete <bounty_id>` | Delete a personal bounty |
| `/track <bounty_id>` | Add a personal bounty to your tracking | | `/track <bounty_id>` | Track a personal bounty |
### Add/Update Syntax ### Add/Update Syntax
@@ -118,12 +131,6 @@ jigaido/
--- ---
## Informed By
When a user triggers `/add`, the bot captures `message.from_user.username` and stores it in `informed_by_username`. This is displayed on bounty listings so the group/DM knows who posted or requested the bounty.
---
## Due Date Parsing ## Due Date Parsing
Uses `dateparser` library. Examples: Uses `dateparser` library. Examples:
@@ -142,7 +149,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 → "⛔ Group creator only." - `/update`/`/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

@@ -35,8 +35,6 @@ def build_app() -> Application:
app.add_handler(CommandHandler("delete", commands.cmd_delete)) app.add_handler(CommandHandler("delete", commands.cmd_delete))
app.add_handler(CommandHandler("track", commands.cmd_track)) app.add_handler(CommandHandler("track", commands.cmd_track))
app.add_handler(CommandHandler("untrack", commands.cmd_untrack)) app.add_handler(CommandHandler("untrack", commands.cmd_untrack))
app.add_handler(CommandHandler("admin_add", commands.cmd_admin_add))
app.add_handler(CommandHandler("admin_remove", commands.cmd_admin_remove))
app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help))

View File

@@ -1,9 +1,11 @@
"""Telegram command handlers for JIGAIDO.""" """Telegram command handlers for JIGAIDO."""
import json import json
import os
import re import re
import time import time
from functools import wraps from functools import wraps
from typing import Optional
import dateparser import dateparser
from telegram import Update from telegram import Update
@@ -13,8 +15,6 @@ import storage
TELEGRAM_BOT_USERNAME = "your_bot_username" TELEGRAM_BOT_USERNAME = "your_bot_username"
REMINDER_WINDOW_DAYS = 7
def extract_args(text: str) -> list[str]: def extract_args(text: str) -> list[str]:
if not text: if not text:
@@ -23,7 +23,7 @@ def extract_args(text: str) -> list[str]:
return tokens[1:] if len(tokens) > 1 else [] return tokens[1:] if len(tokens) > 1 else []
def parse_args(args: list[str]) -> tuple[str | None, str | None, int | None]: def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[int]]:
text = None text = None
link = None link = None
due_date_ts = None due_date_ts = None
@@ -49,7 +49,7 @@ def format_bounty(b: dict, 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["text"]: if b.get("text"):
parts.append(b["text"]) parts.append(b["text"])
if b.get("link"): if b.get("link"):
parts.append(f"🔗 {b['link']}") parts.append(f"🔗 {b['link']}")
@@ -62,7 +62,8 @@ def format_bounty(b: dict, show_id: bool = True) -> str:
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)")
parts.append(f"by @{b.get('informed_by_username', 'unknown')}") if b.get("created_by_user_id"):
parts.append(f"by {b['created_by_user_id']}")
return " | ".join(parts) return " | ".join(parts)
@@ -70,71 +71,21 @@ def is_group(update: Update) -> bool:
return update.effective_chat.type != "private" return update.effective_chat.type != "private"
def ensure_user(update: Update) -> dict: def get_group_id(update: Update) -> int:
user = update.effective_user return update.effective_chat.id
user_data = storage.load_user(user.id)
user_data["username"] = user.username
storage.save_user(user_data)
return user_data
def get_user_by_username(username: str) -> dict | None: def get_user_id(update: Update) -> int:
for path in storage.USERS_DIR.glob("*.json"): return update.effective_user.id
with open(path) as f:
data = json.load(f)
if data.get("username") == username:
return data
return None
def is_group_admin_or_creator(update: Update, group_id: int, user_data: dict) -> bool:
return True
def is_group_creator(update: Update, group_id: int, user_data: dict) -> bool:
return True
def get_all_group_bounties(group_id: int) -> list[dict]:
all_bounties = []
for path in storage.USERS_DIR.glob("*.json"):
with open(path) as f:
user_data = json.load(f)
for bounty in user_data.get("personal_bounties", []):
if bounty.get("group_id") == group_id:
bounty["creator_username"] = user_data.get("username")
all_bounties.append(bounty)
return sorted(all_bounties, key=lambda b: b.get("created_at", 0), reverse=True)
def admin_only(func):
@wraps(func)
async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if is_group(update):
await update.message.reply_text("⛔ Admin only.")
return
return await func(update, ctx)
return wrapper
def creator_only(func):
@wraps(func)
async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if is_group(update):
await update.message.reply_text("⛔ Group creator only.")
return
return await func(update, ctx)
return wrapper
async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if is_group(update): if is_group(update):
bounties = get_all_group_bounties(update.effective_chat.id) data = storage.load_group_bounties(get_group_id(update))
bounties = data.get("bounties", [])
else: else:
user_data = ensure_user(update) data = storage.load_user_personal(get_user_id(update))
bounties = user_data.get("personal_bounties", []) bounties = data.get("bounties", [])
if not bounties: if not bounties:
await update.message.reply_text("No bounties yet.") await update.message.reply_text("No bounties yet.")
@@ -145,25 +96,34 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
user_data = ensure_user(update) user_id = get_user_id(update)
tracked = user_data.get("tracked_bounties", [])
if is_group(update):
group_id = get_group_id(update)
tracking = storage.load_user_tracking(group_id, user_id)
tracked = tracking.get("tracked", [])
else:
data = storage.load_user_personal(user_id)
bounties = data.get("bounties", [])
lines = [format_bounty(dict(b), show_id=True) for b in bounties]
await update.message.reply_text(
"\n".join(lines) if lines else "No personal bounties.",
disable_web_page_preview=True,
)
return
if not tracked: if not tracked:
await update.message.reply_text("You are not tracking any bounties.") await update.message.reply_text("You are not tracking any bounties.")
return return
group_data = storage.load_group_bounties(group_id)
bounty_map = {b["id"]: b for b in group_data.get("bounties", [])}
bounty_lines = [] bounty_lines = []
for tracked_bounty in tracked: for t in tracked:
bounty_id = tracked_bounty.get("bounty_id") bounty = bounty_map.get(t["bounty_id"])
group_id = tracked_bounty.get("group_id") if bounty:
for path in storage.USERS_DIR.glob("*.json"):
with open(path) as f:
creator_data = json.load(f)
for bounty in creator_data.get("personal_bounties", []):
if bounty.get("id") == bounty_id and bounty.get("group_id") == group_id:
bounty["creator_username"] = creator_data.get("username")
bounty_lines.append(format_bounty(bounty, show_id=True)) bounty_lines.append(format_bounty(bounty, show_id=True))
break
if not bounty_lines: if not bounty_lines:
await update.message.reply_text("You are not tracking any bounties.") await update.message.reply_text("You are not tracking any bounties.")
@@ -188,27 +148,13 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("A bounty needs at least text or a link.") await update.message.reply_text("A bounty needs at least text or a link.")
return return
user_data = ensure_user(update) user_id = get_user_id(update)
group_id = None if is_group(update) else None
if is_group(update): if is_group(update):
group_id = update.effective_chat.id group_id = get_group_id(update)
bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts)
informed_by = update.effective_user.username or str(update.effective_user.id) else:
created_at = int(time.time()) bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts)
bounty = {
"id": storage.next_bounty_id(user_data),
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"group_id": group_id,
"informed_by_username": informed_by,
"created_at": created_at,
}
user_data.setdefault("personal_bounties", []).append(bounty)
storage.save_user(user_data)
due_str = "" due_str = ""
if due_date_ts: if due_date_ts:
@@ -239,25 +185,26 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Nothing to update.") await update.message.reply_text("Nothing to update.")
return return
user_data = ensure_user(update) user_id = get_user_id(update)
group_id = None if is_group(update) else None
if is_group(update): if is_group(update):
group_id = update.effective_chat.id group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
for bounty in user_data.get("personal_bounties", []): if not bounty:
if bounty.get("id") == bounty_id and bounty.get("group_id") == group_id:
if text:
bounty["text"] = text
if link:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
storage.save_user(user_data)
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
return
await update.message.reply_text("Bounty not found.") await update.message.reply_text("Bounty not found.")
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)
await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.")
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -272,20 +219,28 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
user_data = ensure_user(update) user_id = get_user_id(update)
group_id = None if is_group(update) else None
if is_group(update): if is_group(update):
group_id = update.effective_chat.id group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
for i, bounty in enumerate(user_data.get("personal_bounties", [])): if not bounty:
if bounty.get("id") == bounty_id and bounty.get("group_id") == group_id:
user_data["personal_bounties"].pop(i)
storage.save_user(user_data)
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
return
await update.message.reply_text("Bounty not found.") await update.message.reply_text("Bounty not found.")
return
if bounty["created_by_user_id"] != user_id:
await update.message.reply_text(
"⛔ Only the creator can delete this bounty."
)
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)
await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -300,29 +255,23 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
group_id = None user_id = get_user_id(update)
if is_group(update): if is_group(update):
group_id = update.effective_chat.id group_id = get_group_id(update)
bounty = storage.get_group_bounty(group_id, bounty_id)
user_data = ensure_user(update) if not bounty:
await update.message.reply_text("Bounty not found.")
for tracked in user_data.get("tracked_bounties", []):
if (
tracked.get("bounty_id") == bounty_id
and tracked.get("group_id") == group_id
):
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
return return
if storage.track_bounty(group_id, user_id, bounty_id):
user_data.setdefault("tracked_bounties", []).append(
{
"bounty_id": bounty_id,
"group_id": group_id,
"created_at": int(time.time()),
}
)
storage.save_user(user_data)
await update.message.reply_text(f"✅ Now tracking bounty #{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}.")
else:
await update.message.reply_text(f"Already tracking bounty #{bounty_id}.")
async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -337,34 +286,22 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Invalid bounty ID.") await update.message.reply_text("Invalid bounty ID.")
return return
user_data = ensure_user(update) user_id = get_user_id(update)
group_id = None if is_group(update) else None
tracked = user_data.get("tracked_bounties", []) if is_group(update):
for i, t in enumerate(tracked): group_id = get_group_id(update)
if t.get("bounty_id") == bounty_id and t.get("group_id") == group_id: if storage.untrack_bounty(group_id, user_id, bounty_id):
tracked.pop(i)
storage.save_user(user_data)
await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.")
return else:
await update.message.reply_text(f"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}.") await update.message.reply_text(f"Not tracking bounty #{bounty_id}.")
async def cmd_admin_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"Admin management has been removed in this version."
)
async def cmd_admin_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"Admin management has been removed in this version."
)
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
ensure_user(update)
if is_group(update): if is_group(update):
await update.message.reply_text( await update.message.reply_text(
"👻 JIGAIDO is watching.\n\n" "👻 JIGAIDO is watching.\n\n"

View File

@@ -1,49 +1,216 @@
"""Per-user JSON file storage for JIGAIDO.""" """Per-group JSON file storage for JIGAIDO."""
import json import json
import tempfile
import os import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
DATA_DIR = Path(__file__).parent / "data" DATA_DIR = Path.home() / ".jigaido"
USERS_DIR = DATA_DIR / "users"
def _ensure_dirs() -> None: def _ensure_dirs() -> None:
USERS_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
def _user_file_path(telegram_user_id: int) -> Path: def _group_dir(group_id: int) -> Path:
return USERS_DIR / f"{telegram_user_id}.json" return DATA_DIR / str(group_id)
def load_user(telegram_user_id: int) -> dict: def _user_personal_dir(user_id: int) -> Path:
"""Load user data from JSON file. Returns empty user structure if not found.""" return DATA_DIR / str(user_id)
_ensure_dirs()
path = _user_file_path(telegram_user_id)
if not path.exists():
return {
"user_id": telegram_user_id,
"username": None,
"personal_bounties": [],
"tracked_bounties": [],
}
with open(path) as f:
return json.load(f)
def save_user(user_data: dict) -> None: def _group_file(group_id: int) -> Path:
"""Atomically save user data to JSON file.""" return _group_dir(group_id) / "group.json"
_ensure_dirs()
path = _user_file_path(user_data["user_id"])
with tempfile.NamedTemporaryFile(mode="w", dir=USERS_DIR, delete=False) as tmp: def _user_tracking_file(group_id: int, user_id: int) -> Path:
json.dump(user_data, tmp, indent=2) 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 tmp_path = tmp.name
os.rename(tmp_path, path) os.rename(tmp_path, path)
def next_bounty_id(user_data: dict) -> int: def load_group_bounties(group_id: int) -> dict:
"""Get next sequential bounty ID for user's file.""" _ensure_dirs()
existing_ids = [b["id"] for b in user_data.get("personal_bounties", [])] path = _group_file(group_id)
return (max(existing_ids) + 1) if existing_ids else 1 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

@@ -101,12 +101,21 @@ class TestParseArgs:
class TestFormatBounty: class TestFormatBounty:
def _row(self, id=1, text="Test bounty", link="https://example.com", def _row(
due_date_ts=None, informed_by_username="alice"): self,
id=1,
text="Test bounty",
link="https://example.com",
due_date_ts=None,
created_by_user_id=123456,
):
row = MagicMock() row = MagicMock()
row.__getitem__ = lambda s, k: { row.__getitem__ = lambda s, k: {
"id": id, "text": text, "link": link, "id": id,
"due_date_ts": due_date_ts, "informed_by_username": informed_by_username "text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_by_user_id": created_by_user_id,
}[k] }[k]
return row return row
@@ -155,12 +164,7 @@ class TestFormatBounty:
out = format_bounty(b) out = format_bounty(b)
assert "OVERDUE" in out assert "OVERDUE" in out
def test_informed_by_shown(self): def test_created_by_shown(self):
b = self._row(informed_by_username="bob") b = self._row(created_by_user_id=999)
out = format_bounty(b) out = format_bounty(b)
assert "@bob" in out assert "999" in out
def test_informed_by_unknown_fallback(self):
b = self._row(informed_by_username=None)
out = format_bounty(b)
assert "@unknown" in out