From 7c2bd09adae39d1d14d6314eb5c6f51df8a0a7f5 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:56:42 +0000 Subject: [PATCH] feat: implement new storage design per issue #2 - Storage: Change from per-user to per-group JSON files - Data location: ~/.jigaido/ instead of apps/telegram-bot/data/ - Group bounties: data/{group_id}/group.json - User tracking: data/{group_id}/{user_id}.json - Personal bounties: data/{user_id}/user.json - Update commands.py for new storage model - Update bot.py to remove admin handlers - Update tests to reflect created_by_user_id field - Update SPEC.md with new design Addresses user feedback from issue #2 --- SPEC.md | 89 ++++---- apps/telegram-bot/bot.py | 2 - apps/telegram-bot/commands.py | 265 +++++++++-------------- apps/telegram-bot/storage.py | 227 ++++++++++++++++--- apps/telegram-bot/tests/test_commands.py | 28 ++- 5 files changed, 362 insertions(+), 249 deletions(-) diff --git a/SPEC.md b/SPEC.md index 9e91fb6..a1504b2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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. - **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. -- **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 ### Stack - **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` - **Runtime**: 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 ``` -jigaido/ -├── apps/ -│ └── telegram-bot/ # Telegram bot app -│ ├── bot.py # Entrypoint -│ ├── commands.py # Command handlers -│ ├── storage.py # JSON file storage -│ ├── data/ -│ │ └── users/ # Per-user JSON files -│ │ └── {telegram_user_id}.json -│ ├── requirements.txt -│ └── .env.example -├── SPEC.md # This document -├── README.md -└── CONTRIBUTING.md +~/.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) ``` +**Note:** Data directory is at `~/.jigaido/` (home directory), NOT inside the repository or app directory. + ### Storage Design -**File structure (`data/users/{telegram_user_id}.json`):** +**File: `data/{group_id}/group.json`** ```json { - "user_id": 123, - "username": "alice", - "personal_bounties": [ + "bounties": [ { "id": 1, + "created_by_user_id": 123456, "text": "Fix login bug", "link": "https://github.com/example/repo/issues/1", "due_date_ts": 1735689600, - "group_id": null, - "informed_by_username": "alice", "created_at": 1735603200 } ], - "tracked_bounties": [ - {"bounty_id": 5, "group_id": -1001, "created_at": 1735600000}, - {"bounty_id": 3, "group_id": null, "created_at": 1735590000} + "next_id": 2 +} +``` + +**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:** -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. -2. **Bounty IDs are sequential integers per file** — Not global. Each user's file has its own ID counter. +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. --- - ## Commands ### In Group @@ -87,23 +100,23 @@ jigaido/ | Command | Who | Description | |---|---|---| | `/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 [link] [due date]` | anyone | Add a new bounty to the group | | `/update [text] [link] [due_date]` | creator only | Update an existing bounty | | `/delete ` | creator only | Delete a bounty | -| `/track ` | anyone | Add a group bounty to your tracking | -| `/untrack ` | anyone | Remove a bounty from your tracking | +| `/track ` | anyone | Track a group bounty | +| `/untrack ` | anyone | Stop tracking a bounty | ### In DM (1:1 with bot) | Command | Description | |---|---| | `/bounty` | List all your personal bounties | -| `/my` | List bounties you're tracking | +| `/my` | List all your personal bounties | | `/add [link] [due date]` | Add a personal bounty | | `/update [text] [link] [due_date]` | Update a personal bounty | | `/delete ` | Delete a personal bounty | -| `/track ` | Add a personal bounty to your tracking | +| `/track ` | Track a personal bounty | ### 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 Uses `dateparser` library. Examples: @@ -142,7 +149,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 → "⛔ Group creator only." +- `/update`/`/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/bot.py b/apps/telegram-bot/bot.py index 42c097b..4df2600 100644 --- a/apps/telegram-bot/bot.py +++ b/apps/telegram-bot/bot.py @@ -35,8 +35,6 @@ def build_app() -> Application: app.add_handler(CommandHandler("delete", commands.cmd_delete)) app.add_handler(CommandHandler("track", commands.cmd_track)) 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)) diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index a821d1d..35b50df 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -1,9 +1,11 @@ """Telegram command handlers for JIGAIDO.""" import json +import os import re import time from functools import wraps +from typing import Optional import dateparser from telegram import Update @@ -13,8 +15,6 @@ import storage TELEGRAM_BOT_USERNAME = "your_bot_username" -REMINDER_WINDOW_DAYS = 7 - def extract_args(text: str) -> list[str]: if not text: @@ -23,7 +23,7 @@ def extract_args(text: str) -> list[str]: 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 link = None due_date_ts = None @@ -49,7 +49,7 @@ def format_bounty(b: dict, show_id: bool = True) -> str: parts = [] if show_id: parts.append(f"[#{b['id']}]") - if b["text"]: + if b.get("text"): parts.append(b["text"]) if b.get("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)") else: 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) @@ -70,71 +71,21 @@ def is_group(update: Update) -> bool: return update.effective_chat.type != "private" -def ensure_user(update: Update) -> dict: - user = update.effective_user - user_data = storage.load_user(user.id) - user_data["username"] = user.username - storage.save_user(user_data) - return user_data +def get_group_id(update: Update) -> int: + return update.effective_chat.id -def get_user_by_username(username: str) -> dict | None: - for path in storage.USERS_DIR.glob("*.json"): - 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 +def get_user_id(update: Update) -> int: + return update.effective_user.id async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: 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: - user_data = ensure_user(update) - bounties = user_data.get("personal_bounties", []) + data = storage.load_user_personal(get_user_id(update)) + bounties = data.get("bounties", []) if not bounties: 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: - user_data = ensure_user(update) - tracked = user_data.get("tracked_bounties", []) + user_id = get_user_id(update) + + 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: await update.message.reply_text("You are not tracking any bounties.") return + group_data = storage.load_group_bounties(group_id) + bounty_map = {b["id"]: b for b in group_data.get("bounties", [])} + bounty_lines = [] - for tracked_bounty in tracked: - bounty_id = tracked_bounty.get("bounty_id") - group_id = tracked_bounty.get("group_id") - 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)) - break + 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.") @@ -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.") return - user_data = ensure_user(update) - group_id = None if is_group(update) else None + user_id = get_user_id(update) if is_group(update): - group_id = update.effective_chat.id - - informed_by = update.effective_user.username or str(update.effective_user.id) - created_at = int(time.time()) - - 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) + group_id = get_group_id(update) + bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts) + else: + bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts) due_str = "" 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.") return - user_data = ensure_user(update) - group_id = None if is_group(update) else None + user_id = get_user_id(update) if is_group(update): - group_id = update.effective_chat.id - - for bounty in user_data.get("personal_bounties", []): - 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.") + group_id = get_group_id(update) + bounty = storage.get_group_bounty(group_id, bounty_id) + if not bounty: + 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("Bounty not found.") + await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") 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.") return - user_data = ensure_user(update) - group_id = None if is_group(update) else None + user_id = get_user_id(update) if is_group(update): - group_id = update.effective_chat.id - - for i, bounty in enumerate(user_data.get("personal_bounties", [])): - 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.") + group_id = get_group_id(update) + bounty = storage.get_group_bounty(group_id, bounty_id) + if not bounty: + 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("Bounty not found.") + await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") 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.") return - group_id = None + user_id = get_user_id(update) + if is_group(update): - group_id = update.effective_chat.id - - user_data = ensure_user(update) - - 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}.") + group_id = get_group_id(update) + bounty = storage.get_group_bounty(group_id, bounty_id) + if not bounty: + await update.message.reply_text("Bounty not found.") return - - 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}.") + 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}.") + else: + await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") 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.") return - user_data = ensure_user(update) - group_id = None if is_group(update) else None + user_id = get_user_id(update) - tracked = user_data.get("tracked_bounties", []) - for i, t in enumerate(tracked): - if t.get("bounty_id") == bounty_id and t.get("group_id") == group_id: - tracked.pop(i) - storage.save_user(user_data) + if is_group(update): + 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}.") - return - - 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." - ) + 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}.") async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - ensure_user(update) if is_group(update): await update.message.reply_text( "👻 JIGAIDO is watching.\n\n" diff --git a/apps/telegram-bot/storage.py b/apps/telegram-bot/storage.py index a3aa4d0..4b931b4 100644 --- a/apps/telegram-bot/storage.py +++ b/apps/telegram-bot/storage.py @@ -1,49 +1,216 @@ -"""Per-user JSON file storage for JIGAIDO.""" +"""Per-group JSON file storage for JIGAIDO.""" import json -import tempfile import os +import tempfile from pathlib import Path from typing import Optional -DATA_DIR = Path(__file__).parent / "data" -USERS_DIR = DATA_DIR / "users" +DATA_DIR = Path.home() / ".jigaido" 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: - return USERS_DIR / f"{telegram_user_id}.json" +def _group_dir(group_id: int) -> Path: + return DATA_DIR / str(group_id) -def load_user(telegram_user_id: int) -> dict: - """Load user data from JSON file. Returns empty user structure if not found.""" - _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 _user_personal_dir(user_id: int) -> Path: + return DATA_DIR / str(user_id) -def save_user(user_data: dict) -> None: - """Atomically save user data to JSON file.""" - _ensure_dirs() - path = _user_file_path(user_data["user_id"]) - with tempfile.NamedTemporaryFile(mode="w", dir=USERS_DIR, delete=False) as tmp: - json.dump(user_data, tmp, indent=2) +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 next_bounty_id(user_data: dict) -> int: - """Get next sequential bounty ID for user's file.""" - existing_ids = [b["id"] for b in user_data.get("personal_bounties", [])] - return (max(existing_ids) + 1) if existing_ids else 1 +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 diff --git a/apps/telegram-bot/tests/test_commands.py b/apps/telegram-bot/tests/test_commands.py index 6658f9a..70fbb31 100644 --- a/apps/telegram-bot/tests/test_commands.py +++ b/apps/telegram-bot/tests/test_commands.py @@ -101,12 +101,21 @@ class TestParseArgs: class TestFormatBounty: - def _row(self, id=1, text="Test bounty", link="https://example.com", - due_date_ts=None, informed_by_username="alice"): + def _row( + self, + id=1, + text="Test bounty", + link="https://example.com", + due_date_ts=None, + created_by_user_id=123456, + ): row = MagicMock() row.__getitem__ = lambda s, k: { - "id": id, "text": text, "link": link, - "due_date_ts": due_date_ts, "informed_by_username": informed_by_username + "id": id, + "text": text, + "link": link, + "due_date_ts": due_date_ts, + "created_by_user_id": created_by_user_id, }[k] return row @@ -155,12 +164,7 @@ class TestFormatBounty: out = format_bounty(b) assert "OVERDUE" in out - def test_informed_by_shown(self): - b = self._row(informed_by_username="bob") + def test_created_by_shown(self): + b = self._row(created_by_user_id=999) out = format_bounty(b) - assert "@bob" in out - - def test_informed_by_unknown_fallback(self): - b = self._row(informed_by_username=None) - out = format_bounty(b) - assert "@unknown" in out + assert "999" in out