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.
|
||||
- **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. |
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue