Skip to main content

Overview

Faculty Bot runs several automated background tasks that handle scheduled posting, feed monitoring, and dynamic channel management. These tasks run continuously in separate tokio tasks and operate independently of user commands.

Task System

All background tasks are spawned when the bot starts:
poise::Event::Ready { data_about_bot } => {
    info!("Ready! Logged in as {}", data_about_bot.user.name);

    // Start meal plan task if enabled
    if data.config.mealplan.post_mealplan {
        info!("Mensaplan task started");
        let context = ctx.clone();
        let d = data.clone();
        tokio::spawn(async move {
            tasks::post_mensaplan(context, d).await.unwrap();
        });
    }

    // Start RSS task if enabled
    if data.config.rss_settings.post_rss {
        info!("RSS task started");
        let context = ctx.clone();
        let d = data.clone();
        tokio::spawn(async move {
            tasks::post_rss(context, d).await.unwrap();
        });
    }
}
Reference: src/eventhandler.rs:20-43

Meal Plan Posting

Automatic Scheduling

The meal plan task checks the time every configured interval and posts when conditions are met:
pub async fn post_mensaplan(ctx: serenity::Context, data: Data) -> Result<(), Error> {
    loop {
        let now = chrono::Local::now();
        let weekday = now.weekday();
        let hour = now.hour();

        // Check if it's time to post
        if weekday == task_conf.post_on_day && hour == task_conf.post_at.hour() {
            let mensa_plan = crate::utils::fetch_mensaplan(&task_conf.mealplan_settings.url)
                .await
                .unwrap();
            let today = now.date_naive().format("%Y-%m-%d").to_string();

            // Check if already posted today
            let mensaplan_posted = sqlx::query_as::<sqlx::Postgres, structs::Mensaplan>(
                "SELECT * FROM mensaplan WHERE date = $1",
            )
            .bind(&today)
            .fetch_optional(&data.db)
            .await?
            .map(|row| row.posted)
            .unwrap_or(false);

            if !mensaplan_posted {
                // Post meal plan...
            }
        }

        // Sleep until next check
        tokio::time::sleep(tokio::time::Duration::from_secs(
            data.config.mealplan.check * 60,
        ))
        .await;
    }
}
Reference: src/tasks.rs:33-116

Posting Flow

1

Time Check

Every N minutes, check if current day/hour matches configured schedule
2

Database Check

Query database to see if meal plan was already posted today
let mensaplan_posted = sqlx::query_as::<sqlx::Postgres, structs::Mensaplan>(
    "SELECT * FROM mensaplan WHERE date = $1",
)
.bind(&today)
.fetch_optional(&data.db)
.await?
.map(|row| row.posted)
.unwrap_or(false);
3

Fetch Image

Download meal plan image from configured URL
4

Post Message

Send message with image and notification button to configured channel
let mut msg = mensaplan_channel
    .send_message(&ctx, |f| {
        f.content(format!("{}", notify_role.mention()))
            .add_file(serenity::AttachmentType::Bytes {
                data: std::borrow::Cow::Borrowed(&mensa_plan),
                filename: "mensaplan.png".to_string(),
            })
            .components(|c| {
                c.create_action_row(|r| {
                    r.create_button(|b| {
                        b.style(serenity::ButtonStyle::Primary)
                            .label("Get Notified on new plans!")
                            .custom_id("mensaplan_notify_button")
                    })
                })
            })
    })
    .await?;
5

Crosspost

If channel is an announcement channel, crosspost to followers
msg.crosspost(&ctx).await?;
6

Update Database

Mark meal plan as posted for today to prevent duplicates
sqlx::query("INSERT INTO mensaplan (date, posted) VALUES ($1, $2)")
    .bind(&today)
    .bind(true)
    .execute(&data.db)
    .await?;
Reference: src/tasks.rs:68-106

Notification Button

Users can click a button to receive the meal plan notification role:
if let serenity::Interaction::MessageComponent(button) = interaction {
    match button.data.custom_id.as_str() {
        "mensaplan_notify_button" => {
            let role = bot_data.config.roles.mealplannotify;
            member
                .clone()
                .add_role(&ctx, role)
                .await?;
            
            button
                .create_interaction_response(&ctx, |f| {
                    f.kind(serenity::InteractionResponseType::ChannelMessageWithSource)
                        .interaction_response_data(|f| {
                            f.flags(serenity::MessageFlags::EPHEMERAL)
                            .content("You will now be notified when the mensaplan is updated!")
                        })
                })
                .await?;
        }
    }
}
Reference: src/eventhandler.rs:256-362

RSS Feed Monitoring

Feed Checking Loop

The RSS task continuously monitors configured feeds for new posts:
pub async fn post_rss(ctx: serenity::Context, data: Data) -> Result<(), Error> {
    let conf = TaskConfigRss {
        map: data.config.rss_settings.rss_feed_data,
        clean_regex: regex::Regex::new(r"\\n(if wk med|all)").unwrap(),
        timeout_hrs: data.config.rss_settings.rss_check_interval_hours,
    };

    loop {
        for (channel_id, feed_url) in conf.map.iter() {
            let channel = fetch_feed(feed_url).await.unwrap();
            let items = channel.items();

            for item in items {
                let title = item.title().unwrap();
                let link = item.link().unwrap();
                let description = item.description().unwrap();
                let date = chrono::DateTime::parse_from_rfc2822(item.pub_date().unwrap()).unwrap();

                // Check if already posted
                let sql_res = sqlx::query_as::<sqlx::Postgres, structs::Rss>(
                    "SELECT * FROM posted_rss WHERE rss_title = $1 AND channel_id = $2",
                )
                .bind(title)
                .bind(channel_id.0 as i64)
                .fetch_optional(&db)
                .await?;

                if sql_res.is_none() {
                    // Post new item
                    post_item(&ctx, &db, channel_id, &conf, title, link, description, &date).await?;
                }
            }
        }

        // Sleep before next check
        tokio::time::sleep(tokio::time::Duration::from_secs(conf.timeout_hrs * 60 * 60)).await;
    }
}
Reference: src/tasks.rs:119-221

RSS Post Format

New RSS items are posted with a rich embed and browser link:
async fn post_item(
    ctx: &serenity::Context,
    db: &sqlx::PgPool,
    channel_id: &serenity::ChannelId,
    conf: &TaskConfigRss,
    title: &str,
    link: &str,
    description: &str,
    date: &chrono::DateTime<chrono::Local>,
) -> Result<(), Error> {
    let msg = channel_id
        .send_message(&ctx, |f| {
            f.content(format!("Neue Nachricht im Planungsportal · {}", title))
                .embed(|e| {
                    e.title(title)
                        .url(link)
                        .description(conf.clean_regex.replace_all(description, ""))
                        .timestamp(date.to_rfc3339())
                        .color(0xb00b69)
                })
                .components(|c| {
                    c.create_action_row(|a| {
                        a.create_button(|b| {
                            b.label("Open in Browser")
                                .style(serenity::ButtonStyle::Link)
                                .url(link)
                        })
                    })
                })
        })
        .await?;

    // Store message ID for update detection
    sqlx::query(
        "INSERT INTO posted_rss (rss_title, channel_id, message_id) VALUES ($1, $2, $3)",
    )
    .bind(title)
    .bind(channel_id.0 as i64)
    .bind(msg.id.0 as i64)
    .execute(db)
    .await?;

    Ok(())
}
Reference: src/tasks.rs:291-340

Update Detection

The system can detect when RSS items are updated and post a follow-up message:
if let Some(exists) = sql_res {
    let msg = channel_id
        .message(&ctx, exists.message_id as u64)
        .await?;
    let embed = msg.embeds.first().unwrap();

    let this_date = embed.timestamp.as_ref().unwrap()
        .parse::<chrono::DateTime<chrono::Local>>()?;
    let item_date = date.with_timezone(&chrono::Local);

    // Compare dates and post update if newer
    if this_date < item_date {
        update_posts(&ctx, &db, channel_id, &conf, title, link, description, &item_date, &msg).await?;
    }
}
Reference: src/tasks.rs:161-193

Temporary Voice Channels

Channel Creation

When a user joins the designated “Create Channel” voice channel, a personal voice channel is automatically created:
poise::Event::VoiceStateUpdate { old, new } => {
    let new_channel = new.channel_id.unwrap().to_channel(&ctx).await?;
    let new_channel = match new_channel {
        serenity::Channel::Guild(channel) => channel,
        _ => return Ok(()),
    };

    if &new_channel.name() == &data.config.channels.create_channel {
        let category = new_channel.parent_id;

        // Create personal voice channel
        let cc = new
            .guild_id
            .unwrap()
            .create_channel(&ctx, |f| {
                f.name(format!(
                    "🔊 {}'s Channel",
                    new.member.as_ref().unwrap().display_name()
                ))
                .kind(serenity::ChannelType::Voice)
                .permissions(vec![
                    serenity::PermissionOverwrite {
                        allow: serenity::Permissions::MANAGE_CHANNELS,
                        deny: serenity::Permissions::empty(),
                        kind: serenity::PermissionOverwriteType::Member(
                            new.member.as_ref().unwrap().user.id,
                        ),
                    },
                ]);
                if category.is_some() {
                    f.category(category.unwrap())
                } else {
                    f
                }
            })
            .await?;

        // Store in database
        sqlx::query("INSERT INTO voice_channels (channel_id, owner_id) VALUES ($1, $2)")
            .bind(cc.id.0 as i64)
            .bind(new.member.as_ref().unwrap().user.id.0 as i64)
            .execute(&mut data.db.acquire().await?)
            .await?;

        // Move user to new channel
        new.member
            .as_ref()
            .unwrap()
            .move_to_voice_channel(&ctx, cc)
            .await?;
    }
}
Reference: src/eventhandler.rs:207-250

Auto-Deletion

When a temporary channel becomes empty, it’s automatically deleted:
if let Some(old_chan) = old {
    let channel = old_chan
        .channel_id
        .unwrap_or_default()
        .to_channel(&ctx)
        .await?;
    
    if let serenity::Channel::Guild(channel) = channel {
        // Don't delete the create channel
        if channel.name() == data.config.channels.create_channel {
            return Ok(());
        }

        // Only delete tracked temp channels
        if !created_channels
            .iter()
            .any(|c| c.channel_id == channel.id.0 as i64)
        {
            return Ok(());
        }

        // Delete if empty
        if channel
            .members(&ctx)
            .await?
            .is_empty()
        {
            channel.delete(&ctx).await?;
            
            // Remove from database
            sqlx::query("DELETE FROM voice_channels WHERE channel_id = $1")
                .bind(channel.id.0 as i64)
                .execute(&mut data.db.acquire().await?)
                .await?;
        }
    }
}
Reference: src/eventhandler.rs:153-193

Channel Permissions

Channel owners automatically receive MANAGE_CHANNELS permission, allowing them to:
  • Rename the channel
  • Set user limits
  • Change bitrate
  • Manage permissions for other users

Monitoring Task

The bot logs latency metrics to InfluxDB for monitoring:
pub async fn log_latency_to_influx(
    ctx: &serenity::Context,
    sm: Arc<serenity::Mutex<serenity::ShardManager>>,
    influx: &influxdb2::Client,
) -> Result<(), Error> {
    loop {
        let shard = ctx.shard_id;
        let locked = sm.lock().await;
        let runner = locked.runners.lock().await;
        let latency = runner
            .get(&ShardId(shard))
            .unwrap()
            .latency
            .unwrap_or(std::time::Duration::from_nanos(0));

        let points = vec![DataPoint::builder("latency")
            .field("latency", latency.as_millis() as i64)
            .timestamp(chrono::Utc::now().timestamp_nanos_opt().unwrap())
            .build()
            .unwrap()];

        influx
            .write("facultymanager", futures::stream::iter(points))
            .await
            .unwrap();

        tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
    }
}
Reference: src/tasks.rs:342-371

Configuration

Meal Plan Settings

mealplan.post_mealplan
boolean
Enable/disable automatic meal plan posting
mealplan.post_on_day
Weekday
Day of week to post (e.g., Monday)
mealplan.post_at_hour
Time
Hour of day to post (24-hour format)
mealplan.check
integer
Check interval in minutes
mealplan.url
string
URL to fetch meal plan image from

RSS Settings

rss_settings.post_rss
boolean
Enable/disable RSS feed monitoring
rss_settings.rss_check_interval_hours
integer
Hours between RSS feed checks
rss_settings.rss_feed_data
HashMap<ChannelId, String>
Map of channel IDs to RSS feed URLs

Task Summary

Meal Plan Posting

Scheduled automatic posting with notification systemRuns every N minutes, posts on configured day/hour

RSS Feed Monitor

Continuous monitoring of RSS feeds with update detectionChecks feeds every N hours

Temp Voice Channels

On-demand voice channel creation and auto-cleanupEvent-driven, no polling required

Latency Logging

Performance monitoring to InfluxDBLogs every 60 seconds

Best Practices

  • Database deduplication: All automated posts check database before posting
  • Graceful error handling: Tasks log errors but continue running
  • Resource efficiency: Tasks sleep between checks to avoid excessive CPU usage
  • Crossposting: Meal plans use Discord’s announcement channel feature for wider reach

Build docs developers (and LLMs) love