Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 110 additions & 20 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::format;
use crate::media;
use async_trait::async_trait;
use std::sync::LazyLock;
use serenity::builder::{CreateActionRow, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage};
use serenity::builder::{CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage};
use serenity::model::application::ButtonStyle;
use serenity::http::Http;
use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction};
use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType};
Expand All @@ -25,6 +26,9 @@ const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10;
/// Maximum entries in the participation cache before eviction.
const PARTICIPATION_CACHE_MAX: usize = 1000;

/// Discord StringSelectMenu hard limit on options.
const SELECT_MENU_PAGE_SIZE: usize = 25;

// --- DiscordAdapter: implements ChatAdapter for Discord via serenity ---

pub struct DiscordAdapter {
Expand Down Expand Up @@ -688,6 +692,9 @@ impl EventHandler for Handler {
Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => {
self.handle_config_select(&ctx, &comp).await;
}
Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_pg:") => {
self.handle_pagination(&ctx, &comp).await;
}
_ => {}
}
}
Expand All @@ -698,21 +705,22 @@ impl EventHandler for Handler {

impl Handler {
/// Build a Discord select menu from ACP configOptions with the given category.
/// Discord limits Select Menus to 25 options; excess options are truncated.
/// The currently selected option is always included (placed first if needed).
fn build_config_select(options: &[ConfigOption], category: &str) -> Option<CreateSelectMenu> {
/// Paginates options in pages of 25 (Discord limit). The current selection is
/// always placed first so it appears on page 0.
fn build_config_select(options: &[ConfigOption], category: &str, page: usize) -> Option<CreateSelectMenu> {
let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?;

// Ensure current selection is in the first 25 by placing it first,
// then filling remaining slots with the rest in original order.
// Put current selection first so it always lands on page 0,
// then fill remaining slots in original order.
let sorted: Vec<_> = opt.options.iter()
.filter(|o| o.value == opt.current_value)
.chain(opt.options.iter().filter(|o| o.value != opt.current_value))
.take(25)
.collect();

let menu_options: Vec<CreateSelectMenuOption> = sorted
.iter()
.skip(page * SELECT_MENU_PAGE_SIZE)
.take(SELECT_MENU_PAGE_SIZE)
.map(|o| {
let mut item = CreateSelectMenuOption::new(&o.name, &o.value);
if let Some(desc) = &o.description {
Expand All @@ -729,15 +737,16 @@ impl Handler {
return None;
}

let truncated = opt.options.len() > 25;
let placeholder = format!(
"Current: {}{}",
opt.options.iter()
.find(|o| o.value == opt.current_value)
.map(|o| o.name.as_str())
.unwrap_or(&opt.current_value),
if truncated { format!(" ({} more not shown)", opt.options.len() - 25) } else { String::new() },
);
let current_name = opt.options.iter()
.find(|o| o.value == opt.current_value)
.map(|o| o.name.as_str())
.unwrap_or(&opt.current_value);
let total_pages = sorted.len().div_ceil(SELECT_MENU_PAGE_SIZE);
let placeholder = if total_pages > 1 {
format!("Current: {} (page {}/{})", current_name, page + 1, total_pages)
} else {
format!("Current: {}", current_name)
};

Some(
CreateSelectMenu::new(
Expand All @@ -748,6 +757,47 @@ impl Handler {
)
}

/// Build ◀/▶ pagination buttons. Returns None when only one page exists.
fn build_pagination_buttons(category: &str, page: usize, total_pages: usize) -> Option<CreateActionRow> {
if total_pages <= 1 {
return None;
}
let prev = CreateButton::new(format!("acp_pg:{}:{}", category, page.saturating_sub(1)))
.label("◀")
.style(ButtonStyle::Secondary)
.disabled(page == 0);
let next = CreateButton::new(format!("acp_pg:{}:{}", category, page + 1))
.label("▶")
.style(ButtonStyle::Secondary)
.disabled(page + 1 >= total_pages);
let indicator = CreateButton::new("acp_pg_noop")
.label(format!("{}/{}", page + 1, total_pages))
.style(ButtonStyle::Secondary)
.disabled(true);
Some(CreateActionRow::Buttons(vec![prev, indicator, next]))
}

/// Build the full component rows (select menu + optional pagination) for a config category.
/// When `page` is `None`, auto-selects the page containing the current value.
fn build_config_components(options: &[ConfigOption], category: &str, page: Option<usize>) -> Option<Vec<CreateActionRow>> {
let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?;
let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE);
let page = match page {
Some(p) => p.min(total_pages.saturating_sub(1)),
None => opt.options.iter()
.position(|o| o.value == opt.current_value)
.map(|i| i / SELECT_MENU_PAGE_SIZE)
.unwrap_or(0),
};

let select = Self::build_config_select(options, category, page)?;
let mut rows = vec![CreateActionRow::SelectMenu(select)];
if let Some(buttons) = Self::build_pagination_buttons(category, page, total_pages) {
rows.push(buttons);
}
Some(rows)
}

async fn handle_config_command(
&self,
ctx: &Context,
Expand All @@ -757,13 +807,12 @@ impl Handler {
) {
let thread_key = format!("discord:{}", cmd.channel_id.get());
let config_options = self.router.pool().get_config_options(&thread_key).await;
let select = Self::build_config_select(&config_options, category);

let response = match select {
Some(menu) => CreateInteractionResponse::Message(
let response = match Self::build_config_components(&config_options, category, None) {
Some(rows) => CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("🔧 Select a {label}:"))
.components(vec![CreateActionRow::SelectMenu(menu)])
.components(rows)
.ephemeral(true),
),
None => CreateInteractionResponse::Message(
Expand Down Expand Up @@ -878,6 +927,47 @@ impl Handler {
tracing::error!(error = %e, "failed to respond to config select");
}
}

async fn handle_pagination(
&self,
ctx: &Context,
comp: &serenity::model::application::ComponentInteraction,
) {
// Parse custom_id format: acp_pg:{category}:{page}
let parts: Vec<&str> = comp.data.custom_id.splitn(3, ':').collect();
let (category, page) = match parts.as_slice() {
[_, cat, pg] => match pg.parse::<usize>() {
Ok(p) => (*cat, p),
Err(_) => return,
},
_ => return,
};

// Only allow known config categories.
if !matches!(category, "model" | "agent") {
return;
}

let thread_key = format!("discord:{}", comp.channel_id.get());
let config_options = self.router.pool().get_config_options(&thread_key).await;

let response = match Self::build_config_components(&config_options, category, Some(page)) {
Some(rows) => CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new()
.content(format!("🔧 Select a {category}:"))
.components(rows),
),
None => CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new()
.content(format!("⚠️ No {category} options available."))
.components(vec![]),
),
};

if let Err(e) = comp.create_response(&ctx.http, response).await {
tracing::error!(error = %e, category, "failed to respond to pagination");
}
}
}

// --- Discord-specific helpers ---
Expand Down
Loading