diff --git a/src/bot.py b/src/bot.py index 3ba3551..cb72442 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 recap import today_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" + "/today - Recap today's captured notes\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" + "/today - Recap today's captured notes\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("today", today_command)) application.add_handler(build_timebox_handler()) diff --git a/src/drive_handler.py b/src/drive_handler.py index bc55924..ae189dc 100644 --- a/src/drive_handler.py +++ b/src/drive_handler.py @@ -5,7 +5,7 @@ import logging import re -from datetime import datetime +from datetime import datetime, timedelta from googleapiclient.discovery import build from googleapiclient.http import MediaInMemoryUpload @@ -250,6 +250,36 @@ def _replace_message_content( return file_content[:header_start] + new_block + file_content[content_end:] +def find_daily_file(service, folder_id: str, day_cutoff_hour: int = 0) -> str | None: + """Return today's markdown file id (read-only), or None if it doesn't exist. + + Mirrors get_or_create_markdown_file's day-cutoff logic but never creates a + file — used by read-only commands like /today. + """ + now = datetime.now() + if day_cutoff_hour > 0 and now.hour < day_cutoff_hour: + target_date = (now - timedelta(days=1)).date() + else: + target_date = now.date() + file_name = target_date.strftime('%Y-%m-%d') + '.md' + try: + results = service.files().list( + q=f"name='{file_name}' and mimeType='{MARKDOWN_MIME_TYPE}' and trashed=false and parents='{folder_id}'", + spaces='drive', + fields='files(id, name)', + ).execute() + files = results.get('files', []) + return files[0]['id'] if files else None + except Exception as e: + logger.error(f"Failed to find daily file {file_name}: {e}") + return None + + +def read_file(service, file_id: str) -> str | None: + """Public read of a Drive file's text content, or None on failure.""" + return _download_file_content(service, file_id) + + def _download_file_content(service, file_id: str) -> str | None: """Download a file's content from Drive.""" try: diff --git a/src/recap.py b/src/recap.py new file mode 100644 index 0000000..c201655 --- /dev/null +++ b/src/recap.py @@ -0,0 +1,108 @@ +"""/today recap: read back the day's captured notes with an LLM TL;DR. + +Read-only counterpart to the capture pipeline — closes the capture→review loop. +Thin Telegram glue; Drive I/O lives in drive_handler, the LLM in scheduler. +""" + +import asyncio +import logging +import re +from functools import lru_cache + +from telegram import Update +from telegram.ext import ContextTypes + +from config import config +import drive_handler +import scheduler + +logger = logging.getLogger(__name__) + +# Splits a captured file on its message markers (). +_BLOCK_RE = re.compile(r"\n") + +_SUMMARY_SYSTEM = ( + "You are the user's second-brain assistant. In 1-2 short sentences, " + "summarize today's captured notes: surface the main themes and any action " + "items. Be concise and concrete. If the notes are trivial, say so briefly." +) + +# Keep the reply under Telegram's hard limit with room for the header/summary. +_MAX_REPLY = 3900 + + +def parse_message_blocks(content: str) -> list[str]: + """Return captured note texts in order, stripped of the msg_id markers. + + The first split fragment is the file header before any message, so it is + dropped. Pure function — unit-tested without Drive or config. + """ + parts = _BLOCK_RE.split(content) + return [p.strip() for p in parts[1:] if p.strip()] + + +@lru_cache(maxsize=1) +def _llm(): + return scheduler.create_llm( + api_key=config.openrouter_api_key, model=config.timebox_llm_model + ) + + +def _summarize(notes: list[str]) -> str | None: + """One-paragraph LLM TL;DR of the notes, or None if the call fails.""" + try: + body = "\n".join(f"- {n}" for n in notes) + resp = _llm().invoke([("system", _SUMMARY_SYSTEM), ("human", body)]) + text = (getattr(resp, "content", "") or "").strip() + return text or None + except Exception as e: + logger.warning(f"Recap summary failed: {e}") + return None + + +async def today_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /today - reply with today's captured notes plus an LLM TL;DR.""" + 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 + + service = drive_handler.get_drive_service(user_id, token_storage) + if not service: + await update.message.reply_text( + "Your Google Drive session expired — please /logout then /authenticate." + ) + return + + folder_id = drive_handler.get_or_create_folder(service, config.drive_folder_name) + if not folder_id: + await update.message.reply_text( + "Couldn't reach your Drive folder — please try again later." + ) + return + + file_id = drive_handler.find_daily_file(service, folder_id, config.day_cutoff_hour) + content = drive_handler.read_file(service, file_id) if file_id else None + notes = parse_message_blocks(content) if content else [] + + if not notes: + await update.message.reply_text("Nothing captured yet today.") + return + + # LLM call is sync; keep it off the event loop. Summary is best-effort. + summary = await asyncio.to_thread(_summarize, notes) + + lines = [f"📋 Today — {len(notes)} note(s)", ""] + if summary: + lines += [f"TL;DR: {summary}", ""] + for n in notes: + lines.append(f"• {n}") + + reply = "\n".join(lines) + if len(reply) > _MAX_REPLY: + reply = reply[:_MAX_REPLY] + "\n…(truncated)" + await update.message.reply_text(reply) diff --git a/tests/test_recap.py b/tests/test_recap.py new file mode 100644 index 0000000..661d035 --- /dev/null +++ b/tests/test_recap.py @@ -0,0 +1,23 @@ +from recap import parse_message_blocks + + +def test_parses_blocks_dropping_header(): + content = ( + "# Telegram Messages\n\n" + "\nbuy milk\n" + "\ncall the dentist\n" + ) + assert parse_message_blocks(content) == ["buy milk", "call the dentist"] + + +def test_preserves_multiline_note(): + content = ( + "# Telegram Messages\n\n" + "\nline one\nline two\n" + ) + assert parse_message_blocks(content) == ["line one\nline two"] + + +def test_empty_or_header_only_returns_nothing(): + assert parse_message_blocks("") == [] + assert parse_message_blocks("# Telegram Messages\n\n") == []