Skip to main content

Event System Overview

Faculty Bot uses Poise’s event handling system to respond to Discord events like messages, voice state changes, and button interactions. All event handling logic is centralized in src/eventhandler.rs.

Event Handler Registration

The event handler is registered in main.rs during framework initialization:
src/main.rs
poise::Framework::builder()
    .options(poise::FrameworkOptions {
        event_handler: |ctx, event, framework, data| {
            Box::pin(async move { 
                eventhandler::event_listener(ctx, event, &framework, data).await 
            })
        },
        // ...
    })
See main.rs:225-229

Main Event Listener

The central event listener matches on different event types:
src/eventhandler.rs
pub async fn event_listener(
    ctx: &serenity::Context,
    event: &poise::Event<'_>,
    fw: &poise::FrameworkContext<'_, Data, Error>,
    data: &Data,
) -> Result<(), Error> {
    match event {
        poise::Event::Ready { data_about_bot } => {
            // Bot startup logic
        },
        poise::Event::Message { new_message } => {
            // Message handling (XP system)
        },
        poise::Event::VoiceStateUpdate { old, new } => {
            // Voice channel management
        },
        poise::Event::InteractionCreate { interaction } => {
            // Button/modal interactions
        },
        _ => {}
    }
    Ok(())
}
See eventhandler.rs:13-313

Event Types

Ready Event

Fired when the bot successfully connects to Discord:
src/eventhandler.rs
poise::Event::Ready { data_about_bot } => {
    info!("Ready! Logged in as {}", data_about_bot.user.name);
    info!("Prefix: {:?}", fw.options.prefix_options.prefix.as_ref());
    
    // Start mensa 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();
        });
    }
    
    // Start logger task
    info!("Logger task started");
    let context = ctx.clone();
    let fw = fw.shard_manager.clone();
    let d = data.influx.clone();
    tokio::spawn(async move {
        tasks::log_latency_to_influx(&context, fw, &d).await.unwrap();
    });
}
Purpose:
  • Log bot startup information
  • Spawn background tasks (meal plan posting, RSS monitoring, metrics logging)
  • Initialize any startup routines
See eventhandler.rs:20-53

Message Event

Fired when any message is sent in a channel the bot can see:
src/eventhandler.rs
poise::Event::Message { new_message } => {
    // Skip bots and messages starting with prefix
    if new_message.author.bot
        || new_message.content.starts_with(
            fw.options.prefix_options.prefix.as_deref().unwrap_or_default()
        )
    {
        return Ok(());
    }
    
    let user_id = i64::from(new_message.author.id);
    let content_len = new_message.content.chars().count();
    let mut pool = data.db.acquire().await.map_err(Error::Database)?;
    
    // Fetch user data or create defaults
    let user_data = sqlx::query_as::<_, structs::UserXP>(
        "SELECT * FROM user_xp WHERE user_id = $1"
    )
    .bind(user_id)
    .fetch_optional(&mut pool)
    .await
    .map_err(Error::Database)?
    .unwrap_or_else(|| structs::UserXP {
        user_id,
        user_xp: 0.0,
        user_level: 0,
    });
    
    // Calculate XP with logarithmic scaling
    let scaling_factor = data.config.general.xp_scaling_factor;
    let base_xp = content_len as f64 / data.config.general.chars_for_level as f64;
    let xp_to_add = base_xp / (1.0 + scaling_factor * (user_data.user_level as f64).ln());
    
    // Update XP in database
    let new_xp = user_data.user_xp + xp_to_add;
    sqlx::query(
        "INSERT INTO user_xp (user_id, user_xp) VALUES ($1, $2)
        ON CONFLICT (user_id) DO UPDATE SET user_xp = $2"
    )
    .bind(user_id)
    .bind(new_xp)
    .execute(&mut pool)
    .await
    .map_err(Error::Database)?;
    
    // Check for level up
    let new_level = (new_xp / 100.0).floor() as i32;
    if new_level > user_data.user_level {
        // Update level
        sqlx::query(
            "INSERT INTO user_xp (user_id, user_level) VALUES ($1, $2)
            ON CONFLICT (user_id) DO UPDATE SET user_level = $2"
        )
        .bind(user_id)
        .bind(new_level)
        .execute(&mut pool)
        .await
        .map_err(Error::Database)?;
        
        // Send level-up notification
        let img = utils::show_levelup_image(&new_message.author, new_level as u16).await?;
        data.config.channels.xp
            .send_message(&ctx, |f| {
                f.content(format!(
                    "congrats {}! you've levelled up to {}!",
                    new_message.author.mention(),
                    new_level
                ))
                .add_file(AttachmentType::Bytes {
                    data: std::borrow::Cow::Borrowed(&img),
                    filename: "levelup.png".to_string(),
                })
            })
            .await
            .map_err(Error::Serenity)?;
    }
}
XP Formula:
base_xp = message_length / chars_for_level
xp_to_add = base_xp / (1 + scaling_factor × ln(current_level))
new_level = floor(total_xp / 100)
Purpose:
  • Award XP for user activity
  • Implement logarithmic scaling to prevent high-level users from gaining XP too quickly
  • Generate level-up notifications with custom images
  • Ignore bot messages and command invocations
See eventhandler.rs:55-143

Voice State Update Event

Fired when a user’s voice state changes (joins, leaves, moves channels):
src/eventhandler.rs
poise::Event::VoiceStateUpdate { old, new } => {
    // Fetch all created temporary channels
    let created_channels = sqlx::query_as::<sqlx::Postgres, structs::VoiceChannels>(
        "SELECT * FROM voice_channels"
    )
    .fetch_all(&mut data.db.acquire().await.map_err(Error::Database)?)
    .await
    .map_err(Error::Database)?;
    
    // Handle user leaving a channel
    if let Some(old_chan) = old {
        if old_chan.channel_id == new.channel_id {
            return Ok(()); // User moved in same channel
        }
        
        // Check if channel is now empty and should be deleted
        let channel = old_chan.channel_id
            .unwrap_or_default()
            .to_channel(&ctx)
            .await
            .map_err(Error::Serenity)?;
            
        if let serenity::Channel::Guild(channel) = channel {
            if channel.name() == data.config.channels.create_channel {
                return Ok(()); // Don't delete the "Create Channel"
            }
            
            // Only delete tracked temporary 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.map_err(Error::Serenity)?.is_empty() {
                channel.delete(&ctx).await.map_err(Error::Serenity)?;
                sqlx::query("DELETE FROM voice_channels WHERE channel_id = $1")
                    .bind(channel.id.0 as i64)
                    .execute(&mut data.db.acquire().await.map_err(Error::Database)?)
                    .await
                    .map_err(Error::Database)?;
            }
        }
    }
    
    // Handle user joining the "Create Channel"
    let new_channel = new.channel_id
        .unwrap_or_default()
        .to_channel(&ctx)
        .await
        .map_err(Error::Serenity)?;
        
    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 a new temporary 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
            .map_err(Error::Serenity)?;
        
        // Track the channel 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.map_err(Error::Database)?)
            .await
            .map_err(Error::Database)?;
        
        // Move user to the new channel
        new.member
            .as_ref()
            .unwrap()
            .move_to_voice_channel(&ctx, cc)
            .await
            .map_err(Error::Serenity)?;
    }
}
Purpose:
  • Create temporary voice channels when users join the “Create Channel”
  • Give channel creator MANAGE_CHANNELS permission
  • Automatically delete temporary channels when they become empty
  • Track temporary channels in database to distinguish from permanent channels
See eventhandler.rs:145-251

Interaction Create Event

Fired when a user interacts with a component (button, select menu, modal):
src/eventhandler.rs
poise::Event::InteractionCreate { interaction } => {
    if let serenity::Interaction::MessageComponent(button) = interaction {
        match button.data.custom_id.as_str() {
            "mensaplan_notify_button" => {
                give_user_mensaplan_role(ctx, button, data).await?
            },
            "reverify" => {
                // Show modal for re-verification
                let data = poise::execute_modal_on_component_interaction::<ReverificationModal>(
                    Arc::new(ctx.clone()),
                    Arc::new(interaction.as_message_component()
                        .expect("Button interaction is always a message component")
                        .clone()),
                    None,
                    None
                ).await.map_err(Error::Serenity)?;
                
                let modal = data.unwrap_or(ReverificationModal {
                    email: "".to_string()
                });
                
                // Validate email
                if !modal.email.ends_with("stud.hs-kempten.de") {
                    button.create_followup_message(&ctx, |f| {
                        f.flags(serenity::model::application::interaction::MessageFlags::EPHEMERAL)
                        .content("Invalid student email!")
                    })
                    .await
                    .map_err(Error::Serenity)?;
                    return Ok(());
                }
                
                // Send verification email
                let code = generate_verification_code();
                let email = utils::CurrentEmail::new(
                    modal.email.clone(),
                    button.user.id,
                    button.user.name.clone(),
                    code.clone()
                );
                email.send().await?;
                
                button.create_followup_message(&ctx, |f| {
                    f.flags(serenity::model::application::interaction::MessageFlags::EPHEMERAL)
                    .content(format!("Email sent to {}!", modal.email))
                })
                .await
                .map_err(Error::Serenity)?;
            }
            _ => not_implemented(ctx, button).await?,
        }
    }
}
Button Handlers:
  1. mensaplan_notify_button: Gives user the meal plan notification role
  2. reverify: Opens a modal for email re-verification
  3. Default: Shows “not implemented” message
See eventhandler.rs:252-308

Modals

Define modals using Poise’s modal system:
src/eventhandler.rs
#[derive(poise::Modal, Clone, Debug)]
#[name = "Re-Verify Your Student Status"]
pub struct ReverificationModal {
    #[name = "Your Student Email"]
    #[placeholder = "name.nachname@stud.hs-kempten.de"]
    email: String,
}
See eventhandler.rs:315-321

Helper Functions

Give User Mensaplan Role

src/eventhandler.rs
async fn give_user_mensaplan_role(
    ctx: &serenity::Context,
    button: &serenity::model::application::interaction::message_component::MessageComponentInteraction,
    bot_data: &Data,
) -> Result<(), Error> {
    let role = bot_data.config.roles.mealplannotify;
    let member = match button.member.as_ref() {
        Some(m) => m,
        None => {
            button.create_interaction_response(&ctx, |f| {
                f.kind(serenity::InteractionResponseType::ChannelMessageWithSource)
                 .interaction_response_data(|f| {
                     f.content("You need to be in a server to use this command")
                 })
            })
            .await
            .map_err(Error::Serenity)?;
            return Ok(());
        }
    };
    
    member.clone()
        .add_role(&ctx, role)
        .await
        .map_err(Error::Serenity)?;
    
    button.create_interaction_response(&ctx, |f| {
        f.kind(serenity::InteractionResponseType::ChannelMessageWithSource)
         .interaction_response_data(|f| {
             f.flags(serenity::model::application::interaction::MessageFlags::EPHEMERAL)
              .content("You will now be notified when the mensaplan is updated!")
         })
    })
    .await
    .map_err(Error::Serenity)?;
    
    Ok(())
}
See eventhandler.rs:323-363

Not Implemented Handler

src/eventhandler.rs
async fn not_implemented(
    ctx: &serenity::Context,
    button: &serenity::model::application::interaction::message_component::MessageComponentInteraction,
) -> Result<(), Error> {
    button.create_interaction_response(&ctx, |f| {
        f.kind(serenity::InteractionResponseType::ChannelMessageWithSource)
         .interaction_response_data(|f| {
             f.flags(serenity::model::application::interaction::MessageFlags::EPHEMERAL)
              .content("This button is not implemented yet, sorry :(")
         })
    })
    .await
    .map_err(Error::Serenity)?;
    
    Ok(())
}
See eventhandler.rs:365-382

Adding New Event Handlers

1

Add Event Match Arm

Add a new match arm in the event_listener function:
poise::Event::GuildMemberAddition { new_member } => {
    handle_member_join(ctx, new_member, data).await?
},
2

Implement Handler Function

Create a helper function for the logic:
async fn handle_member_join(
    ctx: &serenity::Context,
    member: &serenity::Member,
    data: &Data,
) -> Result<(), Error> {
    // Send welcome message
    let channel = data.config.channels.greetings;
    channel.send_message(&ctx, |m| {
        m.content(format!("Welcome {}!", member.mention()))
    })
    .await
    .map_err(Error::Serenity)?;
    
    Ok(())
}
3

Test the Handler

Trigger the event in your test server and verify the behavior.

Available Events

Common Poise events you can handle:
  • Ready: Bot connected to Discord
  • Message: New message sent
  • MessageUpdate: Message edited
  • MessageDelete: Message deleted
  • GuildMemberAddition: User joined server
  • GuildMemberRemoval: User left server
  • VoiceStateUpdate: Voice state changed
  • InteractionCreate: Button/select/modal interaction
  • ReactionAdd: Reaction added to message
  • ReactionRemove: Reaction removed from message
See Poise documentation for full list.

Best Practices

  1. Keep handlers focused: Each handler should do one thing well
  2. Extract helper functions: Move complex logic to separate functions
  3. Error handling: Always use ? operator and convert errors properly
  4. Avoid blocking: Use async operations, never block the event loop
  5. Database efficiency: Batch operations when possible
  6. Spawn long tasks: Use tokio::spawn for long-running operations
  7. Log appropriately: Use tracing macros for debugging

Next Steps

Build docs developers (and LLMs) love