From d8163174fd7c8dd918e1f3c86c7549d61df0cbdb Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sun, 27 Sep 2026 17:25:11 +0530 Subject: [PATCH] feat: rename to /free-models, allow @everyone and custom roles, add number commas --- README.md | 18 ++--- src/main.rs | 155 ++++++++++++++++++++++++++++++-------------- src/presentation.rs | 63 +++++++++++++++--- src/store.rs | 27 ++++---- 4 files changed, 185 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 44d8a7d..ab4daa4 100644 --- a/README.md +++ b/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 ` 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 [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 ``` diff --git a/src/main.rs b/src/main.rs index 143341e..fe3aa73 100644 --- a/src/main.rs +++ b/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, #[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, + #[description = "Notification role or @everyone; enables pings unless explicitly disabled"] + ping_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 = "Enable role pings; requires a configured role or @everyone"] + ping_enabled: Option, #[description = "Short notification rows; omitted retains setting"] compact: Option, - #[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, ) -> 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!(" ()")) .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, 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(); diff --git a/src/presentation.rs b/src/presentation.rs index 597a451..612978b 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -85,24 +85,44 @@ pub fn total_pages(state: &State, options: &Browse) -> usize { count.div_ceil(size).max(1) } +pub fn format_number(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: ().")) .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"); + } } diff --git a/src/store.rs b/src/store.rs index 4e9f45f..9bf64fc 100644 --- a/src/store.rs +++ b/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]