From c1da29bf1f3627c7789a56221b2ff7aa6ebdd0aa Mon Sep 17 00:00:00 2001 From: cristoforows Date: Thu, 25 Jun 2026 02:33:28 +0800 Subject: [PATCH] add /remind: one-off Telegram reminders via the job queue New reminders.py command (auth-gated). Parses ' ' (30m/2h/ 1d, bare number = minutes), schedules a job_queue.run_once nudge, and supports /remind list for pending ones. Reminders are in-memory (lost on restart). Closes #13. --- src/bot.py | 4 ++ src/reminders.py | 133 ++++++++++++++++++++++++++++++++++++++++ tests/test_reminders.py | 34 ++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/reminders.py create mode 100644 tests/test_reminders.py diff --git a/src/bot.py b/src/bot.py index 3ba3551..ec9009c 100644 --- a/src/bot.py +++ b/src/bot.py @@ -15,6 +15,7 @@ from config import config from google_auth import generate_auth_url, TokenStorage from timebox import build_timebox_handler +from reminders import remind_command import drive_handler # Configure logging @@ -38,6 +39,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N "You are authenticated with Google Drive.\n" "Send me any message and I'll save it to your Drive.\n\n" "Commands:\n" + "/remind - Set a reminder (e.g. /remind 30m water)\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -69,6 +71,7 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No "Edit a message here and it updates in Drive too.\n\n" "Commands:\n" "/authenticate - Connect your Google Drive\n" + "/remind - Set a reminder (e.g. /remind 30m water)\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -309,6 +312,7 @@ def register_handlers(application: Application) -> None: application.add_handler(CommandHandler("authenticate", authenticate_command)) application.add_handler(CommandHandler("status", status_command)) application.add_handler(CommandHandler("logout", logout_command)) + application.add_handler(CommandHandler("remind", remind_command)) application.add_handler(build_timebox_handler()) diff --git a/src/reminders.py b/src/reminders.py new file mode 100644 index 0000000..690c67d --- /dev/null +++ b/src/reminders.py @@ -0,0 +1,133 @@ +"""/remind: schedule one-off Telegram nudges via the PTB job queue. + +Thin Telegram glue. The duration parser is a pure function so it can be +unit-tested without a running bot. Reminders are in-memory (held by the job +queue) and do not survive a process restart — acceptable for a personal bot. +""" + +import logging +import re +from telegram import Update +from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + +# A leading duration token: "30m", "2h", "1d", or a bare number (= minutes). +_DURATION_RE = re.compile( + r"^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)?$", re.IGNORECASE +) +_UNIT_SECONDS = { + "s": 1, "sec": 1, "secs": 1, + "m": 60, "min": 60, "mins": 60, + "h": 3600, "hr": 3600, "hrs": 3600, + "d": 86400, "day": 86400, "days": 86400, +} +_MAX_SECONDS = 30 * 86400 # cap reminders at 30 days out + +_USAGE = ( + "Usage: /remind \n" + "e.g. /remind 30m drink water\n" + " /remind 2h call the dentist\n" + " /remind 1d renew passport\n" + "(bare number = minutes; units: s, m, h, d)\n\n" + "/remind list — show pending reminders" +) + + +def parse_reminder(arg: str) -> tuple[int, str] | None: + """Parse ' ' into (delay_seconds, text), or None if invalid. + + A bare leading number is interpreted as minutes. Returns None when the + duration is missing/zero/over the cap, or when no reminder text follows. + """ + arg = (arg or "").strip() + if not arg: + return None + parts = arg.split(maxsplit=1) + m = _DURATION_RE.match(parts[0]) + if not m: + return None + qty = int(m.group(1)) + if qty <= 0: + return None + unit = (m.group(2) or "m").lower() + seconds = qty * _UNIT_SECONDS[unit] + if seconds > _MAX_SECONDS: + return None + text = parts[1].strip() if len(parts) > 1 else "" + if not text: + return None + return seconds, text + + +def humanize(seconds: int) -> str: + """Render a delay in seconds as a short human phrase (e.g. '2h 30m').""" + units = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)) + out = [] + for label, size in units: + if seconds >= size: + out.append(f"{seconds // size}{label}") + seconds %= size + return " ".join(out) or "0s" + + +async def _fire_reminder(context: ContextTypes.DEFAULT_TYPE) -> None: + """Job callback: deliver the reminder text back to the chat.""" + job = context.job + await context.bot.send_message(chat_id=job.chat_id, text=f"⏰ Reminder: {job.data}") + + +async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /remind - schedule a nudge, or list pending ones.""" + token_storage = context.bot_data.get("token_storage") + user_id = update.effective_user.id + if not token_storage or not token_storage.is_authenticated(user_id): + await update.message.reply_text( + "Please authenticate with Google Drive first using /authenticate" + ) + return + + arg = " ".join(context.args) if context.args else "" + if arg.strip().lower() == "list": + await _list_reminders(update, context) + return + + parsed = parse_reminder(arg) + if not parsed: + await update.message.reply_text(_USAGE) + return + + if context.job_queue is None: + await update.message.reply_text( + "Reminders aren't available right now — please try again later." + ) + return + + seconds, text = parsed + chat_id = update.effective_chat.id + context.job_queue.run_once( + _fire_reminder, when=seconds, chat_id=chat_id, data=text, + name=f"reminder:{chat_id}:{update.message.message_id}", + ) + logger.info(f"Reminder scheduled for user {user_id} in {seconds}s") + await update.message.reply_text(f"⏰ Got it — I'll remind you in {humanize(seconds)}: {text}") + + +async def _list_reminders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Reply with this chat's pending reminders, soonest first.""" + chat_id = update.effective_chat.id + if context.job_queue is None: + await update.message.reply_text("Reminders aren't available right now.") + return + jobs = [ + j for j in context.job_queue.jobs() + if (j.name or "").startswith(f"reminder:{chat_id}:") and j.next_t + ] + if not jobs: + await update.message.reply_text("No pending reminders.") + return + jobs.sort(key=lambda j: j.next_t) + lines = ["⏰ Pending reminders:"] + for j in jobs: + lines.append(f"• {j.next_t:%H:%M %d %b} — {j.data}") + await update.message.reply_text("\n".join(lines)) diff --git a/tests/test_reminders.py b/tests/test_reminders.py new file mode 100644 index 0000000..599d285 --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,34 @@ +import pytest + +from reminders import parse_reminder, humanize + + +@pytest.mark.parametrize("arg,expected", [ + ("30m drink water", (1800, "drink water")), + ("2h call the dentist", (7200, "call the dentist")), + ("1d renew passport", (86400, "renew passport")), + ("90 standup", (5400, "standup")), # bare number = minutes + ("45s ping", (45, "ping")), + ("1h spaced out ", (3600, "spaced out")), +]) +def test_parse_valid(arg, expected): + assert parse_reminder(arg) == expected + + +@pytest.mark.parametrize("arg", [ + "", # empty + "drink water", # no duration + "30m", # no text + "0m nothing", # zero + "31d too far", # over the 30-day cap + "abc do thing", # unparseable duration +]) +def test_parse_invalid(arg): + assert parse_reminder(arg) is None + + +def test_humanize(): + assert humanize(45) == "45s" + assert humanize(1800) == "30m" + assert humanize(9000) == "2h 30m" + assert humanize(90000) == "1d 1h"