pub mod commands;
pub mod formatter;
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, TrustyCommand};
use commands::{SlackCommand, parse_slash};
use formatter::SlackFormatter;
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,
},
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
}
}
const SLACK_PID_FILE: &str = "slack.pid";
pub fn pid_file_path() -> std::path::PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(crate::core::paths::FRAMEWORK_DIR_NAME)
.join(SLACK_PID_FILE)
}
fn write_pid_file() {
let path = pid_file_path();
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!("could not create Slack PID dir {}: {e}", parent.display());
return;
}
if let Err(e) = std::fs::write(&path, std::process::id().to_string()) {
tracing::warn!("could not write Slack PID file {}: {e}", path.display());
}
}
struct PidFileGuard {
path: std::path::PathBuf,
}
impl Drop for PidFileGuard {
fn drop(&mut self) {
match std::fs::remove_file(&self.path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
"could not remove Slack PID file {} on exit: {e}",
self.path.display()
);
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum StopOutcome {
Stopped(u32),
NotRunning,
Failed(String),
}
pub fn stop_via_pid_file(path: &Path) -> StopOutcome {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StopOutcome::NotRunning,
Err(e) => return StopOutcome::Failed(format!("could not read PID file: {e}")),
};
let pid: u32 = match raw.trim().parse() {
Ok(pid) => pid,
Err(e) => {
let _ = std::fs::remove_file(path);
return StopOutcome::Failed(format!("PID file is not a valid pid: {e}"));
}
};
let outcome = signal_terminate(pid);
let _ = std::fs::remove_file(path);
outcome
}
fn signal_terminate(pid: u32) -> StopOutcome {
#[cfg(unix)]
{
let rc = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if rc == 0 {
return StopOutcome::Stopped(pid);
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
StopOutcome::NotRunning
} else {
StopOutcome::Failed(format!("failed to signal pid {pid}: {err}"))
}
}
#[cfg(not(unix))]
{
let _ = pid;
StopOutcome::Failed("stopping the Slack bot is only supported on Unix".to_string())
}
}
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("");
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(),
}
}
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,
event: &SlackEvent,
) -> Option<(String, String)> {
match event {
SlackEvent::SlashCommand {
command,
text,
channel,
..
} => {
let body = 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, .. } => {
let body = 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 = reqwest::Client::new();
let executor = Arc::new(CommandExecutor::new(url));
let histories: ChatHistories = Arc::new(Mutex::new(HashMap::new()));
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).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,
) -> 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, &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;