feat: rename to /free-models, allow @everyone and custom roles, add number commas
This commit is contained in:
parent
85bc9e62f6
commit
d8163174fd
4 changed files with 185 additions and 78 deletions
18
README.md
18
README.md
|
|
@ -16,11 +16,11 @@ A lightweight, crash-resilient Discord bot built in Rust with [poise](https://gi
|
|||
- `search`: Filter releases by model identifier or name (e.g., `qwen`, `llama`, `coder`).
|
||||
- `min_context`: Ignore models with context windows below a given token threshold (e.g. `32768`).
|
||||
- `compact`: Dense notification rows for high-volume channels.
|
||||
- **Controlled Opt-In Role Pings:** Mentionable role pings configured per server. Explicitly disallows `@everyone` and `@here` mentions to protect against ping abuse.
|
||||
- **Controlled Opt-In Role Pings:** Role pings configured per server (supports custom roles as well as `@everyone`).
|
||||
- **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.
|
||||
- **Interactive Discord Buttons:** Multi-page `/free-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`).
|
||||
|
|
@ -33,10 +33,10 @@ 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 interactive `[◀ Prev]` / `[Next ▶]` buttons and search filter. |
|
||||
| `/free-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. |
|
||||
| `/watch [ping_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. Supports any role or `@everyone`. 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. |
|
||||
| `/testping` | Manage Server | Dispatch a labeled test notification to verify channel permissions and role mentionability without modifying catalog state. |
|
||||
|
|
@ -114,13 +114,13 @@ cargo run --release --locked
|
|||
|
||||
## 🛠️ Setting Up Alerts in Discord
|
||||
|
||||
1. In your Discord server, create an opt-in role for notifications (e.g. `@ModelAlerts`).
|
||||
2. Ensure the role is **mentionable** in Server Settings → Roles.
|
||||
3. In the channel where alerts should be posted, run:
|
||||
1. In your Discord server, pick a role for notifications (e.g. `@ModelAlerts`, `@everyone`, or any custom role).
|
||||
2. In the channel where alerts should be posted, run:
|
||||
```
|
||||
/watch role:@ModelAlerts ping_enabled:true
|
||||
/watch ping_role:@ModelAlerts ping_enabled:true
|
||||
```
|
||||
4. Verify the setup by running:
|
||||
*(Or `/watch ping_role:@everyone ping_enabled:true` to notify everyone).*
|
||||
3. Verify the setup by running:
|
||||
```
|
||||
/testping
|
||||
```
|
||||
|
|
|
|||
155
src/main.rs
155
src/main.rs
|
|
@ -171,8 +171,8 @@ async fn browse(ctx: Context<'_>, options: Browse, public: bool) -> Result<(), E
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[poise::command(slash_command)]
|
||||
async fn models(
|
||||
#[poise::command(slash_command, rename = "free-models")]
|
||||
async fn free_models(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Filter model ID or name"] search: Option<String>,
|
||||
#[description = "Page number"]
|
||||
|
|
@ -372,19 +372,21 @@ fn patch_subscription(
|
|||
}
|
||||
|
||||
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
|
||||
role.guild_id == guild && role.id.get() != 0
|
||||
}
|
||||
|
||||
fn settings(sub: &Subscription, poll_seconds: u64) -> String {
|
||||
fn settings(sub: &Subscription, guild_id: u64, poll_seconds: u64) -> String {
|
||||
format!(
|
||||
"Channel: <#{}>\nRole: {}\nPings enabled: {}\nScope: {}\nSearch: {}\nMinimum context: {} tokens\nCompact: {}\nQueued alerts: {}\nPoll interval: {poll_seconds} seconds",
|
||||
"Channel: <#{}>\nRole: {}\nPings enabled: {}\nScope: {}\nSearch: {}\nMinimum context: {} tokens\nCompact: {}\nQueued alerts: {}\nPoll interval: {} seconds",
|
||||
sub.channel_id,
|
||||
sub.role_id
|
||||
.map(|id| format!("<@&{id}>"))
|
||||
.map(|id| {
|
||||
if id == guild_id {
|
||||
"@everyone".into()
|
||||
} else {
|
||||
format!("<@&{id}>")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "None".into()),
|
||||
sub.ping_enabled,
|
||||
if sub.free_only {
|
||||
|
|
@ -393,13 +395,14 @@ fn settings(sub: &Subscription, poll_seconds: u64) -> String {
|
|||
"All new models + becoming free"
|
||||
},
|
||||
text(&sub.search, 500),
|
||||
sub.min_context,
|
||||
presentation::format_number(sub.min_context),
|
||||
sub.compact,
|
||||
sub.pending.len()
|
||||
presentation::format_number(sub.pending.len()),
|
||||
presentation::format_number(poll_seconds)
|
||||
)
|
||||
}
|
||||
|
||||
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.";
|
||||
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, select a role or @everyone with /watch ping_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,
|
||||
|
|
@ -410,25 +413,24 @@ const WATCH_HELP: &str = "Automatic alerts cover future catalog detections, not
|
|||
#[allow(clippy::too_many_arguments)]
|
||||
async fn watch(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Mentionable opt-in notification role; enables pings unless explicitly disabled"]
|
||||
role: Option<serenity::Role>,
|
||||
#[description = "Notification role or @everyone; enables pings unless explicitly disabled"]
|
||||
ping_role: Option<serenity::Role>,
|
||||
#[description = "Only free models (new subscriptions default false; omitted retains setting)"]
|
||||
free_only: Option<bool>,
|
||||
#[description = "Filter ID or name; a single space clears; omitted retains setting"]
|
||||
search: Option<String>,
|
||||
#[description = "Minimum context tokens; 0 clears; omitted retains setting"]
|
||||
min_context: Option<u32>,
|
||||
#[description = "Enable role pings; requires a valid mentionable role"] ping_enabled: Option<
|
||||
bool,
|
||||
>,
|
||||
#[description = "Enable role pings; requires a configured role or @everyone"]
|
||||
ping_enabled: Option<bool>,
|
||||
#[description = "Short notification rows; omitted retains setting"] compact: Option<bool>,
|
||||
#[description = "Remove the notification role and disable pings; conflicts with role"]
|
||||
#[description = "Remove the notification role and disable pings; conflicts with ping_role"]
|
||||
clear_role: Option<bool>,
|
||||
) -> Result<(), Error> {
|
||||
ctx.defer_ephemeral().await?;
|
||||
let guild = ctx.guild_id().ok_or("Server required")?;
|
||||
let patch = WatchPatch {
|
||||
role_id: role.as_ref().map(|role| role.id.get()),
|
||||
role_id: ping_role.as_ref().map(|role| role.id.get()),
|
||||
free_only,
|
||||
search: search.map(|value| value.trim().to_owned()),
|
||||
min_context,
|
||||
|
|
@ -436,20 +438,21 @@ async fn watch(
|
|||
compact,
|
||||
clear_role: clear_role.unwrap_or(false),
|
||||
};
|
||||
if patch.clear_role && role.is_some() {
|
||||
if patch.clear_role && ping_role.is_some() {
|
||||
return reply(
|
||||
ctx,
|
||||
embed("Conflicting settings")
|
||||
.description("Choose either clear_role:true or role, not both."),
|
||||
.description("Choose either clear_role:true or ping_role, not both."),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if role.as_ref().is_some_and(|role| !valid_role(role, guild)) {
|
||||
if ping_role
|
||||
.as_ref()
|
||||
.is_some_and(|role| !valid_role(role, guild))
|
||||
{
|
||||
return reply(
|
||||
ctx,
|
||||
embed("Choose an opt-in role").description(
|
||||
"Use a mentionable, unmanaged role from this server; @everyone is not allowed.",
|
||||
),
|
||||
embed("Invalid role").description("Choose a role from this server or @everyone."),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -509,13 +512,13 @@ async fn watch(
|
|||
.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;
|
||||
return reply(ctx, embed("Invalid role").description("The configured role is missing. Set ping_role to a valid role or @everyone, 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),
|
||||
settings(&sub, guild.get(), ctx.data().poll_seconds),
|
||||
false,
|
||||
);
|
||||
let mut next = guard.state.clone();
|
||||
|
|
@ -556,19 +559,33 @@ async fn stats(ctx: Context<'_>) -> Result<(), Error> {
|
|||
.last_success
|
||||
.map(|time| format!("<t:{time}:f> (<t:{time}:R>)"))
|
||||
.unwrap_or_else(|| "Never".into());
|
||||
let total = presentation::format_number(guard.state.catalog.len());
|
||||
let free = presentation::format_number(
|
||||
guard
|
||||
.state
|
||||
.catalog
|
||||
.values()
|
||||
.filter(|model| model.is_free())
|
||||
.count(),
|
||||
);
|
||||
let poll = presentation::format_number(ctx.data().poll_seconds);
|
||||
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(|model| model.is_free()).count().to_string(), true)
|
||||
.field("Catalog", total, true)
|
||||
.field("Free", free, true)
|
||||
.field("Last successful catalog fetch", last, false)
|
||||
.field("Poll interval", format!("{} seconds", ctx.data().poll_seconds), true);
|
||||
.field("Poll interval", format!("{poll} seconds"), true);
|
||||
if let Some(sub) = ctx
|
||||
.guild_id()
|
||||
.and_then(|id| guard.state.subscriptions.get(&id.get()))
|
||||
{
|
||||
result = result.field(
|
||||
"Complete server settings",
|
||||
settings(sub, ctx.data().poll_seconds),
|
||||
settings(
|
||||
sub,
|
||||
ctx.guild_id().map(|g| g.get()).unwrap_or_default(),
|
||||
ctx.data().poll_seconds,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -586,10 +603,11 @@ async fn stats(ctx: Context<'_>) -> Result<(), Error> {
|
|||
#[poise::command(slash_command)]
|
||||
async fn about(ctx: Context<'_>) -> Result<(), Error> {
|
||||
ctx.defer_ephemeral().await?;
|
||||
let poll = presentation::format_number(ctx.data().poll_seconds);
|
||||
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.")
|
||||
"OpenRouter catalog snapshots and automatic opt-in release alerts.\n\n/free-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); ping_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 or @everyone (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);
|
||||
.field("Poll interval", format!("{poll} seconds"), true);
|
||||
if let Some(url) = &ctx.data().support_url {
|
||||
result = result.field(
|
||||
"Optional support",
|
||||
|
|
@ -600,7 +618,11 @@ async fn about(ctx: Context<'_>) -> Result<(), Error> {
|
|||
reply(ctx, result).await
|
||||
}
|
||||
|
||||
fn notification_message(sub: &Subscription, count: usize) -> serenity::CreateMessage {
|
||||
fn notification_message(
|
||||
sub: &Subscription,
|
||||
guild_id: u64,
|
||||
count: usize,
|
||||
) -> serenity::CreateMessage {
|
||||
let description = sub
|
||||
.pending
|
||||
.iter()
|
||||
|
|
@ -620,6 +642,7 @@ fn notification_message(sub: &Subscription, count: usize) -> serenity::CreateMes
|
|||
.join("\n\n");
|
||||
role_message(
|
||||
sub,
|
||||
guild_id,
|
||||
embed("Model Radar · catalog update").description(if description.is_empty() {
|
||||
"No queued detections."
|
||||
} else {
|
||||
|
|
@ -628,14 +651,23 @@ fn notification_message(sub: &Subscription, count: usize) -> serenity::CreateMes
|
|||
)
|
||||
}
|
||||
|
||||
fn role_message(sub: &Subscription, embed: serenity::CreateEmbed) -> serenity::CreateMessage {
|
||||
fn role_message(
|
||||
sub: &Subscription,
|
||||
guild_id: u64,
|
||||
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}>"));
|
||||
if role == guild_id {
|
||||
mentions = mentions.everyone(true);
|
||||
message = message.content("@everyone");
|
||||
} else {
|
||||
mentions = mentions.roles([serenity::RoleId::new(role)]);
|
||||
message = message.content(format!("<@&{role}>"));
|
||||
}
|
||||
}
|
||||
message.allowed_mentions(mentions)
|
||||
}
|
||||
|
|
@ -662,7 +694,7 @@ async fn testping(ctx: Context<'_>) -> Result<(), Error> {
|
|||
return reply(
|
||||
ctx,
|
||||
embed("No subscription")
|
||||
.description("Configure /watch role:@YourRole ping_enabled:true first."),
|
||||
.description("Configure /watch ping_role:@YourRole ping_enabled:true first."),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
|
@ -673,10 +705,26 @@ async fn testping(ctx: Context<'_>) -> Result<(), Error> {
|
|||
.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;
|
||||
return reply(
|
||||
ctx,
|
||||
embed("Pings unavailable").description(
|
||||
"Configure /watch ping_role:@YourRole ping_enabled:true with a valid role or @everyone 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?;
|
||||
serenity::ChannelId::new(sub.channel_id)
|
||||
.send_message(
|
||||
ctx.serenity_context(),
|
||||
role_message(
|
||||
&sub,
|
||||
guild.get(),
|
||||
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(
|
||||
|
|
@ -758,7 +806,7 @@ async fn deliver(store: &Mutex<Store>, http: &serenity::Http) -> Result<(), Erro
|
|||
}
|
||||
let count = sub.pending.len().min(8);
|
||||
if serenity::ChannelId::new(sub.channel_id)
|
||||
.send_message(http, notification_message(&sub, count))
|
||||
.send_message(http, notification_message(&sub, guild, count))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
|
|
@ -876,7 +924,7 @@ async fn main() -> Result<(), Error> {
|
|||
tracing::warn!("Initial catalog fetch failed; using saved state and retrying");
|
||||
}
|
||||
let mut commands = vec![
|
||||
models(),
|
||||
free_models(),
|
||||
status(),
|
||||
model(),
|
||||
watch(),
|
||||
|
|
@ -1253,7 +1301,7 @@ mod tests {
|
|||
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 payload = serde_json::to_value(notification_message(&sub, 1, 8)).unwrap();
|
||||
let mentions = &payload["allowed_mentions"];
|
||||
assert_eq!(mentions["parse"], serde_json::json!([]));
|
||||
assert_eq!(mentions["replied_user"], false);
|
||||
|
|
@ -1266,8 +1314,18 @@ mod tests {
|
|||
assert!(mentions["roles"].as_array().is_none_or(Vec::is_empty));
|
||||
}
|
||||
}
|
||||
// Test @everyone ping (where role_id == guild_id)
|
||||
sub.role_id = Some(1);
|
||||
sub.ping_enabled = true;
|
||||
let payload = serde_json::to_value(notification_message(&sub, 1, 8)).unwrap();
|
||||
assert_eq!(payload["content"], "@everyone");
|
||||
assert_eq!(
|
||||
payload["allowed_mentions"]["parse"],
|
||||
serde_json::json!(["everyone"])
|
||||
);
|
||||
|
||||
sub.role_id = None;
|
||||
let payload = serde_json::to_value(notification_message(&sub, 8)).unwrap();
|
||||
let payload = serde_json::to_value(notification_message(&sub, 1, 8)).unwrap();
|
||||
assert!(payload["content"].as_str().is_none_or(str::is_empty));
|
||||
}
|
||||
|
||||
|
|
@ -1285,12 +1343,12 @@ mod tests {
|
|||
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();
|
||||
let payload = serde_json::to_value(notification_message(&sub, 1, 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),
|
||||
settings(&sub, 1, 86400),
|
||||
false,
|
||||
))
|
||||
.unwrap();
|
||||
|
|
@ -1311,6 +1369,7 @@ mod tests {
|
|||
}
|
||||
let payload = serde_json::to_value(role_message(
|
||||
&sub,
|
||||
1,
|
||||
embed("TEST · Model Radar role ping").description("TEST only; no new model detected."),
|
||||
))
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -85,24 +85,44 @@ pub fn total_pages(state: &State, options: &Browse) -> usize {
|
|||
count.div_ceil(size).max(1)
|
||||
}
|
||||
|
||||
pub fn format_number<T: std::fmt::Display>(n: T) -> String {
|
||||
let s = n.to_string();
|
||||
let (prefix, suffix) = if let Some(idx) = s.find('.') {
|
||||
(&s[..idx], &s[idx..])
|
||||
} else {
|
||||
(s.as_str(), "")
|
||||
};
|
||||
let mut result = String::new();
|
||||
let bytes = prefix.as_bytes();
|
||||
let len = bytes.len();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
result.push(b as char);
|
||||
let rem = len - 1 - i;
|
||||
if rem > 0 && rem % 3 == 0 && b.is_ascii_digit() && (i > 0 || b != b'-') {
|
||||
result.push(',');
|
||||
}
|
||||
}
|
||||
result.push_str(suffix);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn model_line(model: &Model, compact: bool) -> String {
|
||||
let pricing = if model.is_free() {
|
||||
"Free"
|
||||
} else {
|
||||
"Paid / unknown"
|
||||
};
|
||||
let formatted_context = format_number(model.context_length);
|
||||
if compact {
|
||||
format!(
|
||||
"{} · {} tokens · {pricing}",
|
||||
"{} · {formatted_context} tokens · {pricing}",
|
||||
text(&model.id, 220),
|
||||
model.context_length
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"**{}**\n{} · {} tokens · {pricing}",
|
||||
"**{}**\n{} · {formatted_context} tokens · {pricing}",
|
||||
text(&model.name, 85),
|
||||
text(&model.id, 220),
|
||||
model.context_length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,18 +153,34 @@ pub fn snapshot(state: &State, options: &Browse, poll_seconds: u64) -> serenity:
|
|||
.last_success
|
||||
.map(|time| format!("Last successful catalog fetch: <t:{time}:f> (<t:{time}:R>)."))
|
||||
.unwrap_or_else(|| "No successful catalog fetch recorded.".into());
|
||||
let formatted_total = format_number(state.catalog.len());
|
||||
let formatted_free = format_number(
|
||||
state
|
||||
.catalog
|
||||
.values()
|
||||
.filter(|model| model.is_free())
|
||||
.count(),
|
||||
);
|
||||
let formatted_matching = format_number(matches.len());
|
||||
let formatted_min_ctx = format_number(options.min_context);
|
||||
let formatted_poll = format_number(poll_seconds);
|
||||
|
||||
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("Catalog", format!("{formatted_total} total · {formatted_free} free · {formatted_matching} matching"), false)
|
||||
.field("Filters", format!("Scope: {}\nSearch: {}\nMinimum context: {formatted_min_ctx} tokens\nCompact: {}", if options.free_only { "Free only" } else { "All models" }, text(&options.search, 240), 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)
|
||||
.field("Poll interval", format!("{formatted_poll} 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(
|
||||
"Context",
|
||||
format!("{} tokens", format_number(model.context_length)),
|
||||
true,
|
||||
)
|
||||
.field(
|
||||
"Free catalog pricing",
|
||||
if model.is_free() {
|
||||
|
|
@ -364,4 +400,15 @@ pub mod tests {
|
|||
};
|
||||
assert_eq!(total_pages(&state, &empty_filter), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_commas() {
|
||||
assert_eq!(format_number(0), "0");
|
||||
assert_eq!(format_number(999), "999");
|
||||
assert_eq!(format_number(1000), "1,000");
|
||||
assert_eq!(format_number(10000), "10,000");
|
||||
assert_eq!(format_number(128000), "128,000");
|
||||
assert_eq!(format_number(1000000), "1,000,000");
|
||||
assert_eq!(format_number(u64::MAX), "18,446,744,073,709,551,615");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
src/store.rs
27
src/store.rs
|
|
@ -58,13 +58,10 @@ fn validate_ids(state: &State) -> Result<(), Error> {
|
|||
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());
|
||||
}
|
||||
if let Some(role) = sub.role_id
|
||||
&& role == 0
|
||||
{
|
||||
return Err("subscription role ID must be nonzero".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -322,12 +319,7 @@ mod tests {
|
|||
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 (guild, channel, role) in [(0, 42, None), (1, 0, None), (1, 42, Some(0))] {
|
||||
for ping_enabled in [false, true] {
|
||||
let mut state = populated_state();
|
||||
let mut sub = state.subscriptions[&u64::MAX].clone();
|
||||
|
|
@ -341,6 +333,15 @@ mod tests {
|
|||
assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
// role == guild (i.e. @everyone) is valid
|
||||
let mut state = populated_state();
|
||||
let mut sub = state.subscriptions[&u64::MAX].clone();
|
||||
sub.channel_id = 42;
|
||||
sub.role_id = Some(1);
|
||||
state.subscriptions = BTreeMap::from([(1, sub)]);
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
tokio::fs::write(&path, &bytes).await.unwrap();
|
||||
assert!(Store::load(path.clone()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue