Update quick command flow and reset scopes

Make /top, /bottom(/last), and /random use a separate single-item quick session with independent seen tracking and next navigation. Add /reset scope selection (all/list/quick) and expand AGENTS.md with build, test, lint, and style guidance for coding agents.
This commit is contained in:
TheGeneralist 2026-03-03 08:54:28 +01:00
parent 35b234c897
commit e1afcc5148
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
6 changed files with 403 additions and 33 deletions

173
AGENTS.md
View file

@ -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<T>` 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.

View file

@ -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<AppState>,
) -> 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" => {

View file

@ -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<Stri
match session.kind {
SessionKind::Search { .. } => 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<AppState>) {
peeked.clear();
}
pub(super) async fn reset_quick_seen(state: &std::sync::Arc<AppState>) {
let mut quick_seen = state.quick_seen.lock().await;
quick_seen.clear();
}
pub(super) async fn reset_all_seen(state: &std::sync::Arc<AppState>) {
reset_peeked(state).await;
reset_quick_seen(state).await;
}
pub(super) async fn add_undo(
state: &std::sync::Arc<AppState>,
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<usize> {
if entries_len == 0 {
pub(super) fn quick_select_index(
entries: &[EntryBlock],
quick_seen: &HashSet<String>,
mode: QuickSelectMode,
) -> Option<usize> {
let mut unseen_indices: Vec<usize> = 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<usize> = (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()
}
}
}

View file

@ -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<HashMap<String, ListSession>>,
active_sessions: Mutex<HashMap<i64, String>>,
peeked: Mutex<HashSet<String>>,
quick_seen: Mutex<HashSet<String>>,
undo_sessions: Mutex<HashMap<String, UndoSession>>,
pickers: Mutex<HashMap<String, PickerState>>,
add_prompts: Mutex<HashMap<String, AddPrompt>>,
@ -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<AppState>, op: QueuedOp) -> Result<()>
queue.push(op);
save_queue(&state.queue_path, &queue)
}

View file

@ -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 <text>, /list, /top, /last, /random, /search <query>, /delete <query>, /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 <text>, /list, /top, /bottom (or /last), /random, /search <query>, /delete <query>, /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,

View file

@ -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<EntryBlock> = (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]