mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 16:03:47 +00:00
931 lines
31 KiB
Rust
931 lines
31 KiB
Rust
//! AI agents answering in Slack.
|
|
//!
|
|
//! A message reaches an agent when it names one (`+name` or `~name`), when its thread was started
|
|
//! with one, or when its channel has a default agent. Anything else goes to the workspace's
|
|
//! command script as before, or, where there is none, is answered with how to reach an agent.
|
|
//! The agent runs as the workspace member whose email the Slack user
|
|
//! has, so a Slack user who is not a member gets nothing, and what the agent may read or call is
|
|
//! that member's. One Slack thread is one conversation per person: a run's tool results are its
|
|
//! runner's to see, and a shared conversation would hand them to whoever replies next.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use axum::{
|
|
extract::{Path, Query},
|
|
routing::{delete, get},
|
|
Extension, Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::value::RawValue;
|
|
use uuid::Uuid;
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, JsonResult, Result},
|
|
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
|
|
utils::{calculate_hash, require_admin, StripPath},
|
|
variables::{get_secret_value_as_admin, get_workspace_key},
|
|
BASE_URL,
|
|
};
|
|
|
|
use crate::{
|
|
agent_runs::run_agent_as,
|
|
db::{ApiAuthed, DB},
|
|
jobs::RunJobQuery,
|
|
users::fetch_api_authed,
|
|
HTTP_CLIENT,
|
|
};
|
|
|
|
/// How long the reply waits for the agent before pointing at the run instead.
|
|
const MAX_WAIT_SECS: u64 = 3600;
|
|
/// Under the 12,000 characters Slack takes in a markdown block.
|
|
const MAX_REPLY_CHARS: usize = 11_000;
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new()
|
|
.route(
|
|
"/channels",
|
|
get(list_channel_agents).post(set_channel_agent),
|
|
)
|
|
.route("/channels/{channel_id}", delete(remove_channel_agent))
|
|
.route("/available_channels", get(list_available_channels))
|
|
.route("/agent_channels/{*path}", get(list_agent_channels))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ChannelAgent {
|
|
channel_id: String,
|
|
channel_name: String,
|
|
agent_path: String,
|
|
}
|
|
|
|
/// Listed through the caller's permissions: an agent they cannot read stays unnamed.
|
|
async fn list_channel_agents(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(w_id): Path<String>,
|
|
) -> JsonResult<Vec<ChannelAgent>> {
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
let rows = sqlx::query_as!(
|
|
ChannelAgent,
|
|
"SELECT c.channel_id, c.channel_name, c.agent_path FROM slack_channel_agent c
|
|
JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path
|
|
WHERE c.workspace_id = $1 ORDER BY c.channel_name",
|
|
w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct AgentSlack {
|
|
/// The connected Slack workspace's name; `None` when Slack is not connected.
|
|
slack_team_name: Option<String>,
|
|
/// Agents answer only when the instance verifies Slack's request signatures.
|
|
signing_secret_set: bool,
|
|
/// The bot's Slack handle, what people mention; `None` when Slack can't be reached.
|
|
bot_name: Option<String>,
|
|
channels: Vec<ChannelAgent>,
|
|
}
|
|
|
|
async fn list_agent_channels(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
) -> JsonResult<AgentSlack> {
|
|
let slack_team_name = sqlx::query_scalar!(
|
|
"SELECT COALESCE(slack_name, slack_team_id) FROM workspace_settings
|
|
WHERE workspace_id = $1 AND slack_team_id IS NOT NULL",
|
|
w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.flatten();
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
let channels = sqlx::query_as!(
|
|
ChannelAgent,
|
|
"SELECT c.channel_id, c.channel_name, c.agent_path FROM slack_channel_agent c
|
|
JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path
|
|
WHERE c.workspace_id = $1 AND c.agent_path = $2 ORDER BY c.channel_name",
|
|
w_id,
|
|
path.to_path()
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
#[cfg(feature = "oauth2")]
|
|
let signing_secret_set = crate::SLACK_SIGNING_SECRET.is_some();
|
|
#[cfg(not(feature = "oauth2"))]
|
|
let signing_secret_set = false;
|
|
let bot_name = match slack_team_name {
|
|
Some(_) => bot_name(&db, &w_id).await,
|
|
None => None,
|
|
};
|
|
Ok(Json(AgentSlack {
|
|
slack_team_name,
|
|
signing_secret_set,
|
|
bot_name,
|
|
channels,
|
|
}))
|
|
}
|
|
|
|
async fn bot_name(db: &DB, w_id: &str) -> Option<String> {
|
|
let token = bot_token(db, w_id).await.ok()?;
|
|
// On every agent page load: a slow Slack must not hold the page for the client's 20s timeout.
|
|
let res = tokio::time::timeout(
|
|
std::time::Duration::from_secs(3),
|
|
slack_call(&token, "auth.test", &[]),
|
|
)
|
|
.await
|
|
.ok()?
|
|
.ok()?;
|
|
res["user"].as_str().map(str::to_string)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetChannelAgent {
|
|
channel_id: String,
|
|
channel_name: String,
|
|
agent_path: String,
|
|
}
|
|
|
|
async fn set_channel_agent(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Json(body): Json<SetChannelAgent>,
|
|
) -> Result<String> {
|
|
require_admin(authed.is_admin, &authed.username)?;
|
|
let team_id = connected_team(&db, &w_id).await?;
|
|
|
|
let is_agent = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM resource
|
|
WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
|
|
w_id,
|
|
body.agent_path
|
|
)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if !is_agent {
|
|
return Err(Error::BadRequest(format!(
|
|
"{} is not an agent",
|
|
body.agent_path
|
|
)));
|
|
}
|
|
|
|
// A channel belongs to the Slack team, which several workspaces may share: one workspace
|
|
// must not take over a channel another one set.
|
|
let taken = sqlx::query!(
|
|
"INSERT INTO slack_channel_agent
|
|
(slack_team_id, channel_id, channel_name, workspace_id, agent_path)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (slack_team_id, channel_id) DO UPDATE
|
|
SET channel_name = EXCLUDED.channel_name, agent_path = EXCLUDED.agent_path
|
|
WHERE slack_channel_agent.workspace_id = EXCLUDED.workspace_id
|
|
RETURNING channel_id",
|
|
team_id,
|
|
body.channel_id,
|
|
body.channel_name,
|
|
w_id,
|
|
body.agent_path
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.is_none();
|
|
if taken {
|
|
return Err(Error::BadRequest(format!(
|
|
"#{} already has a default agent set from another workspace",
|
|
body.channel_name
|
|
)));
|
|
}
|
|
Ok(format!(
|
|
"{} answers in #{}",
|
|
body.agent_path, body.channel_name
|
|
))
|
|
}
|
|
|
|
async fn remove_channel_agent(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, channel_id)): Path<(String, String)>,
|
|
) -> Result<String> {
|
|
require_admin(authed.is_admin, &authed.username)?;
|
|
sqlx::query!(
|
|
"DELETE FROM slack_channel_agent WHERE workspace_id = $1 AND channel_id = $2",
|
|
w_id,
|
|
channel_id
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
Ok("Removed".to_string())
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SlackChannel {
|
|
id: String,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ChannelsQuery {
|
|
cursor: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SlackChannels {
|
|
channels: Vec<SlackChannel>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
/// The team's public channels, a page at a time, for picking one.
|
|
async fn list_available_channels(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Query(q): Query<ChannelsQuery>,
|
|
) -> JsonResult<SlackChannels> {
|
|
require_admin(authed.is_admin, &authed.username)?;
|
|
let token = bot_token(&db, &w_id).await?;
|
|
let mut form = vec![
|
|
("types", "public_channel".to_string()),
|
|
("exclude_archived", "true".to_string()),
|
|
("limit", "200".to_string()),
|
|
];
|
|
if let Some(cursor) = q.cursor.filter(|c| !c.is_empty()) {
|
|
form.push(("cursor", cursor));
|
|
}
|
|
let res = slack_call(&token, "conversations.list", &form)
|
|
.await
|
|
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
|
let channels = res["channels"]
|
|
.as_array()
|
|
.map(|cs| {
|
|
cs.iter()
|
|
.filter_map(|c| {
|
|
Some(SlackChannel {
|
|
id: c["id"].as_str()?.to_string(),
|
|
name: c["name"].as_str()?.to_string(),
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let next_cursor = res["response_metadata"]["next_cursor"]
|
|
.as_str()
|
|
.filter(|c| !c.is_empty())
|
|
.map(str::to_string);
|
|
Ok(Json(SlackChannels { channels, next_cursor }))
|
|
}
|
|
|
|
async fn connected_team(db: &DB, w_id: &str) -> Result<String> {
|
|
sqlx::query_scalar!(
|
|
"SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1",
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.flatten()
|
|
.ok_or_else(|| Error::BadRequest("Slack is not connected to this workspace".to_string()))
|
|
}
|
|
|
|
async fn bot_token(db: &DB, w_id: &str) -> Result<String> {
|
|
get_secret_value_as_admin(db, w_id, WORKSPACE_SLACK_BOT_TOKEN_PATH).await
|
|
}
|
|
|
|
/// A Slack Web API call, form-encoded since read methods such as `users.info` take no JSON.
|
|
/// Slack answers 200 with `ok: false` on failure; that is returned as the error.
|
|
async fn slack_call(
|
|
token: &str,
|
|
method: &str,
|
|
form: &[(&str, String)],
|
|
) -> anyhow::Result<serde_json::Value> {
|
|
let res: serde_json::Value = HTTP_CLIENT
|
|
.post(format!("https://slack.com/api/{method}"))
|
|
.bearer_auth(token)
|
|
.form(form)
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
if res["ok"].as_bool() != Some(true) {
|
|
let error = res["error"].as_str().unwrap_or("unknown_error");
|
|
let needed = res["needed"].as_str().unwrap_or_default();
|
|
anyhow::bail!("{method}: {error} {needed}");
|
|
}
|
|
Ok(res)
|
|
}
|
|
|
|
/// A message addressed to Windmill, from a mention, a direct message or `/windmill`.
|
|
pub struct SlackMessage {
|
|
pub team_id: String,
|
|
pub channel_id: String,
|
|
pub user_id: String,
|
|
/// With the bot mention stripped, still escaped the way Slack sends it.
|
|
pub text: String,
|
|
/// The message's own ts; `None` for a slash command, which has no message to reply under.
|
|
pub ts: Option<String>,
|
|
pub thread_ts: Option<String>,
|
|
/// A slash command's, where it can be answered when the bot cannot post in the channel.
|
|
pub response_url: Option<String>,
|
|
}
|
|
|
|
enum Route {
|
|
Named(String),
|
|
Agent(String),
|
|
/// Nothing answers here: say how to reach an agent.
|
|
Help,
|
|
}
|
|
|
|
/// Whether an agent answers this message; if so, the answer is under way when this returns.
|
|
/// `false` leaves the message to the workspace's command script.
|
|
pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result<bool> {
|
|
// Without it the Slack endpoints take unsigned requests, and the sender's `user_id` would
|
|
// pick whose permissions the agent runs with.
|
|
#[cfg(feature = "oauth2")]
|
|
if crate::SLACK_SIGNING_SECRET.is_none() {
|
|
return Ok(false);
|
|
}
|
|
let text = decode_entities(&msg.text);
|
|
let (name, question) = split_agent_name(&text);
|
|
let thread_root = msg.thread_ts.clone().or_else(|| msg.ts.clone());
|
|
|
|
// Only a workspace still connected to the team answers: disconnecting keeps its bot token.
|
|
let thread = match &thread_root {
|
|
Some(root) => sqlx::query!(
|
|
"SELECT t.workspace_id, t.agent_path FROM slack_thread_agent t
|
|
JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id
|
|
AND ws.slack_team_id = t.slack_team_id
|
|
WHERE t.slack_team_id = $1 AND t.channel_id = $2 AND t.thread_ts = $3",
|
|
msg.team_id,
|
|
msg.channel_id,
|
|
root
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.map(|r| (r.workspace_id, r.agent_path)),
|
|
None => None,
|
|
};
|
|
let channel = sqlx::query!(
|
|
"SELECT c.workspace_id, c.agent_path FROM slack_channel_agent c
|
|
JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id
|
|
AND ws.slack_team_id = c.slack_team_id
|
|
WHERE c.slack_team_id = $1 AND c.channel_id = $2",
|
|
msg.team_id,
|
|
msg.channel_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.map(|r| (r.workspace_id, r.agent_path));
|
|
|
|
let (w_id, route, source) = if let Some(name) = name {
|
|
// The thread's or the channel's workspace, where several share the team.
|
|
let w_id = match thread.as_ref().or(channel.as_ref()) {
|
|
Some((w_id, _)) => Some(w_id.clone()),
|
|
None => workspace_for_team(db, &msg.team_id).await?,
|
|
};
|
|
let Some(w_id) = w_id else {
|
|
return Ok(false);
|
|
};
|
|
(w_id, Route::Named(name.to_string()), "mention")
|
|
} else if let Some((w_id, path)) = thread {
|
|
(w_id, Route::Agent(path), "thread")
|
|
} else if let Some((w_id, path)) = channel {
|
|
(w_id, Route::Agent(path), "channel_default")
|
|
} else if let Some(w_id) = workspace_without_command_script(db, &msg.team_id).await? {
|
|
(w_id, Route::Help, "help")
|
|
} else {
|
|
return Ok(false);
|
|
};
|
|
|
|
if let Some(ts) = &msg.ts {
|
|
let first = sqlx::query_scalar!(
|
|
"INSERT INTO slack_answered_message (slack_team_id, channel_id, ts)
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING true",
|
|
msg.team_id,
|
|
msg.channel_id,
|
|
ts
|
|
)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.is_some();
|
|
if !first {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
// Recorded before answering, so a follow-up sent while the first answer is on its way
|
|
// already finds its agent. A named agent is recorded once the sender's name resolves.
|
|
if let (Route::Agent(path), Some(root)) = (&route, &thread_root) {
|
|
if let Err(e) =
|
|
record_thread_agent(db, &msg.team_id, &msg.channel_id, root, &w_id, path).await
|
|
{
|
|
tracing::warn!("Could not record the Slack thread's agent: {e:#}");
|
|
}
|
|
}
|
|
|
|
if !matches!(route, Route::Help) {
|
|
windmill_common::feature_usage::log_feature_usage("slack_agent", "invoked", source);
|
|
}
|
|
let db = db.clone();
|
|
let question = question.to_string();
|
|
// ponytail: the reply waits in this API process, so a restart mid-run loses the reply (not
|
|
// the run, which still shows in Windmill); a job-completion hook would make it durable.
|
|
tokio::spawn(async move {
|
|
prune(&db).await;
|
|
if let Err(e) = answer(&db, &w_id, route, &question, msg).await {
|
|
tracing::error!("Slack agent answer failed: {e:#}");
|
|
}
|
|
});
|
|
Ok(true)
|
|
}
|
|
|
|
async fn record_thread_agent(
|
|
db: &DB,
|
|
team_id: &str,
|
|
channel_id: &str,
|
|
thread_ts: &str,
|
|
w_id: &str,
|
|
agent_path: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!(
|
|
"INSERT INTO slack_thread_agent (slack_team_id, channel_id, thread_ts, workspace_id, agent_path)
|
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
|
team_id,
|
|
channel_id,
|
|
thread_ts,
|
|
w_id,
|
|
agent_path
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Best effort: a message already marked answered must still be answered if this fails.
|
|
async fn prune(db: &DB) {
|
|
let res = sqlx::query!(
|
|
"WITH a AS (DELETE FROM slack_answered_message WHERE created_at < now() - interval '1 day')
|
|
DELETE FROM slack_thread_agent WHERE created_at < now() - interval '90 days'"
|
|
)
|
|
.execute(db)
|
|
.await;
|
|
if let Err(e) = res {
|
|
tracing::warn!("Could not prune Slack agent routing: {e:#}");
|
|
}
|
|
}
|
|
|
|
/// `+name rest` or `~name rest` → (name, rest).
|
|
fn split_agent_name(text: &str) -> (Option<&str>, &str) {
|
|
let text = text.trim();
|
|
let Some(after) = text.strip_prefix('+').or_else(|| text.strip_prefix('~')) else {
|
|
return (None, text);
|
|
};
|
|
let (name, rest) = after.split_once(char::is_whitespace).unwrap_or((after, ""));
|
|
if name.is_empty()
|
|
|| !name
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '/' | '.'))
|
|
{
|
|
return (None, text);
|
|
}
|
|
(Some(name), rest.trim())
|
|
}
|
|
|
|
/// Slack escapes these three in message text.
|
|
fn decode_entities(text: &str) -> String {
|
|
text.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace("&", "&")
|
|
}
|
|
|
|
/// The workspace a message naming an agent goes to when neither its thread nor its channel says:
|
|
/// the one whose command script the team's messages already reach, else the team's only one.
|
|
async fn workspace_for_team(db: &DB, team_id: &str) -> Result<Option<String>> {
|
|
let rows = sqlx::query!(
|
|
"SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"
|
|
FROM workspace_settings WHERE slack_team_id = $1
|
|
ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2",
|
|
team_id
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(match rows.as_slice() {
|
|
[only] => Some(only.workspace_id.clone()),
|
|
[first, ..] if first.has_script => Some(first.workspace_id.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The team's workspace when no workspace of the team has a command script to take the message.
|
|
async fn workspace_without_command_script(db: &DB, team_id: &str) -> Result<Option<String>> {
|
|
let has_script = sqlx::query_scalar!(
|
|
"SELECT EXISTS(SELECT 1 FROM workspace_settings
|
|
WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL)",
|
|
team_id
|
|
)
|
|
.fetch_one(db)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if has_script {
|
|
return Ok(None);
|
|
}
|
|
workspace_for_team(db, team_id).await
|
|
}
|
|
|
|
/// How to reach an agent, with the ones this member can use.
|
|
async fn help_text(user_db: &UserDB, authed: &ApiAuthed, w_id: &str) -> Result<String> {
|
|
let mut tx = user_db.clone().begin(authed).await?;
|
|
let agents = sqlx::query_scalar!(
|
|
"SELECT path FROM resource WHERE workspace_id = $1 AND resource_type = 'ai_agent'
|
|
ORDER BY path LIMIT 11",
|
|
w_id
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
let Some(first) = agents.first() else {
|
|
return Ok(format!(
|
|
"No agent answers here by default, and there is no agent in the Windmill workspace *{w_id}* you can use yet."
|
|
));
|
|
};
|
|
let handle = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
|
|
let mut list = agents
|
|
.iter()
|
|
.take(10)
|
|
.map(|p| format!("• `+{}` ({p})", handle(p)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if agents.len() > 10 {
|
|
list.push_str("\n• …");
|
|
}
|
|
Ok(format!(
|
|
"No agent answers here by default. Start your message with an agent's name, for example `+{} what can you do?`\n\nAgents you can use:\n{list}\n\nA workspace admin can make an agent the default for a channel from the agent's page in Windmill.",
|
|
handle(first)
|
|
))
|
|
}
|
|
|
|
async fn answer(
|
|
db: &DB,
|
|
w_id: &str,
|
|
route: Route,
|
|
question: &str,
|
|
msg: SlackMessage,
|
|
) -> anyhow::Result<()> {
|
|
let token = bot_token(db, w_id).await?;
|
|
let channel = msg.channel_id.clone();
|
|
|
|
// A slash command leaves no message to thread under, so the question is posted first.
|
|
let thread_root = match msg.thread_ts.clone().or_else(|| msg.ts.clone()) {
|
|
Some(ts) => ts,
|
|
None => {
|
|
// Special mentions (`<!channel>`, `<!here>`) defused: posted by the bot, they would
|
|
// notify the whole channel on the asker's behalf.
|
|
let echoed = msg.text.replace("<!", "<!");
|
|
let posted = slack_call(
|
|
&token,
|
|
"chat.postMessage",
|
|
&[
|
|
("channel", channel.clone()),
|
|
("text", format!("<@{}>: {}", msg.user_id, echoed)),
|
|
],
|
|
)
|
|
.await;
|
|
match posted {
|
|
Ok(posted) => posted["ts"].as_str().unwrap_or_default().to_string(),
|
|
Err(e) => {
|
|
let not_member = ["not_in_channel", "channel_not_found"]
|
|
.iter()
|
|
.any(|code| e.to_string().contains(code));
|
|
if let (true, Some(url)) = (not_member, &msg.response_url) {
|
|
let _ = HTTP_CLIENT
|
|
.post(url)
|
|
.json(&serde_json::json!({
|
|
"text": "Add the Windmill bot to this conversation to ask agents here."
|
|
}))
|
|
.send()
|
|
.await;
|
|
}
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let reply = Reply::start(&token, &channel, &thread_root).await?;
|
|
|
|
let authed = match member_for_slack_user(db, &token, w_id, &msg.user_id).await {
|
|
Ok(Some(authed)) => authed,
|
|
Ok(None) => {
|
|
return reply.send(
|
|
format!(
|
|
"Your Slack email isn't a member of the Windmill workspace *{w_id}*. Ask an admin to invite you."
|
|
),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
Err(e) if e.to_string().contains("missing_scope") => {
|
|
return reply.send(
|
|
"Windmill can't read your Slack email. A workspace admin needs to reconnect Slack in the workspace settings to grant it.".to_string(),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
let user_db = UserDB::new(db.clone());
|
|
|
|
let agent_path = match route {
|
|
Route::Help => {
|
|
return reply
|
|
.send(help_text(&user_db, &authed, w_id).await?, None)
|
|
.await;
|
|
}
|
|
Route::Agent(path) => path,
|
|
Route::Named(name) => {
|
|
let mut tx = user_db.clone().begin(&authed).await?;
|
|
let matches = sqlx::query_scalar!(
|
|
"SELECT path FROM resource
|
|
WHERE workspace_id = $1 AND resource_type = 'ai_agent'
|
|
AND (path = $2 OR right(path, length($2) + 1) = '/' || $2)
|
|
ORDER BY path LIMIT 6",
|
|
w_id,
|
|
name
|
|
)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
match matches.as_slice() {
|
|
[only] => only.clone(),
|
|
[] => {
|
|
return reply
|
|
.send(
|
|
format!("I can't find an agent named *{name}* that you can use."),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
several => {
|
|
let list = several
|
|
.iter()
|
|
.map(|p| format!("`+{p}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return reply
|
|
.send(
|
|
format!("Several agents are named *{name}*, pick one: {list}"),
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
record_thread_agent(db, &msg.team_id, &channel, &thread_root, w_id, &agent_path).await?;
|
|
|
|
if question.is_empty() {
|
|
return reply
|
|
.send(format!("What would you like to ask `{agent_path}`?"), None)
|
|
.await;
|
|
}
|
|
|
|
// Keyed with the workspace's secret: any member could rebuild the plain thread and user ids
|
|
// and load this person's conversation, tool results included, through the run API.
|
|
let workspace_key = get_workspace_key(w_id, db).await?;
|
|
let run_query = RunJobQuery {
|
|
memory_id: Some(calculate_hash(&format!(
|
|
"{workspace_key}:slack:{}:{}:{}:{}",
|
|
msg.team_id, channel, thread_root, msg.user_id
|
|
))),
|
|
..Default::default()
|
|
};
|
|
let args: HashMap<String, Box<RawValue>> = [(
|
|
"user_message".to_string(),
|
|
windmill_common::worker::to_raw_value(&question),
|
|
)]
|
|
.into();
|
|
let job_id =
|
|
match run_agent_as(&authed, db, &user_db, w_id, &agent_path, &run_query, &args).await {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
return reply
|
|
.send(format!("`{agent_path}` couldn't start: {e}"), None)
|
|
.await;
|
|
}
|
|
};
|
|
|
|
let run_link = format!(
|
|
"<{}/run/{job_id}?workspace={w_id}|View run> · `{agent_path}`",
|
|
BASE_URL.load()
|
|
);
|
|
let thinking = reply.keep_thinking();
|
|
let result = wait_for_result(db, w_id, job_id).await;
|
|
if let Some(thinking) = thinking {
|
|
thinking.abort();
|
|
}
|
|
let text = match result? {
|
|
Some((result, true)) => answer_text(&result),
|
|
Some((result, false)) => format!("The agent failed: {}", error_text(&result)),
|
|
None => "Still working, follow it in the run.".to_string(),
|
|
};
|
|
reply.send(text, Some(run_link)).await
|
|
}
|
|
|
|
async fn member_for_slack_user(
|
|
db: &DB,
|
|
token: &str,
|
|
w_id: &str,
|
|
slack_user: &str,
|
|
) -> anyhow::Result<Option<ApiAuthed>> {
|
|
let info = slack_call(token, "users.info", &[("user", slack_user.to_string())]).await?;
|
|
let Some(email) = info["user"]["profile"]["email"].as_str() else {
|
|
return Ok(None);
|
|
};
|
|
let member = sqlx::query!(
|
|
"SELECT username, email FROM usr
|
|
WHERE workspace_id = $1 AND lower(email) = lower($2) AND NOT disabled",
|
|
w_id,
|
|
email
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
let Some(member) = member else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(
|
|
fetch_api_authed(member.username, member.email, w_id, db, None).await?,
|
|
))
|
|
}
|
|
|
|
async fn wait_for_result(
|
|
db: &DB,
|
|
w_id: &str,
|
|
job_id: Uuid,
|
|
) -> Result<Option<(serde_json::Value, bool)>> {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(MAX_WAIT_SECS);
|
|
while std::time::Instant::now() < deadline {
|
|
let row = sqlx::query!(
|
|
"SELECT result AS \"result: sqlx::types::Json<serde_json::Value>\",
|
|
status = 'success' AS \"success!\"
|
|
FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
|
job_id,
|
|
w_id
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
if let Some(row) = row {
|
|
return Ok(Some((
|
|
row.result.map(|r| r.0).unwrap_or_default(),
|
|
row.success,
|
|
)));
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// The agent step's `output`: its text, or a structured output as a JSON block.
|
|
fn answer_text(result: &serde_json::Value) -> String {
|
|
match &result["output"] {
|
|
serde_json::Value::String(s) if !s.trim().is_empty() => s.clone(),
|
|
serde_json::Value::Null => "The agent returned no answer.".to_string(),
|
|
other => format!(
|
|
"```\n{}\n```",
|
|
serde_json::to_string_pretty(other).unwrap_or_default()
|
|
),
|
|
}
|
|
}
|
|
|
|
fn error_text(result: &serde_json::Value) -> String {
|
|
result["error"]["message"]
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| result.to_string())
|
|
}
|
|
|
|
/// The bot's reply in a thread. While it is being worked out the thread shows the bot as thinking,
|
|
/// or, where Slack refuses that status, a placeholder message the reply then replaces.
|
|
#[derive(Clone)]
|
|
struct Reply {
|
|
token: String,
|
|
channel: String,
|
|
thread_ts: String,
|
|
placeholder: Option<String>,
|
|
}
|
|
|
|
impl Reply {
|
|
async fn start(token: &str, channel: &str, thread_ts: &str) -> anyhow::Result<Self> {
|
|
let mut reply = Reply {
|
|
token: token.to_string(),
|
|
channel: channel.to_string(),
|
|
thread_ts: thread_ts.to_string(),
|
|
placeholder: None,
|
|
};
|
|
if let Err(e) = reply.set_status("is thinking…").await {
|
|
tracing::debug!("Slack refused the thinking status, posting a placeholder: {e:#}");
|
|
let posted = slack_call(
|
|
token,
|
|
"chat.postMessage",
|
|
&[
|
|
("channel", channel.to_string()),
|
|
("thread_ts", thread_ts.to_string()),
|
|
("text", "Thinking…".to_string()),
|
|
],
|
|
)
|
|
.await?;
|
|
reply.placeholder = posted["ts"].as_str().map(str::to_string);
|
|
}
|
|
Ok(reply)
|
|
}
|
|
|
|
async fn set_status(&self, status: &str) -> anyhow::Result<()> {
|
|
slack_call(
|
|
&self.token,
|
|
"assistant.threads.setStatus",
|
|
&[
|
|
("channel_id", self.channel.clone()),
|
|
("thread_ts", self.thread_ts.clone()),
|
|
("status", status.to_string()),
|
|
],
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// Slack drops the status after two minutes without a reply, so a long run renews it.
|
|
fn keep_thinking(&self) -> Option<tokio::task::JoinHandle<()>> {
|
|
if self.placeholder.is_some() {
|
|
return None;
|
|
}
|
|
let reply = self.clone();
|
|
Some(tokio::spawn(async move {
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(90)).await;
|
|
let _ = reply.set_status("is thinking…").await;
|
|
}
|
|
}))
|
|
}
|
|
|
|
async fn send(&self, text: String, footer: Option<String>) -> anyhow::Result<()> {
|
|
let (text, blocks) = reply_blocks(&text, footer)?;
|
|
let mut form = vec![
|
|
("channel", self.channel.clone()),
|
|
("text", text),
|
|
("blocks", blocks),
|
|
];
|
|
match &self.placeholder {
|
|
Some(ts) => {
|
|
form.push(("ts", ts.clone()));
|
|
slack_call(&self.token, "chat.update", &form).await?;
|
|
}
|
|
None => {
|
|
form.push(("thread_ts", self.thread_ts.clone()));
|
|
slack_call(&self.token, "chat.postMessage", &form).await?;
|
|
let _ = self.set_status("").await;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The reply's notification text and its blocks: the answer as markdown, and an optional footer.
|
|
fn reply_blocks(text: &str, footer: Option<String>) -> anyhow::Result<(String, String)> {
|
|
let text = if text.chars().count() > MAX_REPLY_CHARS {
|
|
let cut: String = text.chars().take(MAX_REPLY_CHARS).collect();
|
|
format!("{cut}\n\n_The answer is cut short here; the run has all of it._")
|
|
} else {
|
|
text.to_string()
|
|
};
|
|
let mut blocks = vec![serde_json::json!({ "type": "markdown", "text": text })];
|
|
if let Some(footer) = footer {
|
|
blocks.push(serde_json::json!({
|
|
"type": "context",
|
|
"elements": [{ "type": "mrkdwn", "text": footer }]
|
|
}));
|
|
}
|
|
let fallback: String = text.chars().take(300).collect();
|
|
Ok((fallback, serde_json::to_string(&blocks)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::split_agent_name;
|
|
|
|
#[test]
|
|
fn agent_name_prefix() {
|
|
assert_eq!(
|
|
split_agent_name("+sql_analyst how many?"),
|
|
(Some("sql_analyst"), "how many?")
|
|
);
|
|
assert_eq!(
|
|
split_agent_name("~f/data/sql_analyst hi"),
|
|
(Some("f/data/sql_analyst"), "hi")
|
|
);
|
|
assert_eq!(split_agent_name("+helper"), (Some("helper"), ""));
|
|
assert_eq!(split_agent_name("what is +x"), (None, "what is +x"));
|
|
assert_eq!(split_agent_name("+ spaced"), (None, "+ spaced"));
|
|
}
|
|
}
|