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)
This commit is contained in:
loki5512344 2026-09-11 12:40:37 +02:00
parent 1d49d81fad
commit 8108f5cd27
Signed by: boba
GPG key ID: 253067914055423B
14 changed files with 290 additions and 11 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ out/
tools/userbot/.env
tools/userbot/node_modules/
tools/userbot/
ref/

View file

@ -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 = `<discord-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 - только по базе знаний блога

View file

@ -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<u64>,
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<crate::feature::Data, anyhow::Error>,
) -> anyhow::Result<Client> {
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?;

View file

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

View file

@ -0,0 +1,3 @@
//! Member-count counter (Phase 4) - dedicated voice channel showing live guild size.
pub mod task;

View file

@ -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<ChannelId> {
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<u64> = 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<u64>,
) -> 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(())
}

View file

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

View file

@ -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 <question> <options>` - 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::<Vec<_>>()
.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(())
}

3
src/feature/poll/mod.rs Normal file
View file

@ -0,0 +1,3 @@
//! Poll feature (Phase 4) - `/poll` slash command, embed + number-emoji reactions.
pub mod command;

View file

@ -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<u64> {
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 <duration> <message>` - 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(())
}

View file

@ -0,0 +1,5 @@
//! Reminder feature (Phase 4) - `/remind` slash command + background delivery task.
pub mod command;
pub mod service;
pub mod task;

View file

@ -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<Vec<DueReminder>> {
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(())
}

View file

@ -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::<u64>(), r.channel_id.parse::<u64>())
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(())
}

View file

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