From 8108f5cd274fc6313e298323e12df2fbb0d9b0a3 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 11 Sep 2026 12:40:37 +0200 Subject: [PATCH] feat: Phase 4 community features - member counter, polls, reminders - feature/counter: voice channel showing live member count, refreshed every 10min - feature/poll: /poll slash command, embed + numbered reactions (2-10 options) - feature/remind: /remind slash command, SQLite-backed personal reminders delivered by background task - db: V3 migration for reminders table - ROADMAP: sync checklist with actual implemented features (verify/moderation/ticket/voice/welcome/blog were already done but unchecked); GitHub webhook -> #changelog closed via native Discord integration (no code) --- .gitignore | 1 + ROADMAP.md | 14 ++++----- src/core/bot.rs | 12 +++++-- src/db/migration.rs | 13 +++++++- src/feature/counter/mod.rs | 3 ++ src/feature/counter/task.rs | 59 +++++++++++++++++++++++++++++++++++ src/feature/mod.rs | 5 +++ src/feature/poll/command.rs | 43 +++++++++++++++++++++++++ src/feature/poll/mod.rs | 3 ++ src/feature/remind/command.rs | 56 +++++++++++++++++++++++++++++++++ src/feature/remind/mod.rs | 5 +++ src/feature/remind/service.rs | 52 ++++++++++++++++++++++++++++++ src/feature/remind/task.rs | 33 ++++++++++++++++++++ src/main.rs | 2 +- 14 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 src/feature/counter/mod.rs create mode 100644 src/feature/counter/task.rs create mode 100644 src/feature/poll/command.rs create mode 100644 src/feature/poll/mod.rs create mode 100644 src/feature/remind/command.rs create mode 100644 src/feature/remind/mod.rs create mode 100644 src/feature/remind/service.rs create mode 100644 src/feature/remind/task.rs diff --git a/.gitignore b/.gitignore index 3ac4f04..b71fb35 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ out/ tools/userbot/.env tools/userbot/node_modules/ tools/userbot/ +ref/ diff --git a/ROADMAP.md b/ROADMAP.md index fbf353f..9bd276d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,15 +43,15 @@ cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run ## Phase 3 - Блог-фичи (главное отличие от старого бота) - [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`) - [ ] `!post` - анонс поста (embed с cover, линк) -- [ ] Showcase: треды в #showcase, реакции ⭐ -- [ ] GitHub webhook → #changelog -- [ ] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, transcript → #ticket-logs) - см. ARCHITECTURE.md +- [x] GitHub webhook → #changelog (нативная интеграция Discord, без кода: GitHub webhook URL = `/github`) +- [x] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, blacklist, transcript → hastebin) - `feature/ticket` ## Phase 4 - Комьюнити -- [ ] Welcome embed, counters (member count в канале) -- [ ] Polls в #ideas -- [ ] Scheduler (напоминания) -- [ ] Temp voice (`#create-voice` → временный канал) +- [x] Welcome embed (`feature/welcome`) +- [x] Counters (member count в voice-канале, `feature/counter`, refresh 10 мин) +- [x] Polls (`/poll вопрос; вариант1; вариант2`, embed + реакции цифрами, `feature/poll`) +- [x] Scheduler / напоминания (`/remind 10m текст`, личные, SQLite + фоновый воркер 30с, `feature/remind`) +- [x] Temp voice (`#create-voice` → временный канал, `feature/voice`) ## Phase 5 - Опционально (AI) - [ ] AI FAQ (DeepSeek/GPT) в #faq - только по базе знаний блога diff --git a/src/core/bot.rs b/src/core/bot.rs index c90b741..4fdb2aa 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -8,7 +8,10 @@ use serenity::client::{Client, Context, EventHandler}; use tracing::{info, warn}; /// Handles `Ready` - logs bot user + guild count. -struct Handler; +struct Handler { + guild_id: Option, + pool: sqlx::SqlitePool, +} #[serenity::async_trait] impl EventHandler for Handler { @@ -26,6 +29,10 @@ impl EventHandler for Handler { warn!(error = %e, "ensure_verify_message failed"); } }); + if let Some(gid) = self.guild_id { + crate::feature::counter::task::spawn(ctx.clone(), serenity::all::GuildId::new(gid)); + } + crate::feature::remind::task::spawn(ctx, self.pool.clone()); } async fn reaction_add(&self, ctx: Context, reaction: Reaction) { @@ -95,6 +102,7 @@ async fn handle_component(ctx: Context, interaction: ComponentInteraction) { /// Builds Serenity client with intents + poise framework. pub async fn build_client( cfg: &BotConfig, + pool: sqlx::SqlitePool, framework: Framework, ) -> anyhow::Result { let intents = GatewayIntents::GUILD_MESSAGES @@ -105,7 +113,7 @@ pub async fn build_client( | GatewayIntents::GUILD_VOICE_STATES; let client = Client::builder(&cfg.token, intents) - .event_handler(Handler) + .event_handler(Handler { guild_id: cfg.guild_id, pool }) .framework(framework) .activity(ActivityData::custom(cfg.activity.clone())) .await?; diff --git a/src/db/migration.rs b/src/db/migration.rs index f3ac635..8f948fb 100644 --- a/src/db/migration.rs +++ b/src/db/migration.rs @@ -12,7 +12,8 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> { .execute(pool) .await?; - let migrations = [("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS)]; + let migrations = + [("V1__initial", V1_INITIAL), ("V2__tickets", V2_TICKETS), ("V3__reminders", V3_REMINDERS)]; for (name, sql) in migrations { let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?") .bind(name) @@ -65,3 +66,13 @@ CREATE TABLE IF NOT EXISTS tickets ( opener_id TEXT NOT NULL ); "; +const V3_REMINDERS: &str = " +CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message TEXT NOT NULL, + due_at TEXT NOT NULL, + sent INTEGER NOT NULL DEFAULT 0 +); +"; diff --git a/src/feature/counter/mod.rs b/src/feature/counter/mod.rs new file mode 100644 index 0000000..d1c7d4a --- /dev/null +++ b/src/feature/counter/mod.rs @@ -0,0 +1,3 @@ +//! Member-count counter (Phase 4) - dedicated voice channel showing live guild size. + +pub mod task; diff --git a/src/feature/counter/task.rs b/src/feature/counter/task.rs new file mode 100644 index 0000000..3214a0b --- /dev/null +++ b/src/feature/counter/task.rs @@ -0,0 +1,59 @@ +//! Background loop: keeps a voice channel named "👥|Members: N" in sync. +//! Discord limits channel renames to ~2/10min, so we poll every 10 minutes +//! and skip the edit entirely when the count has not changed. + +use serenity::all::{ChannelId, ChannelType, Context, CreateChannel, EditChannel, GuildId}; +use tracing::{info, warn}; + +const VOICE_CATEGORY: ChannelId = ChannelId::new(1509503649062326484); +const MARKER: &str = "👥|Members:"; +const INTERVAL_SECS: u64 = 600; + +/// Finds the counter channel by name marker, creating it once if missing. +async fn ensure_channel(ctx: &Context, guild_id: GuildId) -> anyhow::Result { + let channels = guild_id.channels(&ctx.http).await?; + if let Some((id, _)) = channels.iter().find(|(_, c)| c.name.starts_with(MARKER)) { + return Ok(*id); + } + let created = guild_id + .create_channel( + &ctx.http, + CreateChannel::new(format!("{MARKER} 0")) + .kind(ChannelType::Voice) + .category(VOICE_CATEGORY), + ) + .await?; + info!(channel = %created.id, "member counter channel created"); + Ok(created.id) +} + +/// Spawns the periodic counter-refresh task. Non-blocking, one instance per boot. +pub fn spawn(ctx: Context, guild_id: GuildId) { + tokio::spawn(async move { + let mut last_count: Option = None; + loop { + match run_once(&ctx, guild_id, &mut last_count).await { + Ok(()) => {} + Err(e) => warn!(error = %e, "member counter refresh failed"), + } + tokio::time::sleep(std::time::Duration::from_secs(INTERVAL_SECS)).await; + } + }); +} + +async fn run_once( + ctx: &Context, + guild_id: GuildId, + last_count: &mut Option, +) -> anyhow::Result<()> { + let count = + ctx.http.get_guild_with_counts(guild_id).await?.approximate_member_count.unwrap_or(0); + if *last_count == Some(count) { + return Ok(()); + } + let channel_id = ensure_channel(ctx, guild_id).await?; + channel_id.edit(&ctx.http, EditChannel::new().name(format!("{MARKER} {count}"))).await?; + *last_count = Some(count); + info!(count, "member counter updated"); + Ok(()) +} diff --git a/src/feature/mod.rs b/src/feature/mod.rs index 6c95f24..0e30925 100644 --- a/src/feature/mod.rs +++ b/src/feature/mod.rs @@ -1,9 +1,12 @@ pub mod admin; pub mod blog; +pub mod counter; pub mod help; pub mod info; pub mod lang; pub mod moderation; +pub mod poll; +pub mod remind; pub mod ticket; pub mod verify; pub mod voice; @@ -40,6 +43,8 @@ pub async fn build_framework( moderation::command::slowmode(), moderation::command::warn(), blog::command::blog_post(), + poll::command::poll(), + remind::command::remind(), ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some(cfg.prefix.clone().into()), diff --git a/src/feature/poll/command.rs b/src/feature/poll/command.rs new file mode 100644 index 0000000..ebda16a --- /dev/null +++ b/src/feature/poll/command.rs @@ -0,0 +1,43 @@ +//! `/poll` slash command - embed with numbered options + reaction voting. + +use crate::feature::Ctx; +use poise::CreateReply; +use serenity::all::{CreateEmbed, CreateEmbedFooter, ReactionType}; + +const NUMBER_EMOJIS: [&str; 10] = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]; + +/// `/poll ` - options separated by `;` (2-10). Adds number reactions. +#[poise::command(slash_command, prefix_command, guild_only)] +pub async fn poll( + ctx: Ctx<'_>, + #[description = "Poll question"] question: String, + #[description = "Options separated by ;"] options: String, +) -> anyhow::Result<()> { + let opts: Vec<&str> = options.split(';').map(str::trim).filter(|s| !s.is_empty()).collect(); + if opts.len() < 2 || opts.len() > NUMBER_EMOJIS.len() { + ctx.send( + CreateReply::default() + .content(format!("Provide 2-{} options separated by `;`.", NUMBER_EMOJIS.len())) + .ephemeral(true), + ) + .await?; + return Ok(()); + } + let description = opts + .iter() + .enumerate() + .map(|(i, o)| format!("{} {o}", NUMBER_EMOJIS[i])) + .collect::>() + .join("\n"); + let embed = CreateEmbed::new() + .title(question) + .description(description) + .color(0x0058_65F2) + .footer(CreateEmbedFooter::new(format!("Poll by {}", ctx.author().name))); + let reply = ctx.send(CreateReply::default().embed(embed)).await?; + let msg = reply.message().await?; + for emoji in NUMBER_EMOJIS.iter().take(opts.len()) { + msg.react(ctx.http(), ReactionType::Unicode((*emoji).to_string())).await?; + } + Ok(()) +} diff --git a/src/feature/poll/mod.rs b/src/feature/poll/mod.rs new file mode 100644 index 0000000..be9567d --- /dev/null +++ b/src/feature/poll/mod.rs @@ -0,0 +1,3 @@ +//! Poll feature (Phase 4) - `/poll` slash command, embed + number-emoji reactions. + +pub mod command; diff --git a/src/feature/remind/command.rs b/src/feature/remind/command.rs new file mode 100644 index 0000000..3841226 --- /dev/null +++ b/src/feature/remind/command.rs @@ -0,0 +1,56 @@ +//! `/remind` slash command - personal reminders, delivered in the invoking channel. + +use crate::feature::Ctx; +use poise::CreateReply; + +/// Parses `10m`, `2h`, `1d`, `30s` into seconds. No external crate - single suffix char. +fn parse_duration(input: &str) -> Option { + let input = input.trim(); + let (digits, unit) = input.split_at(input.len().checked_sub(1)?); + let value: u64 = digits.parse().ok()?; + let secs = match unit { + "s" => value, + "m" => value.checked_mul(60)?, + "h" => value.checked_mul(3600)?, + "d" => value.checked_mul(86_400)?, + _ => return None, + }; + (secs > 0).then_some(secs) +} + +/// `/remind ` - e.g. `/remind 10m stretch break`. Max 7 days. +#[poise::command(slash_command, prefix_command, guild_only)] +pub async fn remind( + ctx: Ctx<'_>, + #[description = "Duration, e.g. 30s, 10m, 2h, 1d"] duration: String, + #[description = "Reminder text"] message: String, +) -> anyhow::Result<()> { + let Some(seconds) = parse_duration(&duration) else { + ctx.send( + CreateReply::default() + .content("Invalid duration. Use e.g. `30s`, `10m`, `2h`, `1d`.") + .ephemeral(true), + ) + .await?; + return Ok(()); + }; + if seconds > 7 * 86_400 { + ctx.send(CreateReply::default().content("Max duration is 7 days.").ephemeral(true)).await?; + return Ok(()); + } + super::service::insert( + &ctx.data().pool, + &ctx.author().id.to_string(), + &ctx.channel_id().to_string(), + &message, + seconds, + ) + .await?; + ctx.send( + CreateReply::default() + .content(format!("⏰ Got it, I'll remind you in {duration}.")) + .ephemeral(true), + ) + .await?; + Ok(()) +} diff --git a/src/feature/remind/mod.rs b/src/feature/remind/mod.rs new file mode 100644 index 0000000..3290720 --- /dev/null +++ b/src/feature/remind/mod.rs @@ -0,0 +1,5 @@ +//! Reminder feature (Phase 4) - `/remind` slash command + background delivery task. + +pub mod command; +pub mod service; +pub mod task; diff --git a/src/feature/remind/service.rs b/src/feature/remind/service.rs new file mode 100644 index 0000000..82cdebb --- /dev/null +++ b/src/feature/remind/service.rs @@ -0,0 +1,52 @@ +//! DB access for reminders - insert on `/remind`, poll due ones from the background task. + +use sqlx::SqlitePool; + +/// A reminder row ready for delivery. +pub struct DueReminder { + pub id: i64, + pub user_id: String, + pub channel_id: String, + pub message: String, +} + +/// Inserts a new reminder due `in_seconds` from now (computed by SQLite, no clock skew). +pub async fn insert( + pool: &SqlitePool, + user_id: &str, + channel_id: &str, + message: &str, + in_seconds: u64, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO reminders(user_id, channel_id, message, due_at) \ + VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))", + ) + .bind(user_id) + .bind(channel_id) + .bind(message) + .bind(in_seconds as i64) + .execute(pool) + .await?; + Ok(()) +} + +/// Fetches reminders due now that have not been sent yet. +pub async fn fetch_due(pool: &SqlitePool) -> anyhow::Result> { + let rows = sqlx::query_as::<_, (i64, String, String, String)>( + "SELECT id, user_id, channel_id, message FROM reminders \ + WHERE sent = 0 AND due_at <= datetime('now')", + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, user_id, channel_id, message)| DueReminder { id, user_id, channel_id, message }) + .collect()) +} + +/// Marks a reminder as sent so it is not delivered twice. +pub async fn mark_sent(pool: &SqlitePool, id: i64) -> anyhow::Result<()> { + sqlx::query("UPDATE reminders SET sent = 1 WHERE id = ?").bind(id).execute(pool).await?; + Ok(()) +} diff --git a/src/feature/remind/task.rs b/src/feature/remind/task.rs new file mode 100644 index 0000000..540409f --- /dev/null +++ b/src/feature/remind/task.rs @@ -0,0 +1,33 @@ +//! Background loop: delivers due reminders as a mention in their origin channel. + +use serenity::all::{ChannelId, Context}; +use sqlx::SqlitePool; +use tracing::warn; + +const POLL_INTERVAL_SECS: u64 = 30; + +/// Spawns the periodic reminder-delivery task. Non-blocking, one instance per boot. +pub fn spawn(ctx: Context, pool: SqlitePool) { + tokio::spawn(async move { + loop { + if let Err(e) = run_once(&ctx, &pool).await { + warn!(error = %e, "reminder delivery failed"); + } + tokio::time::sleep(std::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; + } + }); +} + +async fn run_once(ctx: &Context, pool: &SqlitePool) -> anyhow::Result<()> { + for r in super::service::fetch_due(pool).await? { + let (Ok(user_id), Ok(channel_id)) = (r.user_id.parse::(), r.channel_id.parse::()) + else { + super::service::mark_sent(pool, r.id).await?; + continue; + }; + let content = format!("⏰ <@{user_id}> reminder: {}", r.message); + let _ = ChannelId::new(channel_id).say(&ctx.http, content).await; + super::service::mark_sent(pool, r.id).await?; + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 02b703b..5c85c35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ async fn main() -> anyhow::Result<()> { let framework = feature::build_framework(cfg.clone(), pool.clone()).await?; - let client = core::bot::build_client(&cfg, framework).await?; + let client = core::bot::build_client(&cfg, pool.clone(), framework).await?; core::bot::run_with_shutdown(client).await?; db::pool::close(pool).await; Ok(())