mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 08:12:56 +00:00
* feat: let Slack agents answer through a workspace's own Slack app, with signing secrets set from the UI The instance Slack app's signing secret can be set in its OAuth setting as well as SLACK_SIGNING_SECRET, and a workspace's own Slack app takes its own in the workspace settings. Each secret only vouches for the workspaces connected through its app, so /windmill keeps working for a workspace whose app has none set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep Slack signing secrets out of logs and verify an own app without a secret with the instance's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: report a workspace app's own signing secret in the Slack agents status Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check Slack approval callbacks against the instance signing secret only An approval can be sent through any Slack resource, so the team's workspace connections do not tell which app signed its callback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: share the Slack app setup instructions and require the signing secret for agents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: tidy the Slack signing secret copy and show a set secret as masked Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check a workspace app only against its own signing secret, and approvals against their workspace's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: accept a Slack approval signed by the workspace's app or the instance's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check Slack's signature on approvals only when sent through the workspace's Slack connection Any other Slack resource may belong to any Slack app, so its callbacks rest on the payload HMAC alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review findings on Slack signature checks and the signing secret field - reject Slack signatures older than five minutes, so a captured request cannot be replayed - let a reachable workspace's command script take a message before another workspace's help reply - remove a saved workspace signing secret from its field: the button reads Remove when it is empty - set the signing secret on connect only when one was entered - tell non-admins who can set the signing secret Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep SLACK_SIGNING_SECRET for workspace apps without a secret, and route a verified request only to its workspaces - a workspace on its own app with no secret of its own is checked against SLACK_SIGNING_SECRET, which may hold that app's secret from before workspaces had one; the instance setting's secret still never applies to it - once a secret verifies a request, only the workspaces it verified take it, command scripts included: it proves which app sent the request - compute the replay window with abs_diff, which cannot overflow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to c8d562aa9d75a7e99bf0ecd1ac224f532452a07e This commit updates the EE repository reference after PR #907 was merged in windmill-ee-private. Previous ee-repo-ref: 0864b0ab943defa8b8d55838ef09ab7dacfac17f New ee-repo-ref: c8d562aa9d75a7e99bf0ecd1ac224f532452a07e Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1420 lines
49 KiB
Rust
1420 lines
49 KiB
Rust
use axum::{
|
|
extract::{Path, Query},
|
|
Extension,
|
|
};
|
|
use bytes::Bytes;
|
|
use hmac::{Hmac, Mac};
|
|
use http::HeaderMap;
|
|
use hyper::StatusCode;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sha2::Sha256;
|
|
use sqlx::types::Uuid;
|
|
use std::collections::HashMap;
|
|
use windmill_common::error::{to_anyhow, Error};
|
|
use windmill_common::flows::ApprovalSkin;
|
|
use windmill_common::utils::truncate_with_ellipsis;
|
|
use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key};
|
|
|
|
use crate::db::{ApiAuthed, DB};
|
|
use crate::jobs::{QueryApprover, ResumeUrls};
|
|
use crate::{
|
|
approvals::{
|
|
extract_w_id_from_resume_url, get_approval_step_skin, handle_resume_action,
|
|
ApprovalFormDetails, FieldType, MessageFormat, QueryButtonText, QueryDefaultArgsJson,
|
|
QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
|
},
|
|
auth::OptTokened,
|
|
};
|
|
|
|
// Slack rejects a button value over 2000 characters, and with it the whole post. The button value
|
|
// carries the message on to the modal, so the message is shortened to fit.
|
|
const SLACK_BUTTON_VALUE_MAX_CHARS: usize = 2000;
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
pub struct SlackFormData {
|
|
payload: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct Payload {
|
|
actions: Option<Vec<Action>>,
|
|
view: Option<View>,
|
|
trigger_id: Option<String>,
|
|
r#type: PayloadType,
|
|
container: Option<Container>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum PayloadType {
|
|
ViewSubmission,
|
|
ViewClosed,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug, Serialize)]
|
|
struct Container {
|
|
message_ts: String,
|
|
channel_id: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct View {
|
|
state: Option<State>,
|
|
private_metadata: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(tag = "action_id")]
|
|
enum Action {
|
|
#[serde(rename = "open_modal")]
|
|
OpenModal { value: String },
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct State {
|
|
values: HashMap<String, HashMap<String, ValueInput>>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum ValueInput {
|
|
PlainTextInput { value: Option<Value> },
|
|
Datepicker { selected_date: Option<String> },
|
|
Timepicker { selected_time: Option<String> },
|
|
StaticSelect { selected_option: Option<SelectedOption> },
|
|
RadioButtons { selected_option: Option<SelectedOption> },
|
|
Checkboxes { selected_options: Option<Vec<SelectedOption>> },
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct SelectedOption {
|
|
value: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct QueryResourcePath {
|
|
slack_resource_path: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct QueryChannelId {
|
|
channel_id: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct ModalActionValue {
|
|
w_id: String,
|
|
job_id: String,
|
|
path: String,
|
|
approver: Option<String>,
|
|
message: Option<String>,
|
|
flow_step_id: Option<String>,
|
|
default_args_json: Option<String>,
|
|
dynamic_enums_json: Option<String>,
|
|
resume_button_text: Option<String>,
|
|
cancel_button_text: Option<String>,
|
|
// HMAC over (w_id, job_id, path) keyed on the workspace key; minted by
|
|
// `send_slack_message`, required by the OpenModal callback branch.
|
|
signature: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct PrivateMetadata {
|
|
resume_url: String,
|
|
resource_path: String,
|
|
container: Container,
|
|
hide_cancel: Option<bool>,
|
|
// HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is
|
|
// built, required by `handle_submission` before the resource_path is decrypted.
|
|
signature: Option<String>,
|
|
// Only selects the wording of the updated channel message, so it is left unsigned.
|
|
#[serde(default)]
|
|
skin: ApprovalSkin,
|
|
}
|
|
|
|
/// Slack's signature over a callback, checked once the workspace it belongs to is known.
|
|
#[cfg_attr(not(feature = "oauth2"), allow(dead_code))]
|
|
struct SlackCallbackSignature {
|
|
sig: String,
|
|
ts: String,
|
|
body: String,
|
|
}
|
|
|
|
impl SlackCallbackSignature {
|
|
fn new(headers: &HeaderMap, body: &str) -> Self {
|
|
let header = |name: &str| {
|
|
headers
|
|
.get(name)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string()
|
|
};
|
|
Self {
|
|
sig: header("X-Slack-Signature"),
|
|
ts: header("X-Slack-Request-Timestamp"),
|
|
body: body.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Transport-level check, which also defeats replay. It is NOT the primary defense: authorization of
|
|
// the sensitive actions is anchored on a per-workspace HMAC over the callback payload itself (see
|
|
// `verify_slack_payload`). Only an approval sent through the workspace's Slack connection comes from
|
|
// an app whose secret Windmill knows; any other resource may belong to any Slack app, so its
|
|
// callbacks are left to the HMAC.
|
|
#[cfg(feature = "oauth2")]
|
|
async fn verify_slack_callback_signature(
|
|
db: &DB,
|
|
w_id: &str,
|
|
resource_path: &str,
|
|
request: &SlackCallbackSignature,
|
|
) -> Result<(), Error> {
|
|
if resource_path != windmill_common::oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH {
|
|
return Ok(());
|
|
}
|
|
crate::slack_agents::verify_approval_slack_request(
|
|
db,
|
|
w_id,
|
|
&request.sig,
|
|
&request.ts,
|
|
&request.body,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(not(feature = "oauth2"))]
|
|
async fn verify_slack_callback_signature(
|
|
_db: &DB,
|
|
_w_id: &str,
|
|
_resource_path: &str,
|
|
_request: &SlackCallbackSignature,
|
|
) -> Result<(), Error> {
|
|
Ok(())
|
|
}
|
|
|
|
/// HMAC keyed on the per-workspace encryption key (the same trust anchor as resume-URL
|
|
/// signatures). Used to authenticate the `/api/slack` callback payload itself so the
|
|
/// unauthenticated route cannot be driven into decrypting arbitrary workspace variables,
|
|
/// whether or not a Slack signing secret is set.
|
|
type SlackPayloadHmac = Hmac<Sha256>;
|
|
|
|
/// Domain-separation tag prepended to every Slack-payload MAC. The workspace key is also used
|
|
/// for resume-secret signatures (`create_signature` in `jobs.rs`), and those secrets are
|
|
/// distributed to approvers in resume URLs — so a fixed, scheme-specific prefix makes the two
|
|
/// MAC families non-interchangeable by construction rather than relying on their byte layouts
|
|
/// happening to differ. Bump the version suffix if the signed layout ever changes.
|
|
const SLACK_PAYLOAD_HMAC_DOMAIN: &[u8] = b"slack_payload_v1\0";
|
|
|
|
/// Sign the security-sensitive fields of a Slack callback payload with the workspace key.
|
|
/// Parts are joined with a `\0` delimiter (absent from paths/UUIDs) so distinct field tuples
|
|
/// cannot collide into the same MAC.
|
|
async fn sign_slack_payload(db: &DB, w_id: &str, parts: &[&[u8]]) -> Result<String, Error> {
|
|
let key = get_workspace_key(w_id, db).await?;
|
|
let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
|
mac.update(SLACK_PAYLOAD_HMAC_DOMAIN);
|
|
mac.update(w_id.as_bytes());
|
|
for part in parts {
|
|
mac.update(b"\0");
|
|
mac.update(part);
|
|
}
|
|
Ok(hex::encode(mac.finalize().into_bytes()))
|
|
}
|
|
|
|
/// Verify a signature produced by [`sign_slack_payload`] in constant time. A missing or
|
|
/// malformed signature is rejected: an attacker cannot forge one without the workspace key.
|
|
async fn verify_slack_payload(
|
|
db: &DB,
|
|
w_id: &str,
|
|
parts: &[&[u8]],
|
|
signature: Option<&str>,
|
|
) -> Result<(), Error> {
|
|
let signature = signature.ok_or_else(|| {
|
|
Error::NotAuthorized("Slack callback rejected: missing payload signature".to_string())
|
|
})?;
|
|
let provided = hex::decode(signature).map_err(|_| {
|
|
Error::NotAuthorized("Slack callback rejected: malformed payload signature".to_string())
|
|
})?;
|
|
// Map a missing workspace key (e.g. non-existent workspace) to the same generic 401 as a
|
|
// bad signature, so an unauthenticated caller cannot use the status code (500 vs 401) as a
|
|
// workspace-existence oracle.
|
|
let key = get_workspace_key(w_id, db).await.map_err(|_| {
|
|
Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string())
|
|
})?;
|
|
let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
|
mac.update(SLACK_PAYLOAD_HMAC_DOMAIN);
|
|
mac.update(w_id.as_bytes());
|
|
for part in parts {
|
|
mac.update(b"\0");
|
|
mac.update(part);
|
|
}
|
|
mac.verify_slice(&provided).map_err(|_| {
|
|
Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string())
|
|
})
|
|
}
|
|
|
|
pub async fn slack_app_callback_handler(
|
|
authed: Option<ApiAuthed>,
|
|
opt_tokened: OptTokened,
|
|
Extension(db): Extension<DB>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<StatusCode, Error> {
|
|
let request = SlackCallbackSignature::new(&headers, &String::from_utf8_lossy(&body));
|
|
|
|
let form_data: SlackFormData = serde_urlencoded::from_bytes(&body)
|
|
.map_err(|e| Error::BadRequest(format!("invalid form data: {}", e)))?;
|
|
|
|
tracing::debug!("Form data: {:#?}", form_data);
|
|
let payload: Payload = serde_json::from_str(&form_data.payload)?;
|
|
tracing::debug!("Payload: {:#?}", payload);
|
|
|
|
match payload.r#type {
|
|
PayloadType::ViewSubmission => {
|
|
handle_submission(authed, opt_tokened, db, &payload, &request, "resume").await?
|
|
}
|
|
PayloadType::ViewClosed => {
|
|
handle_submission(authed, opt_tokened, db, &payload, &request, "cancel").await?
|
|
}
|
|
_ => {
|
|
if let Some(actions) = &payload.actions {
|
|
if let Some(action) = actions.first() {
|
|
match action {
|
|
Action::OpenModal { value } => {
|
|
let trigger_id = payload.trigger_id.as_deref().ok_or_else(|| {
|
|
Error::BadRequest("Missing trigger_id".to_string())
|
|
})?;
|
|
|
|
let parsed_value: ModalActionValue =
|
|
serde_json::from_str(value.as_str()).map_err(|_| {
|
|
Error::BadRequest("Invalid JSON in action value".to_string())
|
|
})?;
|
|
|
|
let w_id = &parsed_value.w_id;
|
|
let path = &parsed_value.path;
|
|
let approver = parsed_value.approver.as_deref();
|
|
let message = parsed_value.message.as_deref();
|
|
let job_id = Uuid::parse_str(&parsed_value.job_id)?;
|
|
let flow_step_id = parsed_value.flow_step_id.as_deref();
|
|
|
|
// Authorize the request before any privileged read: the button
|
|
// payload was minted by `send_slack_message` with an HMAC over
|
|
// (w_id, job_id, path) keyed on the workspace key. Without a valid
|
|
// signature an unauthenticated caller cannot reach the decryption
|
|
// below for an arbitrary variable, even when no Slack signing
|
|
// secret is set.
|
|
verify_slack_payload(
|
|
&db,
|
|
w_id,
|
|
&[parsed_value.job_id.as_bytes(), path.as_bytes()],
|
|
parsed_value.signature.as_deref(),
|
|
)
|
|
.await?;
|
|
verify_slack_callback_signature(&db, w_id, path, &request).await?;
|
|
|
|
// Map any lookup/decryption failure to a generic error: the
|
|
// raw error echoes the probed `path`/`w_id` back, which would be
|
|
// a cross-workspace existence oracle. Log the detail server-side.
|
|
let slack_token =
|
|
get_slack_token(&db, path, w_id).await.map_err(|e| {
|
|
tracing::warn!(
|
|
"Failed to resolve slack token for {w_id}/{path}: {e:#}"
|
|
);
|
|
Error::BadRequest("Invalid Slack callback request".to_string())
|
|
})?;
|
|
let client = Client::new();
|
|
let container = payload.container.ok_or_else(|| {
|
|
Error::BadRequest("No container found.".to_string())
|
|
})?;
|
|
|
|
let default_args_json: Option<serde_json::Value> = parsed_value
|
|
.default_args_json
|
|
.as_deref()
|
|
.map(|s| serde_json::from_str(s))
|
|
.transpose()
|
|
.map_err(|_| {
|
|
Error::BadRequest(
|
|
"Invalid JSON in default_args_json".to_string(),
|
|
)
|
|
})?;
|
|
|
|
let dynamic_enums_json: Option<serde_json::Value> = parsed_value
|
|
.dynamic_enums_json
|
|
.as_deref()
|
|
.map(|s| serde_json::from_str(s))
|
|
.transpose()
|
|
.map_err(|_| {
|
|
Error::BadRequest(
|
|
"Invalid JSON in dynamic_enums_json".to_string(),
|
|
)
|
|
})?;
|
|
|
|
tracing::debug!("Default args json: {:#?}", default_args_json);
|
|
tracing::debug!("Dynamic enum json: {:#?}", dynamic_enums_json);
|
|
|
|
open_modal_with_blocks(
|
|
&client,
|
|
slack_token.as_str(),
|
|
trigger_id,
|
|
db,
|
|
w_id,
|
|
path,
|
|
job_id,
|
|
approver,
|
|
message,
|
|
flow_step_id,
|
|
container,
|
|
default_args_json.as_ref(),
|
|
dynamic_enums_json.as_ref(),
|
|
parsed_value.resume_button_text.as_deref(),
|
|
parsed_value.cancel_button_text.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
|
}
|
|
Action::Unknown => println!("Unknown action_id"),
|
|
}
|
|
} else {
|
|
tracing::debug!("Unknown Slack Action!");
|
|
}
|
|
} else {
|
|
tracing::debug!("Unknown Slack Action!");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
pub async fn request_slack_approval(
|
|
_authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, job_id)): Path<(String, Uuid)>,
|
|
Query(approver): Query<QueryApprover>,
|
|
Query(message): Query<QueryMessage>,
|
|
Query(slack_resource_path): Query<QueryResourcePath>,
|
|
Query(channel_id): Query<QueryChannelId>,
|
|
Query(flow_step_id): Query<QueryFlowStepId>,
|
|
Query(default_args_json): Query<QueryDefaultArgsJson>,
|
|
Query(dynamic_enums_json): Query<QueryDynamicEnumJson>,
|
|
Query(button_text): Query<QueryButtonText>,
|
|
) -> Result<StatusCode, Error> {
|
|
let slack_resource_path = slack_resource_path.slack_resource_path;
|
|
let channel_id = channel_id.channel_id;
|
|
let flow_step_id = flow_step_id.flow_step_id;
|
|
|
|
let slack_token = get_slack_token(&db, slack_resource_path.as_str(), &w_id).await?;
|
|
let client = Client::new();
|
|
|
|
tracing::debug!("Approver: {:?}", approver.approver);
|
|
tracing::debug!("Message: {:?}", message.message);
|
|
tracing::debug!("W ID: {:?}", w_id);
|
|
tracing::debug!("Slack Resource Path: {:?}", slack_resource_path);
|
|
tracing::debug!("Channel ID: {:?}", channel_id);
|
|
|
|
send_slack_message(
|
|
&client,
|
|
&db,
|
|
slack_token.as_str(),
|
|
channel_id.as_str(),
|
|
&w_id,
|
|
job_id,
|
|
&slack_resource_path,
|
|
approver.approver.as_deref(),
|
|
message.message.as_deref(),
|
|
flow_step_id.as_str(),
|
|
default_args_json.default_args_json.as_ref(),
|
|
dynamic_enums_json.dynamic_enums_json.as_ref(),
|
|
button_text.resume_button_text.as_deref(),
|
|
button_text.cancel_button_text.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
async fn handle_submission(
|
|
authed: Option<ApiAuthed>,
|
|
opt_tokened: OptTokened,
|
|
db: DB,
|
|
payload: &Payload,
|
|
request: &SlackCallbackSignature,
|
|
action: &str,
|
|
) -> Result<(), Error> {
|
|
let view = payload
|
|
.view
|
|
.as_ref()
|
|
.ok_or_else(|| Error::BadRequest("No view found in payload.".to_string()))?;
|
|
|
|
let state = view
|
|
.state
|
|
.as_ref()
|
|
.ok_or_else(|| Error::BadRequest("No state found in view.".to_string()))?;
|
|
|
|
let values = &state.values;
|
|
tracing::debug!("Values: {:#?}", values);
|
|
|
|
let state_values = parse_state_values(state);
|
|
let state_json = serde_json::to_value(state_values).unwrap_or_else(|_| serde_json::json!({}));
|
|
|
|
let private_metadata = view
|
|
.private_metadata
|
|
.as_ref()
|
|
.ok_or_else(|| Error::BadRequest("No private metadata found.".to_string()))?;
|
|
|
|
tracing::debug!("Private Metadata: {}", private_metadata);
|
|
let private_metadata: PrivateMetadata = serde_json::from_str(private_metadata)?;
|
|
let resume_url = private_metadata.resume_url;
|
|
let resource_path = private_metadata.resource_path;
|
|
let container: Container = private_metadata.container;
|
|
let hide_cancel = private_metadata.hide_cancel;
|
|
let signature = private_metadata.signature;
|
|
let skin = private_metadata.skin;
|
|
|
|
// If hide_cancel is true, we don't need to extract information from the private_metadata
|
|
if hide_cancel.unwrap_or(false) && action == "cancel" {
|
|
return Ok(());
|
|
}
|
|
|
|
let w_id = extract_w_id_from_resume_url(&resume_url)?;
|
|
// Authorize the submission BEFORE taking any action. `resource_path` comes from the
|
|
// (client-held) modal metadata and is not covered by the resume-URL signature, so a
|
|
// tampered/unsigned submission must be rejected up front — otherwise it could still drive
|
|
// the resume/cancel and reach the decryption below with a swapped path. Require the
|
|
// workspace-keyed HMAC minted when the modal was built.
|
|
verify_slack_payload(&db, w_id, &[resource_path.as_bytes()], signature.as_deref()).await?;
|
|
verify_slack_callback_signature(&db, w_id, &resource_path, request).await?;
|
|
|
|
// Use the common handler to process the resume/cancel action
|
|
handle_resume_action(
|
|
authed,
|
|
opt_tokened,
|
|
db.clone(),
|
|
&resume_url,
|
|
state_json,
|
|
action,
|
|
)
|
|
.await?;
|
|
|
|
let slack_token = get_slack_token(&db, &resource_path, w_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}");
|
|
Error::BadRequest("Invalid Slack callback request".to_string())
|
|
})?;
|
|
update_original_slack_message(action, slack_token, container, skin).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn transform_schemas(
|
|
text: &str,
|
|
properties: Option<HashMap<String, ResumeFormField>>,
|
|
urls: &ResumeUrls,
|
|
order: Option<Vec<String>>,
|
|
required: Option<Vec<String>>,
|
|
default_args_json: Option<&serde_json::Value>,
|
|
dynamic_enums_json: Option<&serde_json::Value>,
|
|
skin: ApprovalSkin,
|
|
) -> Result<serde_json::Value, Error> {
|
|
tracing::debug!("Resume urls: {:#?}", urls);
|
|
|
|
let link_label = match skin {
|
|
ApprovalSkin::Detailed => "Flow suspension details",
|
|
ApprovalSkin::Minimal => "View in Windmill",
|
|
};
|
|
let mut blocks = vec![serde_json::json!({
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": format!("{}\n<{}|{link_label}>", text, urls.approvalPage),
|
|
}
|
|
})];
|
|
|
|
if let Some(properties) = properties {
|
|
for key in order.unwrap_or_default() {
|
|
if let Some(schema) = properties.get(&key) {
|
|
let is_required = required.as_ref().map_or(false, |r| r.contains(&key));
|
|
|
|
let default_value = default_args_json.and_then(|json| json.get(&key).cloned());
|
|
let dynamic_enums_value =
|
|
dynamic_enums_json.and_then(|json| json.get(&key).cloned());
|
|
|
|
let input_block = create_input_block(
|
|
&key,
|
|
schema,
|
|
is_required,
|
|
default_value,
|
|
dynamic_enums_value,
|
|
);
|
|
match input_block {
|
|
serde_json::Value::Array(arr) => blocks.extend(arr),
|
|
_ => blocks.push(input_block),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(serde_json::Value::Array(blocks))
|
|
}
|
|
|
|
fn create_input_block(
|
|
key: &str,
|
|
schema: &ResumeFormField,
|
|
required: bool,
|
|
default_value: Option<serde_json::Value>,
|
|
dynamic_enums_value: Option<serde_json::Value>,
|
|
) -> serde_json::Value {
|
|
let placeholder = schema
|
|
.description
|
|
.as_deref()
|
|
.filter(|desc| !desc.is_empty())
|
|
.unwrap_or("Select an option");
|
|
|
|
let title = schema.title.as_deref().unwrap_or(key);
|
|
let title_with_required = if required {
|
|
format!("{}*", title)
|
|
} else {
|
|
title.to_string()
|
|
};
|
|
|
|
// Handle boolean type
|
|
if let FieldType::Boolean = schema.r#type {
|
|
let initial_value = default_value
|
|
.as_ref()
|
|
.and_then(|v| v.as_bool())
|
|
.or_else(|| {
|
|
schema
|
|
.default
|
|
.as_ref()
|
|
.and_then(|default| default.as_bool())
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
let mut element = serde_json::json!({
|
|
"type": "checkboxes",
|
|
"options": [{
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
},
|
|
"value": "true"
|
|
}],
|
|
"action_id": key
|
|
});
|
|
|
|
if initial_value {
|
|
element["initial_options"] = serde_json::json!([
|
|
{
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
},
|
|
"value": "true"
|
|
}
|
|
]);
|
|
}
|
|
|
|
return serde_json::json!({
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": element,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle date-time format
|
|
if let FieldType::String = schema.r#type {
|
|
if schema.format.as_deref() == Some("date-time") {
|
|
tracing::debug!("Date-time type");
|
|
let now = chrono::Local::now();
|
|
let current_date = now.format("%Y-%m-%d").to_string();
|
|
let current_time = now.format("%H:%M").to_string();
|
|
|
|
let (default_date, default_time) = default_value
|
|
.as_ref()
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
|
.map(|parsed_date| {
|
|
(
|
|
parsed_date.format("%Y-%m-%d").to_string(),
|
|
parsed_date.format("%H:%M").to_string(),
|
|
)
|
|
})
|
|
.or_else(|| {
|
|
schema
|
|
.default
|
|
.as_ref()
|
|
.and_then(|default| {
|
|
chrono::DateTime::parse_from_rfc3339(default.as_str().unwrap()).ok()
|
|
})
|
|
.map(|parsed_date| {
|
|
(
|
|
parsed_date.format("%Y-%m-%d").to_string(),
|
|
parsed_date.format("%H:%M").to_string(),
|
|
)
|
|
})
|
|
})
|
|
.unwrap_or((current_date.clone(), current_time.clone()));
|
|
|
|
return serde_json::json!([
|
|
{
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": {
|
|
"type": "datepicker",
|
|
"initial_date": &default_date,
|
|
"placeholder": {
|
|
"type": "plain_text",
|
|
"text": "Select a date",
|
|
"emoji": true
|
|
},
|
|
"action_id": format!("{}_date", key)
|
|
},
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
}
|
|
},
|
|
{
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": {
|
|
"type": "timepicker",
|
|
"initial_time": &default_time,
|
|
"placeholder": {
|
|
"type": "plain_text",
|
|
"text": "Select time",
|
|
"emoji": true
|
|
},
|
|
"action_id": format!("{}_time", key)
|
|
},
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": " ",
|
|
"emoji": true
|
|
}
|
|
}
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Handle enum type
|
|
if let Some(enums) = &schema.r#enum {
|
|
tracing::debug!("Enum type");
|
|
let enums = dynamic_enums_value
|
|
.as_ref()
|
|
.and_then(|v| v.as_array())
|
|
.cloned()
|
|
.unwrap_or_else(|| enums.iter().map(|s| serde_json::json!(s)).collect());
|
|
|
|
let initial_option = schema.default.as_ref().and_then(|default_value| {
|
|
enums
|
|
.iter()
|
|
.find(|enum_value| enum_value == &&serde_json::json!(default_value))
|
|
.map(|enum_value| {
|
|
serde_json::json!({
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": schema.enum_labels.as_ref()
|
|
.and_then(|labels| labels.get(enum_value.as_str().unwrap()))
|
|
.unwrap_or(&enum_value.as_str().unwrap().to_string()),
|
|
"emoji": true
|
|
},
|
|
"value": enum_value
|
|
})
|
|
})
|
|
});
|
|
|
|
let mut element = serde_json::json!({
|
|
"type": "static_select",
|
|
"placeholder": {
|
|
"type": "plain_text",
|
|
"text": placeholder,
|
|
"emoji": true,
|
|
},
|
|
"options": enums.iter().map(|enum_value| {
|
|
serde_json::json!({
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": schema.enum_labels.as_ref()
|
|
.and_then(|labels| labels.get(enum_value.as_str().unwrap()))
|
|
.unwrap_or(&enum_value.as_str().unwrap().to_string()),
|
|
"emoji": true
|
|
},
|
|
"value": enum_value
|
|
})
|
|
}).collect::<Vec<_>>(),
|
|
"action_id": key
|
|
});
|
|
|
|
if let Some(option) = initial_option {
|
|
element["initial_option"] = option;
|
|
}
|
|
|
|
serde_json::json!({
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": element,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
}
|
|
})
|
|
} else if let FieldType::Number | FieldType::Integer = schema.r#type {
|
|
tracing::debug!("Number or integer type");
|
|
// Handle number and integer types
|
|
let initial_value = default_value
|
|
.as_ref()
|
|
.and_then(|v| v.as_f64())
|
|
.or_else(|| schema.default.as_ref().and_then(|default| default.as_f64()))
|
|
.unwrap_or(0.0);
|
|
|
|
let action_id_suffix = if let FieldType::Number = schema.r#type {
|
|
"_type_number"
|
|
} else {
|
|
"_type_integer"
|
|
};
|
|
|
|
serde_json::json!({
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": {
|
|
"type": "plain_text_input",
|
|
"action_id": format!("{}{}", key, action_id_suffix),
|
|
"initial_value": initial_value.to_string()
|
|
},
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
}
|
|
})
|
|
} else {
|
|
tracing::debug!("Other type");
|
|
// Handle other types as string
|
|
let initial_value = default_value
|
|
.as_ref()
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| schema.default.as_ref().and_then(|default| default.as_str()))
|
|
.unwrap_or("");
|
|
|
|
serde_json::json!({
|
|
"type": "input",
|
|
"optional": !required,
|
|
"element": {
|
|
"type": "plain_text_input",
|
|
"action_id": key,
|
|
"initial_value": initial_value.to_string()
|
|
},
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": title_with_required,
|
|
"emoji": true
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_state_values(state: &State) -> HashMap<String, serde_json::Value> {
|
|
state
|
|
.values
|
|
.iter()
|
|
.flat_map(|(_, inputs)| {
|
|
inputs.iter().filter_map(|(action_id, input)| {
|
|
if action_id.ends_with("_date") {
|
|
process_datetime_inputs(action_id, input, &state)
|
|
} else {
|
|
process_non_datetime_inputs(action_id, input)
|
|
}
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn process_datetime_inputs(
|
|
action_id: &str,
|
|
input: &ValueInput,
|
|
state: &State,
|
|
) -> Option<(String, serde_json::Value)> {
|
|
let base_key = action_id.strip_suffix("_date").unwrap();
|
|
let time_key = format!("{}_time", base_key);
|
|
|
|
if let ValueInput::Datepicker { selected_date: Some(date) } = input {
|
|
let matching_time = state
|
|
.values
|
|
.values()
|
|
.flat_map(|inputs| inputs.get(&time_key))
|
|
.find_map(|time_input| match time_input {
|
|
ValueInput::Timepicker { selected_time: Some(time) } => Some(time),
|
|
_ => None,
|
|
});
|
|
|
|
return matching_time.map(|time| {
|
|
(
|
|
base_key.to_string(),
|
|
serde_json::json!(format!("{}T{}:00.000Z", date, time)),
|
|
)
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
fn process_non_datetime_inputs(
|
|
action_id: &str,
|
|
input: &ValueInput,
|
|
) -> Option<(String, serde_json::Value)> {
|
|
match input {
|
|
// Handle number and integer types first
|
|
ValueInput::PlainTextInput { value }
|
|
if action_id.ends_with("_type_number") || action_id.ends_with("_type_integer") =>
|
|
{
|
|
let base_action_id = action_id
|
|
.trim_end_matches("_type_number")
|
|
.trim_end_matches("_type_integer");
|
|
value
|
|
.as_ref()
|
|
.and_then(|v| {
|
|
v.as_str().and_then(|s| s.parse::<f64>().ok()).map(|num| {
|
|
if action_id.ends_with("_type_integer") {
|
|
(
|
|
base_action_id.to_string(),
|
|
serde_json::json!(num.floor() as i64),
|
|
)
|
|
} else {
|
|
(base_action_id.to_string(), serde_json::json!(num))
|
|
}
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
tracing::error!("Failed to parse value to number: {:?}", value);
|
|
None
|
|
})
|
|
}
|
|
|
|
// Plain text input: Extracts the text value
|
|
ValueInput::PlainTextInput { value } => value
|
|
.as_ref()
|
|
.map(|v| (action_id.to_string(), v.clone().into())),
|
|
|
|
// Static select: Extracts the selected option's value
|
|
ValueInput::StaticSelect { selected_option } => selected_option
|
|
.as_ref()
|
|
.map(|so| (action_id.to_string(), serde_json::json!(so.value))),
|
|
|
|
// Radio buttons: Extracts the selected option's value
|
|
ValueInput::RadioButtons { selected_option } => selected_option
|
|
.as_ref()
|
|
.map(|so| (action_id.to_string(), serde_json::json!(so.value))),
|
|
|
|
// Checkboxes: Convert single "true/false" string to boolean true/false
|
|
ValueInput::Checkboxes { selected_options } => selected_options.as_ref().map(|options| {
|
|
let is_true = options.iter().any(|opt| opt.value == "true");
|
|
(action_id.to_string(), serde_json::json!(is_true))
|
|
}),
|
|
|
|
// Default case: Unsupported types return `None`
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn get_slack_token(db: &DB, slack_resource_path: &str, w_id: &str) -> anyhow::Result<String> {
|
|
get_secret_value_as_admin(db, w_id, slack_resource_path)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
|
}
|
|
|
|
// Sends a Slack message with a button that opens a modal
|
|
async fn send_slack_message(
|
|
client: &Client,
|
|
db: &DB,
|
|
bot_token: &str,
|
|
channel_id: &str,
|
|
w_id: &str,
|
|
job_id: Uuid,
|
|
resource_path: &str,
|
|
approver: Option<&str>,
|
|
message: Option<&str>,
|
|
flow_step_id: &str,
|
|
default_args_json: Option<&serde_json::Value>,
|
|
dynamic_enums_json: Option<&serde_json::Value>,
|
|
resume_button_text: Option<&str>,
|
|
cancel_button_text: Option<&str>,
|
|
) -> Result<StatusCode, Box<dyn std::error::Error>> {
|
|
let url = "https://slack.com/api/chat.postMessage";
|
|
|
|
let mut value = serde_json::json!({
|
|
"w_id": w_id,
|
|
"job_id": job_id,
|
|
"path": resource_path,
|
|
"channel": channel_id,
|
|
"flow_step_id": flow_step_id,
|
|
});
|
|
|
|
if let Some(approver) = approver {
|
|
value["approver"] = serde_json::json!(approver);
|
|
}
|
|
|
|
if let Some(default_args_json) = default_args_json {
|
|
value["default_args_json"] = default_args_json.clone();
|
|
}
|
|
|
|
if let Some(dynamic_enums_json) = dynamic_enums_json {
|
|
value["dynamic_enums_json"] = dynamic_enums_json.clone();
|
|
}
|
|
|
|
if let Some(resume_button_text) = resume_button_text {
|
|
value["resume_button_text"] = serde_json::json!(resume_button_text);
|
|
}
|
|
|
|
if let Some(cancel_button_text) = cancel_button_text {
|
|
value["cancel_button_text"] = serde_json::json!(cancel_button_text);
|
|
}
|
|
|
|
// Authenticate the button payload so the unauthenticated callback cannot be driven into
|
|
// decrypting an arbitrary variable: bind (w_id, job_id, path) with the workspace key.
|
|
// `job_id` is signed over its string form to match how it is parsed back on callback.
|
|
let signature = sign_slack_payload(
|
|
db,
|
|
w_id,
|
|
&[job_id.to_string().as_bytes(), resource_path.as_bytes()],
|
|
)
|
|
.await
|
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
|
value["signature"] = serde_json::json!(signature);
|
|
|
|
let skin = get_approval_step_skin(db, w_id, job_id, flow_step_id).await;
|
|
let payload = channel_message_payload(channel_id, skin, message, value);
|
|
|
|
tracing::debug!("Payload: {:?}", payload);
|
|
|
|
let response = client
|
|
.post(url)
|
|
.bearer_auth(bot_token)
|
|
.json(&payload)
|
|
.send()
|
|
.await?;
|
|
|
|
if response.status().is_success() {
|
|
tracing::info!("Interactive Slack approval message sent successfully!");
|
|
tracing::debug!("Response: {:#?}", response.text().await?);
|
|
} else {
|
|
tracing::error!(
|
|
"Failed to send interactive Slack approval message: {}",
|
|
response.text().await?
|
|
);
|
|
}
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
/// The channel post announcing the approval. Its button hands `button_value` to the modal, with
|
|
/// `message` added, shortened to what Slack's button value limit leaves room for.
|
|
fn channel_message_payload(
|
|
channel_id: &str,
|
|
skin: ApprovalSkin,
|
|
message: Option<&str>,
|
|
mut button_value: serde_json::Value,
|
|
) -> serde_json::Value {
|
|
let message = message.map(|m| message_fitting_button_value(&button_value, m));
|
|
if let Some(message) = &message {
|
|
button_value["message"] = serde_json::json!(message);
|
|
}
|
|
let (text, section, button_label) = match skin {
|
|
ApprovalSkin::Detailed => {
|
|
let text = "A flow has been suspended. Please approve or reject the flow.";
|
|
(text, text.to_string(), "View")
|
|
}
|
|
ApprovalSkin::Minimal => {
|
|
let mut section = "*Approval requested*".to_string();
|
|
if let Some(message) = &message {
|
|
section.push('\n');
|
|
section.push_str(message);
|
|
}
|
|
("Approval requested", section, "Review")
|
|
}
|
|
};
|
|
|
|
serde_json::json!({
|
|
"channel": channel_id,
|
|
"text": text,
|
|
"blocks": [
|
|
{
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": section
|
|
}
|
|
},
|
|
{
|
|
"type": "actions",
|
|
"elements": [
|
|
{
|
|
"type": "button",
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": button_label
|
|
},
|
|
"action_id": "open_modal",
|
|
"value": button_value.to_string()
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
/// The longest prefix of `message` that keeps `button_value` carrying it within Slack's limit.
|
|
fn message_fitting_button_value(button_value: &serde_json::Value, message: &str) -> String {
|
|
let mut with_message = button_value.clone();
|
|
let mut fits = |max_chars: usize| {
|
|
let fitted = truncate_with_ellipsis(message, max_chars);
|
|
with_message["message"] = serde_json::json!(fitted);
|
|
(with_message.to_string().chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS).then_some(fitted)
|
|
};
|
|
if let Some(whole) = fits(usize::MAX) {
|
|
return whole;
|
|
}
|
|
// Searched on the serialized length, which escaping makes longer than the raw prefix, and
|
|
// which grows with every character kept.
|
|
let (mut shortest, mut longest) =
|
|
(0, message.chars().count().min(SLACK_BUTTON_VALUE_MAX_CHARS));
|
|
while shortest < longest {
|
|
let mid = (shortest + longest + 1) / 2;
|
|
if fits(mid).is_some() {
|
|
shortest = mid;
|
|
} else {
|
|
longest = mid - 1;
|
|
}
|
|
}
|
|
fits(shortest).unwrap_or_else(|| truncate_with_ellipsis(message, 0))
|
|
}
|
|
|
|
async fn get_modal_blocks(
|
|
db: DB,
|
|
w_id: &str,
|
|
job_id: Uuid,
|
|
resume_id: u32,
|
|
approver: Option<&str>,
|
|
message: Option<&str>,
|
|
trigger_id: &str,
|
|
flow_step_id: Option<&str>,
|
|
resource_path: &str,
|
|
container: Container,
|
|
default_args_json: Option<&serde_json::Value>,
|
|
dynamic_enums_json: Option<&serde_json::Value>,
|
|
resume_button_text: Option<&str>,
|
|
cancel_button_text: Option<&str>,
|
|
) -> Result<axum::Json<serde_json::Value>, Error> {
|
|
// Bind the resource_path embedded in the modal's private_metadata to the workspace key so
|
|
// it cannot be tampered with on the way back in `handle_submission`. Computed before `db`
|
|
// is moved into `get_approval_form_details`.
|
|
let private_metadata_signature =
|
|
sign_slack_payload(&db, w_id, &[resource_path.as_bytes()]).await?;
|
|
|
|
let approval_details = crate::approvals::get_approval_form_details(
|
|
db,
|
|
w_id,
|
|
job_id,
|
|
flow_step_id,
|
|
resume_id,
|
|
approver,
|
|
message,
|
|
MessageFormat::Slack,
|
|
)
|
|
.await?;
|
|
|
|
let ApprovalFormDetails { message_str, urls, schema, skin } = approval_details;
|
|
|
|
// Get the card content
|
|
let card_content = transform_schemas(
|
|
&message_str,
|
|
schema
|
|
.as_ref()
|
|
.and_then(|s| s.resume_form.as_ref())
|
|
.map(|f| {
|
|
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
|
inner_schema.schema.properties
|
|
}),
|
|
&urls,
|
|
schema
|
|
.as_ref()
|
|
.and_then(|s| s.resume_form.as_ref())
|
|
.map(|f| {
|
|
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
|
inner_schema.schema.order
|
|
}),
|
|
schema
|
|
.as_ref()
|
|
.and_then(|s| s.resume_form.as_ref())
|
|
.map(|f| {
|
|
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
|
inner_schema.schema.required
|
|
}),
|
|
default_args_json,
|
|
dynamic_enums_json,
|
|
skin,
|
|
)
|
|
.await?;
|
|
|
|
tracing::debug!("Slack Blocks: {:#?}", card_content);
|
|
Ok(axum::Json(construct_payload(
|
|
card_content,
|
|
schema.as_ref().and_then(|s| s.hide_cancel).unwrap_or(false),
|
|
trigger_id,
|
|
&urls.resume,
|
|
resource_path,
|
|
container,
|
|
resume_button_text,
|
|
cancel_button_text,
|
|
&private_metadata_signature,
|
|
skin,
|
|
)))
|
|
}
|
|
|
|
fn construct_payload(
|
|
blocks: serde_json::Value,
|
|
hide_cancel: bool,
|
|
trigger_id: &str,
|
|
resume_url: &str,
|
|
resource_path: &str,
|
|
container: Container,
|
|
resume_button_text: Option<&str>,
|
|
cancel_button_text: Option<&str>,
|
|
signature: &str,
|
|
skin: ApprovalSkin,
|
|
) -> serde_json::Value {
|
|
let (title, resume_label, cancel_label) = match skin {
|
|
ApprovalSkin::Detailed => ("Workflow Suspended", "Resume Workflow", "Cancel Workflow"),
|
|
ApprovalSkin::Minimal => ("Approval request", "Approve", "Reject"),
|
|
};
|
|
let mut view = serde_json::json!({
|
|
"type": "modal",
|
|
"callback_id": "submit_form",
|
|
"notify_on_close": true,
|
|
"title": {
|
|
"type": "plain_text",
|
|
"text": title
|
|
},
|
|
"blocks": blocks,
|
|
"submit": {
|
|
"type": "plain_text",
|
|
"text": resume_button_text.unwrap_or(resume_label)
|
|
},
|
|
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature, "skin": skin }).to_string(),
|
|
});
|
|
|
|
if !hide_cancel {
|
|
view["close"] = serde_json::json!({
|
|
"type": "plain_text",
|
|
"text": cancel_button_text.unwrap_or(cancel_label)
|
|
});
|
|
}
|
|
|
|
serde_json::json!({
|
|
"trigger_id": trigger_id,
|
|
"view": view
|
|
})
|
|
}
|
|
|
|
async fn open_modal_with_blocks(
|
|
client: &Client,
|
|
bot_token: &str,
|
|
trigger_id: &str,
|
|
db: DB,
|
|
w_id: &str,
|
|
resource_path: &str,
|
|
job_id: Uuid,
|
|
approver: Option<&str>,
|
|
message: Option<&str>,
|
|
flow_step_id: Option<&str>,
|
|
container: Container,
|
|
default_args_json: Option<&serde_json::Value>,
|
|
dynamic_enums_json: Option<&serde_json::Value>,
|
|
resume_button_text: Option<&str>,
|
|
cancel_button_text: Option<&str>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let resume_id = rand::random::<u32>();
|
|
let blocks_json = match get_modal_blocks(
|
|
db,
|
|
w_id,
|
|
job_id,
|
|
resume_id,
|
|
approver,
|
|
message,
|
|
trigger_id,
|
|
flow_step_id,
|
|
resource_path,
|
|
container,
|
|
default_args_json,
|
|
dynamic_enums_json,
|
|
resume_button_text,
|
|
cancel_button_text,
|
|
)
|
|
.await
|
|
{
|
|
Ok(blocks) => blocks,
|
|
Err(e) => axum::Json(serde_json::json!({
|
|
"trigger_id": trigger_id,
|
|
"view": {
|
|
"type": "modal",
|
|
"title": { "type": "plain_text", "text": "Error" },
|
|
"blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": e.to_string() } } ]
|
|
}
|
|
})),
|
|
};
|
|
|
|
let blocks = &*blocks_json;
|
|
|
|
tracing::debug!("Blocks: {:#?}", blocks);
|
|
|
|
let url = "https://slack.com/api/views.open";
|
|
|
|
let response = client
|
|
.post(url)
|
|
.bearer_auth(bot_token)
|
|
.json(&blocks)
|
|
.send()
|
|
.await?;
|
|
|
|
if response.status().is_success() {
|
|
tracing::info!("Slack modal opened successfully!");
|
|
} else {
|
|
tracing::error!("Failed to open Slack modal: {}", response.text().await?);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_original_slack_message(
|
|
action: &str,
|
|
token: String,
|
|
container: Container,
|
|
skin: ApprovalSkin,
|
|
) -> Result<(), Error> {
|
|
let message = match (skin, action == "resume") {
|
|
(ApprovalSkin::Detailed, true) => "\n\n*Workflow has been resumed!* :white_check_mark:",
|
|
(ApprovalSkin::Detailed, false) => "\n\n*Workflow has been canceled!* :x:",
|
|
(ApprovalSkin::Minimal, true) => "*Approved* :white_check_mark:",
|
|
(ApprovalSkin::Minimal, false) => "*Rejected* :x:",
|
|
};
|
|
|
|
let final_blocks = vec![serde_json::json!({
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": message
|
|
}
|
|
})];
|
|
|
|
let payload = serde_json::json!({
|
|
"channel": container.channel_id,
|
|
"ts": container.message_ts,
|
|
"text": message,
|
|
"blocks": final_blocks,
|
|
"mrkdwn": true // Enable markdown to support emojis
|
|
});
|
|
|
|
let client = Client::new();
|
|
|
|
let response = client
|
|
.post("https://slack.com/api/chat.update")
|
|
.bearer_auth(token) // Use the token for authentication
|
|
.header("Content-Type", "application/json")
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| Error::from(anyhow::Error::new(e)))?;
|
|
|
|
if response.status().is_success() {
|
|
tracing::debug!("Slack message updated successfully!");
|
|
} else {
|
|
tracing::error!(
|
|
"Failed to update Slack message. Status: {}, Response: {:?}",
|
|
response.status(),
|
|
response
|
|
.text()
|
|
.await
|
|
.map_err(|e| Error::from(anyhow::Error::new(e)))?
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn long_message_keeps_the_channel_post_within_slack_limits() {
|
|
let button_value = serde_json::json!({
|
|
"w_id": "demo",
|
|
"job_id": Uuid::nil(),
|
|
"path": "u/admin/slack",
|
|
"channel": "C0123456789",
|
|
"flow_step_id": "a",
|
|
"signature": "f".repeat(64),
|
|
});
|
|
let carried = |skin, message: &str| {
|
|
let payload = channel_message_payload("C1", skin, Some(message), button_value.clone());
|
|
let button = payload["blocks"][1]["elements"][0]["value"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(button.chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS);
|
|
let section = payload["blocks"][0]["text"]["text"].as_str().unwrap();
|
|
assert!(section.chars().count() <= 3000);
|
|
serde_json::from_str::<ModalActionValue>(&button)
|
|
.unwrap()
|
|
.message
|
|
.unwrap()
|
|
};
|
|
// Quotes and newlines each cost two characters once escaped into the button value.
|
|
let message = "Expense \"offsite\" line\n".repeat(1000);
|
|
for skin in [ApprovalSkin::Detailed, ApprovalSkin::Minimal] {
|
|
let kept = carried(skin, &message);
|
|
let kept = kept.strip_suffix("...").unwrap();
|
|
assert!(message.starts_with(kept));
|
|
assert!(kept.chars().count() > 1_000);
|
|
assert_eq!(carried(skin, "Short message"), "Short message");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_skin_survives_the_modal_round_trip() {
|
|
let container = Container { message_ts: "1".to_string(), channel_id: "C1".to_string() };
|
|
let payload = construct_payload(
|
|
serde_json::json!([]),
|
|
false,
|
|
"trigger",
|
|
"https://example.com/resume",
|
|
"u/admin/slack",
|
|
container,
|
|
None,
|
|
None,
|
|
"signature",
|
|
ApprovalSkin::Minimal,
|
|
);
|
|
let view = &payload["view"];
|
|
assert_eq!(view["submit"]["text"], "Approve");
|
|
assert_eq!(view["close"]["text"], "Reject");
|
|
let metadata: PrivateMetadata =
|
|
serde_json::from_str(view["private_metadata"].as_str().unwrap()).unwrap();
|
|
assert_eq!(metadata.skin, ApprovalSkin::Minimal);
|
|
}
|
|
}
|