diff --git a/.github/ISSUE_TEMPLATE/v2-simplify-storage.md b/.github/ISSUE_TEMPLATE/v2-simplify-storage.md index be2f46b..03ba979 100644 --- a/.github/ISSUE_TEMPLATE/v2-simplify-storage.md +++ b/.github/ISSUE_TEMPLATE/v2-simplify-storage.md @@ -37,22 +37,56 @@ The bot works and 53/53 tests pass. But `db.py` is ~300 lines with subtle connec ## Proposal -**Replace SQLite with a per-user JSON file storage system.** +**Replace SQLite with a JSON file storage system — one directory per group or DM user.** ### Storage Design ``` data/ -└── users/ - └── {telegram_user_id}.json # one file per user +├── {group_id}/ +│ ├── group.json # group bounties (all bounties in this group) +│ └── {user_id}.json # user tracking within this group (which bounty IDs they track) +└── {user_id}/ + └── user.json # user's personal bounties (DM — only this user) ``` -**File structure (`users/{id}.json`):** +**Bot context lookup:** + +| Context | Entry point | +|---|---| +| In group (`chat_id = -100123`) | `data/-100123/group.json` | +| In DM (`chat_id = 123`) | `data/123/user.json` | + +**File: `data/{group_id}/group.json`** — group bounties: +```json +{ + "group_id": -100123, + "bounties": [ + { + "id": 1, + "created_by_user_id": 456, + "text": "Fix login bug", + "link": "https://github.com/example/repo/issues/1", + "due_date_ts": 1735689600, + "created_at": 1735603200 + } + ] +} +``` + +**File: `data/{group_id}/{user_id}.json`** — user tracking in a group: +```json +{ + "user_id": 456, + "tracked": [1, 5, 9] +} +``` + +**File: `data/{user_id}/user.json`** — user's personal bounties (DM): ```json { "user_id": 123, - "username": "alice", - "personal_bounties": [ + "bounties": [ { "id": 1, "text": "Fix login bug", @@ -60,25 +94,23 @@ data/ "due_date_ts": 1735689600, "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** — No group-level files. Personal bounties live in the creator's file. Group bounties live in the creator's file with `group_id` set. +1. **Group/DM as directory** — `chat_id` is the gateway. Group → `data/{group_id}/group.json`. DM → `data/{user_id}/user.json`. No scanning needed. -2. **Bounty IDs are sequential integers per file** — Not global. Each user's file has its own `next_id` counter. This avoids coordination between users at the cost of non-global IDs (acceptable for personal use). +2. **Tracking is per-group-per-user** — `data/{group_id}/{user_id}.json` stores the list of bounty IDs this user tracks in this group. Simple, isolated. -3. **Cross-group tracking** — When Alice (in Group A) tracks a bounty created by Bob in Group B, Alice's file stores `{bounty_id: X, group_id: -100B}`. To display it, the bot loads Bob's file and finds bounty `X`. +3. **No cross-group access** — Group bounties live only in that group's file. A member of Group A cannot see or track Group B's bounties. -4. **No reminders in v1** — Drop the cron/reminder system entirely. The `reminder_log` table and `cron.py` are removed. Reminders can be added back as a v2 feature with a simpler design (e.g., just a "due soon" filter on `/my`). +4. **Bounty IDs are sequential integers per group** — Not global. Each `group.json` has its own `next_id` counter. -5. **No admin model in v1** — Drop `group_admins` table. Group bounties are open to anyone in the group to add/edit/delete. The creator can be the only one who can modify (enforced by `created_by_user_id` check). +5. **No reminders in v1** — Drop the cron/reminder system entirely. The `reminder_log` table and `cron.py` are removed. + +6. **No admin model in v1** — Anyone in the group can add bounties. Only the bounty creator can edit/delete (enforced by `created_by_user_id` check). ### Deleted components diff --git a/SPEC.md b/SPEC.md index 584a122..a1504b2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,23 +6,21 @@ ## Overview -JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking/reminders. +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. Only group admins can add/update/delete bounties. Any member can track/untrack. -- **DM mode**: Personal bounty list. No admin restrictions — anyone can manage their own bounties. -- **Tracking**: Users can add any bounty (group or DM) to their personal tracking list. -- **Reminders**: Daily cron checks for due dates within 7 days and DMs the user. -- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL` — no reminder. -- **Links**: Optional. If provided, deduplicated per group (no two bounties in the same group can share the same link). Multiple links in one bounty: first link only, user can update later. -- **Informed by**: Every bounty stores the Telegram username of who posted/added it (not who created the record — the person whose message triggered the add). +- **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. +- **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 user ID of who posted/added it. --- - ## Architecture ### Stack - **Bot**: `python-telegram-bot` (pure Python, no C extensions) -- **Database**: SQLite (zero-install, single file) +- **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+ @@ -30,82 +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 -│ ├── cron.py # Daily reminder job -│ ├── db.py # SQLite wrapper -│ ├── schema.sql # Database schema -│ ├── 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. -## Database Schema +### Storage Design -```sql -CREATE TABLE groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - telegram_chat_id INTEGER UNIQUE NOT NULL, - creator_user_id INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); +**File: `data/{group_id}/group.json`** -CREATE TABLE group_admins ( - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL, - PRIMARY KEY (group_id, user_id) -); - -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - telegram_user_id INTEGER UNIQUE NOT NULL, - username TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -CREATE TABLE bounties ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - created_by_user_id INTEGER REFERENCES users(id), - informed_by_username TEXT NOT NULL, - text TEXT, - link TEXT, - due_date_ts INTEGER, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(group_id, link) -); - -CREATE TABLE user_bounty_tracking ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE, - added_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(user_id, bounty_id) -); - -CREATE TABLE reminder_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE, - reminded_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(user_id, bounty_id) -); +```json +{ + "bounties": [ + { + "id": 1, + "created_by_user_id": 123456, + "text": "Fix login bug", + "link": "https://github.com/example/repo/issues/1", + "due_date_ts": 1735689600, + "created_at": 1735603200 + } + ], + "next_id": 2 +} ``` -### Notes -- `group_id = NULL` means a personal/DM bounty. -- `UNIQUE(group_id, link)` — only enforced when `link IS NOT NULL` (SQLite treats NULL as distinct). -- `reminder_log` dedup ensures a user only gets one reminder per bounty. +**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. **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 @@ -114,24 +101,22 @@ CREATE TABLE reminder_log ( |---|---|---| | `/bounty` | anyone | List all bounties in this group | | `/my` | anyone | List bounties tracked by you in this group | -| `/add [link] [due date]` | admin only | Add a new bounty to the group | -| `/update [text] [link] [due_date]` | admin only | Update an existing bounty | -| `/delete ` | admin only | Delete a bounty | -| `/track ` | anyone | Add a group bounty to your tracking | -| `/untrack ` | anyone | Remove a bounty from your tracking | -| `/admin_add ` | creator only | Promote a user to admin | -| `/admin_remove ` | creator only | Demote an admin | +| `/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 | 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 all your tracked personal bounties | +| `/my` | List all your personal bounties | | `/add [link] [due date]` | Add a personal bounty | | `/update [text] [link] [due_date]` | Update a personal bounty | -| `/delete ` | Delete a personal bounty (owner only) | -| `/track ` | Add a personal bounty to your tracking | +| `/delete ` | Delete a personal bounty | +| `/track ` | Track a personal bounty | ### Add/Update Syntax @@ -143,19 +128,6 @@ CREATE TABLE reminder_log ( - `link` is optional - `due_date` is optional, free-form -- If link already exists in group → rejected with error - -### Tracking - -- `/track ` — works in both group and DM. In group: tracks a group bounty. In DM: tracks a personal bounty. -- Users can track any bounty regardless of who created it. -- A bounty can be tracked by multiple users. - ---- - -## 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. --- @@ -168,40 +140,26 @@ Uses `dateparser` library. Examples: - `"2026-04-15"` - `"next friday"` -If parsing fails → `due_date_ts = NULL`. No error is shown to user, reminder just won't fire. +If parsing fails → `due_date_ts = NULL`. No error is shown to user. Stored as Unix timestamp. User-facing display can be localized/converted to any timezone at render time. --- -## Reminders (Cron) - -Runs daily (e.g., 09:00 local). For each user: - -1. Find tracked bounties where `due_date_ts - now() < 7 days` -2. Exclude any already in `reminder_log` for that user -3. Send DM: `"Bounty '{title}' is due in {N} days."` -4. Insert into `reminder_log` - -Does not re-remind. If a bounty is 2 days away today, you get one message. Tomorrow you don't get another. - ---- - -## Admin Management - -- **Creator**: The user who first added the bot to the group. Stored as `creator_user_id` in `groups`. Only the creator can run `/admin_add` and `/admin_remove`. -- **Admins**: Added via `/admin_add `. Can add/update/delete any bounty in the group. Regular members can only track/untrack. -- First admin assignment is automatic when the bot detects a new group. - ---- - ## Error Handling - Unknown command → help text with available commands -- `/add` with duplicate link in same group → rejection message -- `/update`/`/delete` by non-admin → "Admin only" message -- `/admin_add`/`/admin_remove` by non-creator → "Creator only" message -- `/track` already tracked → "Already tracking" (idempotent, no error) -- `/untrack` not tracked → "Not tracking" (idempotent, no error) +- `/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" -- User not found → "User not found" + +--- + +## When to Revert to SQLite + +- Multiple concurrent users with write conflicts +- 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 diff --git a/apps/telegram-bot/bot.py b/apps/telegram-bot/bot.py index 14b06e6..4df2600 100644 --- a/apps/telegram-bot/bot.py +++ b/apps/telegram-bot/bot.py @@ -12,7 +12,6 @@ from telegram.ext import ( filters, ) -import db import commands logging.basicConfig( @@ -21,14 +20,12 @@ logging.basicConfig( ) log = logging.getLogger(__name__) -# Token from environment or config BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "") def build_app() -> Application: app = Application.builder().token(BOT_TOKEN).build() - # Core commands app.add_handler(CommandHandler("start", commands.cmd_start)) app.add_handler(CommandHandler("help", commands.cmd_help)) app.add_handler(CommandHandler("bounty", commands.cmd_bounty)) @@ -38,25 +35,23 @@ 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)) - # Fallback: unknown commands app.add_handler(MessageHandler(filters.COMMAND, commands.cmd_help)) return app async def post_init(app: Application) -> None: - # Set bot commands in menu - await app.bot.set_my_commands([ - ("bounty", "List bounties"), - ("my", "Your tracked bounties"), - ("add", "Add a bounty"), - ("track", "Track a bounty"), - ("untrack", "Stop tracking"), - ("help", "Show help"), - ]) + await app.bot.set_my_commands( + [ + ("bounty", "List bounties"), + ("my", "Your tracked bounties"), + ("add", "Add a bounty"), + ("track", "Track a bounty"), + ("untrack", "Stop tracking"), + ("help", "Show help"), + ] + ) def main() -> None: @@ -64,9 +59,6 @@ def main() -> None: log.error("JIGAIDO_BOT_TOKEN environment variable not set.") sys.exit(1) - db.init_db() - log.info("Database initialized.") - app = build_app() app.post_init = post_init diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index 261961d..35b50df 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -1,33 +1,29 @@ """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 from telegram.ext import ContextTypes -import db +import storage -TELEGRAM_BOT_USERNAME = "your_bot_username" # Set via set_bot_commands / config +TELEGRAM_BOT_USERNAME = "your_bot_username" -REMINDER_WINDOW_DAYS = 7 - - -# ── Helpers ───────────────────────────────────────────────────────────────── def extract_args(text: str) -> list[str]: - """Split command text into tokens, preserving URLs as single tokens.""" if not text: return [] tokens = text.strip().split() - # First token is the command itself (e.g. /add), rest is args return tokens[1:] if len(tokens) > 1 else [] -def parse_args(args: list[str]) -> tuple[str | None, str | None, int | None]: - """Parse /add args into (text, link, due_date_ts).""" +def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[int]]: text = None link = None due_date_ts = None @@ -53,11 +49,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.get("text"): parts.append(b["text"]) - if b["link"]: + if b.get("link"): parts.append(f"🔗 {b['link']}") - if b["due_date_ts"]: + if b.get("due_date_ts"): 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: @@ -66,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['informed_by_username'] or 'unknown'}") + if b.get("created_by_user_id"): + parts.append(f"by {b['created_by_user_id']}") return " | ".join(parts) @@ -74,67 +71,21 @@ def is_group(update: Update) -> bool: return update.effective_chat.type != "private" -def ensure_user(update: Update) -> int: - user = update.effective_user - username = user.username - return db.upsert_user(user.id, username) +def get_group_id(update: Update) -> int: + return update.effective_chat.id -def ensure_group(update: Update) -> tuple[int, int]: - """Ensure group and admin-creator exist. Returns (group_id, creator_user_id).""" - user_id = ensure_user(update) - creator_user_id = db.upsert_user(update.effective_user.id, update.effective_user.username) - group_id = db.upsert_group(update.effective_chat.id, creator_user_id) - # Ensure creator is also an admin - db.add_group_admin(group_id, creator_user_id) - return group_id, creator_user_id +def get_user_id(update: Update) -> int: + return update.effective_user.id -def admin_only(func): - @wraps(func) - async def wrapper(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - if is_group(update): - group = db.get_group(update.effective_chat.id) - if not group: - await update.message.reply_text("Group not found. Try /start in the group first.") - return - user_id = ensure_user(update) - if not db.is_group_admin(group["id"], user_id): - 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): - group = db.get_group(update.effective_chat.id) - if not group: - await update.message.reply_text("Group not found.") - return - user_id = ensure_user(update) - if not db.is_group_creator(group["id"], user_id): - await update.message.reply_text("⛔ Group creator only.") - return - return await func(update, ctx) - return wrapper - - -# ── Commands ───────────────────────────────────────────────────────────────── - async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """List all bounties. Group: group bounties. DM: user's personal bounties.""" if is_group(update): - group = db.get_group(update.effective_chat.id) - if not group: - await update.message.reply_text("Group not initialized. Try /start.") - return - bounties = db.get_group_bounties(group["id"]) + data = storage.load_group_bounties(get_group_id(update)) + bounties = data.get("bounties", []) else: - user_id = ensure_user(update) - bounties = db.get_user_personal_bounties(user_id) + 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,32 +96,51 @@ async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """List bounties tracked by the user. Group: tracked group bounties. DM: tracked personal bounties.""" - user_id = ensure_user(update) + user_id = get_user_id(update) if is_group(update): - group = db.get_group(update.effective_chat.id) - if not group: - await update.message.reply_text("Group not found.") - return - bounties = db.get_user_tracked_bounties_in_group(user_id, group["id"]) + group_id = get_group_id(update) + tracking = storage.load_user_tracking(group_id, user_id) + tracked = tracking.get("tracked", []) else: - bounties = db.get_user_tracked_bounties_personal(user_id) + 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 bounties: + if not tracked: await update.message.reply_text("You are not tracking any bounties.") 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) + group_data = storage.load_group_bounties(group_id) + bounty_map = {b["id"]: b for b in group_data.get("bounties", [])} + + bounty_lines = [] + 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.") + return + + await update.message.reply_text( + "\n".join(bounty_lines), disable_web_page_preview=True + ) -@admin_only async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Add a bounty. Args: [text] [link] [due_date].""" args = extract_args(update.message.text) if not args: - await update.message.reply_text("Usage: /add [link] [due_date]\nExample: /add Fix the bug https://github.com/foo/bar tomorrow") + await update.message.reply_text( + "Usage: /add [link] [due_date]\n" + "Example: /add Fix the bug https://github.com/foo/bar tomorrow" + ) return text, link, due_date_ts = parse_args(args) @@ -178,37 +148,30 @@ 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_id = get_user_id(update) + if is_group(update): - group_id, creator_user_id = ensure_group(update) - created_by = creator_user_id + group_id = get_group_id(update) + bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts) else: - group_id = None - created_by = ensure_user(update) - - informed_by = update.effective_user.username or str(update.effective_user.id) - - try: - bounty_id = db.add_bounty(group_id, created_by, informed_by, text, link, due_date_ts) - except ValueError as e: - await update.message.reply_text(f"⛔ {e}") - return + bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts) 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 (#{bounty['id']}){due_str}", disable_web_page_preview=True, ) -@admin_only async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Update a bounty. Args: [text] [link] [due_date].""" args = extract_args(update.message.text) if len(args) < 1: - await update.message.reply_text("Usage: /update [text] [link] [due_date]") + await update.message.reply_text( + "Usage: /update [text] [link] [due_date]" + ) return try: @@ -222,37 +185,29 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Nothing to update.") return - # Verify bounty belongs to this group / user - bounty = db.get_bounty(bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return + user_id = get_user_id(update) if is_group(update): - group = db.get_group(update.effective_chat.id) - if bounty["group_id"] != group["id"]: - await update.message.reply_text("Bounty not found in this group.") + 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: - if bounty["group_id"] is not None: - await update.message.reply_text("This bounty belongs to a group, not your personal list.") + bounty = storage.get_personal_bounty(user_id, bounty_id) + if not bounty: + await update.message.reply_text("Bounty not found.") return - if bounty["created_by_user_id"] != ensure_user(update): - await update.message.reply_text("You can only update your own bounties.") - return - - try: - db.update_bounty(bounty_id, text, link, due_date_ts) - except ValueError as e: - await update.message.reply_text(f"⛔ {e}") - return + storage.update_personal_bounty(user_id, bounty_id, text, link, due_date_ts) await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") -@admin_only async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Delete a bounty. Args: .""" args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /delete ") @@ -264,27 +219,31 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Invalid bounty ID.") return - bounty = db.get_bounty(bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return + user_id = get_user_id(update) if is_group(update): - group = db.get_group(update.effective_chat.id) - if bounty["group_id"] != group["id"]: - await update.message.reply_text("Bounty not found in this group.") + 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: - if bounty["group_id"] is not None: - await update.message.reply_text("This bounty belongs to a group.") + 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) - db.delete_bounty(bounty_id) await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Track a bounty. Args: .""" args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /track ") @@ -296,31 +255,26 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Invalid bounty ID.") return - bounty = db.get_bounty(bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return + user_id = get_user_id(update) if is_group(update): - group = db.get_group(update.effective_chat.id) - if bounty["group_id"] != group["id"]: - await update.message.reply_text("Bounty not found in this group.") + 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 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 bounty["group_id"] is not None: - await update.message.reply_text("Use /track from the group where the bounty belongs.") - return - - user_id = ensure_user(update) - added = db.track_bounty(user_id, bounty_id) - if added: - await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") - else: - await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") + 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: - """Untrack a bounty. Args: .""" args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /untrack ") @@ -332,83 +286,32 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("Invalid bounty ID.") return - user_id = ensure_user(update) - removed = db.untrack_bounty(user_id, bounty_id) - if removed: - await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") + user_id = get_user_id(update) + + 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}.") + else: + await update.message.reply_text(f"Not tracking bounty #{bounty_id}.") else: - await update.message.reply_text(f"Not tracking bounty #{bounty_id}.") - - -@creator_only -async def cmd_admin_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Promote a user to admin. Args: .""" - if not is_group(update): - await update.message.reply_text("This command only works in groups.") - return - - args = extract_args(update.message.text) - if not args: - await update.message.reply_text("Usage: /admin_add ") - return - - username = args[0].lstrip("@") - user = db.get_user_by_username(username) - if not user: - await update.message.reply_text(f"User @{username} not found. They must interact with the bot first.") - return - - group = db.get_group(update.effective_chat.id) - added = db.add_group_admin(group["id"], user["id"]) - if added: - await update.message.reply_text(f"✅ @{username} is now a group admin.") - else: - await update.message.reply_text(f"@{username} is already an admin.") - - -@creator_only -async def cmd_admin_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: - """Demote an admin. Args: .""" - if not is_group(update): - await update.message.reply_text("This command only works in groups.") - return - - args = extract_args(update.message.text) - if not args: - await update.message.reply_text("Usage: /admin_remove ") - return - - username = args[0].lstrip("@") - user = db.get_user_by_username(username) - if not user: - await update.message.reply_text(f"User @{username} not found.") - return - - group = db.get_group(update.effective_chat.id) - # Prevent removing the creator - if db.is_group_creator(group["id"], user["id"]): - await update.message.reply_text("Cannot remove the group creator.") - return - - removed = db.remove_group_admin(group["id"], user["id"]) - if removed: - await update.message.reply_text(f"✅ @{username} is no longer a group admin.") - else: - await update.message.reply_text(f"@{username} was not an admin.") + 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: if is_group(update): - ensure_group(update) await update.message.reply_text( "👻 JIGAIDO is watching.\n\n" "This group's bounty list is now active.\n" - "Only admins can add/update/delete bounties.\n" - "Anyone can /track and /untrack.\n\n" - "Try /bounty to see all bounties, /add to create one." + "/bounty — list bounties\n" + "/add — create a bounty\n" + "/track — track a bounty\n" + "/my — your tracked bounties" ) else: - ensure_user(update) await update.message.reply_text( "👻 JIGAIDO activated.\n\n" "Personal bounty list ready.\n" @@ -423,14 +326,12 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "👻 JIGAIDO Commands:\n\n" "/bounty — list all bounties\n" "/my — bounties you're tracking\n" - "/add [link] [due] — add bounty (admin/DM only)\n" - "/update [text] [link] [due] — update bounty (admin/DM only)\n" - "/delete — delete bounty (admin/DM only)\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" - "/admin_add — promote to admin (creator only, group)\n" - "/admin_remove — demote admin (creator only, group)\n" - "/start — re-initialize group\n" + "/start — re-initialize\n" "/help — this message", disable_web_page_preview=True, ) diff --git a/apps/telegram-bot/cron.py b/apps/telegram-bot/cron.py deleted file mode 100644 index 108e871..0000000 --- a/apps/telegram-bot/cron.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Daily reminder cron job for JIGAIDO. - -Run with: python -m cron -Or schedule via systemd timer / cron. -""" - -import asyncio -import logging -import os -import sys -import time - -# Add project root to path -sys.path.insert(0, os.path.dirname(__file__)) - -import db - -logging.basicConfig( - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - level=logging.INFO, -) -log = logging.getLogger(__name__) - -# Token from environment -BOT_TOKEN = os.environ.get("JIGAIDO_BOT_TOKEN", "") - -REMINDER_WINDOW_DAYS = 7 - - -async def send_reminder(user_telegram_id: int, bounty: dict, bot) -> None: - days_left = (bounty["due_date_ts"] - int(time.time())) // 86400 - if days_left < 0: - urgency = "OVERDUE" - elif days_left == 0: - urgency = "TODAY" - else: - urgency = f"{days_left} days left" - - due_str = time.strftime("%Y-%m-%d", time.localtime(bounty["due_date_ts"])) - text = f"⏰ Reminder: bounty #{bounty['id']}" - if bounty["text"]: - text += f" — {bounty['text']}" - text += f"\nDue: {due_str} ({urgency})" - - try: - await bot.send_message(chat_id=user_telegram_id, text=text, disable_web_page_preview=True) - log.info(f"Reminder sent to {user_telegram_id} for bounty #{bounty['id']}") - except Exception as e: - log.error(f"Failed to send reminder to {user_telegram_id}: {e}") - - -async def run_reminders() -> None: - if not BOT_TOKEN: - log.error("JIGAIDO_BOT_TOKEN not set") - return - - from telegram import Bot - bot = Bot(BOT_TOKEN) - - user_ids = db.get_all_user_ids() - log.info(f"Running reminders for {len(user_ids)} users...") - - for user_telegram_id in user_ids: - due_bounties = db.get_bounties_due_soon(user_telegram_id, REMINDER_WINDOW_DAYS) - for bounty in due_bounties: - await send_reminder(user_telegram_id, dict(bounty), bot) - db.log_reminder(user_telegram_id, bounty["id"]) - - log.info("Reminder run complete.") - - -def main() -> None: - asyncio.run(run_reminders()) - - -if __name__ == "__main__": - main() diff --git a/apps/telegram-bot/db.py b/apps/telegram-bot/db.py deleted file mode 100644 index 396050d..0000000 --- a/apps/telegram-bot/db.py +++ /dev/null @@ -1,306 +0,0 @@ -"""SQLite database wrapper for JIGAIDO.""" - -import sqlite3 -import time -from pathlib import Path -from typing import Optional - -DB_PATH = Path(__file__).parent / "jigaido.db" - - -def get_conn() -> sqlite3.Connection: - # isolation_level=None enables autocommit mode. - # row_factory disables SQLite Python's implicit transaction management, - # so we need explicit autocommit to make writes work correctly. - conn = sqlite3.connect(DB_PATH, detect_types=sqlite3.PARSE_DECLTYPES) - conn.isolation_level = None - conn.execute("PRAGMA foreign_keys = ON") - conn.row_factory = sqlite3.Row - return conn - - -def _row_to_dict(row: sqlite3.Row) -> dict: - return dict(row) - - -def init_db() -> None: - schema = (Path(__file__).parent / "schema.sql").read_text() - with get_conn() as conn: - conn.executescript(schema) - - -# ── Users ────────────────────────────────────────────────────────────────── - -def upsert_user(telegram_user_id: int, username: str | None) -> int: - with get_conn() as conn: - cur = conn.execute( - """INSERT INTO users (telegram_user_id, username) - VALUES (?, ?) - ON CONFLICT (telegram_user_id) DO UPDATE SET username = excluded.username - RETURNING id""", - (telegram_user_id, username), - ) - return cur.fetchone()["id"] - - -def get_user_by_telegram_id(telegram_user_id: int) -> Optional[dict]: - with get_conn() as conn: - row = conn.execute( - "SELECT * FROM users WHERE telegram_user_id = ?", - (telegram_user_id,), - ).fetchone() - return _row_to_dict(row) if row else None - - -# ── Groups ───────────────────────────────────────────────────────────────── - -def upsert_group(telegram_chat_id: int, creator_user_id: int) -> int: - """Insert group if not exists. Returns group id.""" - with get_conn() as conn: - cur = conn.execute( - """INSERT INTO groups (telegram_chat_id, creator_user_id) - VALUES (?, ?) - ON CONFLICT (telegram_chat_id) DO UPDATE SET creator_user_id = excluded.creator_user_id - WHERE groups.creator_user_id IS NULL OR groups.creator_user_id = excluded.creator_user_id - RETURNING id""", - (telegram_chat_id, creator_user_id), - ) - return cur.fetchone()["id"] - - -def get_group(telegram_chat_id: int) -> Optional[dict]: - with get_conn() as conn: - row = conn.execute( - "SELECT * FROM groups WHERE telegram_chat_id = ?", - (telegram_chat_id,), - ).fetchone() - return _row_to_dict(row) if row else None - - -def get_group_creator_user_id(group_id: int) -> Optional[int]: - with get_conn() as conn: - row = conn.execute( - "SELECT creator_user_id FROM groups WHERE id = ?", - (group_id,), - ).fetchone() - return row["creator_user_id"] if row else None - - -# ── Group Admins ──────────────────────────────────────────────────────────── - -def add_group_admin(group_id: int, user_id: int) -> bool: - """Add user as admin. Returns True if newly added, False if already admin.""" - with get_conn() as conn: - try: - conn.execute( - "INSERT INTO group_admins (group_id, user_id) VALUES (?, ?)", - (group_id, user_id), - ) - return True - except sqlite3.IntegrityError: - return False - - -def remove_group_admin(group_id: int, user_id: int) -> bool: - """Remove user from admins. Returns True if removed, False if not an admin.""" - with get_conn() as conn: - cur = conn.execute( - "DELETE FROM group_admins WHERE group_id = ? AND user_id = ?", - (group_id, user_id), - ) - return cur.rowcount > 0 - - -def is_group_admin(group_id: int, user_id: int) -> bool: - with get_conn() as conn: - row = conn.execute( - "SELECT 1 FROM group_admins WHERE group_id = ? AND user_id = ?", - (group_id, user_id), - ).fetchone() - return row is not None - - -def is_group_creator(group_id: int, user_id: int) -> bool: - return get_group_creator_user_id(group_id) == user_id - - -def get_user_by_username(username: str) -> Optional[dict]: - """Look up user by username (without @).""" - with get_conn() as conn: - row = conn.execute( - "SELECT * FROM users WHERE username = ?", - (username,), - ).fetchone() - return _row_to_dict(row) if row else None - - -# ── Bounties ──────────────────────────────────────────────────────────────── - -def add_bounty( - group_id: int | None, - created_by_user_id: int, - informed_by_username: str, - text: str | None, - link: str | None, - due_date_ts: int | None, -) -> int: - """Add a bounty. Returns bounty id. Raises ValueError on duplicate link.""" - with get_conn() as conn: - try: - cur = conn.execute( - """INSERT INTO bounties - (group_id, created_by_user_id, informed_by_username, text, link, due_date_ts) - VALUES (?, ?, ?, ?, ?, ?) - RETURNING id""", - (group_id, created_by_user_id, informed_by_username, text, link, due_date_ts), - ) - return cur.fetchone()["id"] - except sqlite3.IntegrityError as e: - if "UNIQUE" in str(e) and "link" in str(e): - raise ValueError(f"Link already exists in this group: {link}") - raise - - -def get_bounty(bounty_id: int) -> Optional[dict]: - with get_conn() as conn: - row = conn.execute("SELECT * FROM bounties WHERE id = ?", (bounty_id,)).fetchone() - return _row_to_dict(row) if row else None - - -def get_group_bounties(group_id: int) -> list[dict]: - with get_conn() as conn: - return [_row_to_dict(r) for r in conn.execute( - "SELECT * FROM bounties WHERE group_id = ? ORDER BY created_at DESC", - (group_id,), - )] - - -def get_user_personal_bounties(user_id: int) -> list[dict]: - """Bounties created by user in DM (group_id IS NULL).""" - with get_conn() as conn: - return [_row_to_dict(r) for r in conn.execute( - "SELECT * FROM bounties WHERE group_id IS NULL AND created_by_user_id = ? ORDER BY created_at DESC", - (user_id,), - )] - - -def update_bounty( - bounty_id: int, - text: str | None, - link: str | None, - due_date_ts: int | None, -) -> bool: - """Update bounty fields. Returns True if updated. Raises ValueError on duplicate link.""" - with get_conn() as conn: - try: - cur = conn.execute( - """UPDATE bounties - SET text = COALESCE(?, text), - link = COALESCE(?, link), - due_date_ts = COALESCE(?, due_date_ts) - WHERE id = ?""", - (text, link, due_date_ts, bounty_id), - ) - return cur.rowcount > 0 - except sqlite3.IntegrityError as e: - if "UNIQUE" in str(e) and "link" in str(e): - raise ValueError(f"Link already exists in this group: {link}") - raise - - -def delete_bounty(bounty_id: int) -> bool: - with get_conn() as conn: - cur = conn.execute("DELETE FROM bounties WHERE id = ?", (bounty_id,)) - return cur.rowcount > 0 - - -# ── Tracking ──────────────────────────────────────────────────────────────── - -def track_bounty(user_id: int, bounty_id: int) -> bool: - """Add bounty to user's tracking. Returns True if newly tracked, False if already tracking.""" - with get_conn() as conn: - try: - conn.execute( - "INSERT INTO user_bounty_tracking (user_id, bounty_id) VALUES (?, ?)", - (user_id, bounty_id), - ) - return True - except sqlite3.IntegrityError: - return False - - -def untrack_bounty(user_id: int, bounty_id: int) -> bool: - with get_conn() as conn: - cur = conn.execute( - "DELETE FROM user_bounty_tracking WHERE user_id = ? AND bounty_id = ?", - (user_id, bounty_id), - ) - return cur.rowcount > 0 - - -def is_tracking(user_id: int, bounty_id: int) -> bool: - with get_conn() as conn: - row = conn.execute( - "SELECT 1 FROM user_bounty_tracking WHERE user_id = ? AND bounty_id = ?", - (user_id, bounty_id), - ).fetchone() - return row is not None - - -def get_user_tracked_bounties_in_group(user_id: int, group_id: int) -> list[dict]: - with get_conn() as conn: - return [_row_to_dict(r) for r in conn.execute( - """SELECT b.* FROM bounties b - JOIN user_bounty_tracking t ON t.bounty_id = b.id - WHERE t.user_id = ? AND b.group_id = ? - ORDER BY b.created_at DESC""", - (user_id, group_id), - )] - - -def get_user_tracked_bounties_personal(user_id: int) -> list[dict]: - """Tracked bounties where group_id IS NULL (personal).""" - with get_conn() as conn: - return [_row_to_dict(r) for r in conn.execute( - """SELECT b.* FROM bounties b - JOIN user_bounty_tracking t ON t.bounty_id = b.id - WHERE t.user_id = ? AND b.group_id IS NULL - ORDER BY b.created_at DESC""", - (user_id,), - )] - - -# ── Reminders ─────────────────────────────────────────────────────────────── - -def get_bounties_due_soon(user_id: int, days: int = 7) -> list[dict]: - """Get tracked bounties with due_date within `days` that haven't been reminded yet.""" - now = int(time.time()) - deadline = now + days * 86400 - with get_conn() as conn: - return [_row_to_dict(r) for r in conn.execute( - """SELECT b.*, u.username, u.telegram_user_id FROM bounties b - JOIN user_bounty_tracking t ON t.bounty_id = b.id - JOIN users u ON u.id = b.created_by_user_id - WHERE t.user_id = ? - AND b.due_date_ts IS NOT NULL - AND b.due_date_ts <= ? - AND b.due_date_ts >= ? - AND b.id NOT IN ( - SELECT bounty_id FROM reminder_log WHERE user_id = ? - ) - ORDER BY b.due_date_ts ASC""", - (user_id, deadline, now, user_id), - )] - - -def log_reminder(user_id: int, bounty_id: int) -> None: - with get_conn() as conn: - conn.execute( - "INSERT OR IGNORE INTO reminder_log (user_id, bounty_id) VALUES (?, ?)", - (user_id, bounty_id), - ) - - -def get_all_user_ids() -> list[int]: - with get_conn() as conn: - return [row["telegram_user_id"] for row in conn.execute("SELECT telegram_user_id FROM users")] diff --git a/apps/telegram-bot/schema.sql b/apps/telegram-bot/schema.sql deleted file mode 100644 index 8d069cb..0000000 --- a/apps/telegram-bot/schema.sql +++ /dev/null @@ -1,49 +0,0 @@ --- JIGAIDO Database Schema - -CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - telegram_chat_id INTEGER UNIQUE NOT NULL, - creator_user_id INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -CREATE TABLE IF NOT EXISTS group_admins ( - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL, - PRIMARY KEY (group_id, user_id) -); - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - telegram_user_id INTEGER UNIQUE NOT NULL, - username TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - -CREATE TABLE IF NOT EXISTS bounties ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, - created_by_user_id INTEGER REFERENCES users(id), - informed_by_username TEXT NOT NULL, - text TEXT, - link TEXT, - due_date_ts INTEGER, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(group_id, link) -); - -CREATE TABLE IF NOT EXISTS user_bounty_tracking ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE, - added_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(user_id, bounty_id) -); - -CREATE TABLE IF NOT EXISTS reminder_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - bounty_id INTEGER NOT NULL REFERENCES bounties(id) ON DELETE CASCADE, - reminded_at INTEGER NOT NULL DEFAULT (unixepoch()), - UNIQUE(user_id, bounty_id) -); diff --git a/apps/telegram-bot/storage.py b/apps/telegram-bot/storage.py new file mode 100644 index 0000000..4b931b4 --- /dev/null +++ b/apps/telegram-bot/storage.py @@ -0,0 +1,216 @@ +"""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 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 diff --git a/apps/telegram-bot/tests/test_db.py b/apps/telegram-bot/tests/test_db.py deleted file mode 100644 index 29f69c6..0000000 --- a/apps/telegram-bot/tests/test_db.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Tests for db.py""" - -import time -import pytest -import db as _db - - -class TestUsers: - def test_upsert_user_creates_new(self): - uid = _db.upsert_user(123, "alice") - assert uid > 0 - row = _db.get_user_by_telegram_id(123) - assert row is not None - assert row["telegram_user_id"] == 123 - assert row["username"] == "alice" - - def test_upsert_user_updates_username(self): - # Two upserts to the same telegram_user_id: second one updates the username. - # Returns the same id both times (idempotent). - uid1 = _db.upsert_user(123, "alice") - uid2 = _db.upsert_user(123, "alice_updated") - assert uid1 == uid2 - row = _db.get_user_by_telegram_id(123) - assert row["username"] == "alice_updated" - - def test_get_user_by_telegram_id_not_found(self): - row = _db.get_user_by_telegram_id(999999) - assert row is None - - -class TestGroups: - def test_upsert_group_creates_new(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - assert gid > 0 - row = _db.get_group(-100123) - assert row is not None - assert row["telegram_chat_id"] == -100123 - assert row["creator_user_id"] == uid - - def test_upsert_group_idempotent(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid1 = _db.upsert_group(-100123, uid) - gid2 = _db.upsert_group(-100123, uid) - assert gid1 == gid2 - - def test_get_group_creator_user_id(self, fresh_db): - uid = _db.upsert_user(1, "creator") - _db.upsert_group(-100123, uid) - assert _db.get_group_creator_user_id(_db.get_group(-100123)["id"]) == uid - - def test_get_group_not_found(self, fresh_db): - row = _db.get_group(-999999) - assert row is None - - -class TestGroupAdmins: - def test_add_remove_is_admin(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - - assert not _db.is_group_admin(gid, uid) - added = _db.add_group_admin(gid, uid) - assert added is True - assert _db.is_group_admin(gid, uid) is True - - # Adding again returns False (already admin) - assert _db.add_group_admin(gid, uid) is False - - removed = _db.remove_group_admin(gid, uid) - assert removed is True - assert _db.is_group_admin(gid, uid) is False - - def test_remove_nonexistent_admin_returns_false(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - assert _db.remove_group_admin(gid, uid) is False - - def test_is_group_creator(self, fresh_db): - uid = _db.upsert_user(1, "creator") - other = _db.upsert_user(2, "other") - gid = _db.upsert_group(-100123, uid) - _db.add_group_admin(gid, uid) - - assert _db.is_group_creator(gid, uid) is True - assert _db.is_group_creator(gid, other) is False - - def test_get_user_by_username(self, fresh_db): - uid = _db.upsert_user(1, "alice") - row = _db.get_user_by_username("alice") - assert row is not None - assert row["id"] == uid - assert _db.get_user_by_username("nobody") is None - - -class TestBounties: - def test_add_bounty_group(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - _db.add_group_admin(gid, uid) - - bid = _db.add_bounty( - group_id=gid, - created_by_user_id=uid, - informed_by_username="bob", - text="Fix bug", - link="https://github.com/bob/repo", - due_date_ts=int(time.time()) + 86400, - ) - assert bid > 0 - bounty = _db.get_bounty(bid) - assert bounty["text"] == "Fix bug" - assert bounty["link"] == "https://github.com/bob/repo" - assert bounty["informed_by_username"] == "bob" - assert bounty["group_id"] == gid - - def test_add_bounty_personal(self, fresh_db): - uid = _db.upsert_user(1, "alice") - bid = _db.add_bounty( - group_id=None, - created_by_user_id=uid, - informed_by_username="alice", - text="Personal reminder", - link=None, - due_date_ts=None, - ) - assert bid > 0 - bounty = _db.get_bounty(bid) - assert bounty["group_id"] is None - assert bounty["text"] == "Personal reminder" - - def test_add_bounty_duplicate_link_rejected(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - - _db.add_bounty(gid, uid, "user1", "text1", "https://example.com", None) - with pytest.raises(ValueError, match="Link already exists"): - _db.add_bounty(gid, uid, "user2", "text2", "https://example.com", None) - - def test_add_bounty_null_link_allows_multiples(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - - bid1 = _db.add_bounty(gid, uid, "user1", "text only 1", None, None) - bid2 = _db.add_bounty(gid, uid, "user2", "text only 2", None, None) - assert bid1 != bid2 - - def test_get_group_bounties(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - _db.add_group_admin(gid, uid) - - _db.add_bounty(gid, uid, "user", "bounty1", None, None) - _db.add_bounty(gid, uid, "user", "bounty2", None, None) - - bounties = _db.get_group_bounties(gid) - assert len(bounties) == 2 - - def test_get_user_personal_bounties(self, fresh_db): - uid = _db.upsert_user(1, "alice") - _db.add_bounty(None, uid, "alice", "personal1", None, None) - _db.add_bounty(None, uid, "alice", "personal2", None, None) - - # Group bounty should not appear - other = _db.upsert_user(2, "bob") - gid = _db.upsert_group(-100, other) - _db.add_bounty(gid, other, "bob", "group bounty", None, None) - - personal = _db.get_user_personal_bounties(uid) - assert len(personal) == 2 - - def test_update_bounty(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - bid = _db.add_bounty(gid, uid, "user", "old text", None, None) - - _db.update_bounty(bid, "new text", None, None) - updated = _db.get_bounty(bid) - assert updated["text"] == "new text" - - def test_update_bounty_duplicate_link_rejected(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - _db.add_bounty(gid, uid, "user1", "bounty1", "https://a.com", None) - bid2 = _db.add_bounty(gid, uid, "user2", "bounty2", None, None) - - with pytest.raises(ValueError, match="Link already exists"): - _db.update_bounty(bid2, None, "https://a.com", None) - - def test_delete_bounty(self, fresh_db): - uid = _db.upsert_user(1, "creator") - gid = _db.upsert_group(-100123, uid) - bid = _db.add_bounty(gid, uid, "user", "to delete", None, None) - - assert _db.delete_bounty(bid) is True - assert _db.get_bounty(bid) is None - # Deleting again returns False - assert _db.delete_bounty(bid) is False - - -class TestTracking: - def test_track_untrack_is_tracking(self, fresh_db): - uid = _db.upsert_user(1, "alice") - uid2 = _db.upsert_user(2, "bob") - gid = _db.upsert_group(-100123, uid) - bid = _db.add_bounty(gid, uid, "alice", "task", None, None) - - assert _db.track_bounty(uid, bid) is True - assert _db.is_tracking(uid, bid) is True - # Track again → False (already tracking) - assert _db.track_bounty(uid, bid) is False - - # Other user tracking same bounty - assert _db.track_bounty(uid2, bid) is True - assert _db.is_tracking(uid2, bid) is True - - # Untrack - assert _db.untrack_bounty(uid, bid) is True - assert _db.is_tracking(uid, bid) is False - assert _db.is_tracking(uid2, bid) is True # other user still tracking - # Untrack again → False - assert _db.untrack_bounty(uid, bid) is False - - def test_get_user_tracked_bounties_in_group(self, fresh_db): - uid = _db.upsert_user(1, "alice") - gid = _db.upsert_group(-100123, uid) - bid1 = _db.add_bounty(gid, uid, "alice", "task1", None, None) - bid2 = _db.add_bounty(gid, uid, "alice", "task2", None, None) - - # Different group bounty - other_gid = _db.upsert_group(-100124, uid) - bid3 = _db.add_bounty(other_gid, uid, "alice", "other group task", None, None) - - _db.track_bounty(uid, bid1) - _db.track_bounty(uid, bid3) - - tracked = _db.get_user_tracked_bounties_in_group(uid, gid) - assert len(tracked) == 1 - assert tracked[0]["id"] == bid1 - - def test_get_user_tracked_bounties_personal(self, fresh_db): - uid = _db.upsert_user(1, "alice") - bid1 = _db.add_bounty(None, uid, "alice", "personal1", None, None) - bid2 = _db.add_bounty(None, uid, "alice", "personal2", None, None) - - gid = _db.upsert_group(-100123, uid) - bid3 = _db.add_bounty(gid, uid, "alice", "group task", None, None) - - _db.track_bounty(uid, bid1) - _db.track_bounty(uid, bid3) - - tracked = _db.get_user_tracked_bounties_personal(uid) - assert len(tracked) == 1 - assert tracked[0]["id"] == bid1 - - -class TestReminders: - def test_get_bounties_due_soon(self, fresh_db): - uid = _db.upsert_user(1, "alice") - gid = _db.upsert_group(-100123, uid) - - now = int(time.time()) - # Due in 3 days (< 7 days) - bid_soon = _db.add_bounty(gid, uid, "alice", "soon", None, now + 3 * 86400) - # Due in 10 days (> 7 days) - _db.add_bounty(gid, uid, "alice", "later", None, now + 10 * 86400) - # No due date - bid_no_date = _db.add_bounty(gid, uid, "alice", "no date", None, None) - - _db.track_bounty(uid, bid_soon) - _db.track_bounty(uid, bid_no_date) - - due = _db.get_bounties_due_soon(uid, days=7) - assert len(due) == 1 - assert due[0]["id"] == bid_soon - - def test_reminder_log_prevents_duplicate_reminders(self, fresh_db): - uid = _db.upsert_user(1, "alice") - gid = _db.upsert_group(-100123, uid) - now = int(time.time()) - bid = _db.add_bounty(gid, uid, "alice", "task", None, now + 2 * 86400) - _db.track_bounty(uid, bid) - - due1 = _db.get_bounties_due_soon(uid, days=7) - assert len(due1) == 1 - - # Log that we reminded - _db.log_reminder(uid, bid) - - # Should not appear again - due2 = _db.get_bounties_due_soon(uid, days=7) - assert len(due2) == 0 - - def test_get_all_user_ids(self, fresh_db): - _db.upsert_user(1, "alice") - _db.upsert_user(2, "bob") - ids = _db.get_all_user_ids() - assert sorted(ids) == [1, 2]