From 6ffdb4ca4f90c9370e9af7f20c2c565ea7924626 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Thu, 17 Sep 2026 12:11:12 +0530 Subject: [PATCH] feat: add presentation formatting, filter options, and testping command --- src/catalog.rs | 13 +- src/main.rs | 1018 +++++++++++++++++++++++++++++++++---------- src/presentation.rs | 330 ++++++++++++++ src/store.rs | 211 ++++++++- 4 files changed, 1327 insertions(+), 245 deletions(-) create mode 100644 src/presentation.rs diff --git a/src/catalog.rs b/src/catalog.rs index e3b6476..ee0a999 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -7,7 +7,7 @@ use crate::Error; const ENDPOINT: &str = "https://openrouter.ai/api/v1/models"; const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Model { pub id: String, pub name: String, @@ -45,7 +45,7 @@ where .collect() } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Event { pub model: Model, pub kind: String, @@ -96,7 +96,7 @@ impl Model { } } -fn validate(catalog: &BTreeMap) -> Result<(), Error> { +pub(crate) fn validate(catalog: &BTreeMap) -> Result<(), Error> { if catalog.is_empty() { return Err("catalog is empty".into()); } @@ -353,4 +353,11 @@ mod tests { fn validate_rejects_empty_catalog() { assert!(validate(&BTreeMap::new()).is_err()); } + + #[test] + fn validate_requires_keys_to_match_model_ids() { + let model = model("example/model", &[("prompt", "0"), ("completion", "0")]); + assert!(validate(&BTreeMap::from([("wrong/key".into(), model.clone())])).is_err()); + assert!(validate(&BTreeMap::from([(model.id.clone(), model)])).is_ok()); + } } diff --git a/src/main.rs b/src/main.rs index a457ba4..8d22c14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ mod catalog; +mod presentation; mod store; use catalog::Model; use poise::serenity_prelude as serenity; +use presentation::{Browse, embed, no_mentions, text}; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, path::PathBuf, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -18,52 +20,40 @@ type Context<'a> = poise::Context<'a, Data, Error>; struct Data { store: Arc>, support_url: Option, + poll_seconds: u64, } -fn plain(value: &str, limit: usize) -> String { - value - .chars() - .filter(|c| !c.is_control()) - .take(limit) - .map(|c| { - if matches!( - c, - '`' | '*' | '_' | '~' | '[' | ']' | '<' | '>' | '@' | '|' | '\\' - ) { - ' ' - } else { - c - } - }) - .collect() +fn response(message: serenity::CreateEmbed, public: bool) -> poise::CreateReply { + poise::CreateReply::default() + .embed(message) + .allowed_mentions(no_mentions()) + .ephemeral(!public) } -fn no_mentions() -> serenity::CreateAllowedMentions { - serenity::CreateAllowedMentions::new() - .all_users(false) - .all_roles(false) - .everyone(false) - .replied_user(false) -} - -fn embed(title: &str) -> serenity::CreateEmbed { - serenity::CreateEmbed::new() - .title(title) - .color(0x8367ef) - .footer(serenity::CreateEmbedFooter::new( - "Model Radar • Catalog prices, not unlimited access • Limits may apply", - )) +async fn visible_reply( + ctx: Context<'_>, + message: serenity::CreateEmbed, + public: bool, +) -> Result<(), Error> { + ctx.send(response(message, public)).await?; + Ok(()) } async fn reply(ctx: Context<'_>, message: serenity::CreateEmbed) -> Result<(), Error> { - ctx.send( - poise::CreateReply::default() - .embed(message) - .allowed_mentions(no_mentions()) - .ephemeral(true), - ) - .await?; - Ok(()) + visible_reply(ctx, message, false).await +} + +async fn browse(ctx: Context<'_>, options: Browse, public: bool) -> Result<(), Error> { + if public { + ctx.defer().await?; + } else { + ctx.defer_ephemeral().await?; + } + let result = { + let guard = ctx.data().store.lock().await; + presentation::snapshot(&guard.state, &options, ctx.data().poll_seconds) + }; + visible_reply(ctx, result, public).await } #[poise::command(slash_command)] @@ -74,51 +64,64 @@ async fn models( #[min = 1] page: Option, ) -> Result<(), Error> { - let guard = ctx.data().store.lock().await; - let query = search.unwrap_or_default().to_lowercase(); - let matches: Vec<_> = guard - .state - .catalog - .values() - .filter(|m| { - m.is_free() - && (m.id.to_lowercase().contains(&query) || m.name.to_lowercase().contains(&query)) - }) - .collect(); - let pages = matches.len().div_ceil(10).max(1); - let page = (page.unwrap_or(1) as usize).clamp(1, pages); - let text = matches - .iter() - .skip((page - 1) * 10) - .take(10) - .map(|m| { - format!( - "**{}**\n`{}` · {} tokens", - plain(&m.name, 90), - plain(&m.id, 120), - m.context_length - ) - }) - .collect::>() - .join("\n\n"); - let result = embed(&format!( - "Free models · {} found · {page}/{pages}", - matches.len() - )) - .description(if text.is_empty() { - "No matching free models.".to_owned() - } else { - text - }); - drop(guard); - reply(ctx, result).await + browse( + ctx, + Browse { + search: search.unwrap_or_default(), + page: page.unwrap_or(1), + ..Browse::default() + }, + false, + ) + .await +} + +#[poise::command(slash_command)] +#[allow(clippy::too_many_arguments)] +async fn status( + ctx: Context<'_>, + #[description = "Only free catalog pricing (default true)"] free_only: Option, + #[description = "Filter model ID or name"] search: Option, + #[description = "Minimum context tokens"] min_context: Option, + #[description = "Page number"] + #[min = 1] + page: Option, + #[description = "Models per page (1–10, default 10)"] + #[min = 1] + #[max = 10] + page_size: Option, + #[description = "Short model rows (default false)"] compact: Option, + #[description = "Show snapshot to everyone (default false)"] public: Option, +) -> Result<(), Error> { + browse( + ctx, + Browse { + free_only: free_only.unwrap_or(true), + search: search.unwrap_or_default(), + min_context: min_context.unwrap_or(0), + page: page.unwrap_or(1), + page_size: page_size.unwrap_or(10), + compact: compact.unwrap_or(false), + }, + public.unwrap_or(false), + ) + .await } #[poise::command(slash_command)] async fn model( ctx: Context<'_>, - #[description = "Exact model ID from /models"] id: String, + #[description = "Exact model ID from /models or /status"] 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, ) -> Result<(), Error> { + let public = public.unwrap_or(false); + if public { + ctx.defer().await?; + } else { + ctx.defer_ephemeral().await?; + } let found = ctx .data() .store @@ -128,65 +131,164 @@ async fn model( .catalog .get(&id) .cloned(); - let Some(m) = found else { - return reply( - ctx, - embed("Model not found").description("Use /models to find an exact model ID."), - ) - .await; + let result = match found { + Some(model) => presentation::model_details( + &model, + compact.unwrap_or(false), + show_pricing.unwrap_or(true), + ), + None => embed("Model not found") + .description("Use /status free_only:false to find an exact model ID."), }; - let pricing = m - .pricing - .iter() - .take(12) - .map(|(key, value)| format!("{}: {}", plain(key, 35), plain(value, 35))) - .collect::>() - .join("\n"); - reply( - ctx, - embed(&plain(&m.name, 200)) - .description(plain(&m.description, 2000)) - .field("Model ID", plain(&m.id, 200), false) - .field("Context", format!("{} tokens", m.context_length), true) - .field( - "Free catalog pricing", - if m.is_free() { "Yes" } else { "No / unknown" }, - true, - ) - .field( - "Raw USD prices (prompt/completion per token)", - if pricing.is_empty() { - "Unavailable".into() - } else { - pricing - }, - false, - ), - ) - .await + visible_reply(ctx, result, public).await } +#[derive(Default)] +struct WatchPatch { + role_id: Option, + free_only: Option, + search: Option, + min_context: Option, + ping_enabled: Option, + compact: Option, + clear_role: bool, +} + +fn prune_pending(sub: &mut Subscription) { + let mut seen = BTreeSet::new(); + sub.pending = sub + .pending + .iter() + .filter(|event| { + sub.matches(&event.model) && seen.insert((event.model.id.clone(), event.kind.clone())) + }) + .cloned() + .collect(); +} + +fn patch_subscription( + previous: Option<&Subscription>, + channel_id: u64, + patch: &WatchPatch, +) -> Result { + if patch.clear_role && patch.role_id.is_some() { + return Err("clear_role:true cannot be combined with role.".into()); + } + let mut sub = previous.cloned().unwrap_or(Subscription { + channel_id, + role_id: None, + free_only: false, + pending: Vec::new(), + search: String::new(), + min_context: 0, + ping_enabled: false, + compact: false, + }); + sub.channel_id = channel_id; + if patch.clear_role { + sub.role_id = None; + sub.ping_enabled = false; + } + if let Some(role) = patch.role_id { + sub.role_id = Some(role); + sub.ping_enabled = true; + } + if let Some(value) = patch.free_only { + sub.free_only = value; + } + if let Some(value) = &patch.search { + sub.search = value.clone(); + } + if let Some(value) = patch.min_context { + sub.min_context = value; + } + if let Some(value) = patch.ping_enabled { + sub.ping_enabled = value; + } + if let Some(value) = patch.compact { + sub.compact = value; + } + if sub.ping_enabled && sub.role_id.is_none_or(|id| id == 0) { + return Err("Pings require a mentionable opt-in role. Use /watch role:@YourRole, or ping_enabled:false.".into()); + } + prune_pending(&mut sub); + Ok(sub) +} + +fn valid_role(role: &serenity::Role, guild: serenity::GuildId) -> bool { + role.guild_id == guild + && role.id.get() != guild.get() + && role.id.get() != 0 + && role.mentionable + && !role.managed +} + +fn settings(sub: &Subscription, poll_seconds: u64) -> String { + format!( + "Channel: <#{}>\nRole: {}\nPings enabled: {}\nScope: {}\nSearch: {}\nMinimum context: {} tokens\nCompact: {}\nQueued alerts: {}\nPoll interval: {poll_seconds} seconds", + sub.channel_id, + sub.role_id + .map(|id| format!("<@&{id}>")) + .unwrap_or_else(|| "None".into()), + sub.ping_enabled, + if sub.free_only { + "Free models" + } else { + "All new models + becoming free" + }, + text(&sub.search, 500), + sub.min_context, + sub.compact, + sub.pending.len() + ) +} + +const WATCH_HELP: &str = "Automatic alerts cover future catalog detections, not historical releases. One channel per server. Omitted /watch options retain settings and matching queued alerts. To enable pings, make an unmanaged opt-in role mentionable in Server Settings → Roles, then run /watch role:@YourRole ping_enabled:true. Use ping_enabled:false to silence pings, clear_role:true to remove the role, search: with a single space to clear the search, min_context:0 to clear the context filter, or /unwatch to stop. /status shows a catalog freshness snapshot, not a live-updating message."; + #[poise::command( slash_command, guild_only, required_permissions = "MANAGE_GUILD", default_member_permissions = "MANAGE_GUILD" )] +#[allow(clippy::too_many_arguments)] async fn watch( ctx: Context<'_>, - #[description = "Optional opt-in, mentionable notification role"] role: Option, - #[description = "Only free releases and paid-to-free changes (default true)"] free_only: Option< + #[description = "Mentionable opt-in notification role; enables pings unless explicitly disabled"] + role: Option, + #[description = "Only free models (new subscriptions default false; omitted retains setting)"] + free_only: Option, + #[description = "Filter ID or name; a single space clears; omitted retains setting"] + search: Option, + #[description = "Minimum context tokens; 0 clears; omitted retains setting"] + min_context: Option, + #[description = "Enable role pings; requires a valid mentionable role"] ping_enabled: Option< bool, >, + #[description = "Short notification rows; omitted retains setting"] compact: Option, + #[description = "Remove the notification role and disable pings; conflicts with role"] + clear_role: Option, ) -> Result<(), Error> { ctx.defer_ephemeral().await?; let guild = ctx.guild_id().ok_or("Server required")?; - if let Some(role) = &role - && (role.guild_id != guild - || role.id.get() == guild.get() - || !role.mentionable - || role.managed) - { + let patch = WatchPatch { + role_id: role.as_ref().map(|role| role.id.get()), + free_only, + search: search.map(|value| value.trim().to_owned()), + min_context, + ping_enabled, + compact, + clear_role: clear_role.unwrap_or(false), + }; + if patch.clear_role && role.is_some() { + return reply( + ctx, + embed("Conflicting settings") + .description("Choose either clear_role:true or role, not both."), + ) + .await; + } + if role.as_ref().is_some_and(|role| !valid_role(role, guild)) { return reply( ctx, embed("Choose an opt-in role").description( @@ -227,20 +329,44 @@ async fn watch( ) .await; } + let roles = guild.roles(ctx.serenity_context()).await?; let mut guard = ctx.data().store.lock().await; + let sub = match patch_subscription( + guard.state.subscriptions.get(&guild.get()), + channel.id.get(), + &patch, + ) { + Ok(sub) => sub, + Err(error) => { + drop(guard); + return reply( + ctx, + embed("Invalid alert settings").description(error.to_string()), + ) + .await; + } + }; + if sub.ping_enabled + && !sub + .role_id + .and_then(|id| roles.get(&serenity::RoleId::new(id))) + .is_some_and(|role| valid_role(role, guild)) + { + drop(guard); + return reply(ctx, embed("Choose an opt-in role").description("The configured role is missing or not mentionable/unmanaged. Set role to a valid opt-in role, or use ping_enabled:false or clear_role:true.")).await; + } + let result = embed("Alerts configured here") + .description(WATCH_HELP) + .field( + "Complete settings", + settings(&sub, ctx.data().poll_seconds), + false, + ); let mut next = guard.state.clone(); - next.subscriptions.insert( - guild.get(), - Subscription { - channel_id: channel.id.get(), - role_id: role.map(|r| r.id.get()), - free_only: free_only.unwrap_or(true), - pending: Vec::new(), - }, - ); + next.subscriptions.insert(guild.get(), sub); guard.commit(next).await?; drop(guard); - reply(ctx, embed("Alerts enabled here").description("Checking every 10 minutes. Only future detections are announced. Members can opt into your notification role. Run /watch again to change settings; /unwatch stops alerts. One channel per server.")).await + reply(ctx, result).await } #[poise::command( @@ -267,53 +393,47 @@ async fn unwatch(ctx: Context<'_>) -> Result<(), Error> { #[poise::command(slash_command)] async fn stats(ctx: Context<'_>) -> Result<(), Error> { + ctx.defer_ephemeral().await?; let guard = ctx.data().store.lock().await; let last = guard .state .last_success - .map(|t| format!("")) + .map(|time| format!(" ()")) .unwrap_or_else(|| "Never".into()); - let mut result = embed("Radar status") + let mut result = embed("Catalog freshness and alert settings") + .description("Catalog snapshot only, not uptime or health; not live updating. Use /status to browse the saved catalog.") .field("Catalog", guard.state.catalog.len().to_string(), true) - .field( - "Free", - guard - .state - .catalog - .values() - .filter(|m| m.is_free()) - .count() - .to_string(), - true, - ) - .field("Last successful fetch", last, false); + .field("Free", guard.state.catalog.values().filter(|model| model.is_free()).count().to_string(), true) + .field("Last successful catalog fetch", last, false) + .field("Poll interval", format!("{} seconds", ctx.data().poll_seconds), true); if let Some(sub) = ctx .guild_id() .and_then(|id| guard.state.subscriptions.get(&id.get())) { result = result.field( - "This server", - format!( - "Channel: <#{}>\nScope: {}\nQueued alerts: {}", - sub.channel_id, - if sub.free_only { - "Free models" - } else { - "All new models + becoming free" - }, - sub.pending.len() - ), + "Complete server settings", + settings(sub, ctx.data().poll_seconds), + false, + ); + } else { + result = result.field( + "Server alerts", + "Not configured here; run /watch in a server text channel.", false, ); } + result = result.field("Configure alerts", WATCH_HELP, false); drop(guard); reply(ctx, result).await } #[poise::command(slash_command)] async fn about(ctx: Context<'_>) -> Result<(), Error> { - let mut result = embed("Model Radar") - .description("OpenRouter's free model catalog, with opt-in release alerts.\n\n/models — browse free models\n/model — details and raw pricing\n/watch — configure alerts here (Manage Server)\n/unwatch — remove subscription\n/stats — freshness and settings\n\nFree to use. No OpenRouter key needed. New means newly observed in the catalog, not a verified launch date. Pricing and availability can change; provider limits apply. Server/channel/role IDs are stored for alerts; /unwatch deletes the subscription."); + ctx.defer_ephemeral().await?; + let mut result = embed("Model Radar").description( + "OpenRouter catalog snapshots and automatic opt-in release alerts.\n\n/models — free models; search and page\n/status — snapshot, not live updating or uptime/health; free_only (default true), search, min_context, page, page_size (1–10), compact, public (default false)\n/model — exact ID; compact, show_pricing (default true), public (default false)\n/watch — configure alerts here (Manage Server); role, free_only (default false for new subscriptions), search, min_context, ping_enabled, compact, clear_role; omitted options retain settings\n/testping — send an explicitly labeled TEST to the configured role (Manage Server)\n/unwatch — remove subscription\n/stats — catalog freshness and complete settings\n\nFree to use. No OpenRouter key needed. New means newly observed, not a verified launch date. Pricing and availability can change; provider limits apply. Server/channel/role IDs and filters are stored for alerts; /unwatch deletes the subscription.") + .field("Configure pings", WATCH_HELP, false) + .field("Poll interval", format!("{} seconds", ctx.data().poll_seconds), true); if let Some(url) = &ctx.data().support_url { result = result.field( "Optional support", @@ -324,16 +444,111 @@ async fn about(ctx: Context<'_>) -> Result<(), Error> { reply(ctx, result).await } +fn notification_message(sub: &Subscription, count: usize) -> serenity::CreateMessage { + let description = sub + .pending + .iter() + .take(count.min(8)) + .map(|event| { + format!( + "**{}** · {}", + if event.kind == "paid_to_free" { + "Now free" + } else { + "New in catalog" + }, + presentation::model_line(&event.model, sub.compact) + ) + }) + .collect::>() + .join("\n\n"); + role_message( + sub, + embed("Model Radar · catalog update").description(if description.is_empty() { + "No queued detections." + } else { + &description + }), + ) +} + +fn role_message(sub: &Subscription, embed: serenity::CreateEmbed) -> serenity::CreateMessage { + let mut mentions = no_mentions(); + let mut message = serenity::CreateMessage::new().embed(embed); + if sub.ping_enabled + && let Some(role) = sub.role_id.filter(|id| *id != 0) + { + mentions = mentions.roles([serenity::RoleId::new(role)]); + message = message.content(format!("<@&{role}>")); + } + message.allowed_mentions(mentions) +} + +#[poise::command( + slash_command, + guild_only, + required_permissions = "MANAGE_GUILD", + default_member_permissions = "MANAGE_GUILD" +)] +async fn testping(ctx: Context<'_>) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + let guild = ctx.guild_id().ok_or("Server required")?; + let snapshot = ctx + .data() + .store + .lock() + .await + .state + .subscriptions + .get(&guild.get()) + .cloned(); + let Some(sub) = snapshot else { + return reply( + ctx, + embed("No subscription") + .description("Configure /watch role:@YourRole ping_enabled:true first."), + ) + .await; + }; + let roles = guild.roles(ctx.serenity_context()).await?; + if !sub.ping_enabled + || !sub + .role_id + .and_then(|id| roles.get(&serenity::RoleId::new(id))) + .is_some_and(|role| valid_role(role, guild)) + { + return reply(ctx, embed("Pings unavailable").description("Configure /watch role:@YourRole ping_enabled:true with a mentionable opt-in role first.")).await; + } + serenity::ChannelId::new(sub.channel_id).send_message(ctx.serenity_context(), role_message(&sub, + embed("TEST · Model Radar role ping").description("TEST notification only. No new model was detected and no catalog or queued alerts were changed."))).await?; + reply( + ctx, + embed("TEST sent").description( + "Sent an explicitly labeled TEST role ping to the configured alert channel.", + ), + ) + .await +} + fn updated(mut state: State, current: BTreeMap) -> State { if state.initialized { let events = catalog::changes(&state.catalog, ¤t); for sub in state.subscriptions.values_mut() { - sub.pending.extend( - events - .iter() - .filter(|event| !sub.free_only || event.model.is_free()) - .cloned(), - ); + prune_pending(sub); + let mut seen: BTreeSet<_> = sub + .pending + .iter() + .map(|event| (event.model.id.clone(), event.kind.clone())) + .collect(); + let additions: Vec<_> = events + .iter() + .filter(|event| { + sub.matches(&event.model) + && seen.insert((event.model.id.clone(), event.kind.clone())) + }) + .cloned() + .collect(); + sub.pending.extend(additions); } } state.catalog = current; @@ -354,6 +569,17 @@ async fn refresh(store: &Mutex, client: &reqwest::Client) -> Result<(), E guard.commit(next).await } +fn acknowledge(state: &mut State, guild: u64, snapshot: &Subscription, count: usize) -> bool { + if let Some(current) = state.subscriptions.get_mut(&guild) + && current == snapshot + { + current.pending.drain(..count.min(current.pending.len())); + true + } else { + false + } +} + async fn deliver(store: &Mutex, http: &serenity::Http) -> Result<(), Error> { let guilds: Vec<_> = store .lock() @@ -364,61 +590,42 @@ async fn deliver(store: &Mutex, http: &serenity::Http) -> Result<(), Erro .copied() .collect(); for guild in guilds { - let mut guard = store.lock().await; - let Some(sub) = guard.state.subscriptions.get(&guild) else { + let snapshot = store.lock().await.state.subscriptions.get(&guild).cloned(); + let Some(sub) = snapshot else { continue; }; if sub.pending.is_empty() { continue; } let count = sub.pending.len().min(8); - let text = sub - .pending - .iter() - .take(count) - .map(|event| { - format!( - "**{} · {}**\n`{}` · {} tokens · {}", - if event.kind == "paid_to_free" { - "Now free" - } else { - "New in catalog" - }, - plain(&event.model.name, 80), - plain(&event.model.id, 120), - event.model.context_length, - if event.model.is_free() { - "Free pricing" - } else { - "Paid / unknown pricing" - } - ) - }) - .collect::>() - .join("\n\n"); - let mut mentions = no_mentions(); - let mut message = serenity::CreateMessage::new() - .embed(embed("Model Radar · catalog update").description(text)); - if let Some(role) = sub.role_id { - mentions = mentions.roles([serenity::RoleId::new(role)]); - message = message.content(format!("<@&{role}>")); - } - let sent = serenity::ChannelId::new(sub.channel_id) - .send_message(http, message.allowed_mentions(mentions)) - .await; - if sent.is_err() { + if serenity::ChannelId::new(sub.channel_id) + .send_message(http, notification_message(&sub, count)) + .await + .is_err() + { tracing::warn!(guild, "Alert delivery failed; retained for next poll"); continue; } + let mut guard = store.lock().await; let mut next = guard.state.clone(); - if let Some(sub) = next.subscriptions.get_mut(&guild) { - sub.pending.drain(..count); + if acknowledge(&mut next, guild, &sub, count) { + guard.commit(next).await?; } - guard.commit(next).await?; } Ok(()) } +fn parse_poll_seconds(raw: Option<&str>) -> Result { + match raw { + None => Ok(600), + Some(raw) => raw + .parse::() + .ok() + .filter(|seconds| (60..=86400).contains(seconds)) + .ok_or_else(|| "POLL_SECONDS must be an integer from 60 to 86400".into()), + } +} + fn support_url() -> Result, Error> { let Ok(raw) = std::env::var("SUPPORT_URL") else { return Ok(None); @@ -430,7 +637,7 @@ fn support_url() -> Result, Error> { || url.password().is_some() || raw .chars() - .any(|c| c.is_control() || matches!(c, '(' | ')' | '<' | '>')) + .any(|ch| ch.is_control() || matches!(ch, '(' | ')' | '<' | '>')) || raw.len() > 500 { return Err("SUPPORT_URL must be a plain HTTPS URL without credentials".into()); @@ -449,13 +656,20 @@ async fn main() -> Result<(), Error> { let args: Vec<_> = std::env::args().skip(1).collect(); if args.iter().any(|arg| arg == "--help" || arg == "-h") { println!( - "Model Radar\n\nRun: DISCORD_TOKEN from your environment; cargo run --locked\nCheck public catalog without Discord: cargo run --locked -- --check\nOptional: SUPPORT_URL (HTTPS donation link), STATE_PATH (default state.json), DISCORD_GUILD_ID (instant test-server registration).\n\nCreate a bot in Discord Developer Portal. Invite using bot + applications.commands scopes with View Channel, Send Messages, Embed Links. No privileged intents or Administrator permission needed. Keep the token secret; never paste it in chat.\nRun /watch in a server text channel with Manage Server permission. Optionally choose a mentionable opt-in role. Default alerts cover free models; free_only:false includes paid releases.\nOne process per state file. Keep the machine running for 24/7 alerts. State uses atomic writes; failed deliveries retry next poll; a crash after sending may duplicate an alert. First fetch is a silent baseline. Catalog reappearances count as new detections.\nChecks: cargo fmt --check; cargo test; cargo check; cargo clippy --all-targets -- -D warnings" + "Model Radar\n\nRun: DISCORD_TOKEN from your environment; cargo run --locked\nCheck public catalog without Discord: cargo run --locked -- --check\nOptional: POLL_SECONDS (integer 60–86400, default 600), SUPPORT_URL (HTTPS donation link), STATE_PATH (default state.json), DISCORD_GUILD_ID (instant test-server registration).\n\nCreate a bot in Discord Developer Portal. Invite using bot + applications.commands scopes with View Channel, Send Messages, Embed Links. No privileged intents or Administrator permission needed. Keep the token secret; never paste it in chat.\n/models: free models, search/page. /status: catalog freshness snapshot, NOT uptime/health or live updating; free_only (default true), search, min_context, page, page_size (1–10), compact, public (default false). /model: exact ID, compact (default false), show_pricing (default true), public (default false).\n/watch (Manage Server): role, free_only (default false for NEW subscriptions, includes paid releases), search (single space clears), min_context (0 clears), ping_enabled, compact, clear_role. Omitted options preserve settings and matching queued alerts. Setting role enables pings unless ping_enabled:false. clear_role:true removes role and disables pings; cannot be combined with role. Make an unmanaged opt-in role mentionable in Server Settings > Roles, then /watch role:@YourRole ping_enabled:true. Pings require a valid mentionable role. /testping sends a labeled TEST only. /stats shows complete settings; /unwatch removes alerts; /about explains commands.\nOne process per state file. Keep the machine running for 24/7 alerts. State uses atomic writes; failed deliveries retry next poll; a crash or concurrent settings update after sending may duplicate an alert. First fetch is a silent baseline. Catalog reappearances count as new detections; queued events deduplicate model ID + event kind.\nChecks: cargo fmt --check; cargo test; cargo check; cargo clippy --all-targets -- -D warnings" ); return Ok(()); } if args.iter().any(|arg| arg != "--check") { return Err("Unknown argument; use --help".into()); } + let poll_raw = std::env::var("POLL_SECONDS") + .map(Some) + .or_else(|error| match error { + std::env::VarError::NotPresent => Ok(None), + other => Err(other), + })?; + let poll_seconds = parse_poll_seconds(poll_raw.as_deref())?; let http_client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .user_agent("model-radar/0.1") @@ -465,7 +679,7 @@ async fn main() -> Result<(), Error> { println!( "Catalog OK: {} models, {} free", catalog.len(), - catalog.values().filter(|m| m.is_free()).count() + catalog.values().filter(|model| model.is_free()).count() ); return Ok(()); } @@ -487,14 +701,25 @@ async fn main() -> Result<(), Error> { } tracing::warn!("Initial catalog fetch failed; using saved state and retrying"); } - let mut commands = vec![models(), model(), watch(), unwatch(), stats(), about()]; + let mut commands = vec![ + models(), + status(), + model(), + watch(), + unwatch(), + stats(), + about(), + testping(), + ]; let descriptions = [ "Browse OpenRouter's free models", + "Show a catalog freshness snapshot (not live updating)", "Inspect a model's details and pricing", - "Enable release alerts in this channel", + "Configure automatic release alerts in this channel", "Remove this server's alerts", - "Show catalog freshness and alert settings", + "Show catalog freshness and complete alert settings", "About Model Radar and optional support", + "Send an explicitly labeled TEST role ping", ]; for (command, description) in commands.iter_mut().zip(descriptions) { command.description = Some(description.into()); @@ -519,7 +744,7 @@ async fn main() -> Result<(), Error> { let poll_store = Arc::clone(&store); let http = Arc::clone(&ctx.http); tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(600)); + let mut interval = tokio::time::interval(Duration::from_secs(poll_seconds)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { interval.tick().await; @@ -527,8 +752,8 @@ async fn main() -> Result<(), Error> { if deliver(&poll_store, &http).await.is_err() { tracing::warn!("Saving delivery progress failed; alerts may retry"); } } }); - tracing::info!("Model Radar connected; catalog polling every 10 minutes"); - Ok(Data { store, support_url }) + tracing::info!(poll_seconds, "Model Radar connected; catalog polling configured"); + Ok(Data { store, support_url, poll_seconds }) })) .build(); let mut client = serenity::ClientBuilder::new(token, serenity::GatewayIntents::GUILDS) @@ -546,8 +771,8 @@ mod tests { use super::*; fn catalog(free: bool) -> BTreeMap { - let m = Model { - id: "test/model".into(), + let model = Model { + id: "test/model_under`tick".into(), name: "Test".into(), description: String::new(), context_length: 10, @@ -556,51 +781,364 @@ mod tests { ("completion".into(), "0".into()), ]), }; - BTreeMap::from([(m.id.clone(), m)]) + BTreeMap::from([(model.id.clone(), model)]) + } + + fn subscription() -> Subscription { + patch_subscription(None, 42, &WatchPatch::default()).unwrap() + } + + fn event(free: bool) -> catalog::Event { + catalog::Event { + model: catalog(free).into_values().next().unwrap(), + kind: "new".into(), + } } #[test] - fn baseline_is_silent_and_alerts_are_filtered() { + fn baseline_is_silent_and_alerts_are_filtered_and_deduplicated() { let mut state = State::default(); for (id, free_only) in [(1, true), (2, false)] { - state.subscriptions.insert( - id, - Subscription { - channel_id: id, - role_id: None, - free_only, - pending: vec![], - }, - ); + let mut sub = subscription(); + sub.free_only = free_only; + state.subscriptions.insert(id, sub); } let baseline = updated(state, catalog(false)); assert!( baseline .subscriptions .values() - .all(|s| s.pending.is_empty()) + .all(|sub| sub.pending.is_empty()) ); let free = updated(baseline, catalog(true)); - assert!(free.subscriptions.values().all(|s| s.pending.len() == 1)); + assert!( + free.subscriptions + .values() + .all(|sub| sub.pending.len() == 1) + ); let unchanged = updated(free, catalog(true)); assert!( unchanged .subscriptions .values() - .all(|s| s.pending.len() == 1) + .all(|sub| sub.pending.len() == 1) ); - let mut next = catalog(true); - let mut paid = catalog(false).remove("test/model").unwrap(); - paid.id = "paid/new".into(); - next.insert(paid.id.clone(), paid); - let result = updated(unchanged, next); - assert_eq!(result.subscriptions[&1].pending.len(), 1); - assert_eq!(result.subscriptions[&2].pending.len(), 2); + let changed = updated(updated(unchanged, catalog(false)), catalog(true)); + assert!( + changed + .subscriptions + .values() + .all(|sub| sub.pending.len() == 1) + ); + let removed = updated(changed, BTreeMap::new()); + let returned = updated(removed, catalog(false)); + assert_eq!(returned.subscriptions[&1].pending.len(), 1); + assert_eq!(returned.subscriptions[&2].pending.len(), 2); + let again = updated(updated(returned, BTreeMap::new()), catalog(false)); + assert_eq!(again.subscriptions[&2].pending.len(), 2); } #[test] - fn external_text_is_bounded_and_sanitized() { - assert_eq!(plain("@everyone **test**\n", 100), " everyone test "); - assert_eq!(plain("ééé", 2), "éé"); + fn patches_preserve_settings_and_pending_and_prune_filters() { + let mut sub = subscription(); + assert!(!sub.free_only); + assert!(!sub.ping_enabled); + sub.role_id = Some(99); + sub.ping_enabled = true; + sub.compact = true; + sub.search = "TEST".into(); + sub.min_context = 5; + sub.pending = vec![event(false), event(true)]; + let kept = patch_subscription(Some(&sub), 42, &WatchPatch::default()).unwrap(); + assert_eq!(kept.pending.len(), 1); + sub.pending.truncate(1); + assert_eq!(kept, sub); + let moved = patch_subscription(Some(&sub), 43, &WatchPatch::default()).unwrap(); + assert_eq!(moved.channel_id, 43); + assert_eq!(moved.pending, sub.pending); + let filtered = patch_subscription( + Some(&sub), + 42, + &WatchPatch { + free_only: Some(true), + ..Default::default() + }, + ) + .unwrap(); + assert!(filtered.pending.is_empty()); + for patch in [ + WatchPatch { + search: Some("absent".into()), + ..Default::default() + }, + WatchPatch { + min_context: Some(11), + ..Default::default() + }, + ] { + assert!( + patch_subscription(Some(&sub), 42, &patch) + .unwrap() + .pending + .is_empty() + ); + } + let reset = patch_subscription( + Some(&sub), + 42, + &WatchPatch { + search: Some(String::new()), + min_context: Some(0), + compact: Some(false), + clear_role: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(reset.search.is_empty()); + assert_eq!(reset.min_context, 0); + assert!(!reset.compact); + assert!(!reset.ping_enabled); + assert_eq!(reset.role_id, None); + assert_eq!(reset.pending, sub.pending); + } + + #[test] + fn role_patch_rules() { + assert!( + patch_subscription( + None, + 42, + &WatchPatch { + clear_role: true, + role_id: Some(99), + ..Default::default() + } + ) + .is_err() + ); + assert!( + patch_subscription( + None, + 42, + &WatchPatch { + ping_enabled: Some(true), + ..Default::default() + } + ) + .is_err() + ); + let sub = patch_subscription( + None, + 42, + &WatchPatch { + role_id: Some(99), + ..Default::default() + }, + ) + .unwrap(); + assert!(sub.ping_enabled); + let muted = patch_subscription( + Some(&sub), + 42, + &WatchPatch { + role_id: Some(100), + ping_enabled: Some(false), + ..Default::default() + }, + ) + .unwrap(); + assert!(!muted.ping_enabled); + assert_eq!(muted.role_id, Some(100)); + assert!( + patch_subscription( + Some(&sub), + 42, + &WatchPatch { + clear_role: true, + ping_enabled: Some(true), + ..Default::default() + } + ) + .is_err() + ); + } + + #[test] + fn updated_uses_search_and_context_filters() { + let mut sub = subscription(); + sub.search = "TEST".into(); + sub.min_context = 10; + let mut state = State { + initialized: true, + ..Default::default() + }; + state.subscriptions.insert(1, sub.clone()); + sub.search = "missing".into(); + state.subscriptions.insert(2, sub.clone()); + sub.search.clear(); + sub.min_context = 11; + state.subscriptions.insert(3, sub); + let state = updated(state, catalog(false)); + assert_eq!(state.subscriptions[&1].pending.len(), 1); + assert!(state.subscriptions[&2].pending.is_empty()); + assert!(state.subscriptions[&3].pending.is_empty()); + } + + #[test] + fn acknowledgement_requires_exact_snapshot() { + let mut sub = subscription(); + sub.pending = vec![event(false)]; + let mut state = State::default(); + state.subscriptions.insert(1, sub.clone()); + let mut changed = sub.clone(); + changed.compact = true; + assert!(!acknowledge(&mut state, 1, &changed, 1)); + changed = sub.clone(); + changed.pending.push(event(true)); + assert!(!acknowledge(&mut state, 1, &changed, 1)); + assert!(!acknowledge(&mut state, 2, &sub, 1)); + assert_eq!(state.subscriptions[&1].pending.len(), 1); + assert!(acknowledge(&mut state, 1, &sub, 1)); + assert!(state.subscriptions[&1].pending.is_empty()); + } + + #[test] + fn notification_payload_only_allows_opt_in_role() { + let mut sub = subscription(); + sub.role_id = Some(99); + sub.pending = vec![event(false)]; + sub.pending[0].model.name = "@everyone <@123> <@&456>".into(); + for enabled in [false, true] { + sub.ping_enabled = enabled; + let payload = serde_json::to_value(notification_message(&sub, 8)).unwrap(); + let mentions = &payload["allowed_mentions"]; + assert_eq!(mentions["parse"], serde_json::json!([])); + assert_eq!(mentions["replied_user"], false); + assert!(mentions["users"].as_array().is_none_or(Vec::is_empty)); + if enabled { + assert_eq!(payload["content"], "<@&99>"); + assert_eq!(mentions["roles"], serde_json::json!(["99"])); + } else { + assert!(payload["content"].as_str().is_none_or(str::is_empty)); + assert!(mentions["roles"].as_array().is_none_or(Vec::is_empty)); + } + } + sub.role_id = None; + let payload = serde_json::to_value(notification_message(&sub, 8)).unwrap(); + assert!(payload["content"].as_str().is_none_or(str::is_empty)); + } + + #[test] + fn notification_and_settings_payloads_are_bounded() { + let mut sub = subscription(); + sub.search = "\u{1d11e}_`".repeat(2000); + sub.channel_id = u64::MAX; + sub.role_id = Some(u64::MAX); + sub.min_context = u32::MAX; + let mut event = event(false); + event.model.name = sub.search.clone(); + event.model.id = sub.search.clone(); + event.model.context_length = u64::MAX; + sub.pending = vec![event; 30]; + for compact in [false, true] { + sub.compact = compact; + let payload = serde_json::to_value(notification_message(&sub, usize::MAX)).unwrap(); + presentation::tests::assert_embed_bounds(&payload["embeds"][0]); + } + let payload = serde_json::to_value(embed("Settings").description(WATCH_HELP).field( + "Complete settings", + settings(&sub, 86400), + false, + )) + .unwrap(); + presentation::tests::assert_embed_bounds(&payload); + let setting = payload["fields"][0]["value"].as_str().unwrap(); + for label in [ + "Channel:", + "Role:", + "Pings enabled:", + "Scope:", + "Search:", + "Minimum context:", + "Compact:", + "Queued alerts:", + "Poll interval:", + ] { + assert!(setting.contains(label)); + } + let payload = serde_json::to_value(role_message( + &sub, + embed("TEST · Model Radar role ping").description("TEST only; no new model detected."), + )) + .unwrap(); + assert!( + payload["embeds"][0]["title"] + .as_str() + .unwrap() + .starts_with("TEST") + ); + presentation::tests::assert_embed_bounds(&payload["embeds"][0]); + let mentions = serde_json::to_value(no_mentions()).unwrap(); + assert_eq!(mentions["parse"], serde_json::json!([])); + assert!(mentions["roles"].as_array().is_none_or(Vec::is_empty)); + assert!(mentions["users"].as_array().is_none_or(Vec::is_empty)); + } + + #[test] + fn public_and_private_replies_never_allow_mentions() { + for public in [false, true] { + let reply = response(embed("@everyone <@123> <@&99>"), public); + let initial = serde_json::to_value( + reply + .clone() + .to_slash_initial_response(serenity::CreateInteractionResponseMessage::new()), + ) + .unwrap(); + assert_eq!( + initial["flags"].as_u64().unwrap_or_default() & 64 != 0, + !public + ); + let edit = serde_json::to_value( + reply + .clone() + .to_slash_initial_response_edit(serenity::EditInteractionResponse::new()), + ) + .unwrap(); + let followup = serde_json::to_value( + reply + .to_slash_followup_response(serenity::CreateInteractionResponseFollowup::new()), + ) + .unwrap(); + for payload in [initial, edit, followup] { + assert_eq!(payload["allowed_mentions"]["parse"], serde_json::json!([])); + assert_eq!(payload["allowed_mentions"]["roles"], serde_json::json!([])); + assert_eq!(payload["allowed_mentions"]["users"], serde_json::json!([])); + assert_eq!(payload["allowed_mentions"]["replied_user"], false); + } + } + } + + #[test] + fn poll_interval_validation() { + assert_eq!(parse_poll_seconds(None).unwrap(), 600); + for value in ["60", "600", "86400"] { + assert_eq!( + parse_poll_seconds(Some(value)).unwrap(), + value.parse::().unwrap() + ); + } + for value in [ + "", + "0", + "59", + "86401", + "-60", + "60.0", + "hello", + "18446744073709551616", + ] { + assert!(parse_poll_seconds(Some(value)).is_err()); + } } } diff --git a/src/presentation.rs b/src/presentation.rs new file mode 100644 index 0000000..864ee39 --- /dev/null +++ b/src/presentation.rs @@ -0,0 +1,330 @@ +use crate::{catalog::Model, serenity, store::State}; + +pub fn no_mentions() -> serenity::CreateAllowedMentions { + serenity::CreateAllowedMentions::new() + .all_users(false) + .all_roles(false) + .everyone(false) + .replied_user(false) +} + +pub fn text(value: &str, limit: usize) -> String { + if limit == 0 { + return String::new(); + } + let mut result = String::new(); + let mut length = 0; + for ch in value.chars().filter(|ch| !ch.is_control() || *ch == '\n') { + let escape = matches!( + ch, + '\\' | '`' | '*' | '_' | '~' | '[' | ']' | '<' | '>' | '|' | '#' | '-' + ); + let width = ch.len_utf16() + usize::from(escape); + if length + width > limit.saturating_sub(1) { + result.push('…'); + break; + } + if escape { + result.push('\\'); + } + result.push(ch); + length += width; + } + if result.is_empty() { + "—".into() + } else { + result + } +} + +pub fn embed(title: &str) -> serenity::CreateEmbed { + serenity::CreateEmbed::new() + .title(text(title, 256)) + .color(0x8367ef) + .footer(serenity::CreateEmbedFooter::new( + "Model Radar • Catalog prices, not unlimited access • Limits may apply", + )) +} + +pub struct Browse { + pub free_only: bool, + pub search: String, + pub min_context: u32, + pub page: u32, + pub page_size: u32, + pub compact: bool, +} + +impl Default for Browse { + fn default() -> Self { + Self { + free_only: true, + search: String::new(), + min_context: 0, + page: 1, + page_size: 10, + compact: false, + } + } +} + +pub fn model_line(model: &Model, compact: bool) -> String { + let pricing = if model.is_free() { + "Free" + } else { + "Paid / unknown" + }; + if compact { + format!( + "{} · {} tokens · {pricing}", + text(&model.id, 220), + model.context_length + ) + } else { + format!( + "**{}**\n{} · {} tokens · {pricing}", + text(&model.name, 85), + text(&model.id, 220), + model.context_length + ) + } +} + +pub fn snapshot(state: &State, options: &Browse, poll_seconds: u64) -> serenity::CreateEmbed { + let query = options.search.to_lowercase(); + let matches: Vec<_> = 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)) + }) + .collect(); + let size = options.page_size.clamp(1, 10) as usize; + let pages = matches.len().div_ceil(size).max(1); + let page = (options.page as usize).clamp(1, pages); + let lines = matches + .iter() + .skip((page - 1) * size) + .take(size) + .map(|model| model_line(model, options.compact)) + .collect::>() + .join("\n\n"); + let freshness = state + .last_success + .map(|time| format!("Last successful catalog fetch: ().")) + .unwrap_or_else(|| "No successful catalog fetch recorded.".into()); + embed("Catalog snapshot") + .description(format!("Snapshot only; not live updating. Catalog freshness, not bot uptime or health.\n{freshness}\n\n{}", if lines.is_empty() { "No matching models." } else { &lines })) + .field("Catalog", format!("{} total · {} free · {} matching", state.catalog.len(), state.catalog.values().filter(|model| model.is_free()).count(), matches.len()), false) + .field("Filters", format!("Scope: {}\nSearch: {}\nMinimum context: {} tokens\nCompact: {}", if options.free_only { "Free only" } else { "All models" }, text(&options.search, 240), options.min_context, options.compact), false) + .field("Page", format!("{page}/{pages} · {size} per page"), true) + .field("Poll interval", format!("{poll_seconds} seconds · run /status again to refresh this snapshot"), false) +} + +pub fn model_details(model: &Model, compact: bool, show_pricing: bool) -> serenity::CreateEmbed { + let mut result = embed(&model.name) + .field("Model ID", text(&model.id, 1024), false) + .field("Context", format!("{} tokens", model.context_length), true) + .field( + "Free catalog pricing", + if model.is_free() { + "Yes" + } else { + "No / unknown" + }, + true, + ); + if !compact { + result = result.description(text(&model.description, 2000)); + } + if show_pricing { + let prices = model + .pricing + .iter() + .take(10) + .map(|(key, value)| format!("{}: {}", text(key, 40), text(value, 45))) + .collect::>() + .join("\n"); + result = result.field( + "Raw USD prices (prompt/completion per token)", + if prices.is_empty() { + "Unavailable" + } else { + &prices + }, + false, + ); + } + result +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::collections::BTreeMap; + + pub fn assert_embed_bounds(value: &serde_json::Value) { + let units = + |value: &serde_json::Value| value.as_str().unwrap_or_default().encode_utf16().count(); + assert!(units(&value["title"]) <= 256); + assert!(units(&value["description"]) <= 4096); + assert!(units(&value["footer"]["text"]) <= 2048); + let mut total = + units(&value["title"]) + units(&value["description"]) + units(&value["footer"]["text"]); + if let Some(fields) = value["fields"].as_array() { + assert!(fields.len() <= 25); + for field in fields { + assert!((1..=256).contains(&units(&field["name"]))); + assert!((1..=1024).contains(&units(&field["value"]))); + total += units(&field["name"]) + units(&field["value"]); + } + } + assert!(total <= 6000, "embed has {total} UTF-16 units"); + } + + fn model(id: &str, free: bool, context: u64) -> Model { + Model { + id: id.into(), + name: "Example".into(), + description: "Description".into(), + context_length: context, + pricing: BTreeMap::from([ + ("prompt".into(), if free { "0" } else { "1" }.into()), + ("completion".into(), "0".into()), + ]), + } + } + + #[test] + fn markdown_escaping_preserves_id_characters() { + assert_eq!( + text("vendor/model_under`tick", 1024), + "vendor/model\\_under\\`tick" + ); + assert_eq!(text("a\\b[*]", 1024), "a\\\\b\\[\\*\\]"); + let payload = serde_json::to_value(model_details( + &model("vendor/model_under`tick", true, 8192), + false, + true, + )) + .unwrap(); + assert_eq!(payload["fields"][0]["value"], "vendor/model\\_under\\`tick"); + assert_embed_bounds(&payload); + } + + #[test] + fn unicode_lengths_and_all_payloads_are_bounded() { + let large = "\u{1d11e}_`\\[]".repeat(2000); + for limit in [0, 1, 2, 3, 40, 256, 1024, 2000] { + assert!(text(&large, limit).encode_utf16().count() <= limit); + } + let mut example = model(&large, false, u64::MAX); + example.name = large.clone(); + example.description = large.clone(); + example.pricing = (0..30) + .map(|index| (format!("{index}{large}"), large.clone())) + .collect(); + for compact in [false, true] { + for show_pricing in [false, true] { + let value = + serde_json::to_value(model_details(&example, compact, show_pricing)).unwrap(); + assert_embed_bounds(&value); + assert_eq!( + value["fields"].as_array().unwrap().len(), + if show_pricing { 4 } else { 3 } + ); + assert_eq!(value.get("description").is_none(), compact); + } + let state = State { + catalog: (0..30) + .map(|index| (index.to_string(), example.clone())) + .collect(), + last_success: Some(u64::MAX), + ..Default::default() + }; + for page_size in [0, 1, 10, u32::MAX] { + let options = Browse { + free_only: false, + compact, + page_size, + ..Default::default() + }; + assert_embed_bounds( + &serde_json::to_value(snapshot(&state, &options, 86400)).unwrap(), + ); + } + } + } + + #[test] + fn snapshot_filters_pagination_and_freshness() { + let state = State { + catalog: [ + model("a/free", true, 100), + model("b/paid", false, 200), + model("c/free", true, 50), + ] + .into_iter() + .map(|model| (model.id.clone(), model)) + .collect(), + last_success: Some(123), + ..Default::default() + }; + let value = serde_json::to_value(snapshot(&state, &Browse::default(), 60)).unwrap(); + let description = value["description"].as_str().unwrap(); + assert!(description.contains("not live updating")); + assert!(description.contains("not bot uptime or health")); + assert!(description.contains("")); + assert!(!description.contains("b/paid")); + assert!(description.contains("a/free") && description.contains("c/free")); + let options = Browse { + free_only: false, + search: "PAID".into(), + min_context: 200, + page: u32::MAX, + page_size: 1, + compact: true, + }; + let value = serde_json::to_value(snapshot(&state, &options, 75)).unwrap(); + assert!(value["description"].as_str().unwrap().contains("b/paid")); + assert_eq!(value["fields"][2]["value"], "1/1 · 1 per page"); + assert!( + value["fields"][3]["value"] + .as_str() + .unwrap() + .contains("75 seconds") + ); + let options = Browse { + min_context: 201, + ..options + }; + let value = serde_json::to_value(snapshot(&state, &options, 600)).unwrap(); + assert!( + value["description"] + .as_str() + .unwrap() + .contains("No matching models.") + ); + let value = + serde_json::to_value(snapshot(&State::default(), &Browse::default(), 600)).unwrap(); + assert!( + value["description"] + .as_str() + .unwrap() + .contains("No successful catalog fetch") + ); + let options = Browse { + free_only: false, + page_size: 1, + page: 2, + ..Default::default() + }; + let value = serde_json::to_value(snapshot(&state, &options, 600)).unwrap(); + 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"); + } +} diff --git a/src/store.rs b/src/store.rs index e077b60..4e9f45f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; use crate::Error; +use crate::catalog::validate as validate_catalog; use crate::catalog::{Event, Model}; static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -18,14 +19,57 @@ pub struct State { pub last_success: Option, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Subscription { pub channel_id: u64, pub role_id: Option, pub free_only: bool, + #[serde(default)] + pub search: String, + #[serde(default)] + pub min_context: u32, + #[serde(default = "default_true")] + pub ping_enabled: bool, + #[serde(default)] + pub compact: bool, pub pending: Vec, } +fn default_true() -> bool { + true +} + +impl Subscription { + pub fn matches(&self, model: &Model) -> bool { + if (self.free_only && !model.is_free()) + || model.context_length < u64::from(self.min_context) + { + return false; + } + let search = self.search.trim().to_lowercase(); + search.is_empty() + || model.name.to_lowercase().contains(&search) + || model.id.to_lowercase().contains(&search) + } +} + +fn validate_ids(state: &State) -> Result<(), Error> { + for (guild, sub) in &state.subscriptions { + if *guild == 0 || sub.channel_id == 0 { + return Err("subscription IDs must be nonzero".into()); + } + if let Some(role) = sub.role_id { + if role == 0 { + return Err("subscription role ID must be nonzero".into()); + } + if role == *guild { + return Err("subscription role ID must differ from guild ID".into()); + } + } + } + Ok(()) +} + pub struct Store { pub state: State, path: PathBuf, @@ -33,11 +77,15 @@ pub struct Store { impl Store { pub async fn load(path: PathBuf) -> Result { - let state = match tokio::fs::read(&path).await { + let state: State = match tokio::fs::read(&path).await { Ok(bytes) => serde_json::from_slice(&bytes)?, Err(error) if error.kind() == ErrorKind::NotFound => State::default(), Err(error) => return Err(error.into()), }; + if state.initialized || !state.catalog.is_empty() { + validate_catalog(&state.catalog)?; + } + validate_ids(&state)?; Ok(Self { state, path }) } @@ -134,6 +182,10 @@ mod tests { channel_id: 42, role_id: Some(99), free_only: true, + search: " Example ".into(), + min_context: 8192, + ping_enabled: false, + compact: true, pending: vec![Event { model, kind: "new".into(), @@ -146,6 +198,10 @@ mod tests { channel_id: 43, role_id: None, free_only: false, + search: String::new(), + min_context: 0, + ping_enabled: true, + compact: false, pending: Vec::new(), }, ), @@ -186,6 +242,157 @@ mod tests { assert_eq!(std::fs::read_dir(&directory.0).unwrap().count(), 1); } + #[tokio::test] + async fn legacy_states_preserve_pending_and_default_settings() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let mut expected = populated_state(); + let mut legacy = serde_json::to_value(&expected).unwrap(); + for sub in legacy["subscriptions"] + .as_object_mut() + .unwrap() + .values_mut() + { + for field in ["search", "min_context", "ping_enabled", "compact"] { + sub.as_object_mut().unwrap().remove(field); + } + } + for sub in expected.subscriptions.values_mut() { + sub.search.clear(); + sub.min_context = 0; + sub.ping_enabled = true; + sub.compact = false; + } + let bytes = serde_json::to_vec(&legacy).unwrap(); + let decoded: State = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded.subscriptions, expected.subscriptions); + tokio::fs::write(&path, &bytes).await.unwrap(); + let mut loaded = Store::load(path.clone()).await.unwrap(); + assert_eq!(loaded.state.subscriptions, expected.subscriptions); + assert_eq!(loaded.state.catalog, expected.catalog); + assert_eq!(loaded.state.last_success, expected.last_success); + assert!(loaded.state.initialized); + assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes); + loaded.commit(loaded.state.clone()).await.unwrap(); + assert_eq!( + Store::load(path).await.unwrap().state.subscriptions, + expected.subscriptions + ); + } + + #[test] + fn subscription_filters_match_name_or_id_and_combine_constraints() { + let state = populated_state(); + let mut model = state.catalog["example/model"].clone(); + model.name = "Friendly ÉXAMPLE".into(); + let mut sub = state.subscriptions[&u64::MAX].clone(); + for search in ["", " \t\n", " EXAMPLE/MO ", " fRiEnDlY ", "éxample"] { + sub.search = search.into(); + assert!(sub.matches(&model), "{search:?}"); + } + for search in ["missing", "Description", "friendly example/model"] { + sub.search = search.into(); + assert!(!sub.matches(&model), "{search:?}"); + } + sub.search = "friendly".into(); + sub.min_context = 8193; + assert!(!sub.matches(&model)); + sub.min_context = 8192; + assert!(sub.matches(&model)); + model.pricing.insert("prompt".into(), "1".into()); + assert!(!sub.matches(&model)); + sub.free_only = false; + assert!(sub.matches(&model)); + sub.search = "missing".into(); + assert!(!sub.matches(&model)); + sub.search.clear(); + model.context_length = 0; + assert!(!sub.matches(&model)); + sub.min_context = 0; + assert!(sub.matches(&model)); + sub.min_context = u32::MAX; + model.context_length = u64::MAX; + assert!(sub.matches(&model)); + model.pricing.clear(); + sub.free_only = true; + assert!(!sub.matches(&model)); + } + + #[tokio::test] + async fn load_rejects_invalid_subscription_ids_without_overwriting() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + for (guild, channel, role) in [ + (0, 42, None), + (1, 0, None), + (1, 42, Some(0)), + (1, 42, Some(1)), + ] { + for ping_enabled in [false, true] { + let mut state = populated_state(); + let mut sub = state.subscriptions[&u64::MAX].clone(); + sub.channel_id = channel; + sub.role_id = role; + sub.ping_enabled = ping_enabled; + state.subscriptions = BTreeMap::from([(guild, sub)]); + let bytes = serde_json::to_vec(&state).unwrap(); + tokio::fs::write(&path, &bytes).await.unwrap(); + assert!(Store::load(path.clone()).await.is_err()); + assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes); + } + } + } + + #[tokio::test] + async fn load_validates_catalog_for_initialized_and_uninitialized_states() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + for initialized in [false, true] { + for invalid in ["empty", "key", "id", "name"] { + let mut state = populated_state(); + state.initialized = initialized; + match invalid { + "empty" => state.catalog.clear(), + "key" => { + let model = state.catalog.pop_first().unwrap().1; + state.catalog.insert("wrong/key".into(), model); + } + "id" => { + let mut model = state.catalog.pop_first().unwrap().1; + model.id = " ".into(); + state.catalog.insert(model.id.clone(), model); + } + _ => state.catalog.values_mut().next().unwrap().name = " ".into(), + } + let bytes = serde_json::to_vec(&state).unwrap(); + tokio::fs::write(&path, &bytes).await.unwrap(); + let loaded = Store::load(path.clone()).await; + assert_eq!(loaded.is_ok(), !initialized && invalid == "empty"); + assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes); + } + } + } + + #[tokio::test] + async fn new_settings_round_trip_preserves_pending_without_filtering() { + let directory = TestDirectory::new(); + let path = directory.state_path(); + let mut store = Store::load(path.clone()).await.unwrap(); + for ping_enabled in [false, true] { + let mut state = populated_state(); + let sub = state.subscriptions.get_mut(&u64::MAX).unwrap(); + sub.search = " Unmatched ÉXAMPLE ".into(); + sub.min_context = u32::MAX; + sub.ping_enabled = ping_enabled; + sub.compact = !ping_enabled; + assert!(!sub.matches(&sub.pending[0].model)); + store.commit(state.clone()).await.unwrap(); + let loaded = Store::load(path.clone()).await.unwrap(); + assert_eq!(loaded.state.subscriptions, state.subscriptions); + assert_eq!(loaded.state.catalog, state.catalog); + } + } + #[tokio::test] async fn corrupt_files_are_errors_and_not_overwritten() { let directory = TestDirectory::new();