use anyhow::{bail, Context, Result};
use chrono::{Duration, Utc};
use mecha_slack::binding::{Binding, Credentials, PendingLink, SlackStore};
use mecha_slack::envelope::Inbound;
use mecha_slack::{chat, Slack, SocketMode, SocketOptions};
use crate::slack::threads::{ThreadState, ThreadStore};
use crate::GlobalOpts;
use serde_json::{json, Value};
const BOT_TOKEN_ENV: &str = "MECHA_SLACK_BOT_TOKEN";
const APP_TOKEN_ENV: &str = "MECHA_SLACK_APP_TOKEN";
#[derive(clap::Args, Debug)]
pub struct Args {
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[derive(clap::Subcommand, Debug)]
pub enum Cmd {
Status,
Auth,
Link {
#[arg(long, default_value_t = 10)]
timeout: i64,
#[arg(long)]
force: bool,
},
Threads {
#[arg(long)]
state: Option<String>,
},
Connect,
Sweep,
Notify {
#[arg(long)]
title: Option<String>,
},
Send {
path: std::path::PathBuf,
#[arg(long)]
comment: Option<String>,
},
Remote {
#[arg(long)]
sweep: bool,
},
Unlink,
}
pub async fn run(global: &GlobalOpts, args: Args) -> Result<()> {
let store = open_store()?;
match args.cmd.unwrap_or(Cmd::Status) {
Cmd::Status => status(&store).await,
Cmd::Auth => auth(&store).await,
Cmd::Link { timeout, force } => link(&store, timeout, force).await,
Cmd::Threads { state } => threads(state.as_deref()),
Cmd::Sweep => sweep(),
Cmd::Notify { title } => notify(&store, title.as_deref()).await,
Cmd::Send { path, comment } => send(&path, comment.as_deref()).await,
Cmd::Remote { sweep } => remote(sweep),
Cmd::Connect => crate::slack::connector::run(global).await,
Cmd::Unlink => {
store.clear_binding()?;
store.clear_pending_link()?;
println!("Unbound. The tokens are still stored; `mecha slack link` re-binds.");
Ok(())
}
}
}
fn open_store() -> Result<SlackStore> {
let root = mecha_core::work::mecha_home()?.join("slack");
Ok(SlackStore::open(root)?)
}
fn credentials(store: &SlackStore) -> Result<Credentials> {
store
.credentials()?
.context("no Slack tokens stored — run `mecha slack auth` first")
}
async fn auth(store: &SlackStore) -> Result<()> {
let bot_token = std::env::var(BOT_TOKEN_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.with_context(|| format!("set {BOT_TOKEN_ENV} (the `xoxb-` bot token)"))?;
let app_token = std::env::var(APP_TOKEN_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.with_context(|| format!("set {APP_TOKEN_ENV} (the `xapp-` app-level token)"))?;
if !bot_token.starts_with("xoxb-") {
bail!("{BOT_TOKEN_ENV} does not look like a bot token (expected it to start with `xoxb-`)");
}
if !app_token.starts_with("xapp-") {
bail!("{APP_TOKEN_ENV} does not look like an app-level token (expected `xapp-`)");
}
let slack = Slack::new(bot_token.trim());
let who: Value = match slack.call::<Value>("auth.test", json!({})).await {
Ok(who) => who,
Err(e) => bail!("{}", explain(&e)),
};
store.save_credentials(&Credentials {
bot_token: bot_token.trim().to_string(),
app_token: app_token.trim().to_string(),
})?;
println!(
"Stored. Bot `{}` in workspace `{}` ({}).",
who["user"].as_str().unwrap_or("?"),
who["team"].as_str().unwrap_or("?"),
who["team_id"].as_str().unwrap_or("?")
);
println!("Next: `mecha slack link` to say who may drive this agent.");
Ok(())
}
fn explain(e: &mecha_slack::SlackError) -> String {
let hint = match e {
mecha_slack::SlackError::Auth { code, .. } => match code.as_str() {
"account_inactive" => Some(
"the app no longer exists in that workspace — it was deleted, or its \
installation was removed. Check https://api.slack.com/apps, reinstall, \
and copy the fresh Bot User OAuth Token",
),
"invalid_auth" | "not_authed" => Some(
"the token was not accepted at all. Check it was copied whole, and that \
it is the Bot User OAuth Token (`xoxb-`) rather than an app-level token",
),
"token_revoked" => Some("the token was revoked — generate a new one and reinstall"),
"missing_scope" => Some(
"the app is installed but lacks a scope this call needs. Add it under \
OAuth & Permissions, then reinstall — scope changes need a reinstall",
),
_ => None,
},
_ => None,
};
match hint {
Some(hint) => format!("{e}\n\n {hint}."),
None => e.to_string(),
}
}
fn threads(filter: Option<&str>) -> Result<()> {
if let Some(f) = filter {
if !ThreadState::ALL.iter().any(|s| s.as_str() == f) {
let valid: Vec<_> = ThreadState::ALL.iter().map(|s| s.as_str()).collect();
bail!("no such state `{f}`. Valid: {}", valid.join(", "));
}
}
let store = ThreadStore::open(thread_root()?)?;
let all = store.all()?;
let shown: Vec<_> = all
.iter()
.filter(|r| filter.is_none_or(|f| r.state.as_str() == f))
.collect();
if shown.is_empty() {
println!(
"No threads{}.",
filter.map(|f| format!(" in {f}")).unwrap_or_default()
);
println!("store {}", store.root().display());
return Ok(());
}
for record in shown {
println!(
"{} {} mode={} {}",
record.key,
record.state.as_str(),
record.mode,
record.updated_at.format("%Y-%m-%d %H:%M UTC")
);
println!(" {}", record.state.describe());
println!(" resolved by: {}", record.state.resolved_by());
if let Some(session) = &record.session_id {
println!(" session {session}");
}
}
Ok(())
}
async fn notify(store: &SlackStore, title: Option<&str>) -> Result<()> {
let (slack, owner) = crate::slack::send::owner_client(store)?;
let mut body = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut body)
.context("reading the message from stdin")?;
let body = body.trim();
if body.is_empty() {
return Ok(());
}
let channel = crate::slack::send::open_dm(&slack, &owner).await?;
let text = match title {
Some(t) => format!("*{t}*\n{body}"),
None => body.to_string(),
};
chat::post_message(&slack, &channel, None, &text, None).await?;
Ok(())
}
async fn send(path: &std::path::Path, comment: Option<&str>) -> Result<()> {
let sent = crate::slack::send::send_file(path, comment).await?;
println!(
"sent {} ({}) to your Slack DM",
sent.filename,
crate::slack::send::human(sent.bytes)
);
Ok(())
}
fn remote(sweep: bool) -> Result<()> {
let store = crate::slack::remote::RemoteStore::open_default()?;
if sweep {
let cooled = store.sweep()?;
if cooled.is_empty() {
println!("Nothing to sweep — no attachment names a process that has gone.");
} else {
for rec in &cooled {
println!("{} cooled (was session {})", rec.name, rec.session_id);
}
}
return Ok(());
}
let records = store.list()?;
if records.is_empty() {
println!("No named threads yet — `/remote-control <name>` in the TUI makes one.");
return Ok(());
}
for rec in &records {
let state = if rec.is_live() { "live" } else { "cold" };
println!(
"{:<16} {:<5} {} {}",
rec.name,
state,
rec.session_id,
rec.workspace.display()
);
if let Some(reason) = &rec.ended_reason {
println!("{:<16} {:<5} {reason}", "", "");
}
}
Ok(())
}
fn sweep() -> Result<()> {
let store = ThreadStore::open(thread_root()?)?;
let orphaned = store.sweep()?;
if orphaned.is_empty() {
println!("Nothing to sweep — no thread is mid-flight without a live run.");
return Ok(());
}
for record in &orphaned {
println!("{} orphaned ({})", record.key, record.state.describe());
}
println!(
"\n{} thread(s) marked. The connector announces these in Slack; \
until it runs, they are visible here.",
orphaned.len()
);
Ok(())
}
fn thread_root() -> Result<std::path::PathBuf> {
Ok(mecha_core::work::mecha_home()?
.join("slack")
.join("threads"))
}
async fn status(store: &SlackStore) -> Result<()> {
println!("store {}", store.root().display());
match store.credentials()? {
None => println!("tokens none — run `mecha slack auth`"),
Some(creds) => {
let slack = Slack::new(&creds.bot_token);
match slack.call::<Value>("auth.test", json!({})).await {
Ok(who) => println!(
"tokens ok — bot `{}` in `{}`",
who["user"].as_str().unwrap_or("?"),
who["team"].as_str().unwrap_or("?")
),
Err(e) => println!("tokens stored, but Slack refused them: {e}"),
}
}
}
match store.binding()? {
None => println!("binding none — run `mecha slack link`"),
Some(b) => {
println!(
"binding workspace {} · bound {}",
b.team_id,
b.bound_at.format("%Y-%m-%d %H:%M UTC")
);
for owner in &b.owners {
println!("owner {owner}");
}
}
}
if let Some(pending) = store.pending_link()? {
if pending.is_live(Utc::now()) {
println!("pending a link code is live and waiting");
}
}
Ok(())
}
async fn link(store: &SlackStore, timeout_minutes: i64, force: bool) -> Result<()> {
let creds = credentials(store)?;
let existing = store.binding()?;
let pending = PendingLink::mint(Duration::minutes(timeout_minutes));
store.save_pending_link(&pending)?;
println!("Send this code to the app in a Slack DM:\n");
println!(" {}\n", pending.nonce);
println!(
"It is good for {timeout_minutes} minutes and can be used once. \
Waiting… (Ctrl-C to stop)"
);
let slack = Slack::new(&creds.bot_token);
let socket = SocketMode::new(
slack.clone(),
SocketOptions {
app_token: creds.app_token.clone(),
debug_reconnects: false,
},
);
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let driver = tokio::spawn(async move { socket.run(tx, || false).await });
let deadline = std::time::Duration::from_secs((timeout_minutes.max(1) * 60) as u64);
let outcome = tokio::time::timeout(deadline, async {
while let Some(inbound) = rx.recv().await {
let Inbound::Event { event, .. } = inbound else {
continue;
};
if event.kind != "message"
|| event.channel_type.as_deref() != Some("im")
|| !event.is_from_a_human()
{
continue;
}
let Some(text) = event.text.as_deref() else {
continue;
};
if !pending.matches(text) {
continue;
}
if !pending.is_live(Utc::now()) {
return Err(anyhow::anyhow!("that code had already expired"));
}
let (Some(user), Some(team)) = (event.user.clone(), event.team_id.clone()) else {
continue;
};
return Ok((user, team, event.channel.clone()));
}
Err(anyhow::anyhow!("the Slack connection closed"))
})
.await;
driver.abort();
let (user, team, channel) = match outcome {
Ok(Ok(found)) => found,
Ok(Err(e)) => {
store.clear_pending_link()?;
return Err(e);
}
Err(_) => {
store.clear_pending_link()?;
bail!("nobody sent the code within {timeout_minutes} minutes");
}
};
let binding = match existing {
Some(mut b) if b.team_id == team => {
if !b.owners.contains(&user) {
b.owners.push(user.clone());
}
b
}
Some(b) if !force => {
store.clear_pending_link()?;
bail!(
"this install is bound to workspace {} and the code came from {team}. \
Re-run with --force to rebind, which drops the existing owners.",
b.team_id
);
}
_ => Binding {
team_id: team.clone(),
enterprise_id: None,
owners: vec![user.clone()],
bound_at: Utc::now(),
},
};
store.save_binding(&binding)?;
store.clear_pending_link()?;
println!("\nBound: {user} in workspace {team}.");
if let Some(channel) = channel {
let _ = chat::post_message(
&slack,
&channel,
None,
"Linked. I heard you, and you are the owner of this agent.",
None,
)
.await;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::explain;
use mecha_slack::SlackError;
fn auth(code: &str) -> SlackError {
SlackError::Auth {
method: "auth.test".into(),
code: code.into(),
}
}
#[test]
fn the_codes_a_person_actually_hits_say_what_to_do() {
let text = explain(&auth("account_inactive"));
assert!(
text.contains("account_inactive"),
"keep Slack's own code: {text}"
);
assert!(text.contains("reinstall"), "and say what to do: {text}");
for code in [
"invalid_auth",
"not_authed",
"token_revoked",
"missing_scope",
] {
assert!(
explain(&auth(code)).lines().count() > 1,
"{code} has no hint"
);
}
}
#[test]
fn an_unrecognised_code_is_passed_through_rather_than_guessed_at() {
let text = explain(&auth("some_future_code"));
assert!(text.contains("some_future_code"));
assert_eq!(text.lines().count(), 1, "no invented hint: {text}");
}
}