commit be5fe04a9006e5c3719d47830b0b8a2d58f28dc0 Author: shokollm <270575765+shokollm@users.noreply.github.com> Date: Wed Apr 1 07:38:18 2026 +0000 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 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) +);