aof_core/
trigger.rs

1// AOF Core - Standalone Trigger resource type
2//
3// Trigger represents a decoupled message source that can be shared across
4// multiple flows via FlowBindings. This enables:
5// - Reusing the same trigger configuration across flows
6// - Separating trigger concerns from flow logic
7// - Multi-tenant deployments with different routing
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Trigger - Standalone message source
13///
14/// Example:
15/// ```yaml
16/// apiVersion: aof.dev/v1
17/// kind: Trigger
18/// metadata:
19///   name: slack-prod-channel
20/// spec:
21///   type: Slack
22///   config:
23///     bot_token: ${SLACK_BOT_TOKEN}
24///     signing_secret: ${SLACK_SIGNING_SECRET}
25///     channels: [production, prod-alerts]
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct Trigger {
30    /// API version (e.g., "aof.dev/v1")
31    #[serde(default = "default_api_version")]
32    pub api_version: String,
33
34    /// Resource kind, always "Trigger"
35    #[serde(default = "default_trigger_kind")]
36    pub kind: String,
37
38    /// Trigger metadata
39    pub metadata: TriggerMetadata,
40
41    /// Trigger specification
42    pub spec: TriggerSpec,
43}
44
45fn default_api_version() -> String {
46    "aof.dev/v1".to_string()
47}
48
49fn default_trigger_kind() -> String {
50    "Trigger".to_string()
51}
52
53/// Trigger metadata
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TriggerMetadata {
56    /// Trigger name (unique identifier)
57    pub name: String,
58
59    /// Namespace
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub namespace: Option<String>,
62
63    /// Labels for categorization
64    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65    pub labels: HashMap<String, String>,
66
67    /// Annotations for additional metadata
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub annotations: HashMap<String, String>,
70}
71
72/// Trigger specification
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct TriggerSpec {
76    /// Trigger type (Slack, Telegram, Discord, HTTP, Schedule, etc.)
77    #[serde(rename = "type")]
78    pub trigger_type: StandaloneTriggerType,
79
80    /// Trigger-specific configuration
81    #[serde(default)]
82    pub config: StandaloneTriggerConfig,
83
84    /// Whether this trigger is enabled
85    #[serde(default = "default_enabled")]
86    pub enabled: bool,
87
88    /// Command bindings for this trigger
89    /// Maps slash commands to agents, fleets, or flows
90    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91    pub commands: HashMap<String, CommandBinding>,
92
93    /// Default agent for @mentions and natural language messages
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub default_agent: Option<String>,
96}
97
98/// Command binding - routes a slash command to an agent, fleet, or flow
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub struct CommandBinding {
102    /// Route to a specific agent
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub agent: Option<String>,
105
106    /// Route to a fleet (multi-agent team)
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub fleet: Option<String>,
109
110    /// Route to a flow (multi-step workflow)
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub flow: Option<String>,
113
114    /// Description for help text
115    #[serde(default, skip_serializing_if = "String::is_empty")]
116    pub description: String,
117}
118
119fn default_enabled() -> bool {
120    true
121}
122
123/// Types of standalone triggers
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
125pub enum StandaloneTriggerType {
126    /// Slack events (mentions, messages, slash commands)
127    Slack,
128    /// Telegram bot events
129    Telegram,
130    /// Discord bot events
131    Discord,
132    /// WhatsApp Business API events
133    WhatsApp,
134    /// Generic HTTP webhook
135    HTTP,
136    /// Cron/schedule-based trigger
137    Schedule,
138    /// PagerDuty incidents
139    PagerDuty,
140    /// GitHub webhooks
141    GitHub,
142    /// Jira webhooks
143    Jira,
144    /// Manual trigger (CLI invocation)
145    Manual,
146}
147
148impl std::fmt::Display for StandaloneTriggerType {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Self::Slack => write!(f, "slack"),
152            Self::Telegram => write!(f, "telegram"),
153            Self::Discord => write!(f, "discord"),
154            Self::WhatsApp => write!(f, "whatsapp"),
155            Self::HTTP => write!(f, "http"),
156            Self::Schedule => write!(f, "schedule"),
157            Self::PagerDuty => write!(f, "pagerduty"),
158            Self::GitHub => write!(f, "github"),
159            Self::Jira => write!(f, "jira"),
160            Self::Manual => write!(f, "manual"),
161        }
162    }
163}
164
165/// Trigger-specific configuration
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub struct StandaloneTriggerConfig {
169    // ============================================
170    // Chat Platform Common Fields
171    // ============================================
172
173    /// Bot token (or env var reference ${VAR_NAME})
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub bot_token: Option<String>,
176
177    /// Signing secret (Slack)
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub signing_secret: Option<String>,
180
181    /// App secret (Discord)
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub app_secret: Option<String>,
184
185    /// Events to listen for
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub events: Vec<String>,
188
189    /// Channels to listen on (names or IDs)
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub channels: Vec<String>,
192
193    /// Chat IDs (Telegram)
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub chat_ids: Vec<i64>,
196
197    /// Guild IDs (Discord)
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub guild_ids: Vec<String>,
200
201    /// Users to respond to (user IDs)
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub users: Vec<String>,
204
205    /// Message patterns to match (regex)
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub patterns: Vec<String>,
208
209    // ============================================
210    // HTTP Webhook Fields
211    // ============================================
212
213    /// HTTP path pattern (for HTTP trigger)
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub path: Option<String>,
216
217    /// HTTP methods to accept
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub methods: Vec<String>,
220
221    /// Required headers for authentication
222    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
223    pub required_headers: HashMap<String, String>,
224
225    /// Webhook secret for signature verification
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub webhook_secret: Option<String>,
228
229    // ============================================
230    // Schedule Fields
231    // ============================================
232
233    /// Cron expression (for Schedule trigger)
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub cron: Option<String>,
236
237    /// Timezone (for Schedule trigger)
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub timezone: Option<String>,
240
241    // ============================================
242    // PagerDuty Fields
243    // ============================================
244
245    /// PagerDuty API key
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub api_key: Option<String>,
248
249    /// PagerDuty routing key
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub routing_key: Option<String>,
252
253    /// PagerDuty service IDs to monitor
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub service_ids: Vec<String>,
256
257    // ============================================
258    // GitHub Fields
259    // ============================================
260
261    /// GitHub webhook events (push, pull_request, issues, etc.)
262    #[serde(default, skip_serializing_if = "Vec::is_empty")]
263    pub github_events: Vec<String>,
264
265    /// Repository filter (owner/repo)
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub repositories: Vec<String>,
268
269    // ============================================
270    // WhatsApp Fields
271    // ============================================
272
273    /// WhatsApp Business Account ID
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub business_account_id: Option<String>,
276
277    /// WhatsApp phone number ID
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub phone_number_id: Option<String>,
280
281    /// WhatsApp verify token
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub verify_token: Option<String>,
284
285    // ============================================
286    // Common Fields
287    // ============================================
288
289    /// Port to listen on (for webhook-based triggers)
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub port: Option<u16>,
292
293    /// Host to bind to
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub host: Option<String>,
296
297    /// Additional configuration
298    #[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
299    pub extra: HashMap<String, serde_json::Value>,
300}
301
302impl Trigger {
303    /// Get the trigger name
304    pub fn name(&self) -> &str {
305        &self.metadata.name
306    }
307
308    /// Get the trigger type
309    pub fn trigger_type(&self) -> StandaloneTriggerType {
310        self.spec.trigger_type
311    }
312
313    /// Validate the trigger configuration
314    /// Note: This validates structure, not that env vars are set (they're expanded at runtime)
315    pub fn validate(&self) -> Result<(), String> {
316        // Check name
317        if self.metadata.name.is_empty() {
318            return Err("Trigger name is required".to_string());
319        }
320
321        // Type-specific validation - check that required fields are present
322        // (env var references like ${VAR} are valid - they'll be expanded later)
323        match self.spec.trigger_type {
324            StandaloneTriggerType::Slack => {
325                if self.spec.config.bot_token.is_none() {
326                    return Err("Slack trigger requires bot_token".to_string());
327                }
328            }
329            StandaloneTriggerType::Telegram => {
330                if self.spec.config.bot_token.is_none() {
331                    return Err("Telegram trigger requires bot_token".to_string());
332                }
333            }
334            StandaloneTriggerType::Discord => {
335                if self.spec.config.bot_token.is_none() {
336                    return Err("Discord trigger requires bot_token".to_string());
337                }
338            }
339            StandaloneTriggerType::Schedule => {
340                if self.spec.config.cron.is_none() {
341                    return Err("Schedule trigger requires cron expression".to_string());
342                }
343            }
344            StandaloneTriggerType::PagerDuty => {
345                if self.spec.config.api_key.is_none() && self.spec.config.routing_key.is_none() {
346                    return Err("PagerDuty trigger requires api_key or routing_key".to_string());
347                }
348            }
349            StandaloneTriggerType::WhatsApp => {
350                if self.spec.config.bot_token.is_none() {
351                    return Err("WhatsApp trigger requires bot_token (access token)".to_string());
352                }
353            }
354            _ => {}
355        }
356
357        Ok(())
358    }
359
360    /// Expand environment variables in configuration
361    pub fn expand_env_vars(&mut self) {
362        let config = &mut self.spec.config;
363
364        if let Some(ref token) = config.bot_token {
365            config.bot_token = Some(expand_env_var(token));
366        }
367        if let Some(ref secret) = config.signing_secret {
368            config.signing_secret = Some(expand_env_var(secret));
369        }
370        if let Some(ref secret) = config.app_secret {
371            config.app_secret = Some(expand_env_var(secret));
372        }
373        if let Some(ref secret) = config.webhook_secret {
374            config.webhook_secret = Some(expand_env_var(secret));
375        }
376        if let Some(ref key) = config.api_key {
377            config.api_key = Some(expand_env_var(key));
378        }
379        if let Some(ref key) = config.routing_key {
380            config.routing_key = Some(expand_env_var(key));
381        }
382        if let Some(ref token) = config.verify_token {
383            config.verify_token = Some(expand_env_var(token));
384        }
385    }
386
387    /// Check if a message matches this trigger's filters
388    pub fn matches(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> bool {
389        // Platform must match
390        let trigger_platform = self.spec.trigger_type.to_string().to_lowercase();
391        if trigger_platform != platform.to_lowercase() {
392            return false;
393        }
394
395        let config = &self.spec.config;
396
397        // Channel filter (if specified)
398        if !config.channels.is_empty() {
399            if let Some(ch) = channel {
400                if !config.channels.iter().any(|c| c == ch) {
401                    return false;
402                }
403            } else {
404                return false;
405            }
406        }
407
408        // User filter (if specified)
409        if !config.users.is_empty() {
410            if let Some(u) = user {
411                if !config.users.iter().any(|allowed| allowed == u) {
412                    return false;
413                }
414            } else {
415                return false;
416            }
417        }
418
419        // Pattern filter (if specified)
420        if !config.patterns.is_empty() {
421            if let Some(t) = text {
422                let matches_pattern = config.patterns.iter().any(|p| {
423                    if let Ok(re) = regex::Regex::new(p) {
424                        re.is_match(t)
425                    } else {
426                        t.contains(p)
427                    }
428                });
429                if !matches_pattern {
430                    return false;
431                }
432            } else {
433                return false;
434            }
435        }
436
437        true
438    }
439
440    /// Calculate a match score for routing priority
441    /// Higher score = more specific match = higher priority
442    pub fn match_score(&self, platform: &str, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> u32 {
443        if !self.matches(platform, channel, user, text) {
444            return 0;
445        }
446
447        let config = &self.spec.config;
448        let mut score = 10; // Base score for platform match
449
450        // Channel specificity
451        if !config.channels.is_empty() && channel.is_some() {
452            score += 100;
453        }
454
455        // User specificity
456        if !config.users.is_empty() && user.is_some() {
457            score += 80;
458        }
459
460        // Pattern specificity
461        if !config.patterns.is_empty() && text.is_some() {
462            score += 60;
463        }
464
465        score
466    }
467}
468
469/// Expand ${VAR_NAME} patterns in a string
470fn expand_env_var(value: &str) -> String {
471    let mut result = value.to_string();
472    let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
473
474    for cap in re.captures_iter(value) {
475        let var_name = &cap[1];
476        if let Ok(var_value) = std::env::var(var_name) {
477            result = result.replace(&cap[0], &var_value);
478        }
479    }
480
481    result
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_parse_slack_trigger() {
490        let yaml = r#"
491apiVersion: aof.dev/v1
492kind: Trigger
493metadata:
494  name: slack-prod-channel
495  labels:
496    environment: production
497spec:
498  type: Slack
499  config:
500    bot_token: ${SLACK_BOT_TOKEN}
501    signing_secret: ${SLACK_SIGNING_SECRET}
502    channels:
503      - production
504      - prod-alerts
505    events:
506      - app_mention
507      - message
508"#;
509
510        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
511        assert_eq!(trigger.metadata.name, "slack-prod-channel");
512        assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Slack);
513        assert_eq!(trigger.spec.config.channels.len(), 2);
514        assert!(trigger.validate().is_ok());
515    }
516
517    #[test]
518    fn test_parse_telegram_trigger() {
519        let yaml = r#"
520apiVersion: aof.dev/v1
521kind: Trigger
522metadata:
523  name: telegram-oncall
524spec:
525  type: Telegram
526  config:
527    bot_token: ${TELEGRAM_BOT_TOKEN}
528    chat_ids:
529      - -1001234567890
530    users:
531      - "123456789"
532"#;
533
534        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
535        assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Telegram);
536        assert_eq!(trigger.spec.config.chat_ids.len(), 1);
537        assert!(trigger.validate().is_ok());
538    }
539
540    #[test]
541    fn test_parse_schedule_trigger() {
542        let yaml = r#"
543apiVersion: aof.dev/v1
544kind: Trigger
545metadata:
546  name: daily-report
547spec:
548  type: Schedule
549  config:
550    cron: "0 9 * * *"
551    timezone: "America/New_York"
552"#;
553
554        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
555        assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::Schedule);
556        assert_eq!(trigger.spec.config.cron, Some("0 9 * * *".to_string()));
557        assert!(trigger.validate().is_ok());
558    }
559
560    #[test]
561    fn test_parse_http_trigger() {
562        let yaml = r#"
563apiVersion: aof.dev/v1
564kind: Trigger
565metadata:
566  name: webhook-endpoint
567spec:
568  type: HTTP
569  config:
570    path: /webhook/github
571    methods:
572      - POST
573    webhook_secret: ${WEBHOOK_SECRET}
574"#;
575
576        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
577        assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::HTTP);
578        assert_eq!(trigger.spec.config.path, Some("/webhook/github".to_string()));
579        assert!(trigger.validate().is_ok());
580    }
581
582    #[test]
583    fn test_parse_pagerduty_trigger() {
584        let yaml = r#"
585apiVersion: aof.dev/v1
586kind: Trigger
587metadata:
588  name: pagerduty-incidents
589spec:
590  type: PagerDuty
591  config:
592    api_key: ${PAGERDUTY_API_KEY}
593    service_ids:
594      - P123ABC
595      - P456DEF
596"#;
597
598        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
599        assert_eq!(trigger.spec.trigger_type, StandaloneTriggerType::PagerDuty);
600        assert!(trigger.validate().is_ok());
601    }
602
603    #[test]
604    fn test_validation_errors() {
605        // Empty name
606        let yaml = r#"
607apiVersion: aof.dev/v1
608kind: Trigger
609metadata:
610  name: ""
611spec:
612  type: Slack
613  config:
614    bot_token: token
615"#;
616        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
617        assert!(trigger.validate().is_err());
618
619        // Missing bot_token for Slack
620        let yaml2 = r#"
621apiVersion: aof.dev/v1
622kind: Trigger
623metadata:
624  name: test
625spec:
626  type: Slack
627  config: {}
628"#;
629        let trigger2: Trigger = serde_yaml::from_str(yaml2).unwrap();
630        assert!(trigger2.validate().is_err());
631
632        // Missing cron for Schedule
633        let yaml3 = r#"
634apiVersion: aof.dev/v1
635kind: Trigger
636metadata:
637  name: test
638spec:
639  type: Schedule
640  config: {}
641"#;
642        let trigger3: Trigger = serde_yaml::from_str(yaml3).unwrap();
643        assert!(trigger3.validate().is_err());
644    }
645
646    #[test]
647    fn test_matches() {
648        let yaml = r#"
649apiVersion: aof.dev/v1
650kind: Trigger
651metadata:
652  name: test
653spec:
654  type: Slack
655  config:
656    bot_token: token
657    channels:
658      - production
659    patterns:
660      - kubectl
661"#;
662
663        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
664
665        // Matches
666        assert!(trigger.matches("slack", Some("production"), None, Some("kubectl get pods")));
667
668        // Wrong platform
669        assert!(!trigger.matches("telegram", Some("production"), None, Some("kubectl get pods")));
670
671        // Wrong channel
672        assert!(!trigger.matches("slack", Some("staging"), None, Some("kubectl get pods")));
673
674        // Pattern doesn't match
675        assert!(!trigger.matches("slack", Some("production"), None, Some("hello world")));
676    }
677
678    #[test]
679    fn test_match_score() {
680        // Trigger with channel + pattern filter (most specific)
681        let yaml1 = r#"
682apiVersion: aof.dev/v1
683kind: Trigger
684metadata:
685  name: specific
686spec:
687  type: Slack
688  config:
689    bot_token: token
690    channels: [production]
691    patterns: [kubectl]
692"#;
693
694        // Trigger with only platform (catch-all)
695        let yaml2 = r#"
696apiVersion: aof.dev/v1
697kind: Trigger
698metadata:
699  name: catchall
700spec:
701  type: Slack
702  config:
703    bot_token: token
704"#;
705
706        let specific: Trigger = serde_yaml::from_str(yaml1).unwrap();
707        let catchall: Trigger = serde_yaml::from_str(yaml2).unwrap();
708
709        let score1 = specific.match_score("slack", Some("production"), None, Some("kubectl get pods"));
710        let score2 = catchall.match_score("slack", Some("production"), None, Some("kubectl get pods"));
711
712        // More specific trigger should have higher score
713        assert!(score1 > score2);
714    }
715
716    #[test]
717    fn test_expand_env_var() {
718        std::env::set_var("TEST_TOKEN", "secret123");
719        let result = expand_env_var("Bearer ${TEST_TOKEN}");
720        assert_eq!(result, "Bearer secret123");
721        std::env::remove_var("TEST_TOKEN");
722    }
723}