mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 08:03:38 +00:00
* feat: add an approval skin to the approval page and slack/teams messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: point ee-repo-ref at the teams approval skin commit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: resolve the approval skin from the step awaiting approval Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: shorten the slack approval message to fit the button value limit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: rename skins to detailed/minimal and keep long slack messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: title the minimal approval page from the step and flow summaries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: let wait_for_approval set the description approvers see Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a finished workflow's approval description, still gated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a login-required approval locked after the run moves on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: hide the windmill version on the approval page Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: update ee-repo-ref to e92abc9d1fba3ba898640df0cfeb0af8a50849b4 This commit updates the EE repository reference after PR #786 was merged in windmill-ee-private. Previous ee-repo-ref: bf1766ff49458f62d3f11746f1a06893ae3c2325 New ee-repo-ref: e92abc9d1fba3ba898640df0cfeb0af8a50849b4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1385 lines
48 KiB
Rust
1385 lines
48 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,
|
|
}
|
|
|
|
// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify
|
|
// the Slack request signature (which also defeats replay). It is NOT the primary defense:
|
|
// the secret is unset in the default deployment, so authorization of the sensitive actions
|
|
// is instead anchored on a per-workspace HMAC over the callback payload itself (see
|
|
// `verify_slack_payload`), which holds even when this check is a no-op.
|
|
#[cfg(feature = "oauth2")]
|
|
fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> {
|
|
if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() {
|
|
let sig = headers
|
|
.get("X-Slack-Signature")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
let ts = headers
|
|
.get("X-Slack-Request-Timestamp")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
sv.verify(ts, body, sig)
|
|
.map_err(|_| Error::BadRequest("Slack signature verification failed".to_string()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "oauth2"))]
|
|
fn verify_slack_callback_signature(_headers: &HeaderMap, _body: &str) -> 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,
|
|
/// regardless of whether `SLACK_SIGNING_SECRET` is configured.
|
|
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 body_str = String::from_utf8_lossy(&body);
|
|
verify_slack_callback_signature(&headers, &body_str)?;
|
|
|
|
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, "resume").await?
|
|
}
|
|
PayloadType::ViewClosed => {
|
|
handle_submission(authed, opt_tokened, db, &payload, "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 SLACK_SIGNING_SECRET
|
|
// is unset.
|
|
verify_slack_payload(
|
|
&db,
|
|
w_id,
|
|
&[parsed_value.job_id.as_bytes(), path.as_bytes()],
|
|
parsed_value.signature.as_deref(),
|
|
)
|
|
.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,
|
|
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?;
|
|
|
|
// 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);
|
|
}
|
|
}
|