From be5fe04a9006e5c3719d47830b0b8a2d58f28dc0 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:38:18 +0000 Subject: [PATCH] Initial commit: project scaffold, full spec, database schema, bot and cron - SPEC.md: full project specification from design discussion - schema.sql: SQLite database schema - db.py: database wrapper with all query functions - bot.py: telegram bot entrypoint - commands.py: all command handlers with admin/creator guards - cron.py: daily reminder job - requirements.txt: python-telegram-bot, dateparser - README.md, CONTRIBUTING.md - .gitignore --- .gitignore | 30 ++++ CONTRIBUTING.md | 34 ++++ README.md | 74 ++++++++ SPEC.md | 204 ++++++++++++++++++++++ bot.py | 78 +++++++++ commands.py | 436 +++++++++++++++++++++++++++++++++++++++++++++++ cron.py | 77 +++++++++ db.py | 294 ++++++++++++++++++++++++++++++++ requirements.txt | 2 + schema.sql | 49 ++++++ 10 files changed, 1278 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 bot.py create mode 100644 commands.py create mode 100644 cron.py create mode 100644 db.py create mode 100644 requirements.txt create mode 100644 schema.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2da6596 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Environments +.env +.venv/ +venv/ +env/ + +# Database +*.db + +# Logs +*.log + +# Distribution +dist/ +build/ +*.egg-info/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0061016 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to JIGAIDO + +## Development Setup + +```bash +git clone https://git.fbrns.co/shoko/jigaido.git +cd jigaido +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run bot +export JIGAIDO_BOT_TOKEN="test:token" +python bot.py +``` + +## Code Style + +- Python (no strict formatter enforced yet) +- Async/await for Telegram handlers +- Type hints where obvious +- Docstrings for public functions + +## Pull Request Workflow + +1. Branch from `main` +2. Make changes +3. Test locally +4. Open PR with description of what changed and why +5. Someone reviews and merges + +## Issues + +Use GitHub Issues for bugs and feature requests. Please be specific — include error messages, steps to reproduce, and your environment. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15dce24 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# JIGAIDO + +> Named after Nanami Kento's Cursed Technique restriction. Suppresses power during normal hours, exerts it during overtime. + +A lightweight bounty tracker for Telegram groups and individuals. Track obligations, deadlines, and tasks with optional reminders. + +## Features + +- **Group bounties**: Each Telegram group has its own bounty list +- **Personal bounties**: Private DM bounty list for individuals +- **Admin-only posting**: Only group admins can add/update/delete bounties +- **Universal tracking**: Any member can track bounties to their personal list +- **Due date reminders**: Daily cron notifies users when bounties are due within 7 days +- **Free-form dates**: Natural language due dates (`"tomorrow"`, `"in 3 days"`, `"april 15"`) +- **Link deduplication**: No duplicate links within the same group +- **Zero infrastructure**: SQLite + Python, deploys on any $5 VPS + +## Quick Start + +```bash +# Install dependencies +pip install -r requirements.txt + +# Set bot token +export JIGAIDO_BOT_TOKEN="your:token_here" + +# Initialize database and start bot +python bot.py +``` + +## Commands + +| Command | Where | Who | Description | +|---|---|---|---| +| `/bounty` | Group / DM | Anyone | List all bounties | +| `/my` | Group / DM | Anyone | List your tracked bounties | +| `/add [link] [due]` | Group | Admin | Add bounty | +| `/add [link] [due]` | DM | Anyone | Add personal bounty | +| `/update [text] [link] [due]` | Group | Admin | Update bounty | +| `/delete ` | Group | Admin | Delete bounty | +| `/track ` | Group / DM | Anyone | Track a bounty | +| `/untrack ` | Group / DM | Anyone | Stop tracking | +| `/admin_add ` | Group | Creator | Promote to admin | +| `/admin_remove ` | Group | Creator | Demote admin | +| `/start` | Group / DM | Anyone | Re-initialize | +| `/help` | Anywhere | Anyone | Show help | + +## Reminders + +Schedule a daily cron job: + +```bash +# crontab -e +0 9 * * * cd /path/to/jigaido && JIGAIDO_BOT_TOKEN="your:token" python -m cron +``` + +Or via systemd timer (see `cron.py`). + +## Project Structure + +``` +jigaido/ +├── bot.py # Telegram bot entrypoint +├── db.py # SQLite database wrapper +├── schema.sql # Database schema +├── cron.py # Daily reminder job +├── commands.py # Command handlers +├── requirements.txt +└── SPEC.md # Full specification +``` + +## License + +MIT diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..493ec0f --- /dev/null +++ b/SPEC.md @@ -0,0 +1,204 @@ +# JIGAIDO — Specification + +> Named after Nanami Kento's Cursed Technique restriction. Suppresses power during normal hours, exerts it during overtime. A bounty tracker that keeps you honest when the clock runs long. + +--- + +## 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. + +- **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). + +--- + +## Architecture + +### Stack +- **Bot**: `python-telegram-bot` (pure Python, no C extensions) +- **Database**: SQLite (zero-install, single file) +- **Date parsing**: `dateparser` +- **Runtime**: Python 3.10+ +- **Deployment**: Any $5 VPS with Python 3.10+ + +### Directory Structure + +``` +jigaido/ +├── bot.py # Telegram bot entrypoint, command handlers +├── db.py # SQLite wrapper +├── schema.sql # Database schema +├── cron.py # Daily reminder job +├── requirements.txt +├── .gitignore +├── README.md +├── SPEC.md # This document +└── CONTRIBUTING.md +``` + +--- + +## Database Schema + +```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()) +); + +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) +); +``` + +### 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. + +--- + +## Commands + +### In Group + +| Command | Who | Description | +|---|---|---| +| `/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 | + +### In DM (1:1 with bot) + +| Command | Description | +|---|---| +| `/bounty` | List all your personal bounties | +| `/my` | List all your tracked 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 | + +### Add/Update Syntax + +``` +/add Some task description https://example.com in 3 days +/add Just a text reminder +/add https://link-only.example.com tomorrow +``` + +- `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. + +--- + +## Due Date Parsing + +Uses `dateparser` library. Examples: +- `"april 15"`, `"April 15, 2026"` +- `"in 3 days"` +- `"tomorrow"` +- `"2026-04-15"` +- `"next friday"` + +If parsing fails → `due_date_ts = NULL`. No error is shown to user, reminder just won't fire. + +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) +- Bounty not found → "Bounty not found" +- User not found → "User not found" diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..14b06e6 --- /dev/null +++ b/bot.py @@ -0,0 +1,78 @@ +"""JIGAIDO Telegram bot entrypoint.""" + +import logging +import os +import sys + +from telegram import Update +from telegram.ext import ( + Application, + CommandHandler, + MessageHandler, + filters, +) + +import db +import commands + +logging.basicConfig( + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + level=logging.INFO, +) +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)) + app.add_handler(CommandHandler("my", commands.cmd_my)) + app.add_handler(CommandHandler("add", commands.cmd_add)) + app.add_handler(CommandHandler("update", commands.cmd_update)) + 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"), + ]) + + +def main() -> None: + if not BOT_TOKEN: + 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 + + log.info("JIGAIDO starting...") + app.run_polling(drop_pending_updates=True) + + +if __name__ == "__main__": + main() diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..261961d --- /dev/null +++ b/commands.py @@ -0,0 +1,436 @@ +"""Telegram command handlers for JIGAIDO.""" + +import re +import time +from functools import wraps + +import dateparser +from telegram import Update +from telegram.ext import ContextTypes + +import db + +TELEGRAM_BOT_USERNAME = "your_bot_username" # Set via set_bot_commands / config + +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).""" + text = None + link = None + due_date_ts = None + + remaining = [] + for arg in args: + if not link and (arg.startswith("http://") or arg.startswith("https://")): + link = arg + elif due_date_ts is None: + parsed = dateparser.parse(arg) + if parsed: + due_date_ts = int(parsed.timestamp()) + else: + remaining.append(arg) + else: + remaining.append(arg) + + text = " ".join(remaining) if remaining else None + return text, link, due_date_ts + + +def format_bounty(b: dict, show_id: bool = True) -> str: + parts = [] + if show_id: + parts.append(f"[#{b['id']}]") + if b["text"]: + parts.append(b["text"]) + if b["link"]: + parts.append(f"🔗 {b['link']}") + if b["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: + parts.append(f"⏰ {due_str} (OVERDUE)") + elif days_left == 0: + 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'}") + return " | ".join(parts) + + +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 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 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"]) + else: + user_id = ensure_user(update) + bounties = db.get_user_personal_bounties(user_id) + + 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_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) + + 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"]) + else: + bounties = db.get_user_tracked_bounties_personal(user_id) + + if not bounties: + 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) + + +@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") + return + + text, link, due_date_ts = parse_args(args) + if not text and not link: + await update.message.reply_text("A bounty needs at least text or a link.") + return + + if is_group(update): + group_id, creator_user_id = ensure_group(update) + created_by = creator_user_id + 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 + + 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}", + 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]") + return + + try: + bounty_id = int(args[0]) + except ValueError: + await update.message.reply_text("Invalid bounty ID.") + return + + text, link, due_date_ts = parse_args(args[1:]) + if not text and not link and due_date_ts is 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 + + 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.") + return + else: + if bounty["group_id"] is not None: + await update.message.reply_text("This bounty belongs to a group, not your personal list.") + 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 + + 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 ") + return + + try: + bounty_id = int(args[0]) + except ValueError: + 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 + + 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.") + return + else: + if bounty["group_id"] is not None: + await update.message.reply_text("This bounty belongs to a group.") + return + + 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 ") + return + + try: + bounty_id = int(args[0]) + except ValueError: + 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 + + 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.") + return + 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}.") + + +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 ") + return + + try: + bounty_id = int(args[0]) + except ValueError: + 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}.") + 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.") + + +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." + ) + else: + ensure_user(update) + await update.message.reply_text( + "👻 JIGAIDO activated.\n\n" + "Personal bounty list ready.\n" + "/bounty — list your bounties\n" + "/add — create a bounty\n" + "/my — your tracked bounties" + ) + + +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 (admin/DM only)\n" + "/update [text] [link] [due] — update bounty (admin/DM only)\n" + "/delete — delete bounty (admin/DM only)\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" + "/help — this message", + disable_web_page_preview=True, + ) diff --git a/cron.py b/cron.py new file mode 100644 index 0000000..108e871 --- /dev/null +++ b/cron.py @@ -0,0 +1,77 @@ +"""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/db.py b/db.py new file mode 100644 index 0000000..f7b385b --- /dev/null +++ b/db.py @@ -0,0 +1,294 @@ +"""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: + conn = sqlite3.connect(DB_PATH, detect_types=sqlite3.PARSE_DECLTYPES) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +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[sqlite3.Row]: + with get_conn() as conn: + return conn.execute( + "SELECT * FROM users WHERE telegram_user_id = ?", + (telegram_user_id,), + ).fetchone() + + +# ── 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[sqlite3.Row]: + with get_conn() as conn: + return conn.execute( + "SELECT * FROM groups WHERE telegram_chat_id = ?", + (telegram_chat_id,), + ).fetchone() + + +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[sqlite3.Row]: + """Look up user by username (without @).""" + with get_conn() as conn: + return conn.execute( + "SELECT * FROM users WHERE username = ?", + (username,), + ).fetchone() + + +# ── 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[sqlite3.Row]: + with get_conn() as conn: + return conn.execute("SELECT * FROM bounties WHERE id = ?", (bounty_id,)).fetchone() + + +def get_group_bounties(group_id: int) -> list[sqlite3.Row]: + with get_conn() as conn: + return list(conn.execute( + "SELECT * FROM bounties WHERE group_id = ? ORDER BY created_at DESC", + (group_id,), + )) + + +def get_user_personal_bounties(user_id: int) -> list[sqlite3.Row]: + """Bounties created by user in DM (group_id IS NULL).""" + with get_conn() as conn: + return list(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[sqlite3.Row]: + with get_conn() as conn: + return list(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[sqlite3.Row]: + """Tracked bounties where group_id IS NULL (personal).""" + with get_conn() as conn: + return list(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[sqlite3.Row]: + """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 list(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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee7d602 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-telegram-bot==21.6 +dateparser==1.2.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..8d069cb --- /dev/null +++ b/schema.sql @@ -0,0 +1,49 @@ +-- 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) +);