storkit: merge 394_story_whatsapp_and_slack_permission_prompt_forwarding

This commit is contained in:
dave
2026-03-25 15:31:54 +00:00
parent 65e3643655
commit 6521c83eec
3 changed files with 219 additions and 39 deletions
+101 -20
View File
@@ -11,12 +11,14 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio::sync::Mutex as TokioMutex;
use crate::agents::AgentPool;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::slog;
use crate::chat::{ChatTransport, MessageId};
use crate::http::context::{PermissionDecision, PermissionForward};
// ── Slack API base URL (overridable for tests) ──────────────────────────
@@ -506,6 +508,13 @@ pub struct SlackWebhookContext {
pub history_size: usize,
/// Allowed channel IDs (messages from other channels are ignored).
pub channel_ids: HashSet<String>,
/// Permission requests from the MCP `prompt_permission` tool arrive here.
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
/// Pending permission replies keyed by channel ID.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
/// Seconds before an unanswered permission prompt is auto-denied.
pub permission_timeout_secs: u64,
}
/// POST /webhook/slack — receive incoming events from Slack Events API.
@@ -696,6 +705,15 @@ pub async fn slash_command_receive(
}
/// Dispatch an incoming Slack message to bot commands or LLM.
/// Returns `true` if the message body should be interpreted as permission approval.
fn is_permission_approval(body: &str) -> bool {
let trimmed = body.trim().to_ascii_lowercase();
matches!(
trimmed.as_str(),
"yes" | "y" | "approve" | "allow" | "ok"
)
}
async fn handle_incoming_message(
ctx: &SlackWebhookContext,
channel: &str,
@@ -704,6 +722,28 @@ async fn handle_incoming_message(
) {
use crate::chat::commands::{CommandDispatch, try_handle_command};
// If there is a pending permission prompt for this channel, interpret the
// message as a yes/no response instead of starting a new command/LLM flow.
{
let mut pending = ctx.pending_perm_replies.lock().await;
if let Some(tx) = pending.remove(channel) {
let decision = if is_permission_approval(message) {
PermissionDecision::Approve
} else {
PermissionDecision::Deny
};
let _ = tx.send(decision);
let confirmation = if decision == PermissionDecision::Approve {
"Permission approved."
} else {
"Permission denied."
};
let formatted = markdown_to_slack(confirmation);
let _ = ctx.transport.send_message(channel, &formatted, "").await;
return;
}
}
let dispatch = CommandDispatch {
bot_name: &ctx.bot_name,
bot_user_id: &ctx.bot_user_id,
@@ -856,26 +896,67 @@ async fn handle_llm_message(
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
let project_root_str = ctx.project_root.to_string_lossy().to_string();
let result = provider
.chat_stream(
&prompt,
&project_root_str,
resume_session_id.as_deref(),
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
buf.push_str(token);
let paragraphs = drain_complete_paragraphs(&mut buf);
for chunk in paragraphs {
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
let _ = msg_tx_for_callback.send(chunk);
}
},
|_thinking| {},
|_activity| {},
)
.await;
let chat_fut = provider.chat_stream(
&prompt,
&project_root_str,
resume_session_id.as_deref(),
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
buf.push_str(token);
let paragraphs = drain_complete_paragraphs(&mut buf);
for chunk in paragraphs {
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
let _ = msg_tx_for_callback.send(chunk);
}
},
|_thinking| {},
|_activity| {},
);
tokio::pin!(chat_fut);
// Lock the permission receiver for the duration of this chat session.
let mut perm_rx_guard = ctx.perm_rx.lock().await;
let result = loop {
tokio::select! {
r = &mut chat_fut => break r,
Some(perm_fwd) = perm_rx_guard.recv() => {
let prompt_msg = format!(
"*Permission Request*\n\nTool: `{}`\n```json\n{}\n```\n\nReply *yes* to approve or *no* to deny.",
perm_fwd.tool_name,
serde_json::to_string_pretty(&perm_fwd.tool_input)
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
);
let formatted = markdown_to_slack(&prompt_msg);
let _ = ctx.transport.send_message(channel, &formatted, "").await;
// Store the response sender so the incoming message handler
// can resolve it when the user replies yes/no.
ctx.pending_perm_replies
.lock()
.await
.insert(channel.to_string(), perm_fwd.response_tx);
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.pending_perm_replies);
let timeout_channel = channel.to_string();
let timeout_transport = Arc::clone(&ctx.transport);
let timeout_secs = ctx.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.lock().await.remove(&timeout_channel) {
let _ = tx.send(PermissionDecision::Deny);
let msg = "Permission request timed out — denied (fail-closed).";
let _ = timeout_transport.send_message(&timeout_channel, msg, "").await;
}
});
}
}
};
drop(perm_rx_guard);
// Flush remaining text.
let remaining = buffer.lock().unwrap().trim().to_string();