diff --git a/AGENTS.md b/AGENTS.md index 7f7bcb6..42e418c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,170 @@ -# Repository Guidelines +# AGENTS.md -## NixOS Module Notes +This file is the operating guide for coding agents working in this repository. -- `services.readlater-bot.settings` is rendered to TOML without the token; the token must come from `services.readlater-bot.tokenFile` and is combined at runtime in `/run/readlater-bot/config.toml` to keep secrets out of the Nix store. -- If you override `services.readlater-bot.user`/`group`, ensure the group exists; otherwise systemd fails at step GROUP. Defaults only auto-create the `readlater-bot` user/group when you keep the defaults. +## Project Summary -## Build Checks +- Language: Rust (edition 2021). +- Binary crate: `readlater-bot`. +- Main domain: Telegram bot for managing Read Later / Finished entries in markdown files. +- Packaging/deployment: Nix flake + NixOS module (`flake.nix`). +- Runtime model: async bot handlers + serialized filesystem writes + retry queue. -- Run `cargo check` after changes (patches, features, or other code edits). -- Create atomic commits after changes. +## Rule Sources Checked + +- `.cursor/rules/`: not present. +- `.cursorrules`: not present. +- `.github/copilot-instructions.md`: not present. +- Therefore, this `AGENTS.md` is the canonical agent guide. + +## Repository Layout (Current) + +- `src/main.rs`: app wiring, shared types/state, op-application flow. +- `src/message_handlers.rs`: Telegram message and slash-command handling. +- `src/callback_handlers.rs`: callback query handling. +- `src/helpers.rs`: rendering, parsing, utility helpers, retry helpers. +- `src/integrations.rs`: git/sync/yt-dlp/python integration helpers. +- `src/tests.rs`: unit tests. +- `flake.nix`: package and NixOS module definitions. + +## Required Build/Test Commands + +Run these after any meaningful Rust change: + +1. `cargo check` +2. `cargo test` + +These are mandatory project checks for agents. + +## Lint/Format Commands + +Use these when touching multiple files or refactoring: + +- Format: `cargo fmt --all` +- Format check: `cargo fmt --all -- --check` +- Lint: `cargo clippy --all-targets --all-features -- -D warnings` + +Notes: + +- `clippy` may be slower; run at least before opening PRs or large commits. +- If a lint is noisy but valid, prefer code fix over allow attributes. + +## Test Commands (Including Single Test) + +Full suite: + +- `cargo test` + +Run one test by name substring: + +- `cargo test quick_select_index_supports_top_bottom_random` +- `cargo test normalize_markdown_links` + +Run exact test path: + +- `cargo test tests::quick_select_index_supports_top_bottom_random` + +Show logs/output for a test: + +- `cargo test tests::quick_select_index_supports_top_bottom_random -- --nocapture` + +Useful for iterative work: + +- `cargo test -- --test-threads=1` (if debugging order-sensitive behavior) + +## Nix/Packaging Commands + +- Flake checks: `nix flake check` +- Build package output: `nix build .#default` +- Evaluate all systems (optional): `nix flake check --all-systems` + +If prompted, pass `--accept-flake-config` when trusting flake cache settings. + +## Coding Style: Rust + +### Formatting and Structure + +- Follow `rustfmt` defaults; do not hand-format against rustfmt. +- Keep functions focused; extract helper functions instead of deeply nested branches. +- Prefer early returns (`let Some(x) = ... else { ... };`) for control flow clarity. +- Keep lock scope (`Mutex`) as short as practical. + +### Imports + +- Group imports in this order: + 1. `std` + 2. external crates + 3. internal modules (`crate::...` / `super::...`) +- Avoid unused imports; remove during edits. +- Avoid broad/glob imports in new modules unless already established pattern. + +### Naming + +- Types/enums/traits: `UpperCamelCase`. +- Functions/variables/modules: `snake_case`. +- Constants: `SCREAMING_SNAKE_CASE`. +- Use domain terms consistently (`entry`, `session`, `peeked`, `quick_seen`, `undo`). + +### Types and Data Modeling + +- Prefer explicit domain types already in code (`EntryBlock`, `ListSession`, `QueuedOp`). +- Use `Path`/`PathBuf` for filesystem paths, not raw strings. +- Use `Option` for nullable state; avoid sentinel values. +- Keep enums exhaustive and explicit for state machines. + +### Error Handling + +- Use `anyhow::Result` for fallible functions. +- Add context with `.context(...)` / `.with_context(...)` around I/O/process failures. +- Do not use `unwrap`/`expect` in production code paths. +- `unwrap` is acceptable in tests when setup failures should panic. +- On user-facing failures, send concise Telegram feedback and keep logs actionable. + +### Logging + +- Prefer minimal, useful logs (`error!`) for failure points. +- Avoid noisy info/debug logs unless they materially help operations. + +### Async and Concurrency + +- Use `tokio::spawn`/`spawn_blocking` appropriately for blocking tasks. +- Never hold async mutex guards longer than needed. +- Preserve serialized write behavior via existing write lock and queue pattern. + +## Bot UX Conventions to Preserve + +- Ephemeral acknowledgments use `send_ephemeral(...)` and auto-delete. +- Non-ephemeral informational/error messages should include delete-button UX helpers. +- Keep `/help` command list aligned with implemented commands. +- For commands with aliases, keep behavior consistent (for example `/bottom` and `/last`). + +## Data and File Safety + +- Preserve markdown entry boundary behavior and preamble handling. +- Keep line-ending normalization and trailing newline behavior. +- Use existing atomic write helpers; do not replace with direct unsafe writes. +- Preserve dedupe semantics (exact full-block match). + +## NixOS Module Notes (Important) + +- `services.readlater-bot.settings` is rendered to TOML without token. +- Token must come from `services.readlater-bot.tokenFile`. +- Runtime config is assembled at `/run/readlater-bot/config.toml`. +- This avoids writing secrets to the Nix store. +- If overriding `services.readlater-bot.user`/`group`, ensure group exists. +- Defaults only auto-create `readlater-bot` user/group when defaults are kept. + +## Git and Commit Hygiene for Agents + +- Run `cargo check` after edits; run `cargo test` when behavior changes. +- Keep commits atomic and scoped to one logical change. +- Do not commit secrets (tokens, API keys, credentials, local env files). +- Do not include local scratch directories/files (for example `.sync-x-work/`). + +## Change Checklist (Before Finalizing) + +- Code compiles: `cargo check`. +- Tests pass: `cargo test` (or targeted tests + rationale). +- Formatting/lint considered for non-trivial edits. +- `/help` text updated if command surface changed. +- No secrets or unrelated local artifacts included. diff --git a/src/callback_handlers.rs b/src/callback_handlers.rs index 29889e2..13487c0 100644 --- a/src/callback_handlers.rs +++ b/src/callback_handlers.rs @@ -28,6 +28,8 @@ pub(super) async fn handle_callback( handle_undos_callback(bot, q, state).await?; } else if data.starts_with("undo:") { handle_undo_callback(bot, q, state).await?; + } else if data.starts_with("reset:") { + handle_reset_callback(bot, q, state).await?; } } @@ -464,6 +466,41 @@ async fn handle_message_delete_callback(bot: Bot, q: CallbackQuery) -> Result<() Ok(()) } +async fn handle_reset_callback( + bot: Bot, + q: CallbackQuery, + state: std::sync::Arc, +) -> Result<()> { + let Some(data) = q.data.as_deref() else { + return Ok(()); + }; + let scope = data.trim_start_matches("reset:"); + let chat_id = chat_id_from_user_id(q.from.id.0); + + match scope { + "all" => { + reset_all_seen(&state).await; + send_ephemeral(&bot, chat_id, "Reset list and quick seen state.", ACK_TTL_SECS).await?; + } + "list" => { + reset_peeked(&state).await; + send_ephemeral(&bot, chat_id, "Reset list seen state.", ACK_TTL_SECS).await?; + } + "quick" => { + reset_quick_seen(&state).await; + send_ephemeral(&bot, chat_id, "Reset quick seen state.", ACK_TTL_SECS).await?; + } + "cancel" => {} + _ => {} + } + + if let Some(message) = q.message.clone() { + let _ = bot.delete_message(message.chat.id, message.id).await; + } + bot.answer_callback_query(q.id).await?; + Ok(()) +} + async fn handle_list_callback( bot: Bot, q: CallbackQuery, @@ -535,6 +572,27 @@ async fn handle_list_callback( mode, page: page + 1, }; + } else if let (SessionKind::Quick { mode }, ListView::Selected { .. }) = + (session.kind.clone(), session.view.clone()) + { + let quick_seen_snapshot = state.quick_seen.lock().await.clone(); + if let Some(index) = quick_select_index(&session.entries, &quick_seen_snapshot, mode) { + session.view = ListView::Selected { + return_to: Box::new(ListView::Menu), + index, + }; + if let Some(entry) = session.entries.get(index) { + state.quick_seen.lock().await.insert(entry.block_string()); + } + } else { + send_ephemeral( + &bot, + message.chat.id, + "No more unseen quick items. Use /reset.", + ACK_TTL_SECS, + ) + .await?; + } } } "prev" => { @@ -553,7 +611,7 @@ async fn handle_list_callback( }; } "close" => { - if matches!(&session.kind, SessionKind::Search { .. }) { + if matches!(&session.kind, SessionKind::Search { .. } | SessionKind::Quick { .. }) { delete_embedded_media_messages( &bot, message.chat.id, @@ -610,6 +668,27 @@ async fn handle_list_callback( } } } + } else if matches!(&session.kind, SessionKind::Quick { .. }) { + let quick_seen_snapshot = state.quick_seen.lock().await.clone(); + if let Some(index) = + quick_select_index(&session.entries, &quick_seen_snapshot, QuickSelectMode::Random) + { + session.view = ListView::Selected { + return_to: Box::new(ListView::Menu), + index, + }; + if let Some(entry) = session.entries.get(index) { + state.quick_seen.lock().await.insert(entry.block_string()); + } + } else { + send_ephemeral( + &bot, + message.chat.id, + "No unseen quick items. Use /reset.", + ACK_TTL_SECS, + ) + .await?; + } } } "pick" => { diff --git a/src/helpers.rs b/src/helpers.rs index b5ffd54..0672511 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -367,6 +367,17 @@ pub(super) fn build_download_quality_keyboard( InlineKeyboardMarkup::new(rows) } +pub(super) fn build_reset_scope_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("All", "reset:all"), + InlineKeyboardButton::callback("List", "reset:list"), + InlineKeyboardButton::callback("Quick", "reset:quick"), + ], + vec![InlineKeyboardButton::callback("Cancel", "reset:cancel")], + ]) +} + pub(super) fn render_list_view( session_id: &str, session: &ListSession, @@ -420,6 +431,25 @@ pub(super) fn build_menu_view(session_id: &str, session: &ListSession) -> (Strin (text, InlineKeyboardMarkup::new(rows)) } + SessionKind::Quick { mode } => { + let mode_label = match mode { + QuickSelectMode::Top => "top", + QuickSelectMode::Bottom => "bottom", + QuickSelectMode::Random => "random", + }; + let text = format!("Quick view ({})", mode_label); + let rows = vec![ + vec![InlineKeyboardButton::callback( + "Next", + format!("ls:{}:next", session_id), + )], + vec![InlineKeyboardButton::callback( + "Close", + format!("ls:{}:close", session_id), + )], + ]; + (text, InlineKeyboardMarkup::new(rows)) + } SessionKind::Search { query } => { let text = if count == 0 { format!("No matches for \"{}\".", query) @@ -468,6 +498,14 @@ pub(super) fn build_peek_view( let page_display = if total_pages == 0 { 0 } else { page + 1 }; format!("{} (page {})\n", title, page_display) } + SessionKind::Quick { mode } => { + let label = match mode { + QuickSelectMode::Top => "top", + QuickSelectMode::Bottom => "bottom", + QuickSelectMode::Random => "random", + }; + format!("Quick view ({})\n", label) + } SessionKind::Search { query } => { if total_pages > 0 { format!( @@ -526,6 +564,16 @@ pub(super) fn build_peek_view( InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), ]); } + SessionKind::Quick { .. } => { + rows.push(vec![InlineKeyboardButton::callback( + "Next", + format!("ls:{}:next", session_id), + )]); + rows.push(vec![InlineKeyboardButton::callback( + "Close", + format!("ls:{}:close", session_id), + )]); + } SessionKind::Search { .. } => { rows.push(vec![InlineKeyboardButton::callback( "Close", @@ -572,6 +620,26 @@ pub(super) fn build_selected_view( format!("ls:{}:back", session_id), )], ], + SessionKind::Quick { .. } => vec![ + vec![ + InlineKeyboardButton::callback( + "Mark Finished", + format!("ls:{}:finish", session_id), + ), + InlineKeyboardButton::callback( + "Add Resource", + format!("ls:{}:resource", session_id), + ), + ], + vec![ + InlineKeyboardButton::callback("Delete", format!("ls:{}:delete", session_id)), + InlineKeyboardButton::callback("Random", format!("ls:{}:random", session_id)), + ], + vec![InlineKeyboardButton::callback( + "Next", + format!("ls:{}:next", session_id), + )], + ], SessionKind::Search { .. } => vec![ vec![InlineKeyboardButton::callback( "Add Resource", @@ -719,6 +787,7 @@ pub(super) fn count_visible_entries(session: &ListSession, peeked: &HashSet session.entries.len(), SessionKind::List => count_unpeeked_entries(&session.entries, peeked), + SessionKind::Quick { .. } => session.entries.len(), } } @@ -787,6 +856,7 @@ pub(super) fn peek_indices_for_session( match session.kind { SessionKind::Search { .. } => peek_indices_all(&session.entries, mode, page), SessionKind::List => peek_indices(&session.entries, peeked, mode, page), + SessionKind::Quick { .. } => peek_indices_all(&session.entries, mode, page), } } @@ -908,6 +978,16 @@ pub(super) async fn reset_peeked(state: &std::sync::Arc) { peeked.clear(); } +pub(super) async fn reset_quick_seen(state: &std::sync::Arc) { + let mut quick_seen = state.quick_seen.lock().await; + quick_seen.clear(); +} + +pub(super) async fn reset_all_seen(state: &std::sync::Arc) { + reset_peeked(state).await; + reset_quick_seen(state).await; +} + pub(super) async fn add_undo( state: &std::sync::Arc, kind: UndoKind, @@ -1518,18 +1598,27 @@ pub(super) fn parse_command(text: &str) -> Option<&str> { Some(cmd.split('@').next().unwrap_or(cmd)) } -pub(super) fn quick_select_index(entries_len: usize, mode: QuickSelectMode) -> Option { - if entries_len == 0 { +pub(super) fn quick_select_index( + entries: &[EntryBlock], + quick_seen: &HashSet, + mode: QuickSelectMode, +) -> Option { + let mut unseen_indices: Vec = entries + .iter() + .enumerate() + .filter(|(_, entry)| !quick_seen.contains(&entry.block_string())) + .map(|(idx, _)| idx) + .collect(); + if unseen_indices.is_empty() { return None; } match mode { - QuickSelectMode::Top => Some(0), - QuickSelectMode::Last => Some(entries_len - 1), + QuickSelectMode::Top => unseen_indices.first().copied(), + QuickSelectMode::Bottom => unseen_indices.last().copied(), QuickSelectMode::Random => { - let mut indices: Vec = (0..entries_len).collect(); let mut rng = rand::thread_rng(); - indices.shuffle(&mut rng); - indices.first().copied() + unseen_indices.shuffle(&mut rng); + unseen_indices.first().copied() } } } diff --git a/src/main.rs b/src/main.rs index c586320..922401d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -297,6 +297,7 @@ struct UndoSession { #[derive(Clone, Debug)] enum SessionKind { List, + Quick { mode: QuickSelectMode }, Search { query: String }, } @@ -344,7 +345,7 @@ enum ListMode { #[derive(Clone, Debug, Copy)] enum QuickSelectMode { Top, - Last, + Bottom, Random, } @@ -354,6 +355,7 @@ struct AppState { sessions: Mutex>, active_sessions: Mutex>, peeked: Mutex>, + quick_seen: Mutex>, undo_sessions: Mutex>, pickers: Mutex>, add_prompts: Mutex>, @@ -402,6 +404,7 @@ async fn main() -> Result<()> { sessions: Mutex::new(HashMap::new()), active_sessions: Mutex::new(HashMap::new()), peeked: Mutex::new(HashSet::new()), + quick_seen: Mutex::new(HashSet::new()), undo_sessions: Mutex::new(HashMap::new()), pickers: Mutex::new(HashMap::new()), add_prompts: Mutex::new(HashMap::new()), @@ -591,4 +594,3 @@ async fn queue_op(state: &std::sync::Arc, op: QueuedOp) -> Result<()> queue.push(op); save_queue(&state.queue_path, &queue) } - diff --git a/src/message_handlers.rs b/src/message_handlers.rs index 17a9b67..78efddf 100644 --- a/src/message_handlers.rs +++ b/src/message_handlers.rs @@ -131,7 +131,7 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc .trim(); match cmd { "start" | "help" => { - let help = "Send any text to save it. Commands: /start, /help, /add , /list, /top, /last, /random, /search , /delete , /download [url], /undos, /reset_peeked, /pull, /pull theirs, /push, /sync, /sync_x. Use --- to split a message into multiple items. In list views, use buttons for Mark Finished, Add Resource, Delete, Random. Quick actions: reply with del/delete to remove the current item, or send norm to normalize links."; + let help = "Send any text to save it. Commands: /start, /help, /add , /list, /top, /bottom (or /last), /random, /search , /delete , /download [url], /undos, /reset, /pull, /pull theirs, /push, /sync, /sync_x. Use --- to split a message into multiple items. In list views, use buttons for Mark Finished, Add Resource, Delete, Random. Quick actions: reply with del/delete to remove the current item, or send norm to normalize links."; send_message_with_delete_button(&bot, msg.chat.id, help).await?; return Ok(()); } @@ -169,12 +169,23 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc let _ = bot.delete_message(msg.chat.id, msg.id).await; return Ok(()); } + "bottom" => { + handle_quick_select_command( + bot.clone(), + msg.clone(), + state, + QuickSelectMode::Bottom, + ) + .await?; + let _ = bot.delete_message(msg.chat.id, msg.id).await; + return Ok(()); + } "last" => { handle_quick_select_command( bot.clone(), msg.clone(), state, - QuickSelectMode::Last, + QuickSelectMode::Bottom, ) .await?; let _ = bot.delete_message(msg.chat.id, msg.id).await; @@ -196,8 +207,8 @@ pub(super) async fn handle_message(bot: Bot, msg: Message, state: std::sync::Arc let _ = bot.delete_message(msg.chat.id, msg.id).await; return Ok(()); } - "reset_peeked" => { - reset_peeked(&state).await; + "reset" | "reset_peeked" => { + handle_reset_command(&bot, msg.chat.id).await?; let _ = bot.delete_message(msg.chat.id, msg.id).await; return Ok(()); } @@ -599,8 +610,14 @@ async fn handle_quick_select_command( mode: QuickSelectMode, ) -> Result<()> { let entries = read_entries(&state.config.read_later_path)?.1; - let Some(index) = quick_select_index(entries.len(), mode) else { - send_ephemeral(&bot, msg.chat.id, "Read Later is empty.", ACK_TTL_SECS).await?; + let quick_seen_snapshot = state.quick_seen.lock().await.clone(); + let Some(index) = quick_select_index(&entries, &quick_seen_snapshot, mode) else { + let empty_text = if entries.is_empty() { + "Read Later is empty." + } else { + "Everything in quick view is already seen. Use /reset." + }; + send_ephemeral(&bot, msg.chat.id, empty_text, ACK_TTL_SECS).await?; return Ok(()); }; @@ -608,7 +625,7 @@ async fn handle_quick_select_command( let mut session = ListSession { id: session_id.clone(), chat_id: msg.chat.id.0, - kind: SessionKind::List, + kind: SessionKind::Quick { mode }, entries, view: ListView::Selected { return_to: Box::new(ListView::Menu), @@ -619,11 +636,8 @@ async fn handle_quick_select_command( sent_media_message_ids: Vec::new(), }; - if matches!(mode, QuickSelectMode::Random) { - session.seen_random.insert(index); - } if let Some(entry) = session.entries.get(index) { - state.peeked.lock().await.insert(entry.block_string()); + state.quick_seen.lock().await.insert(entry.block_string()); } let peeked_snapshot = state.peeked.lock().await.clone(); @@ -649,6 +663,13 @@ async fn handle_quick_select_command( Ok(()) } +async fn handle_reset_command(bot: &Bot, chat_id: ChatId) -> Result<()> { + let text = "Reset seen state for which scope?"; + let kb = build_reset_scope_keyboard(); + bot.send_message(chat_id, text).reply_markup(kb).await?; + Ok(()) +} + async fn handle_search_command( bot: Bot, msg: Message, diff --git a/src/tests.rs b/src/tests.rs index f006694..b951fde 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -301,12 +301,32 @@ fn command_keywords_are_case_insensitive() { } #[test] -fn quick_select_index_supports_top_last_random() { - assert_eq!(quick_select_index(0, QuickSelectMode::Top), None); - assert_eq!(quick_select_index(4, QuickSelectMode::Top), Some(0)); - assert_eq!(quick_select_index(4, QuickSelectMode::Last), Some(3)); - let random = quick_select_index(4, QuickSelectMode::Random).unwrap(); +fn quick_select_index_supports_top_bottom_random() { + let entries: Vec = (0..4).map(|i| entry(&format!("item {}", i))).collect(); + let seen = HashSet::new(); + assert_eq!(quick_select_index(&[], &seen, QuickSelectMode::Top), None); + assert_eq!( + quick_select_index(&entries, &seen, QuickSelectMode::Top), + Some(0) + ); + assert_eq!( + quick_select_index(&entries, &seen, QuickSelectMode::Bottom), + Some(3) + ); + let random = quick_select_index(&entries, &seen, QuickSelectMode::Random).unwrap(); assert!(random < 4); + + let mut seen_some = HashSet::new(); + seen_some.insert(entries[0].block_string()); + seen_some.insert(entries[3].block_string()); + assert_eq!( + quick_select_index(&entries, &seen_some, QuickSelectMode::Top), + Some(1) + ); + assert_eq!( + quick_select_index(&entries, &seen_some, QuickSelectMode::Bottom), + Some(2) + ); } #[test]