diff --git a/SPEC.md b/SPEC.md index 9e91fb6..f9b7143 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,12 +8,12 @@ JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking. -- **Group mode**: Each Telegram group has its own bounty list. Anyone can add bounties. Only creator can edit/delete. -- **DM mode**: Personal bounty list. Anyone can manage their own bounties. -- **Tracking**: Users can track any bounty (group or personal) to their tracking list. +- **Group mode**: Each Telegram group has its own bounty list. Anyone can add bounties. Only bounty creator can edit/delete. +- **DM mode**: Personal bounty list. Creator can manage their own bounties. +- **Tracking**: Users can track group bounties 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. +- **Created by**: Every bounty stores the Telegram username of who created it. --- @@ -21,7 +21,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-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,54 +29,75 @@ 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 directory (home of user running bot) +├── {group_id}/ +│ ├── group.json # Group bounties +│ └── user_{user_id}.json # User tracking within this group +└── user_{user_id}/ + └── user.json # User's personal bounties (DM mode) ``` ### Storage Design -**File structure (`data/users/{telegram_user_id}.json`):** +**File structure (`~/.jigaido/{group_id}/group.json`):** ```json { - "user_id": 123, - "username": "alice", - "personal_bounties": [ + "group_id": -100123456789, + "bounties": [ { "id": 1, "text": "Fix login bug", "link": "https://github.com/example/repo/issues/1", "due_date_ts": 1735689600, - "group_id": null, - "informed_by_username": "alice", + "created_by_user_id": 123456, + "created_by_username": "alice", + "created_at": 1735603200 + } + ] +} +``` + +**File structure (`~/.jigaido/{group_id}/user_{user_id}.json`):** + +```json +{ + "user_id": 123456, + "tracked": [ + {"bounty_id": 1, "tracked_at": 1735600000} + ] +} +``` + +**File structure (`~/.jigaido/user_{user_id}/user.json`):** + +```json +{ + "user_id": 123456, + "username": "alice", + "bounties": [ + { + "id": 1, + "text": "Personal task", + "link": "https://example.com/task", + "due_date_ts": null, + "created_by_user_id": 123456, + "created_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} ] } ``` **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. -3. **Atomic writes** — Uses `tempfile` + `rename` for safe writes. -4. **No reminders in v1** — Dropped for simplicity. +1. **Data at ~/.jigaido/** — Not inside the app directory, so data persists across app updates. +2. **Per-group bounties** — All group bounties stored in `group.json`, not scattered across creator files. +3. **Per-user tracking** — Each user's tracking within a group stored in their own `user_{id}.json`. +4. **Bounty IDs are sequential per group** — Not global. Each `group.json` has its own ID counter. +5. **Personal bounties isolated** — Stored in `user_{id}/user.json` separate from group bounties. +6. **Atomic writes** — Uses `tempfile` + `rename` for safe writes. +7. **No reminders in v1** — Dropped for simplicity. --- @@ -87,7 +108,7 @@ jigaido/ | Command | Who | Description | |---|---|---| | `/bounty` | anyone | List all bounties in this group | -| `/my` | anyone | List bounties tracked by you | +| `/my` | anyone | List bounties you're tracking | | `/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 | @@ -99,11 +120,10 @@ jigaido/ | Command | Description | |---|---| | `/bounty` | List all your personal bounties | -| `/my` | List bounties you're tracking | +| `/my` | List your personal bounties (same as /bounty in DM) | | `/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 | ### Add/Update Syntax @@ -118,9 +138,9 @@ jigaido/ --- -## Informed By +## Created 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. +When a user triggers `/add`, the bot captures `message.from_user.username` and stores it in `created_by_username`. This is displayed on bounty listings so the group/DM knows who created the bounty. --- @@ -142,10 +162,11 @@ 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" +- Bounty not found → "Bounty not found." +- Tracking in DM → "Tracking is only available in groups." --- @@ -155,4 +176,4 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any - Complex queries across users - Reminder system with proper dedup - Scale > 1,000 users -- Need ACID guarantees on concurrent writes \ No newline at end of file +- Need ACID guarantees on concurrent writes 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..d0d2d76 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -13,8 +13,6 @@ import storage TELEGRAM_BOT_USERNAME = "your_bot_username" -REMINDER_WINDOW_DAYS = 7 - def extract_args(text: str) -> list[str]: if not text: @@ -49,11 +47,11 @@ def format_bounty(b: dict, show_id: bool = True) -> str: parts = [] if show_id: parts.append(f"[#{b['id']}]") - if b["text"]: + if b["text"] is not None: parts.append(b["text"]) - if b.get("link"): + if b["link"] is not None: parts.append(f"🔗 {b['link']}") - if b.get("due_date_ts"): + if b["due_date_ts"] is not None: due_str = time.strftime("%Y-%m-%d", time.localtime(b["due_date_ts"])) days_left = (b["due_date_ts"] - int(time.time())) // 86400 if days_left < 0: @@ -62,7 +60,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')}") + username = b["created_by_username"] + parts.append(f"by @{username if username is not None else 'unknown'}") return " | ".join(parts) @@ -70,71 +69,31 @@ 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) +def ensure_user_personal(user_id: int, username: str | None) -> dict: + user_data = storage.load_user_personal(user_id) + user_data["username"] = username + storage.save_user_personal(user_data) return user_data -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 ensure_user_tracking(group_id: int, user_id: int) -> dict: + return storage.load_user_tracking(group_id, user_id) -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 is_bounty_creator(bounty: dict, user_id: int) -> bool: + return bounty.get("created_by_user_id") == 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) + group_id = update.effective_chat.id + group_data = storage.load_group(group_id) + bounties = group_data.get("bounties", []) else: - user_data = ensure_user(update) - bounties = user_data.get("personal_bounties", []) + user_id = update.effective_user.id + username = update.effective_user.username + user_data = ensure_user_personal(user_id, username) + bounties = user_data.get("bounties", []) if not bounties: await update.message.reply_text("No bounties yet.") @@ -145,33 +104,43 @@ 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 = update.effective_user.id - if not tracked: - await update.message.reply_text("You are not tracking any bounties.") - return + if is_group(update): + group_id = update.effective_chat.id + tracking_data = storage.load_user_tracking(group_id, user_id) + tracked = tracking_data.get("tracked", []) - 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 + if not tracked: + await update.message.reply_text("You are not tracking any bounties.") + return - if not bounty_lines: - await update.message.reply_text("You are not tracking any bounties.") - return + group_data = storage.load_group(group_id) + bounty_map = {b["id"]: b for b in group_data.get("bounties", [])} - await update.message.reply_text( - "\n".join(bounty_lines), disable_web_page_preview=True - ) + bounty_lines = [] + for t in tracked: + bounty_id = t.get("bounty_id") + if bounty_id in bounty_map: + bounty_lines.append(format_bounty(bounty_map[bounty_id], show_id=True)) + + if not bounty_lines: + await update.message.reply_text("You are not tracking any bounties.") + return + + await update.message.reply_text( + "\n".join(bounty_lines), disable_web_page_preview=True + ) + else: + user_data = storage.load_user_personal(user_id) + bounties = user_data.get("bounties", []) + + if not bounties: + await update.message.reply_text("No bounties yet.") + return + + lines = [format_bounty(dict(b), show_id=True) for b in bounties] + await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -188,34 +157,48 @@ 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 = update.effective_user.id + username = update.effective_user.username or str(user_id) + created_at = int(time.time()) if is_group(update): group_id = update.effective_chat.id + group_data = storage.load_group(group_id) - informed_by = update.effective_user.username or str(update.effective_user.id) - created_at = int(time.time()) + bounty = { + "id": storage.next_bounty_id(group_data), + "text": text, + "link": link, + "due_date_ts": due_date_ts, + "created_by_user_id": user_id, + "created_by_username": username, + "created_at": created_at, + } - 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, - } + group_data.setdefault("bounties", []).append(bounty) + storage.save_group(group_data) + else: + user_data = ensure_user_personal(user_id, username) - user_data.setdefault("personal_bounties", []).append(bounty) - storage.save_user(user_data) + bounty = { + "id": storage.next_personal_bounty_id(user_data), + "text": text, + "link": link, + "due_date_ts": due_date_ts, + "created_by_user_id": user_id, + "created_by_username": username, + "created_at": created_at, + } + + user_data.setdefault("bounties", []).append(bounty) + storage.save_user_personal(user_data) due_str = "" if due_date_ts: due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" await update.message.reply_text( - f"✅ Bounty added (#{bounty['id']}){due_str}", + f"✅ Bounty added{due_str}", disable_web_page_preview=True, ) @@ -239,25 +222,46 @@ 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 = update.effective_user.id if is_group(update): group_id = update.effective_chat.id + group_data = storage.load_group(group_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.") - return + for bounty in group_data.get("bounties", []): + if bounty.get("id") == bounty_id: + if not is_bounty_creator(bounty, user_id): + await update.message.reply_text( + "⛔ Only the creator can edit this bounty." + ) + return + 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_group(group_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.") + else: + user_data = storage.load_user_personal(user_id) + + for bounty in user_data.get("bounties", []): + if bounty.get("id") == bounty_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_personal(user_data) + await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") + return + + await update.message.reply_text("Bounty not found.") async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -272,20 +276,36 @@ 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 = update.effective_user.id if is_group(update): group_id = update.effective_chat.id + group_data = storage.load_group(group_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.") - return + for i, bounty in enumerate(group_data.get("bounties", [])): + if bounty.get("id") == bounty_id: + if not is_bounty_creator(bounty, user_id): + await update.message.reply_text( + "⛔ Only the creator can delete this bounty." + ) + return + group_data["bounties"].pop(i) + storage.save_group(group_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.") + else: + user_data = storage.load_user_personal(user_id) + + for i, bounty in enumerate(user_data.get("bounties", [])): + if bounty.get("id") == bounty_id: + user_data["bounties"].pop(i) + storage.save_user_personal(user_data) + await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") + return + + await update.message.reply_text("Bounty not found.") async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -300,28 +320,35 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Invalid bounty ID.") return - group_id = None - if is_group(update): - group_id = update.effective_chat.id + if not is_group(update): + await update.message.reply_text("Tracking is only available in groups.") + return - user_data = ensure_user(update) + group_id = update.effective_chat.id + user_id = update.effective_user.id - for tracked in user_data.get("tracked_bounties", []): - if ( - tracked.get("bounty_id") == bounty_id - and tracked.get("group_id") == group_id - ): + group_data = storage.load_group(group_id) + bounty_exists = any( + b.get("id") == bounty_id for b in group_data.get("bounties", []) + ) + if not bounty_exists: + await update.message.reply_text("Bounty not found in this group.") + return + + tracking_data = storage.load_user_tracking(group_id, user_id) + + for t in tracking_data.get("tracked", []): + if t.get("bounty_id") == bounty_id: await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") return - user_data.setdefault("tracked_bounties", []).append( + tracking_data.setdefault("tracked", []).append( { "bounty_id": bounty_id, - "group_id": group_id, - "created_at": int(time.time()), + "tracked_at": int(time.time()), } ) - storage.save_user(user_data) + storage.save_user_tracking(tracking_data, group_id) await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") @@ -337,35 +364,35 @@ 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 + if not is_group(update): + await update.message.reply_text("Tracking is only available in groups.") + return - tracked = user_data.get("tracked_bounties", []) + group_id = update.effective_chat.id + user_id = update.effective_user.id + + tracking_data = storage.load_user_tracking(group_id, user_id) + + tracked = tracking_data.get("tracked", []) for i, t in enumerate(tracked): - if t.get("bounty_id") == bounty_id and t.get("group_id") == group_id: + if t.get("bounty_id") == bounty_id: tracked.pop(i) - storage.save_user(user_data) + tracking_data["tracked"] = tracked + storage.save_user_tracking(tracking_data, group_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." - ) - - async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - ensure_user(update) + user_id = update.effective_user.id + username = update.effective_user.username + if is_group(update): + group_id = update.effective_chat.id + storage.load_group(group_id) + storage.load_user_tracking(group_id, user_id) await update.message.reply_text( "👻 JIGAIDO is watching.\n\n" "This group's bounty list is now active.\n" @@ -375,6 +402,7 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "/my — your tracked bounties" ) else: + ensure_user_personal(user_id, username) await update.message.reply_text( "👻 JIGAIDO activated.\n\n" "Personal bounty list ready.\n" @@ -385,16 +413,29 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - await update.message.reply_text( - "👻 JIGAIDO Commands:\n\n" - "/bounty — list all bounties\n" - "/my — bounties you're tracking\n" - "/add [link] [due] — add bounty\n" - "/update [text] [link] [due] — update bounty\n" - "/delete — delete bounty\n" - "/track — track a bounty\n" - "/untrack — stop tracking\n" - "/start — re-initialize\n" - "/help — this message", - disable_web_page_preview=True, - ) + if is_group(update): + await update.message.reply_text( + "👻 JIGAIDO Commands:\n\n" + "/bounty — list all bounties\n" + "/my — bounties you're tracking\n" + "/add [link] [due] — add bounty\n" + "/update [text] [link] [due] — update bounty (creator only)\n" + "/delete — delete bounty (creator only)\n" + "/track — track a bounty\n" + "/untrack — stop tracking\n" + "/start — re-initialize\n" + "/help — this message", + disable_web_page_preview=True, + ) + else: + await update.message.reply_text( + "👻 JIGAIDO Commands:\n\n" + "/bounty — list your bounties\n" + "/add [link] [due] — add bounty\n" + "/update [text] [link] [due] — update bounty\n" + "/delete — delete bounty\n" + "/my — your tracked bounties\n" + "/start — re-initialize\n" + "/help — this message", + disable_web_page_preview=True, + ) diff --git a/apps/telegram-bot/storage.py b/apps/telegram-bot/storage.py index a3aa4d0..06f8afe 100644 --- a/apps/telegram-bot/storage.py +++ b/apps/telegram-bot/storage.py @@ -1,4 +1,4 @@ -"""Per-user JSON file storage for JIGAIDO.""" +"""Per-group JSON file storage for JIGAIDO.""" import json import tempfile @@ -6,44 +6,118 @@ import os 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_path(group_id: int) -> Path: + return DATA_DIR / f"{group_id}" -def load_user(telegram_user_id: int) -> dict: - """Load user data from JSON file. Returns empty user structure if not found.""" +def user_personal_dir_path(user_id: int) -> Path: + return DATA_DIR / f"user_{user_id}" + + +def group_file_path(group_id: int) -> Path: + return group_dir_path(group_id) / "group.json" + + +def user_tracking_file_path(group_id: int, user_id: int) -> Path: + return group_dir_path(group_id) / f"user_{user_id}.json" + + +def user_personal_file_path(user_id: int) -> Path: + return user_personal_dir_path(user_id) / "user.json" + + +def load_group(group_id: int) -> dict: + """Load group data from JSON file. Returns empty group structure if not found.""" _ensure_dirs() - path = _user_file_path(telegram_user_id) + path = group_file_path(group_id) if not path.exists(): return { - "user_id": telegram_user_id, - "username": None, - "personal_bounties": [], - "tracked_bounties": [], + "group_id": group_id, + "bounties": [], } with open(path) as f: return json.load(f) -def save_user(user_data: dict) -> None: - """Atomically save user data to JSON file.""" +def save_group(group_data: dict) -> None: + """Atomically save group 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: + group_id = group_data["group_id"] + dir_path = group_dir_path(group_id) + dir_path.mkdir(parents=True, exist_ok=True) + path = group_file_path(group_id) + with tempfile.NamedTemporaryFile(mode="w", dir=dir_path, delete=False) as tmp: + json.dump(group_data, tmp, indent=2) + tmp_path = tmp.name + os.rename(tmp_path, path) + + +def next_bounty_id(group_data: dict) -> int: + """Get next sequential bounty ID for group.""" + existing_ids = [b["id"] for b in group_data.get("bounties", [])] + return (max(existing_ids) + 1) if existing_ids else 1 + + +def load_user_tracking(group_id: int, user_id: int) -> dict: + """Load user tracking data for a group. Returns empty tracking structure if not found.""" + _ensure_dirs() + path = user_tracking_file_path(group_id, user_id) + if not path.exists(): + return { + "user_id": user_id, + "tracked": [], + } + with open(path) as f: + return json.load(f) + + +def save_user_tracking(tracking_data: dict, group_id: int) -> None: + """Atomically save user tracking data to JSON file.""" + _ensure_dirs() + dir_path = group_dir_path(group_id) + dir_path.mkdir(parents=True, exist_ok=True) + path = user_tracking_file_path(group_id, tracking_data["user_id"]) + with tempfile.NamedTemporaryFile(mode="w", dir=dir_path, delete=False) as tmp: + json.dump(tracking_data, tmp, indent=2) + tmp_path = tmp.name + os.rename(tmp_path, path) + + +def load_user_personal(user_id: int) -> dict: + """Load user's personal bounties (DM mode). Returns empty user structure if not found.""" + _ensure_dirs() + path = user_personal_file_path(user_id) + if not path.exists(): + return { + "user_id": user_id, + "username": None, + "bounties": [], + } + with open(path) as f: + return json.load(f) + + +def save_user_personal(user_data: dict) -> None: + """Atomically save user's personal bounties to JSON file.""" + _ensure_dirs() + user_id = user_data["user_id"] + dir_path = user_personal_dir_path(user_id) + dir_path.mkdir(parents=True, exist_ok=True) + path = user_personal_file_path(user_id) + with tempfile.NamedTemporaryFile(mode="w", dir=dir_path, delete=False) as tmp: json.dump(user_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", [])] +def next_personal_bounty_id(user_data: dict) -> int: + """Get next sequential bounty ID for user's personal bounties.""" + existing_ids = [b["id"] for b in user_data.get("bounties", [])] return (max(existing_ids) + 1) if existing_ids else 1 diff --git a/apps/telegram-bot/tests/conftest.py b/apps/telegram-bot/tests/conftest.py index 81485ed..75fe93e 100644 --- a/apps/telegram-bot/tests/conftest.py +++ b/apps/telegram-bot/tests/conftest.py @@ -1,27 +1 @@ """Pytest fixtures for telegram-bot tests.""" - -import sys -import tempfile -from pathlib import Path - -import pytest - -# Add the app directory to path so `import db` works when running pytest -sys.path.insert(0, str(Path(__file__).parent.parent)) - - -@pytest.fixture(autouse=True) -def fresh_db(monkeypatch): - """Replace DB_PATH with a temp file before any test runs.""" - import db as _db - - tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) - tmp_path = Path(tmp.name) - tmp.close() - - monkeypatch.setattr(_db, "DB_PATH", tmp_path) - _db.init_db() - - yield tmp_path - - tmp_path.unlink(missing_ok=True) diff --git a/apps/telegram-bot/tests/test_commands.py b/apps/telegram-bot/tests/test_commands.py index 6658f9a..3f68d87 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_username="alice", + ): 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_username": created_by_username, }[k] return row @@ -155,12 +164,12 @@ 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_username="bob") out = format_bounty(b) assert "@bob" in out - def test_informed_by_unknown_fallback(self): - b = self._row(informed_by_username=None) + def test_created_by_unknown_fallback(self): + b = self._row(created_by_username=None) out = format_bounty(b) assert "@unknown" in out