mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Adopts uv's clippy lint configuration: pedantic group at warn priority, with noisy lints allowed, plus restriction lints for print/dbg/exit/use_self. Fixes all violations across the workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
use std::sync::Arc;
|
|
|
|
use fabro_interview::WebInterviewer;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use tokio::time::sleep;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
use crate::client::{SlackApiError, SlackClient, parse_wss_url};
|
|
use crate::dispatch::{DispatchAction, dispatch};
|
|
use crate::socket::{SocketAck, SocketEnvelope};
|
|
use crate::threads::ThreadRegistry;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConnectionError {
|
|
#[error("WebSocket error: {0}")]
|
|
WebSocket(String),
|
|
#[error("Protocol error: {0}")]
|
|
Protocol(String),
|
|
#[error("API error: {0}")]
|
|
Api(#[from] SlackApiError),
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum ProcessOutcome {
|
|
Continue,
|
|
Reconnect,
|
|
Closed,
|
|
}
|
|
|
|
/// Process a single raw WebSocket text message: parse, ack, dispatch.
|
|
pub fn process_message(
|
|
text: &str,
|
|
thread_registry: &ThreadRegistry,
|
|
) -> (Option<String>, ProcessOutcome, DispatchAction) {
|
|
let envelope: SocketEnvelope = if let Ok(e) = serde_json::from_str(text) {
|
|
e
|
|
} else {
|
|
warn!("Failed to parse WebSocket message as envelope");
|
|
return (None, ProcessOutcome::Continue, DispatchAction::Ignored);
|
|
};
|
|
|
|
let ack_json = envelope
|
|
.envelope_id
|
|
.as_deref()
|
|
.map(|id| serde_json::to_string(&SocketAck::new(id)).expect("ack serialization"));
|
|
|
|
let action = dispatch(&envelope, thread_registry);
|
|
|
|
let outcome = match &action {
|
|
DispatchAction::Reconnect => ProcessOutcome::Reconnect,
|
|
_ => ProcessOutcome::Continue,
|
|
};
|
|
|
|
(ack_json, outcome, action)
|
|
}
|
|
|
|
/// Fetch a WebSocket URL from Slack's `apps.connections.open` endpoint.
|
|
pub async fn open_socket_url(
|
|
http: &reqwest::Client,
|
|
app_token: &str,
|
|
) -> Result<String, ConnectionError> {
|
|
let resp = http
|
|
.post("https://slack.com/api/apps.connections.open")
|
|
.bearer_auth(app_token)
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.send()
|
|
.await
|
|
.map_err(|e| ConnectionError::WebSocket(e.to_string()))?;
|
|
|
|
let json: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| ConnectionError::WebSocket(e.to_string()))?;
|
|
|
|
parse_wss_url(&json).map_err(ConnectionError::Api)
|
|
}
|
|
|
|
/// Run the Socket Mode event loop. Connects, reads messages, acks, dispatches.
|
|
/// On disconnect, returns so the caller can reconnect.
|
|
pub async fn run_event_loop(
|
|
wss_url: &str,
|
|
interviewer: &Arc<WebInterviewer>,
|
|
thread_registry: &ThreadRegistry,
|
|
) -> Result<(), ConnectionError> {
|
|
let (ws_stream, _) = tokio_tungstenite::connect_async(wss_url)
|
|
.await
|
|
.map_err(|e| ConnectionError::WebSocket(e.to_string()))?;
|
|
|
|
let (mut write, mut read) = ws_stream.split();
|
|
info!("Socket Mode WebSocket connected");
|
|
|
|
while let Some(msg) = read.next().await {
|
|
let msg = match msg {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
error!("WebSocket read error: {e}");
|
|
return Err(ConnectionError::WebSocket(e.to_string()));
|
|
}
|
|
};
|
|
|
|
let text = match msg {
|
|
Message::Text(t) => t,
|
|
Message::Close(_) => {
|
|
info!("WebSocket closed by server");
|
|
return Ok(());
|
|
}
|
|
Message::Ping(data) => {
|
|
let _ = write.send(Message::Pong(data)).await;
|
|
continue;
|
|
}
|
|
_ => continue,
|
|
};
|
|
|
|
let (ack_json, outcome, action) = process_message(&text, thread_registry);
|
|
|
|
// Send ack immediately (Slack requires within 3 seconds)
|
|
if let Some(ack) = ack_json {
|
|
if let Err(e) = write.send(Message::Text(ack.into())).await {
|
|
error!("Failed to send ack: {e}");
|
|
}
|
|
}
|
|
|
|
// Handle dispatch action
|
|
match action {
|
|
DispatchAction::SubmitAnswer {
|
|
question_id,
|
|
answer,
|
|
} => {
|
|
debug!(question_id, "Submitting answer from Slack");
|
|
let _ = interviewer.submit_answer(&question_id, answer);
|
|
}
|
|
DispatchAction::Connected => {
|
|
info!("Socket Mode handshake complete");
|
|
}
|
|
DispatchAction::Reconnect | DispatchAction::Ignored => {}
|
|
}
|
|
|
|
if outcome == ProcessOutcome::Reconnect {
|
|
info!("Server requested disconnect, will reconnect");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
info!("WebSocket stream ended");
|
|
Ok(())
|
|
}
|
|
|
|
/// Top-level runner: connects, runs the event loop, and reconnects on disconnect.
|
|
pub async fn run(
|
|
slack_client: &SlackClient,
|
|
app_token: &str,
|
|
interviewer: Arc<WebInterviewer>,
|
|
thread_registry: &ThreadRegistry,
|
|
) {
|
|
let mut backoff = std::time::Duration::from_secs(1);
|
|
let max_backoff = std::time::Duration::from_secs(30);
|
|
|
|
loop {
|
|
let wss_url = match open_socket_url(slack_client.http(), app_token).await {
|
|
Ok(url) => {
|
|
backoff = std::time::Duration::from_secs(1);
|
|
url
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to open Socket Mode connection: {e}");
|
|
sleep(backoff).await;
|
|
backoff = (backoff * 2).min(max_backoff);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match run_event_loop(&wss_url, &interviewer, thread_registry).await {
|
|
Ok(()) => {
|
|
info!("Event loop ended, reconnecting...");
|
|
backoff = std::time::Duration::from_secs(1);
|
|
}
|
|
Err(e) => {
|
|
error!("Event loop error: {e}, reconnecting...");
|
|
sleep(backoff).await;
|
|
backoff = (backoff * 2).min(max_backoff);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use fabro_interview::AnswerValue;
|
|
|
|
fn registry() -> ThreadRegistry {
|
|
ThreadRegistry::new()
|
|
}
|
|
|
|
#[test]
|
|
fn process_hello_message() {
|
|
let text = r#"{"type":"hello","num_connections":1}"#;
|
|
let (ack, outcome, action) = process_message(text, ®istry());
|
|
assert!(ack.is_none());
|
|
assert_eq!(outcome, ProcessOutcome::Continue);
|
|
assert!(matches!(action, DispatchAction::Connected));
|
|
}
|
|
|
|
#[test]
|
|
fn process_interactive_message_acks_and_dispatches() {
|
|
let text = r#"{
|
|
"type": "interactive",
|
|
"envelope_id": "env-1",
|
|
"payload": {
|
|
"type": "block_actions",
|
|
"actions": [{
|
|
"action_id": "q-1:yes",
|
|
"type": "button",
|
|
"value": "yes"
|
|
}]
|
|
}
|
|
}"#;
|
|
let (ack, outcome, action) = process_message(text, ®istry());
|
|
assert!(ack.is_some());
|
|
assert!(ack.unwrap().contains("env-1"));
|
|
assert_eq!(outcome, ProcessOutcome::Continue);
|
|
match action {
|
|
DispatchAction::SubmitAnswer {
|
|
question_id,
|
|
answer,
|
|
} => {
|
|
assert_eq!(question_id, "q-1");
|
|
assert_eq!(answer.value, AnswerValue::Yes);
|
|
}
|
|
other => panic!("expected SubmitAnswer, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_disconnect_signals_reconnect() {
|
|
let text = r#"{"type":"disconnect","reason":"link_disabled"}"#;
|
|
let (ack, outcome, action) = process_message(text, ®istry());
|
|
assert!(ack.is_none());
|
|
assert_eq!(outcome, ProcessOutcome::Reconnect);
|
|
assert!(matches!(action, DispatchAction::Reconnect));
|
|
}
|
|
|
|
#[test]
|
|
fn process_invalid_json_is_ignored() {
|
|
let text = "not valid json {{{";
|
|
let (ack, outcome, action) = process_message(text, ®istry());
|
|
assert!(ack.is_none());
|
|
assert_eq!(outcome, ProcessOutcome::Continue);
|
|
assert!(matches!(action, DispatchAction::Ignored));
|
|
}
|
|
|
|
#[test]
|
|
fn process_events_api_acks_but_ignores() {
|
|
let text = r#"{
|
|
"type": "events_api",
|
|
"envelope_id": "env-99",
|
|
"payload": {
|
|
"event": { "type": "app_mention", "text": "hi" }
|
|
}
|
|
}"#;
|
|
let (ack, outcome, action) = process_message(text, ®istry());
|
|
assert!(ack.is_some());
|
|
assert!(ack.unwrap().contains("env-99"));
|
|
assert_eq!(outcome, ProcessOutcome::Continue);
|
|
assert!(matches!(action, DispatchAction::Ignored));
|
|
}
|
|
|
|
#[test]
|
|
fn process_thread_reply_with_registered_question() {
|
|
let reg = registry();
|
|
reg.register("1234.5678", "q-10");
|
|
let text = serde_json::json!({
|
|
"type": "events_api",
|
|
"envelope_id": "env-50",
|
|
"payload": {
|
|
"event": {
|
|
"type": "message",
|
|
"text": "my answer",
|
|
"thread_ts": "1234.5678",
|
|
"user": "U123"
|
|
}
|
|
}
|
|
})
|
|
.to_string();
|
|
let (ack, outcome, action) = process_message(&text, ®);
|
|
assert!(ack.is_some());
|
|
assert_eq!(outcome, ProcessOutcome::Continue);
|
|
match action {
|
|
DispatchAction::SubmitAnswer {
|
|
question_id,
|
|
answer,
|
|
} => {
|
|
assert_eq!(question_id, "q-10");
|
|
assert_eq!(answer.value, AnswerValue::Text("my answer".to_string()));
|
|
}
|
|
other => panic!("expected SubmitAnswer, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_answer_reaches_web_interviewer() {
|
|
let interviewer = Arc::new(WebInterviewer::new());
|
|
let i_clone = Arc::clone(&interviewer);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
use fabro_interview::{Interviewer, Question, QuestionType};
|
|
let q = Question::new("approve?", QuestionType::YesNo);
|
|
i_clone.ask(q).await
|
|
});
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
let pending = interviewer.pending_questions();
|
|
assert_eq!(pending.len(), 1);
|
|
|
|
let question_id = pending[0].id.clone();
|
|
let text = serde_json::json!({
|
|
"type": "interactive",
|
|
"envelope_id": "e1",
|
|
"payload": {
|
|
"type": "block_actions",
|
|
"actions": [{
|
|
"action_id": format!("{question_id}:yes"),
|
|
"type": "button",
|
|
"value": "yes"
|
|
}]
|
|
}
|
|
})
|
|
.to_string();
|
|
let (_, _, action) = process_message(&text, ®istry());
|
|
match action {
|
|
DispatchAction::SubmitAnswer {
|
|
question_id: qid,
|
|
answer,
|
|
} => {
|
|
assert!(interviewer.submit_answer(&qid, answer));
|
|
}
|
|
other => panic!("expected SubmitAnswer, got {other:?}"),
|
|
}
|
|
|
|
let answer = handle.await.unwrap();
|
|
assert_eq!(answer.value, AnswerValue::Yes);
|
|
}
|
|
}
|