feat: rename to /free-models, allow @everyone and custom roles, add number commas

This commit is contained in:
katR 2026-09-27 17:25:11 +05:30
parent 85bc9e62f6
commit d8163174fd
4 changed files with 185 additions and 78 deletions

View file

@ -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
```

View file

@ -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();

View file

@ -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");
}
}

View file

@ -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]