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:
parent
1d49d81fad
commit
8108f5cd27
14 changed files with 290 additions and 11 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ out/
|
|||
tools/userbot/.env
|
||||
tools/userbot/node_modules/
|
||||
tools/userbot/
|
||||
ref/
|
||||
|
|
|
|||
14
ROADMAP.md
14
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 = `<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 - только по базе знаний блога
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
";
|
||||
|
|
|
|||
3
src/feature/counter/mod.rs
Normal file
3
src/feature/counter/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Member-count counter (Phase 4) - dedicated voice channel showing live guild size.
|
||||
|
||||
pub mod task;
|
||||
59
src/feature/counter/task.rs
Normal file
59
src/feature/counter/task.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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()),
|
||||
|
|
|
|||
43
src/feature/poll/command.rs
Normal file
43
src/feature/poll/command.rs
Normal 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
3
src/feature/poll/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Poll feature (Phase 4) - `/poll` slash command, embed + number-emoji reactions.
|
||||
|
||||
pub mod command;
|
||||
56
src/feature/remind/command.rs
Normal file
56
src/feature/remind/command.rs
Normal 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(())
|
||||
}
|
||||
5
src/feature/remind/mod.rs
Normal file
5
src/feature/remind/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Reminder feature (Phase 4) - `/remind` slash command + background delivery task.
|
||||
|
||||
pub mod command;
|
||||
pub mod service;
|
||||
pub mod task;
|
||||
52
src/feature/remind/service.rs
Normal file
52
src/feature/remind/service.rs
Normal 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(())
|
||||
}
|
||||
33
src/feature/remind/task.rs
Normal file
33
src/feature/remind/task.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue