use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::io::Error as IoError;
use std::sync::Arc;
use chrono::Utc;
use rspc::{Error as RspcError, ProcedureError, ResolverError};
use schemars::JsonSchema;
use serde::de::Error as SerdeDeError;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Error as JsonError, Map, Value, json};
use specta::Type;
use thiserror::Error;
use tokio::sync::mpsc::error::SendError;
use uuid::Uuid;
use zeroize::Zeroizing;
use aves::ErrorCode;
use aves::bus::DispatchContextError;
use aves::flow::FlowError;
use aves::runtime::GeneratedCallError;
use aves::utils::{bigint::SpectaBigInt, integers::IntegerCastError, uuid::SpectaUuid};
use super::channels::MessagingSubscription;
use super::database::StoreError;
use super::messages::{ListenerType, MessagingResponse};
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema, Default)]
pub(super) struct MessagingConfig {
pub(super) telegram: Option<TelegramConfig>,
pub(super) slack: Option<SlackConfig>,
pub(super) email: Option<EmailConfig>,
}
#[derive(Clone)]
pub enum MessagingCredentials {
Telegram { token: Zeroizing<String> },
Slack {
app_token: Zeroizing<String>,
bot_token: Zeroizing<String>,
},
Email {
username: Zeroizing<String>,
password: Zeroizing<String>,
},
}
impl Debug for MessagingCredentials {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter.write_str("MessagingCredentials(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubscriptionTriggerConfig {
pub connection_id: SpectaUuid,
#[serde(default)]
#[serde(deserialize_with = "deserialize_subscribed_channels")]
pub subscribed_channels: Option<Vec<String>>,
#[serde(default)]
pub allowed_senders: Vec<String>,
#[serde(default)]
pub persistence: PersistencePolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema, Default)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PersistencePolicy {
#[default]
None,
Note {
directory: String,
},
Data {
namespace: String,
},
}
pub(super) fn persistence_identifier(scope: &str) -> (String, String) {
let id = Uuid::new_v4();
let date = Utc::now().format("%Y-%m-%d");
(format!("{scope}/{id}"), format!("{date}-{id}.md"))
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum MessagingSenderConfig {
Email(EmailSenderConfig),
Slack(SlackSenderConfig),
Telegram(TelegramSenderConfig),
}
impl MessagingSenderConfig {
pub(super) fn connection_info(&self) -> (ListenerType, SpectaUuid, &PersistencePolicy) {
match self {
Self::Email(config) => (ListenerType::Email, config.connection_id, &config.persistence),
Self::Slack(config) => (ListenerType::Slack, config.connection_id, &config.persistence),
Self::Telegram(config) => (ListenerType::Telegram, config.connection_id, &config.persistence),
}
}
pub(super) fn from_destination(provider: ListenerType, connection_id: SpectaUuid, destination: &Value) -> Self {
let field = |name| {
destination
.get(name)
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned()
};
match provider {
ListenerType::Email => Self::Email(EmailSenderConfig {
connection_id,
from: field("from"),
persistence: PersistencePolicy::None,
}),
ListenerType::Slack => Self::Slack(SlackSenderConfig {
connection_id,
channel_id: field("channel_id"),
persistence: PersistencePolicy::None,
}),
ListenerType::Telegram => Self::Telegram(TelegramSenderConfig {
connection_id,
chat_id: field("chat_id"),
persistence: PersistencePolicy::None,
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EmailSenderConfig {
pub connection_id: SpectaUuid,
pub from: String,
#[serde(default)]
pub persistence: PersistencePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SlackSenderConfig {
pub connection_id: SpectaUuid,
pub channel_id: String,
#[serde(default)]
pub persistence: PersistencePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TelegramSenderConfig {
pub connection_id: SpectaUuid,
pub chat_id: String,
#[serde(default)]
pub persistence: PersistencePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct MessageNodeConfig {
pub(super) connection_id: SpectaUuid,
pub(super) provider: ListenerType,
pub(super) destination: Value,
pub(super) body: String,
#[serde(default)]
pub(super) metadata: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct PermissionNodeConfig {
pub(super) connection_id: SpectaUuid,
pub(super) provider: ListenerType,
pub(super) destination: Value,
pub(super) body: String,
#[serde(default)]
pub(super) metadata: Value,
pub(super) duration_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct EmailSendMetadata {
pub(super) to: Vec<String>,
pub(super) cc: Vec<String>,
pub(super) bcc: Vec<String>,
pub(super) subject: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub(super) struct SlackSendMetadata {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub(super) struct TelegramSendMetadata {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema)]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum MessagingSendReceipt {
ProviderMessageId { message_id: String },
SmtpAccepted { response: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MessagingSendErrorCode {
Transport,
Server,
RateLimited,
InvalidCredentials,
InvalidDestination,
PermissionDenied,
Validation,
}
#[derive(Error, Debug, Clone, Serialize, Deserialize, Type, JsonSchema, ErrorCode)]
pub enum MessagingSendError {
#[error("message send retryable failure: {message}")]
Retryable {
code: MessagingSendErrorCode,
message: String,
retry_after_seconds: Option<u64>,
},
#[error("message send terminal failure: {message}")]
Terminal {
code: MessagingSendErrorCode,
message: String,
},
}
fn deserialize_subscribed_channels<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Vec<String>>, D::Error> {
let channels = Option::<Vec<String>>::deserialize(deserializer)?;
if channels
.as_ref()
.is_some_and(|channels| channels.is_empty() || channels.iter().any(|channel| channel.trim().is_empty()))
{
return Err(SerdeDeError::custom(
"An explicit channel selection must contain non-empty channel identities",
));
}
Ok(channels)
}
impl From<&SubscriptionTriggerConfig> for MessagingSubscription {
fn from(trigger: &SubscriptionTriggerConfig) -> Self {
Self {
subscribed_channels: trigger.subscribed_channels.clone(),
allowed_senders: trigger.allowed_senders.clone(),
}
}
}
#[derive(Default, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct TelegramConfig {
pub api_url: Option<String>,
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
pub token: Option<Zeroizing<String>>,
}
impl Debug for TelegramConfig {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter.write_str("TelegramConfig(<authentication redacted>)")
}
}
#[derive(Default, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct SlackConfig {
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
pub app_token: Option<Zeroizing<String>>,
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
pub bot_token: Option<Zeroizing<String>>,
pub auto_thread_reply: Option<bool>,
#[specta(type = Option<SpectaBigInt>)]
pub thread_ttl_hours: Option<u64>,
pub unfurl_links: Option<bool>,
}
impl Debug for SlackConfig {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter.write_str("SlackConfig(<authentication redacted>)")
}
}
#[derive(Default, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct EmailConfig {
pub imap_server: Option<String>,
pub imap_port: Option<u16>,
pub smtp_server: Option<String>,
pub smtp_port: Option<u16>,
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
pub username: Option<Zeroizing<String>>,
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
pub password: Option<Zeroizing<String>>,
}
impl Debug for EmailConfig {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter.write_str("EmailConfig(<authentication redacted>)")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
pub struct NormalizedMessage {
pub metadata: Value,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConnectionTransport {
Email(EmailConfig),
Slack(SlackConfig),
Telegram(TelegramConfig),
}
impl ConnectionTransport {
pub(super) fn provider(&self) -> ListenerType {
match self {
Self::Email(_) => ListenerType::Email,
Self::Slack(_) => ListenerType::Slack,
Self::Telegram(_) => ListenerType::Telegram,
}
}
pub(super) fn messaging_config(&self) -> MessagingConfig {
match self {
Self::Email(config) => MessagingConfig {
email: Some(config.clone()),
..Default::default()
},
Self::Slack(config) => MessagingConfig {
slack: Some(config.clone()),
..Default::default()
},
Self::Telegram(config) => MessagingConfig {
telegram: Some(config.clone()),
..Default::default()
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, JsonSchema)]
pub struct RetentionDuration {
#[specta(type = SpectaBigInt)]
pub seconds: u64,
}
impl RetentionDuration {
pub(super) fn new(seconds: u64) -> Result<Self, MessagingError> {
if seconds == 0 {
return Err(MessagingError::InvalidRetention {
reason: "duration must be positive".to_owned(),
});
}
Ok(Self { seconds })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
pub struct ConnectionRecord {
pub id: SpectaUuid,
pub display_name: String,
pub provider: ListenerType,
pub credential_id: SpectaUuid,
pub transport: ConnectionTransport,
pub enabled: bool,
pub retention: RetentionDuration,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NewConnection {
pub display_name: String,
pub provider: ListenerType,
pub credential_id: SpectaUuid,
pub transport: ConnectionTransport,
pub enabled: bool,
pub retention: RetentionDuration,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EditConnection {
pub id: SpectaUuid,
pub display_name: String,
pub provider: ListenerType,
pub credential_id: SpectaUuid,
pub transport: ConnectionTransport,
pub retention: RetentionDuration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct PermissionDuration {
pub(super) seconds: u64,
}
impl PermissionDuration {
pub(super) fn new(seconds: u64) -> Result<Self, MessagingError> {
if seconds == 0 {
return Err(MessagingError::InvalidPermissionDuration {
reason: "duration must be positive".to_owned(),
});
}
Ok(Self { seconds })
}
pub(super) fn expires_at(&self, now_seconds: i64) -> Result<i64, MessagingError> {
let seconds = i64::try_from(self.seconds).map_err(|_| MessagingError::InvalidPermissionDuration {
reason: "duration exceeds timestamp range".to_owned(),
})?;
now_seconds
.checked_add(seconds)
.ok_or_else(|| MessagingError::InvalidPermissionDuration {
reason: "expiration timestamp overflow".to_owned(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum InteractionAction {
Approve,
Deny,
Unknown,
}
impl InteractionAction {
pub(super) fn as_str(&self) -> &'static str {
match self {
Self::Approve => "approve",
Self::Deny => "deny",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone)]
pub(super) struct MessagingInteraction {
pub(super) request_id: SpectaUuid,
pub(super) action: InteractionAction,
pub(super) raw_payload: String,
}
pub(super) struct PermissionButtonPayload;
impl PermissionButtonPayload {
pub(super) fn encode(request_id: SpectaUuid, action: InteractionAction) -> String {
let prefix = match action {
InteractionAction::Approve => "ap",
InteractionAction::Deny => "dn",
InteractionAction::Unknown => panic!("unknown actions cannot be encoded"),
};
format!("{prefix}|{}", request_id.0)
}
pub(super) fn decode(data: &str) -> Option<(SpectaUuid, InteractionAction)> {
let (prefix, uuid) = data.split_once('|')?;
if uuid.contains('|') {
return None;
}
let action = match prefix {
"ap" => InteractionAction::Approve,
"dn" => InteractionAction::Deny,
_ => return None,
};
Uuid::parse_str(uuid)
.ok()
.map(|id| (SpectaUuid(id), action))
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize, Type, JsonSchema, ErrorCode)]
pub enum MessagingError {
#[error("bus dispatch failed: {message}")]
BusDispatch { message: String },
#[error("bus authentication failed: {message}")]
BusAuthentication { message: String },
#[error("unexpected bus response: expected {expected}, received {actual}")]
UnexpectedBusResponse { expected: String, actual: String },
#[error(transparent)]
#[serde(skip)]
#[specta(skip)]
#[schemars(skip)]
Flow(Arc<FlowError>),
#[error("Flow trigger source service is unavailable")]
FlowServiceUnavailable,
#[error("channel send error for {target_domain}.{operation}: {cause}")]
ChannelSend {
target_domain: String,
operation: String,
cause: String,
},
#[error("channel send error for {target_domain}.{operation}: {cause}")]
InfrastructureChannelSend {
target_domain: String,
operation: String,
cause: String,
},
#[error("channel receive error for {target_domain}.{operation}: {cause}")]
ChannelRecv {
target_domain: String,
operation: String,
cause: String,
},
#[error("channel receive error for {target_domain}.{operation}: {cause}")]
InfrastructureChannelRecv {
target_domain: String,
operation: String,
cause: String,
},
#[error("subscription not found: {id}")]
SubscriptionNotFound {
id: u32,
},
#[error("serialization error: {message}")]
Serialization {
message: String,
},
#[error("io error at {path}: {message}")]
Io {
path: String,
message: String,
},
#[error("oneshot reply timed out for {target_domain}.{operation} after {timeout}")]
OneshotReplyTimeout {
target_domain: String,
operation: String,
timeout: String,
},
#[error("oneshot reply channel closed for {target_domain}.{operation}: {cause}")]
OneshotReplyMissing {
target_domain: String,
operation: String,
cause: String,
},
#[error("broadcast error for {target_domain}.{operation}: {cause}")]
Broadcast {
target_domain: String,
operation: String,
cause: String,
},
#[error("bus error: {message}")]
BusError {
message: String,
},
#[error("Channel connection failed for {listener_type:?}: {message}")]
ChannelConnectionFailed {
listener_type: ListenerType,
message: String,
},
#[error("Failed to parse channel procedure '{procedure}': {reason}")]
ChannelParseError {
procedure: String,
reason: String,
},
#[error("database error: {message}")]
Database {
message: String,
},
#[error("message database unavailable")]
DatabaseUnavailable,
#[error(transparent)]
Send(#[from] MessagingSendError),
#[error("connection not found: {connection_id}")]
ConnectionNotFound { connection_id: String },
#[error("interactive messaging is unsupported for {provider:?}")]
InteractiveUnsupported { provider: ListenerType },
#[error("invalid permission duration: {reason}")]
InvalidPermissionDuration { reason: String },
#[error("invalid retention duration: {reason}")]
InvalidRetention { reason: String },
#[error("connection {connection_id} has pending permission requests")]
DisableRejectedPending {
connection_id: String,
pending_request_ids: Vec<String>,
},
#[error("invalid inbound configuration for {provider:?}: {reason}")]
InboundConfigurationInvalid { provider: ListenerType, reason: String },
#[error("invalid callback payload: {payload}")]
InvalidCallbackPayload { payload: String },
#[error("permission request not found: {request_id}")]
PermissionRequestNotFound { request_id: String },
}
impl MessagingError {
pub fn rpc_identity(&self) -> (&str, &str, Map<String, Value>) {
match self {
Self::Flow(error) => ("flow", error.rpc_code(), error.rpc_details()),
_ => ("subscription", self.rpc_code(), Map::new()),
}
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize, Type, JsonSchema)]
pub(super) enum MessagingSubscriptionError {
#[error("channel configuration error for {listener_type:?}: {message}")]
Configuration {
listener_type: ListenerType,
message: String,
},
#[error("channel connection error for {listener_type:?}: {message}")]
Connection {
listener_type: ListenerType,
message: String,
},
#[error("channel authentication error for {listener_type:?}: {message}")]
Authentication {
listener_type: ListenerType,
message: String,
},
#[error("channel protocol error for {listener_type:?}: {message}")]
Protocol {
listener_type: ListenerType,
message: String,
},
#[error("channel timeout for {listener_type:?}.{operation} after {timeout}")]
Timeout {
listener_type: ListenerType,
operation: String,
timeout: String,
},
#[error("channel message send error for {listener_type:?}: {cause}")]
MessageSend {
listener_type: ListenerType,
cause: String,
},
#[error("channel stream closed for {listener_type:?}: {message}")]
StreamClosed {
listener_type: ListenerType,
message: String,
},
}
impl From<IntegerCastError> for MessagingError {
fn from(error: IntegerCastError) -> Self {
MessagingError::Database {
message: error.to_string(),
}
}
}
impl From<GeneratedCallError<MessagingError>> for MessagingError {
fn from(error: GeneratedCallError<MessagingError>) -> Self {
match error {
GeneratedCallError::DomainError(error) => error,
GeneratedCallError::Authentication(error) => Self::BusAuthentication {
message: error.to_string(),
},
GeneratedCallError::InputClosed => Self::ChannelSend {
target_domain: "subscription".to_owned(),
operation: "generated.dispatch".to_owned(),
cause: "input closed".to_owned(),
},
GeneratedCallError::ReplyDropped => Self::OneshotReplyMissing {
target_domain: "subscription".to_owned(),
operation: "generated.reply".to_owned(),
cause: "reply dropped".to_owned(),
},
GeneratedCallError::UnexpectedResponse { expected, actual } => {
Self::UnexpectedBusResponse { expected, actual }
}
}
}
}
impl From<DispatchContextError> for MessagingError {
fn from(error: DispatchContextError) -> Self {
Self::BusDispatch {
message: error.to_string(),
}
}
}
impl From<IoError> for MessagingError {
fn from(e: IoError) -> Self {
MessagingError::Io {
path: String::new(),
message: e.to_string(),
}
}
}
impl From<JsonError> for MessagingError {
fn from(e: JsonError) -> Self {
MessagingError::Serialization { message: e.to_string() }
}
}
impl From<SendError<MessagingResponse>> for MessagingError {
fn from(_: SendError<MessagingResponse>) -> Self {
Self::ChannelSend {
target_domain: "subscription".to_owned(),
operation: "broadcast".to_owned(),
cause: "receiver closed".to_owned(),
}
}
}
impl From<FlowError> for MessagingError {
fn from(error: FlowError) -> Self {
Self::Flow(Arc::new(error))
}
}
impl From<StoreError> for MessagingError {
fn from(_: StoreError) -> Self {
Self::DatabaseUnavailable
}
}
impl RspcError for MessagingError {
fn into_procedure_error(self) -> ProcedureError {
let (domain, code, details) = self.rpc_identity();
let safe_error = json!({ "DomainError": {
"domain": domain, "code": code, "message": self.to_string(), "details": details,
}});
ProcedureError::Resolver(ResolverError::new(safe_error, None::<Self>))
}
}