feat: add interactive pagination buttons, model autocomplete, and web links
This commit is contained in:
parent
da20cb537b
commit
85bc9e62f6
3 changed files with 214 additions and 7 deletions
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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`).
|
- **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 |
|
| Command | Scope | Description |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `/models [search] [page]` | Public / Ephemeral | Browse all currently available free models in OpenRouter's catalog with pagination and optional search filter. |
|
| `/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, active search queries, and token constraints. |
|
| `/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 for an exact model ID (context window length, full description, prompt/completion token pricing). |
|
| `/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. |
|
| `/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. |
|
| `/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. |
|
| `/stats` | Ephemeral | View catalog freshness, last successful fetch timestamp, polling interval, and complete server subscription settings. |
|
||||||
|
|
|
||||||
175
src/main.rs
175
src/main.rs
|
|
@ -43,17 +43,132 @@ async fn reply(ctx: Context<'_>, message: serenity::CreateEmbed) -> Result<(), E
|
||||||
visible_reply(ctx, message, false).await
|
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> {
|
async fn browse(ctx: Context<'_>, options: Browse, public: bool) -> Result<(), Error> {
|
||||||
if public {
|
if public {
|
||||||
ctx.defer().await?;
|
ctx.defer().await?;
|
||||||
} else {
|
} else {
|
||||||
ctx.defer_ephemeral().await?;
|
ctx.defer_ephemeral().await?;
|
||||||
}
|
}
|
||||||
let result = {
|
let (result, total_pages) = {
|
||||||
let guard = ctx.data().store.lock().await;
|
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)]
|
#[poise::command(slash_command)]
|
||||||
|
|
@ -108,10 +223,35 @@ async fn status(
|
||||||
.await
|
.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)]
|
#[poise::command(slash_command)]
|
||||||
async fn model(
|
async fn model(
|
||||||
ctx: Context<'_>,
|
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 = "Hide the description (default false)"] compact: Option<bool>,
|
||||||
#[description = "Include raw pricing (default true)"] show_pricing: Option<bool>,
|
#[description = "Include raw pricing (default true)"] show_pricing: Option<bool>,
|
||||||
#[description = "Show details to everyone (default false)"] public: 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]
|
#[test]
|
||||||
fn poll_interval_validation() {
|
fn poll_interval_validation() {
|
||||||
assert_eq!(parse_poll_seconds(None).unwrap(), 600);
|
assert_eq!(parse_poll_seconds(None).unwrap(), 600);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ pub fn embed(title: &str) -> serenity::CreateEmbed {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub struct Browse {
|
pub struct Browse {
|
||||||
pub free_only: bool,
|
pub free_only: bool,
|
||||||
pub search: String,
|
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 {
|
pub fn model_line(model: &Model, compact: bool) -> String {
|
||||||
let pricing = if model.is_free() {
|
let pricing = if model.is_free() {
|
||||||
"Free"
|
"Free"
|
||||||
|
|
@ -137,6 +154,14 @@ pub fn model_details(model: &Model, compact: bool, show_pricing: bool) -> sereni
|
||||||
},
|
},
|
||||||
true,
|
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 {
|
if !compact {
|
||||||
result = result.description(text(&model.description, 2000));
|
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("b/paid"));
|
||||||
assert!(!value["description"].as_str().unwrap().contains("a/free"));
|
assert!(!value["description"].as_str().unwrap().contains("a/free"));
|
||||||
assert_eq!(value["fields"][2]["value"], "2/3 · 1 per page");
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue