Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand Down
32 changes: 31 additions & 1 deletion src/drive_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions src/recap.py
Original file line number Diff line number Diff line change
@@ -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 (<!-- msg_id: 123 -->).
_BLOCK_RE = re.compile(r"<!-- msg_id: \d+ -->\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)
23 changes: 23 additions & 0 deletions tests/test_recap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from recap import parse_message_blocks


def test_parses_blocks_dropping_header():
content = (
"# Telegram Messages\n\n"
"<!-- msg_id: 1 -->\nbuy milk\n"
"<!-- msg_id: 2 -->\ncall the dentist\n"
)
assert parse_message_blocks(content) == ["buy milk", "call the dentist"]


def test_preserves_multiline_note():
content = (
"# Telegram Messages\n\n"
"<!-- msg_id: 7 -->\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") == []
Loading