feat: add interactive pagination buttons, model autocomplete, and web links

This commit is contained in:
katR 2026-09-27 15:33:49 +05:30
parent da20cb537b
commit 85bc9e62f6
3 changed files with 214 additions and 7 deletions

View file

@ -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 <id>` 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 <id> [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 <id> [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. |

View file

@ -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<serenity::CreateActionRow> {
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<bool>,
#[description = "Include raw pricing (default true)"] show_pricing: Option<bool>,
#[description = "Show details to everyone (default false)"] public: Option<bool>,
@ -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);

View file

@ -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);
}
}