diff --git a/README.md b/README.md index 3fd5673..44d8a7d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ A lightweight, crash-resilient Discord bot built in Rust with [poise](https://gi - **Crash-Resilient State:** Subscriptions and catalog states are stored atomically (`.tmp` write followed by atomic filesystem rename) to guarantee state integrity across crashes and container reboots. - **Queue Bounds:** Unreachable alert channels (e.g., deleted channel or revoked bot permissions) are capped at 50 queued events, preventing unbounded state growth. - **Discord API Safety:** Strict UTF-16 character limit enforcement and markdown character escaping prevent broken embeds or Discord 400 Bad Request rejections. +- **Interactive Discord Buttons:** Multi-page `/models` and `/status` views include interactive `[◀ Prev]` and `[Next ▶]` buttons with automatic timeout disabling. +- **Model ID Autocomplete:** Live slash command autocomplete for `/model ` to instantly find any model in the catalog as you type. +- **Direct OpenRouter Web Links:** Embed titles link directly to the model's page on OpenRouter for quick inspection in the browser. - **Dry-Run Diagnostic Mode:** Test public catalog availability and parsing without needing a Discord token (`--check`). --- @@ -30,9 +33,9 @@ All interactions use Discord Slash Commands: | Command | Scope | Description | | :--- | :--- | :--- | -| `/models [search] [page]` | Public / Ephemeral | Browse all currently available free models in OpenRouter's catalog with pagination and optional search filter. | -| `/status [free_only] [search] [min_context] [page] [page_size] [compact] [public]` | Configurable | View a catalog freshness snapshot with model counts, active search queries, and token constraints. | -| `/model [compact] [show_pricing] [public]` | Configurable | Inspect detailed specifications for an exact model ID (context window length, full description, prompt/completion token pricing). | +| `/models [search] [page]` | Public / Ephemeral | Browse all currently available free models in OpenRouter's catalog with interactive `[◀ Prev]` / `[Next ▶]` buttons and search filter. | +| `/status [free_only] [search] [min_context] [page] [page_size] [compact] [public]` | Configurable | View a catalog freshness snapshot with model counts, interactive pagination, and token constraints. | +| `/model [compact] [show_pricing] [public]` | Configurable | Inspect detailed specifications with live model ID autocomplete and direct clickable links to OpenRouter. | | `/watch [role] [free_only] [search] [min_context] [ping_enabled] [compact] [clear_role]` | Manage Server | Set up or update automatic catalog release alerts in the current channel. Omitted options retain existing settings. | | `/unwatch` | Manage Server | Remove the server's subscription and delete all queued pending alerts. | | `/stats` | Ephemeral | View catalog freshness, last successful fetch timestamp, polling interval, and complete server subscription settings. | diff --git a/src/main.rs b/src/main.rs index 60be13f..143341e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,17 +43,132 @@ async fn reply(ctx: Context<'_>, message: serenity::CreateEmbed) -> Result<(), E visible_reply(ctx, message, false).await } +fn make_nav_row( + prev_id: &str, + next_id: &str, + current: usize, + total: usize, + disabled: bool, +) -> Vec { + vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new(prev_id) + .label("◀ Prev") + .style(serenity::ButtonStyle::Secondary) + .disabled(disabled || current <= 1), + serenity::CreateButton::new(next_id) + .label("Next ▶") + .style(serenity::ButtonStyle::Primary) + .disabled(disabled || current >= total), + ])] +} + async fn browse(ctx: Context<'_>, options: Browse, public: bool) -> Result<(), Error> { if public { ctx.defer().await?; } else { ctx.defer_ephemeral().await?; } - let result = { + let (result, total_pages) = { let guard = ctx.data().store.lock().await; - presentation::snapshot(&guard.state, &options, ctx.data().poll_seconds) + let pages = presentation::total_pages(&guard.state, &options); + let embed = presentation::snapshot(&guard.state, &options, ctx.data().poll_seconds); + (embed, pages) }; - visible_reply(ctx, result, public).await + + if total_pages <= 1 { + return visible_reply(ctx, result, public).await; + } + + let ctx_id = ctx.id(); + let prev_id = format!("{ctx_id}:prev"); + let next_id = format!("{ctx_id}:next"); + let mut current_page = (options.page as usize).clamp(1, total_pages); + + let initial = response(result, public).components(make_nav_row( + &prev_id, + &next_id, + current_page, + total_pages, + false, + )); + let reply_handle = ctx.send(initial).await?; + + let author_id = ctx.author().id; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + .filter(move |press| press.data.custom_id.starts_with(&format!("{ctx_id}:"))) + .timeout(Duration::from_secs(300)) + .await + { + if press.user.id != author_id { + let _ = press + .create_response( + ctx.serenity_context(), + serenity::CreateInteractionResponse::Acknowledge, + ) + .await; + continue; + } + + if press.data.custom_id == next_id { + if current_page < total_pages { + current_page += 1; + } + } else if press.data.custom_id == prev_id { + if current_page > 1 { + current_page -= 1; + } + } else { + continue; + } + + let mut next_options = options.clone(); + next_options.page = current_page as u32; + + let next_embed = { + let guard = ctx.data().store.lock().await; + presentation::snapshot(&guard.state, &next_options, ctx.data().poll_seconds) + }; + + let updated = serenity::CreateInteractionResponseMessage::new() + .embed(next_embed) + .components(make_nav_row( + &prev_id, + &next_id, + current_page, + total_pages, + false, + )); + + let _ = press + .create_response( + ctx.serenity_context(), + serenity::CreateInteractionResponse::UpdateMessage(updated), + ) + .await; + } + + let final_embed = { + let guard = ctx.data().store.lock().await; + let mut final_options = options.clone(); + final_options.page = current_page as u32; + presentation::snapshot(&guard.state, &final_options, ctx.data().poll_seconds) + }; + let _ = reply_handle + .edit( + ctx, + poise::CreateReply::default() + .embed(final_embed) + .components(make_nav_row( + &prev_id, + &next_id, + current_page, + total_pages, + true, + )), + ) + .await; + + Ok(()) } #[poise::command(slash_command)] @@ -108,10 +223,35 @@ async fn status( .await } +async fn autocomplete_model_id<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> serenity::CreateAutocompleteResponse { + let guard = ctx.data().store.lock().await; + let query = partial.trim().to_lowercase(); + let choices: Vec<_> = guard + .state + .catalog + .keys() + .filter(|id| { + if query.is_empty() { + true + } else { + id.to_lowercase().contains(&query) + } + }) + .take(25) + .map(|id| serenity::AutocompleteChoice::from(id.as_str())) + .collect(); + serenity::CreateAutocompleteResponse::new().set_choices(choices) +} + #[poise::command(slash_command)] async fn model( ctx: Context<'_>, - #[description = "Exact model ID from /models or /status"] id: String, + #[description = "Exact model ID from /models or /status"] + #[autocomplete = "autocomplete_model_id"] + id: String, #[description = "Hide the description (default false)"] compact: Option, #[description = "Include raw pricing (default true)"] show_pricing: Option, #[description = "Show details to everyone (default false)"] public: Option, @@ -1221,6 +1361,33 @@ mod tests { } } + #[test] + fn nav_row_button_states() { + let row_p1 = make_nav_row("prev", "next", 1, 3, false); + let val_p1 = serde_json::to_value(&row_p1).unwrap(); + let buttons_p1 = &val_p1[0]["components"]; + assert_eq!(buttons_p1[0]["disabled"], true); + assert_eq!(buttons_p1[1]["disabled"], false); + + let row_p2 = make_nav_row("prev", "next", 2, 3, false); + let val_p2 = serde_json::to_value(&row_p2).unwrap(); + let buttons_p2 = &val_p2[0]["components"]; + assert_eq!(buttons_p2[0]["disabled"], false); + assert_eq!(buttons_p2[1]["disabled"], false); + + let row_p3 = make_nav_row("prev", "next", 3, 3, false); + let val_p3 = serde_json::to_value(&row_p3).unwrap(); + let buttons_p3 = &val_p3[0]["components"]; + assert_eq!(buttons_p3[0]["disabled"], false); + assert_eq!(buttons_p3[1]["disabled"], true); + + let row_dis = make_nav_row("prev", "next", 2, 3, true); + let val_dis = serde_json::to_value(&row_dis).unwrap(); + let buttons_dis = &val_dis[0]["components"]; + assert_eq!(buttons_dis[0]["disabled"], true); + assert_eq!(buttons_dis[1]["disabled"], true); + } + #[test] fn poll_interval_validation() { assert_eq!(parse_poll_seconds(None).unwrap(), 600); diff --git a/src/presentation.rs b/src/presentation.rs index 864ee39..597a451 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -46,6 +46,7 @@ pub fn embed(title: &str) -> serenity::CreateEmbed { )) } +#[derive(Clone, Debug)] pub struct Browse { pub free_only: bool, pub search: String, @@ -68,6 +69,22 @@ impl Default for Browse { } } +pub fn total_pages(state: &State, options: &Browse) -> usize { + let query = options.search.to_lowercase(); + let count = state + .catalog + .values() + .filter(|model| { + (!options.free_only || model.is_free()) + && model.context_length >= u64::from(options.min_context) + && (model.id.to_lowercase().contains(&query) + || model.name.to_lowercase().contains(&query)) + }) + .count(); + let size = options.page_size.clamp(1, 10) as usize; + count.div_ceil(size).max(1) +} + pub fn model_line(model: &Model, compact: bool) -> String { let pricing = if model.is_free() { "Free" @@ -137,6 +154,14 @@ pub fn model_details(model: &Model, compact: bool, show_pricing: bool) -> sereni }, true, ); + let web_url = format!("https://openrouter.ai/{}", model.id.trim()); + if web_url.len() <= 2048 + && !model + .id + .contains(|ch: char| ch.is_whitespace() || ch.is_control()) + { + result = result.url(web_url); + } if !compact { result = result.description(text(&model.description, 2000)); } @@ -326,5 +351,17 @@ pub mod tests { assert!(value["description"].as_str().unwrap().contains("b/paid")); assert!(!value["description"].as_str().unwrap().contains("a/free")); assert_eq!(value["fields"][2]["value"], "2/3 · 1 per page"); + assert_eq!(total_pages(&state, &Browse::default()), 1); + let multi_page = Browse { + free_only: false, + page_size: 1, + ..Default::default() + }; + assert_eq!(total_pages(&state, &multi_page), 3); + let empty_filter = Browse { + search: "nonexistent".into(), + ..Default::default() + }; + assert_eq!(total_pages(&state, &empty_filter), 1); } }