pub mod commands;
pub mod focus;
pub mod formatter;
pub mod lifecycle;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::client::{
ChatMessage, CommandExecutor, CommandResult, FreeTextRoute, ManagedBackend, SessionProxy,
TrustyCommand, route_free_text,
};
use commands::{SlackCommand, parse_slash};
use focus::ProxyVerb;
use formatter::SlackFormatter;
use lifecycle::{PidFileGuard, write_pid_file};
pub use lifecycle::{StopOutcome, pid_file_path, stop_via_pid_file};
const SLACK_CONNECT_TIMEOUT_SECS: u64 = 10;
const SLACK_REQUEST_TIMEOUT_SECS: u64 = 30;
fn build_slack_client(
connect_timeout: std::time::Duration,
request_timeout: std::time::Duration,
) -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.build()
.unwrap_or_else(|e| {
tracing::warn!(
error = %e,
"slack: reqwest::ClientBuilder::build failed, falling back to an \
UNBOUNDED default client (connect_timeout/timeout bounds are NOT applied)"
);
reqwest::Client::default()
})
}
type ChatHistories = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
const MAX_CHAT_HISTORY_TURNS: usize = 20;
const LLM_NOT_CONFIGURED: &str =
"LLM chat not configured — set OPENROUTER_API_KEY in .env.local and enable the overseer";
const CONNECTIONS_OPEN_URL: &str = "https://slack.com/api/apps.connections.open";
const RECONNECT_BACKOFF_BASE: std::time::Duration = std::time::Duration::from_secs(2);
const RECONNECT_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(60);
const POST_MESSAGE_URL: &str = "https://slack.com/api/chat.postMessage";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlackEvent {
SlashCommand {
envelope_id: String,
command: String,
text: String,
channel: String,
},
Message {
envelope_id: String,
text: String,
channel: String,
thread: Option<String>,
},
Disconnect {
reason: String,
},
Ignored {
envelope_id: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisconnectKind {
Transient,
Permanent,
}
pub fn classify_disconnect_reason(reason: &str) -> DisconnectKind {
const PERMANENT_REASONS: &[&str] = &[
"app_deactivated",
"invalid_auth",
"account_inactive",
"token_revoked",
"token_expired",
"not_authed",
"missing_scope",
"no_permission",
];
let needle = reason.to_ascii_lowercase();
if PERMANENT_REASONS.iter().any(|r| needle.contains(r)) {
DisconnectKind::Permanent
} else {
DisconnectKind::Transient
}
}
pub fn resolve_token(var_name: &str) -> Option<String> {
for file in [".env.local", ".env"] {
if let Some(value) = read_dotenv_key(Path::new(file), var_name) {
return Some(value);
}
}
std::env::var(var_name).ok().filter(|v| !v.is_empty())
}
fn read_dotenv_key(path: &Path, var_name: &str) -> Option<String> {
let contents = std::fs::read_to_string(path).ok()?;
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=')
&& key.trim() == var_name
{
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
pub fn parse_envelope(raw: &str) -> SlackEvent {
let v: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => return SlackEvent::Ignored { envelope_id: None },
};
let envelope_id = v
.get("envelope_id")
.and_then(|e| e.as_str())
.map(str::to_string);
let kind = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match kind {
"slash_commands" => {
let p = &v["payload"];
let command = p.get("command").and_then(|c| c.as_str()).unwrap_or("");
let text = p.get("text").and_then(|t| t.as_str()).unwrap_or("");
let channel = p.get("channel_id").and_then(|c| c.as_str()).unwrap_or("");
match envelope_id {
Some(envelope_id) if !command.is_empty() && !channel.is_empty() => {
SlackEvent::SlashCommand {
envelope_id,
command: command.to_string(),
text: text.to_string(),
channel: channel.to_string(),
}
}
other => SlackEvent::Ignored { envelope_id: other },
}
}
"events_api" => parse_events_api(&v, envelope_id),
"disconnect" => {
let reason = v
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("")
.to_string();
SlackEvent::Disconnect { reason }
}
_ => SlackEvent::Ignored { envelope_id },
}
}
fn parse_events_api(v: &serde_json::Value, envelope_id: Option<String>) -> SlackEvent {
let event = &v["payload"]["event"];
let is_message = event.get("type").and_then(|t| t.as_str()) == Some("message");
let is_bot = event.get("bot_id").is_some()
|| event.get("subtype").and_then(|s| s.as_str()) == Some("bot_message");
let text = event.get("text").and_then(|t| t.as_str()).unwrap_or("");
let channel = event.get("channel").and_then(|c| c.as_str()).unwrap_or("");
let thread = event
.get("thread_ts")
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty())
.map(str::to_string);
match envelope_id {
Some(envelope_id)
if is_message && !is_bot && !text.trim().is_empty() && !channel.is_empty() =>
{
SlackEvent::Message {
envelope_id,
text: text.to_string(),
channel: channel.to_string(),
thread,
}
}
other => SlackEvent::Ignored { envelope_id: other },
}
}
fn ack_frame(envelope_id: &str) -> String {
serde_json::json!({ "envelope_id": envelope_id }).to_string()
}
fn record_chat_turn(entry: &mut Vec<ChatMessage>, text: &str, reply: &str) {
entry.push(ChatMessage::user(text));
if !reply.is_empty() {
entry.push(ChatMessage::assistant(reply));
}
if entry.len() > MAX_CHAT_HISTORY_TURNS {
let overflow = entry.len() - MAX_CHAT_HISTORY_TURNS;
entry.drain(0..overflow);
}
}
fn action_footer(actions: Option<&[String]>) -> Option<String> {
let verbs = actions.filter(|v| !v.is_empty())?;
let joined = verbs.to_vec().join(", ");
Some(format!("\n\n_ran: {joined}_"))
}
async fn dispatch_slash(executor: &CommandExecutor, cmd: SlackCommand) -> String {
let result: CommandResult = executor.execute(TrustyCommand::from(cmd)).await;
SlackFormatter::format(&result)
}
async fn action_chat_reply(
executor: &CommandExecutor,
histories: &ChatHistories,
channel: &str,
text: &str,
) -> String {
let history = {
let guard = histories.lock().unwrap_or_else(|e| e.into_inner());
guard.get(channel).cloned().unwrap_or_default()
};
match executor
.client()
.coordinator_chat(text, &history, true)
.await
{
Ok(Some(outcome)) => {
{
let mut guard = histories.lock().unwrap_or_else(|e| e.into_inner());
let entry = guard.entry(channel.to_string()).or_default();
record_chat_turn(entry, text, &outcome.reply);
}
let mut body = outcome.reply.clone();
if let Some(output) = outcome.command_output.as_deref()
&& !output.is_empty()
{
body.push('\n');
body.push_str(output);
}
if let Some(footer) = action_footer(outcome.actions_taken.as_deref()) {
body.push_str(&footer);
}
body
}
Ok(None) => LLM_NOT_CONFIGURED.to_string(),
Err(e) => format!("❌ chat: daemon error: {e}"),
}
}
async fn reply_for_event(
executor: &CommandExecutor,
histories: &ChatHistories,
proxy: &SessionProxy,
event: &SlackEvent,
) -> Option<(String, String)> {
match event {
SlackEvent::SlashCommand {
command,
text,
channel,
..
} => {
let conv = focus::conv(channel, None);
let body = match focus::proxy_verb(command) {
Some(ProxyVerb::Focus) => focus::handle_focus(proxy, &conv, text).await,
Some(ProxyVerb::Unfocus) => focus::handle_unfocus(proxy, &conv),
Some(ProxyVerb::Summary) => focus::handle_summary(proxy, &conv).await,
None => match parse_slash(command, text) {
Some(cmd) => dispatch_slash(executor, cmd).await,
None => {
let combined = format!("{command} {text}");
action_chat_reply(executor, histories, channel, combined.trim()).await
}
},
};
Some((channel.clone(), body))
}
SlackEvent::Message {
text,
channel,
thread,
..
} => {
let conv = focus::effective_conv(proxy, channel, thread.as_deref());
let has_focus = proxy.current_focus(&conv).is_some();
let body = match route_free_text(text, has_focus) {
FreeTextRoute::Inject => focus::inject_reply(proxy, &conv, text).await,
FreeTextRoute::Coordinator => {
action_chat_reply(executor, histories, channel, text).await
}
};
Some((channel.clone(), body))
}
SlackEvent::Disconnect { .. } | SlackEvent::Ignored { .. } => None,
}
}
async fn open_socket_url(http: &reqwest::Client, app_token: &str) -> anyhow::Result<String> {
let resp: serde_json::Value = http
.post(CONNECTIONS_OPEN_URL)
.bearer_auth(app_token)
.send()
.await?
.error_for_status()?
.json()
.await?;
if !resp.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
let err = resp
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown");
anyhow::bail!("apps.connections.open failed: {err}");
}
resp.get("url")
.and_then(|u| u.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("apps.connections.open returned no url"))
}
async fn post_message(http: &reqwest::Client, bot_token: &str, channel: &str, text: &str) {
let body = serde_json::json!({ "channel": channel, "text": text, "mrkdwn": true });
match http
.post(POST_MESSAGE_URL)
.bearer_auth(bot_token)
.json(&body)
.send()
.await
{
Ok(resp) => {
if let Ok(v) = resp.json::<serde_json::Value>().await
&& !v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false)
{
tracing::warn!(error = ?v.get("error"), "chat.postMessage reported not-ok");
}
}
Err(e) => tracing::warn!("chat.postMessage transport error: {e}"),
}
}
enum SocketOutcome {
Dropped(anyhow::Error),
TransientDisconnect(String),
PermanentDisconnect(String),
}
fn next_backoff(current: std::time::Duration, cap: std::time::Duration) -> std::time::Duration {
current.saturating_mul(2).min(cap)
}
pub async fn run(
url: String,
bot_token: Option<String>,
app_token: Option<String>,
check: bool,
) -> anyhow::Result<()> {
if check {
println!("trusty-mpm Slack bot configuration:");
println!(" daemon url : {url}");
println!(
" bot token : {}",
if bot_token.is_some() { "yes" } else { "no" }
);
println!(
" app token : {}",
if app_token.is_some() { "yes" } else { "no" }
);
println!(" connection mode : Socket Mode (no public webhook required)");
println!();
println!("{}", crate::client::command::help_text());
return Ok(());
}
let bot_token = bot_token.ok_or_else(|| {
anyhow::anyhow!("SLACK_BOT_TOKEN is required (or pass --check to validate config)")
})?;
let app_token = app_token.ok_or_else(|| {
anyhow::anyhow!("SLACK_APP_TOKEN is required (or pass --check to validate config)")
})?;
let http = build_slack_client(
std::time::Duration::from_secs(SLACK_CONNECT_TIMEOUT_SECS),
std::time::Duration::from_secs(SLACK_REQUEST_TIMEOUT_SECS),
);
let executor = Arc::new(CommandExecutor::new(url));
let histories: ChatHistories = Arc::new(Mutex::new(HashMap::new()));
let proxy_backend = Arc::clone(&executor) as Arc<dyn ManagedBackend>;
let proxy = Arc::new(SessionProxy::new(proxy_backend));
write_pid_file();
let _pid_guard = PidFileGuard {
path: pid_file_path(),
};
tracing::info!("Slack adapter starting (Socket Mode)");
let mut backoff = RECONNECT_BACKOFF_BASE;
loop {
let ws_url = match open_socket_url(&http, &app_token).await {
Ok(url) => url,
Err(e) => {
if classify_disconnect_reason(&e.to_string()) == DisconnectKind::Permanent {
return Err(e.context("Slack connection permanently failed"));
}
tracing::warn!("apps.connections.open failed, retrying: {e}");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff, RECONNECT_BACKOFF_CAP);
continue;
}
};
let connected_at = std::time::Instant::now();
match socket_loop(&http, &bot_token, &ws_url, &executor, &histories, &proxy).await {
SocketOutcome::PermanentDisconnect(reason) => {
anyhow::bail!("Slack sent a permanent disconnect ({reason}); stopping");
}
SocketOutcome::TransientDisconnect(reason) => {
tracing::warn!("Slack disconnect ({reason}), reconnecting");
}
SocketOutcome::Dropped(e) => {
tracing::warn!("Slack socket dropped, reconnecting: {e}");
}
}
if connected_at.elapsed() >= RECONNECT_BACKOFF_CAP {
backoff = RECONNECT_BACKOFF_BASE;
}
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff, RECONNECT_BACKOFF_CAP);
}
}
async fn socket_loop(
http: &reqwest::Client,
bot_token: &str,
ws_url: &str,
executor: &Arc<CommandExecutor>,
histories: &ChatHistories,
proxy: &Arc<SessionProxy>,
) -> SocketOutcome {
let (ws, _) = match tokio_tungstenite::connect_async(ws_url).await {
Ok(ws) => ws,
Err(e) => return SocketOutcome::Dropped(e.into()),
};
let (mut write, mut read) = ws.split();
while let Some(frame) = read.next().await {
let raw = match frame {
Ok(WsMessage::Text(raw)) => raw,
Ok(_) => continue,
Err(e) => return SocketOutcome::Dropped(e.into()),
};
let event = parse_envelope(&raw);
if let SlackEvent::Disconnect { reason } = &event {
return match classify_disconnect_reason(reason) {
DisconnectKind::Permanent => SocketOutcome::PermanentDisconnect(reason.clone()),
DisconnectKind::Transient => SocketOutcome::TransientDisconnect(reason.clone()),
};
}
if let Some(id) = envelope_id_of(&event)
&& let Err(e) = write.send(WsMessage::Text(ack_frame(&id))).await
{
return SocketOutcome::Dropped(e.into());
}
if let Some((channel, body)) = reply_for_event(executor, histories, proxy, &event).await {
post_message(http, bot_token, &channel, &body).await;
}
}
SocketOutcome::Dropped(anyhow::anyhow!("socket closed"))
}
fn envelope_id_of(event: &SlackEvent) -> Option<String> {
match event {
SlackEvent::SlashCommand { envelope_id, .. } => Some(envelope_id.clone()),
SlackEvent::Message { envelope_id, .. } => Some(envelope_id.clone()),
SlackEvent::Disconnect { .. } => None,
SlackEvent::Ignored { envelope_id } => envelope_id.clone(),
}
}
#[cfg(test)]
mod tests;