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/.env
|
||||||
tools/userbot/node_modules/
|
tools/userbot/node_modules/
|
||||||
tools/userbot/
|
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 - Блог-фичи (главное отличие от старого бота)
|
## Phase 3 - Блог-фичи (главное отличие от старого бота)
|
||||||
- [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`)
|
- [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`)
|
||||||
- [ ] `!post` - анонс поста (embed с cover, линк)
|
- [ ] `!post` - анонс поста (embed с cover, линк)
|
||||||
- [ ] Showcase: треды в #showcase, реакции ⭐
|
- [x] GitHub webhook → #changelog (нативная интеграция Discord, без кода: GitHub webhook URL = `<discord-webhook-url>/github`)
|
||||||
- [ ] GitHub webhook → #changelog
|
- [x] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, blacklist, transcript → hastebin) - `feature/ticket`
|
||||||
- [ ] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, transcript → #ticket-logs) - см. ARCHITECTURE.md
|
|
||||||
|
|
||||||
## Phase 4 - Комьюнити
|
## Phase 4 - Комьюнити
|
||||||
- [ ] Welcome embed, counters (member count в канале)
|
- [x] Welcome embed (`feature/welcome`)
|
||||||
- [ ] Polls в #ideas
|
- [x] Counters (member count в voice-канале, `feature/counter`, refresh 10 мин)
|
||||||
- [ ] Scheduler (напоминания)
|
- [x] Polls (`/poll вопрос; вариант1; вариант2`, embed + реакции цифрами, `feature/poll`)
|
||||||
- [ ] Temp voice (`#create-voice` → временный канал)
|
- [x] Scheduler / напоминания (`/remind 10m текст`, личные, SQLite + фоновый воркер 30с, `feature/remind`)
|
||||||
|
- [x] Temp voice (`#create-voice` → временный канал, `feature/voice`)
|
||||||
|
|
||||||
## Phase 5 - Опционально (AI)
|
## Phase 5 - Опционально (AI)
|
||||||
- [ ] AI FAQ (DeepSeek/GPT) в #faq - только по базе знаний блога
|
- [ ] AI FAQ (DeepSeek/GPT) в #faq - только по базе знаний блога
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ use serenity::client::{Client, Context, EventHandler};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Handles `Ready` - logs bot user + guild count.
|
/// Handles `Ready` - logs bot user + guild count.
|
||||||
struct Handler;
|
struct Handler {
|
||||||
|
guild_id: Option<u64>,
|
||||||
|
pool: sqlx::SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
#[serenity::async_trait]
|
#[serenity::async_trait]
|
||||||
impl EventHandler for Handler {
|
impl EventHandler for Handler {
|
||||||
|
|
@ -26,6 +29,10 @@ impl EventHandler for Handler {
|
||||||
warn!(error = %e, "ensure_verify_message failed");
|
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) {
|
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.
|
/// Builds Serenity client with intents + poise framework.
|
||||||
pub async fn build_client(
|
pub async fn build_client(
|
||||||
cfg: &BotConfig,
|
cfg: &BotConfig,
|
||||||
|
pool: sqlx::SqlitePool,
|
||||||
framework: Framework<crate::feature::Data, anyhow::Error>,
|
framework: Framework<crate::feature::Data, anyhow::Error>,
|
||||||
) -> anyhow::Result<Client> {
|
) -> anyhow::Result<Client> {
|
||||||
let intents = GatewayIntents::GUILD_MESSAGES
|
let intents = GatewayIntents::GUILD_MESSAGES
|
||||||
|
|
@ -105,7 +113,7 @@ pub async fn build_client(
|
||||||
| GatewayIntents::GUILD_VOICE_STATES;
|
| GatewayIntents::GUILD_VOICE_STATES;
|
||||||
|
|
||||||
let client = Client::builder(&cfg.token, intents)
|
let client = Client::builder(&cfg.token, intents)
|
||||||
.event_handler(Handler)
|
.event_handler(Handler { guild_id: cfg.guild_id, pool })
|
||||||
.framework(framework)
|
.framework(framework)
|
||||||
.activity(ActivityData::custom(cfg.activity.clone()))
|
.activity(ActivityData::custom(cfg.activity.clone()))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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 {
|
for (name, sql) in migrations {
|
||||||
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
||||||
.bind(name)
|
.bind(name)
|
||||||
|
|
@ -65,3 +66,13 @@ CREATE TABLE IF NOT EXISTS tickets (
|
||||||
opener_id TEXT NOT NULL
|
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 admin;
|
||||||
pub mod blog;
|
pub mod blog;
|
||||||
|
pub mod counter;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod lang;
|
pub mod lang;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
|
pub mod poll;
|
||||||
|
pub mod remind;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
pub mod verify;
|
pub mod verify;
|
||||||
pub mod voice;
|
pub mod voice;
|
||||||
|
|
@ -40,6 +43,8 @@ pub async fn build_framework(
|
||||||
moderation::command::slowmode(),
|
moderation::command::slowmode(),
|
||||||
moderation::command::warn(),
|
moderation::command::warn(),
|
||||||
blog::command::blog_post(),
|
blog::command::blog_post(),
|
||||||
|
poll::command::poll(),
|
||||||
|
remind::command::remind(),
|
||||||
],
|
],
|
||||||
prefix_options: poise::PrefixFrameworkOptions {
|
prefix_options: poise::PrefixFrameworkOptions {
|
||||||
prefix: Some(cfg.prefix.clone().into()),
|
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 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?;
|
core::bot::run_with_shutdown(client).await?;
|
||||||
db::pool::close(pool).await;
|
db::pool::close(pool).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue