use std::sync::Arc;
use crate::domain::channel_events::{
Button, ChannelIdentity, ConversationId, IncomingEvent, OutboundMessage, TextMessage,
};
use crate::ports::channel_ports::ChannelPort;
use super::super::server::AppState;
use super::super::state::{ChannelState, with_write};
use super::formatting::*;
use super::handlers::handle_project_sessions;
pub struct CallbackContext {
pub channel: Arc<dyn ChannelPort>,
pub channel_id: ChannelIdentity,
pub action: String,
pub data: String,
pub callback_message_id: Option<String>,
pub original_text: Option<String>,
pub scope: String,
pub channel_state: Arc<tokio::sync::RwLock<ChannelState>>,
pub app_state: Arc<AppState>,
}
pub async fn handle_callback(ctx: CallbackContext) -> anyhow::Result<()> {
let CallbackContext {
channel,
channel_id,
action,
data,
callback_message_id,
original_text,
scope,
channel_state,
app_state,
} = ctx;
match action.as_str() {
"sess" => {
handle_session_callback(
channel.as_ref(),
&channel_id,
&data,
callback_message_id,
original_text,
&scope,
&channel_state,
)
.await
}
"proj" => {
handle_project_callback(channel.as_ref(), &channel_id, &data, callback_message_id).await
}
"model" => {
handle_model_callback(
channel.as_ref(),
&channel_id,
&data,
callback_message_id,
original_text,
&scope,
&channel_state,
)
.await
}
"new" => {
handle_new_callback(
channel.as_ref(),
&channel_id,
&data,
callback_message_id,
original_text,
&scope,
&channel_state,
)
.await
}
"reply" => {
handle_reply_callback(
channel.as_ref(),
&channel_id,
callback_message_id,
original_text,
)
.await
}
"choice" => {
handle_choice_callback(
channel.as_ref(),
&channel_id,
&data,
callback_message_id,
original_text,
app_state,
)
.await
}
_ => {
tracing::warn!(action, data, "Unknown callback action");
Ok(())
}
}
}
async fn handle_session_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
data: &str,
callback_message_id: Option<String>,
original_text: Option<String>,
scope: &str,
state: &Arc<tokio::sync::RwLock<ChannelState>>,
) -> anyhow::Result<()> {
let (project_dir, session_prefix) = match data.split_once(':') {
Some((dir, prefix)) => (dir, prefix),
None => {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Invalid session reference.",
)
.await;
}
};
let Some(projects_dir) = super::super::sessions::claude_projects_dir() else {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"No projects found.",
)
.await;
};
let sessions =
super::super::sessions::discover_project_sessions(&projects_dir, project_dir, 50);
let matched = sessions
.iter()
.find(|s| s.session_id.starts_with(session_prefix));
let Some(session) = matched else {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Session not found.",
)
.await;
};
{
with_write(state, |cs| {
cs.set_session_id(scope, &session.session_id);
let cwd = session
.cwd
.as_deref()
.or(session.project_path.as_deref())
.unwrap_or("");
if !cwd.is_empty() {
cs.set_working_dir(scope, cwd);
}
})
.await;
}
let preview = session.first_message.as_deref().unwrap_or("(no message)");
let result = format!(
"Switched to session {}...\nProject: {}\nFirst: {}",
&session.session_id[..8],
session.project_name,
truncate_chars(preview, 80)
);
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
&result,
)
.await
}
async fn handle_project_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
encoded_dir: &str,
callback_message_id: Option<String>,
) -> anyhow::Result<()> {
dismiss_keyboard(channel, channel_id, callback_message_id, None, "Loading...").await?;
handle_project_sessions(channel, channel_id, encoded_dir).await
}
async fn handle_model_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
model: &str,
callback_message_id: Option<String>,
original_text: Option<String>,
scope: &str,
state: &Arc<tokio::sync::RwLock<ChannelState>>,
) -> anyhow::Result<()> {
if !["sonnet", "opus", "haiku"].contains(&model) {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Unknown model.",
)
.await;
}
{
with_write(state, |cs| cs.set_model(scope, model)).await;
}
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
&format!("✅ Model set to: {model}"),
)
.await
}
async fn dismiss_keyboard(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
callback_message_id: Option<String>,
original_text: Option<String>,
result: &str,
) -> anyhow::Result<()> {
let text = match original_text.as_deref().filter(|t| !t.is_empty()) {
Some(question) => format!("{question}\n\n{result}"),
None => result.to_string(),
};
let Some(msg_id) = callback_message_id else {
return reply(channel, channel_id, &text).await;
};
channel
.edit_message(&OutboundMessage {
conversation_id: ConversationId::new(),
channel: channel_id.clone(),
text,
message_ref: Some(msg_id),
interaction: None,
})
.await
}
async fn handle_reply_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
callback_message_id: Option<String>,
original_text: Option<String>,
) -> anyhow::Result<()> {
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Type your response below.",
)
.await
}
async fn handle_choice_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
data: &str,
callback_message_id: Option<String>,
original_text: Option<String>,
app_state: Arc<AppState>,
) -> anyhow::Result<()> {
{
let scope = crate::adapters::channel::state::scope_key(
channel_id.platform.as_str(),
&channel_id.channel_id,
&channel_id.user_id,
);
let active = app_state.active_claude.try_lock();
match active {
Ok(guard) if guard.contains_key(&scope) => {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Claude is busy — wait for the current response to finish.",
)
.await;
}
Err(_) => {
return dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Claude is busy — wait for the current response to finish.",
)
.await;
}
_ => {} }
}
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
&format!("> {data}"),
)
.await?;
let synthetic = IncomingEvent::TextMessage(TextMessage {
conversation_id: ConversationId::new(),
channel: channel_id.clone(),
text: data.to_string(),
reply_to_id: None,
});
super::super::server::spawn_process_event(app_state, synthetic);
Ok(())
}
async fn handle_new_callback(
channel: &dyn ChannelPort,
channel_id: &ChannelIdentity,
data: &str,
callback_message_id: Option<String>,
original_text: Option<String>,
scope: &str,
state: &Arc<tokio::sync::RwLock<ChannelState>>,
) -> anyhow::Result<()> {
match data {
"session" => {
dismiss_keyboard(channel, channel_id, callback_message_id, original_text, "").await?;
reply_with_buttons(
channel,
channel_id,
"New session:".to_string(),
"Choose scope",
vec![
Button {
id: "new:current".to_string(),
label: "Current project".to_string(),
},
Button {
id: "new:project".to_string(),
label: "Other project".to_string(),
},
],
)
.await
}
"current" => {
let cwd_info = with_write(state, |cs| {
cs.clear_session(scope);
cs.clear_waiting_for_dir(scope);
cs.working_dir(scope)
.map(|s| s.to_string())
.unwrap_or_else(|| "default workspace".to_string())
})
.await;
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
&format!("New session started.\nWorking dir: {}", cwd_info),
)
.await
}
"project" => {
with_write(state, |cs| {
cs.set_waiting_for_dir(scope);
})
.await;
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Enter the project folder path:",
)
.await
}
_ => {
dismiss_keyboard(
channel,
channel_id,
callback_message_id,
original_text,
"Unknown new-session option.",
)
.await
}
}
}