Skip to content
314 changes: 314 additions & 0 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,8 @@ impl EventHandler for Handler {
"delay",
"Delay before firing (e.g. 30m, 2h, 1d)",
).required(true)),
CreateCommand::new("auth")
.description("Authenticate the backend agent (device flow)"),
CreateCommand::new("export-thread")
.description("Download this thread as a text file")
.add_option(CreateCommandOption::new(
Expand Down Expand Up @@ -1259,6 +1261,9 @@ impl EventHandler for Handler {
Interaction::Command(cmd) if cmd.data.name == "export-thread" => {
self.handle_export_thread_command(&ctx, &cmd).await;
}
Interaction::Command(cmd) if cmd.data.name == "auth" => {
self.handle_auth_command(&ctx, &cmd).await;
}
Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => {
self.handle_config_select(&ctx, &comp).await;
}
Expand Down Expand Up @@ -1660,6 +1665,315 @@ impl Handler {
}
}

async fn handle_auth_command(
&self,
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
) {
// Reject bot users — consistent with other slash-command handlers (e.g. /remind).
if cmd.user.bot {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("🤖 Bots cannot use `/auth`.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// Access control — only allowed users can trigger auth.
if is_denied_user(
false,
self.allow_all_users,
&self.allowed_users,
cmd.user.id.get(),
) {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("🚫 You are not allowed to use this bot.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// DM-only — auth codes are sensitive; reject if not in a DM channel.
if cmd.guild_id.is_some() {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("🔒 `/auth` is only available in DMs for security. Please DM me and run `/auth` there.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// Single-flight guard — prevent concurrent /auth invocations.
static AUTH_IN_PROGRESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if AUTH_IN_PROGRESS.swap(true, std::sync::atomic::Ordering::Acquire) {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ Authentication already in progress. Please wait for it to complete.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

let auth_cmd = match std::env::var("OPENAB_AGENT_AUTH_COMMAND") {
Ok(val) if !val.is_empty() => val,
_ => {
AUTH_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release);
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ No auth command configured (`OPENAB_AGENT_AUTH_COMMAND` not set).")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}
};

// Acknowledge with a deferred ephemeral response so we have time to run the command.
let defer = CreateInteractionResponse::Defer(
CreateInteractionResponseMessage::new().ephemeral(true),
);
if let Err(e) = cmd.create_response(&ctx.http, defer).await {
AUTH_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release);
tracing::error!(error = %e, "failed to defer /auth response");
return;
}

let http = ctx.http.clone();
let token = cmd.token.clone();
let user_id = cmd.user.id.get();

tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
use tokio::process::Command as TokioCommand;
use std::sync::Arc;

// Drop guard ensures AUTH_IN_PROGRESS is cleared even on panic.
struct AuthGuard;
impl Drop for AuthGuard {
fn drop(&mut self) {
AUTH_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release);
}
}
let _guard = AuthGuard;

info!(user_id, "/auth: starting auth command");

let child = TokioCommand::new("sh")
.arg("-c")
.arg(&auth_cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();

let mut child = match child {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "/auth: failed to spawn auth command");
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content(format!("❌ Failed to start auth command: {e}"))
.ephemeral(true),
Vec::new(),
).await;
return;
}
};

let stdout = child.stdout.take();
let stderr = child.stderr.take();

let lines = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let url_found = Arc::new(tokio::sync::Notify::new());

// Spawn background drain tasks — they run to EOF, keeping pipes open.
let lines_out = lines.clone();
let url_found_out = url_found.clone();
let stdout_task = tokio::spawn(async move {
if let Some(stdout) = stdout {
let mut reader = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
let has_url = line.contains("http://") || line.contains("https://");
lines_out.lock().unwrap_or_else(|e| e.into_inner()).push(line);
if has_url {
url_found_out.notify_one();
}
}
}
});

let lines_err = lines.clone();
let url_found_err = url_found.clone();
let stderr_task = tokio::spawn(async move {
if let Some(stderr) = stderr {
let mut reader = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
let has_url = line.contains("http://") || line.contains("https://");
lines_err.lock().unwrap_or_else(|e| e.into_inner()).push(line);
if has_url {
url_found_err.notify_one();
}
}
}
});

// Wait for a URL to appear, the command to exit early, or a 30s timeout.
let mut early_exit: Option<std::io::Result<std::process::ExitStatus>> = None;
tokio::select! {
_ = url_found.notified() => {
info!("/auth: URL detected in output");
// Brief sleep to let trailing lines (code/instructions) be captured.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
res = child.wait() => {
// The auth command exited before printing a URL — fail fast
// instead of waiting out the full collection window.
warn!("/auth: auth command exited before a URL was detected");
early_exit = Some(res);
}
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
warn!("/auth: 30s URL-collection window expired without detecting URL");
}
}

// Handle an early exit (the command terminated during the URL window).
if let Some(res) = early_exit {
let _ = tokio::join!(stdout_task, stderr_task);
let collected = lines
.lock()
.unwrap_or_else(|e| e.into_inner())
.join("\n");
let detail = if collected.trim().is_empty() {
String::new()
} else {
let snippet: String = collected.chars().take(500).collect();
format!("\n```\n{snippet}\n```")
};
let content = match res {
Ok(status) if status.success() => {
format!(
"⚠️ Auth command exited (status 0) before a login URL was detected. Run `/auth` again to retry.{detail}"
)
}
Ok(status) => {
format!(
"❌ Auth command exited early ({status}) before producing a login URL.{detail}"
)
}
Err(e) => format!("❌ Error waiting for auth command: {e}"),
};
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content(content)
.ephemeral(true),
Vec::new(),
).await;
return;
}

let collected_lines = lines.lock().unwrap_or_else(|e| e.into_inner()).clone();

if collected_lines.is_empty() {
warn!("/auth: no output captured, killing child process");
let _ = child.kill().await;
let _ = tokio::join!(stdout_task, stderr_task);
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content("⚠️ Auth command produced no output within 30 seconds. Verify `OPENAB_AGENT_AUTH_COMMAND` is set and prints a login URL to stdout/stderr.")
.ephemeral(true),
Vec::new(),
).await;
return;
}

// Send the captured output (truncated to Discord's 2000-char limit).
let output = collected_lines.join("\n");
let prefix = "🔐 **Agent Authentication**\n```\n";
let suffix = "\n```\nFollow the instructions above. Waiting for authorization...";
// Discord enforces the 2000-char limit in UTF-16 code units, so budget
// and truncate by UTF-16 units rather than Unicode scalar values.
let budget = 2000usize
.saturating_sub(prefix.encode_utf16().count())
.saturating_sub(suffix.encode_utf16().count());
let mut truncated = String::new();
let mut used = 0usize;
for ch in output.chars() {
let w = ch.len_utf16();
if used + w > budget {
break;
}
used += w;
truncated.push(ch);
}
let msg = format!("{prefix}{truncated}{suffix}");
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content(msg)
.ephemeral(true),
Vec::new(),
).await;

// Wait for the process to complete (user authorizes in browser).
// Use 14min (not 15) to leave headroom for the Discord interaction token TTL.
let timeout = std::time::Duration::from_secs(14 * 60);
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) if status.success() => {
info!("/auth: authentication successful");
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content("✅ Authentication successful!")
.ephemeral(true),
Vec::new(),
).await;
}
Ok(Ok(status)) => {
warn!(%status, "/auth: authentication failed");
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content(format!("❌ Authentication failed (exit code: {}).", status))
.ephemeral(true),
Vec::new(),
).await;
}
Ok(Err(e)) => {
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content(format!("❌ Auth process error: {e}"))
.ephemeral(true),
Vec::new(),
).await;
}
Err(_) => {
warn!("/auth: timed out waiting for authorization");
let _ = child.kill().await;
let _ = http.create_followup_message(
&token,
&CreateInteractionResponseFollowup::new()
.content("⏰ Authentication timed out. Run `/auth` again to retry.")
.ephemeral(true),
Vec::new(),
).await;
}
}

// Let background drain tasks complete.
let _ = tokio::join!(stdout_task, stderr_task);
});
}

async fn handle_export_thread_command(
&self,
ctx: &Context,
Expand Down
38 changes: 37 additions & 1 deletion docs/slash-commands.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Slash Commands

OpenAB registers Discord slash commands for session control. These work in both guild threads and DMs.
OpenAB registers Discord slash commands for session control and agent management. Most work in both guild threads and DMs — the exception is `/auth`, which is **DM-only** for security (see [`/auth`](#auth) below).

## Commands

Expand All @@ -10,6 +10,7 @@ OpenAB registers Discord slash commands for session control. These work in both
| `/agents` | Select the agent mode via dropdown menu | Yes |
| `/cancel` | Cancel the current in-flight operation | Yes |
| `/reset` | Reset the conversation session (clear history, start fresh) | Yes |
| `/auth` | Authenticate the backend agent via device flow (**DM-only**) | No |
| `/remind` | Set a one-shot delayed reminder to mention users/roles | No |
| `/export-thread` | Export thread/DM as `.txt` (default: last 100 messages) | No |

Expand Down Expand Up @@ -150,3 +151,38 @@ Set a one-shot delayed reminder that mentions users or roles in the channel afte
"Review PR #42"
cc @Alice @Bob
```

## `/auth`

Trigger the backend agent's device-flow authentication. OAB executes the command defined in `OPENAB_AGENT_AUTH_COMMAND`, captures the device code URL from stdout/stderr, and relays it to the user as an ephemeral Discord message.

**Flow:**
1. User runs `/auth`
2. OAB executes `$OPENAB_AGENT_AUTH_COMMAND` (e.g. `codex login --device-auth`)
3. OAB captures the device URL + code from the command's output
4. OAB sends an ephemeral reply with the URL and code
5. User opens the URL in their browser, enters the code
6. The auth command exits successfully → OAB confirms "✅ Authentication successful!"

**Requirements:**
- `OPENAB_AGENT_AUTH_COMMAND` environment variable must be set
- The auth command must use OAuth device flow (print URL + code to stdout, then block until authorized)
- No interactive stdin input required (headless-compatible)
- Must be invoked in a **DM** with the bot (rejected in guild channels/threads for security)

**Timeout:** 14 minutes. If the user doesn't authorize within that window, the process is killed and the user is prompted to run `/auth` again. (Reduced from 15min to leave headroom for Discord's interaction token TTL.)

**Behavior notes:**
- Only users in the `allowed_users` list can invoke `/auth`
- Bot users are rejected — `/auth` is for human operators only
- A 30-second URL-collection window waits for the auth command to print its URL. Slow-starting CLIs that take longer may show "no output".
- Only one `/auth` flow can run at a time (single-flight). A second concurrent invocation is rejected with "already in progress".

**Error cases:**
- `OPENAB_AGENT_AUTH_COMMAND` not set → immediate error message
- Invoked by a bot user → rejected
- Invoked outside a DM (in a guild channel/thread) → rejected for security
- Auth command fails to start → error message
- Auth command exits **before** printing a login URL (within the 30s window) → warning that no URL was produced, with a retry prompt
- Auth command exits with non-zero → failure message with exit code
- Timeout → process killed, retry prompt
Loading