Skip to main content

punch_kernel/
triggers.rs

1//! Event-driven trigger engine for the Punch Agent Combat System.
2//!
3//! The [`TriggerEngine`] manages triggers that automatically fire actions
4//! when conditions are met. Supports scheduled triggers (cron-like),
5//! keyword matching in messages, event pattern matching, and webhook triggers.
6//!
7//! Inspired by OpenFang's trigger system, adapted for Punch's combat metaphor.
8
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use dashmap::DashMap;
13use serde::{Deserialize, Serialize};
14use tracing::{debug, info};
15use uuid::Uuid;
16
17use punch_types::{FighterId, GorillaId, PunchEvent};
18
19// ---------------------------------------------------------------------------
20// TriggerId
21// ---------------------------------------------------------------------------
22
23/// Unique identifier for a trigger.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct TriggerId(pub Uuid);
26
27impl TriggerId {
28    pub fn new() -> Self {
29        Self(Uuid::new_v4())
30    }
31}
32
33impl Default for TriggerId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl std::fmt::Display for TriggerId {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(f, "{}", self.0)
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Trigger types
47// ---------------------------------------------------------------------------
48
49/// What kind of condition activates a trigger.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", tag = "type")]
52pub enum TriggerCondition {
53    /// Fire on a cron-like schedule (interval in seconds).
54    Schedule {
55        /// Interval in seconds between fires.
56        interval_secs: u64,
57    },
58    /// Fire when a message contains one of the specified keywords (case-insensitive).
59    Keyword {
60        /// Keywords to match against (any match triggers).
61        keywords: Vec<String>,
62    },
63    /// Fire when a specific [`PunchEvent`] variant occurs.
64    Event {
65        /// The event kind to match (e.g. "fighter_spawned", "gorilla_unleashed").
66        event_kind: String,
67    },
68    /// Fire when an HTTP webhook is received.
69    Webhook {
70        /// Optional secret for webhook validation.
71        secret: Option<String>,
72    },
73}
74
75/// What action to perform when a trigger fires.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "action")]
78pub enum TriggerAction {
79    /// Spawn a fighter from a template name.
80    SpawnFighter { template_name: String },
81    /// Send a message to a specific fighter.
82    SendMessage {
83        fighter_id: FighterId,
84        message: String,
85    },
86    /// Execute a workflow by ID.
87    ExecuteWorkflow { workflow_id: String, input: String },
88    /// Trigger a single gorilla tick.
89    RunGorilla { gorilla_id: GorillaId },
90    /// Log a message (useful for testing and debugging).
91    Log { message: String },
92}
93
94/// A registered trigger definition.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Trigger {
97    /// Unique trigger ID.
98    pub id: TriggerId,
99    /// Human-readable name.
100    pub name: String,
101    /// The condition that activates this trigger.
102    pub condition: TriggerCondition,
103    /// The action to perform when triggered.
104    pub action: TriggerAction,
105    /// Whether this trigger is currently active.
106    pub enabled: bool,
107    /// When this trigger was created.
108    pub created_at: DateTime<Utc>,
109    /// How many times this trigger has fired.
110    pub fire_count: u64,
111    /// Maximum number of times this trigger can fire (0 = unlimited).
112    pub max_fires: u64,
113}
114
115/// Summary information about a trigger (for listing).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TriggerSummary {
118    /// Human-readable name.
119    pub name: String,
120    /// Description of the condition.
121    pub condition_type: String,
122    /// Whether active.
123    pub enabled: bool,
124    /// How many times fired.
125    pub fire_count: u64,
126    /// When created.
127    pub created_at: DateTime<Utc>,
128}
129
130// ---------------------------------------------------------------------------
131// TriggerEngine
132// ---------------------------------------------------------------------------
133
134/// The trigger engine manages event-to-action routing.
135pub struct TriggerEngine {
136    /// All registered triggers.
137    triggers: DashMap<TriggerId, Trigger>,
138}
139
140impl TriggerEngine {
141    /// Create a new trigger engine.
142    pub fn new() -> Self {
143        Self {
144            triggers: DashMap::new(),
145        }
146    }
147
148    /// Register a new trigger and return its ID.
149    pub fn register_trigger(&self, trigger: Trigger) -> TriggerId {
150        let id = trigger.id;
151        info!(trigger_id = %id, name = %trigger.name, "trigger registered");
152        self.triggers.insert(id, trigger);
153        id
154    }
155
156    /// Remove a trigger by ID.
157    pub fn remove_trigger(&self, id: &TriggerId) {
158        if let Some((_, trigger)) = self.triggers.remove(id) {
159            info!(trigger_id = %id, name = %trigger.name, "trigger removed");
160        }
161    }
162
163    /// List all triggers with summary information.
164    pub fn list_triggers(&self) -> Vec<(TriggerId, TriggerSummary)> {
165        self.triggers
166            .iter()
167            .map(|entry| {
168                let t = entry.value();
169                let condition_type = match &t.condition {
170                    TriggerCondition::Schedule { interval_secs } => {
171                        format!("schedule({}s)", interval_secs)
172                    }
173                    TriggerCondition::Keyword { keywords } => {
174                        format!("keyword({})", keywords.join(", "))
175                    }
176                    TriggerCondition::Event { event_kind } => {
177                        format!("event({})", event_kind)
178                    }
179                    TriggerCondition::Webhook { .. } => "webhook".to_string(),
180                };
181                (
182                    *entry.key(),
183                    TriggerSummary {
184                        name: t.name.clone(),
185                        condition_type,
186                        enabled: t.enabled,
187                        fire_count: t.fire_count,
188                        created_at: t.created_at,
189                    },
190                )
191            })
192            .collect()
193    }
194
195    /// Check if a message matches any keyword triggers.
196    ///
197    /// Returns the IDs of all matching triggers and increments their fire counts.
198    pub async fn check_keyword(&self, message: &str) -> Vec<TriggerId> {
199        let lower_message = message.to_lowercase();
200        let mut matched = Vec::new();
201
202        for mut entry in self.triggers.iter_mut() {
203            let trigger = entry.value_mut();
204            if !trigger.enabled {
205                continue;
206            }
207            if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
208                trigger.enabled = false;
209                continue;
210            }
211
212            if let TriggerCondition::Keyword { keywords } = &trigger.condition {
213                let is_match = keywords
214                    .iter()
215                    .any(|kw| lower_message.contains(&kw.to_lowercase()));
216                if is_match {
217                    trigger.fire_count += 1;
218                    matched.push(trigger.id);
219                    debug!(
220                        trigger_id = %trigger.id,
221                        name = %trigger.name,
222                        fire_count = trigger.fire_count,
223                        "keyword trigger fired"
224                    );
225                }
226            }
227        }
228
229        matched
230    }
231
232    /// Check if a [`PunchEvent`] matches any event triggers.
233    ///
234    /// Returns the IDs of all matching triggers and increments their fire counts.
235    pub async fn check_event(&self, event: &PunchEvent) -> Vec<TriggerId> {
236        let event_kind = event_kind_string(event);
237        let mut matched = Vec::new();
238
239        for mut entry in self.triggers.iter_mut() {
240            let trigger = entry.value_mut();
241            if !trigger.enabled {
242                continue;
243            }
244            if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
245                trigger.enabled = false;
246                continue;
247            }
248
249            if let TriggerCondition::Event {
250                event_kind: pattern,
251            } = &trigger.condition
252                && (pattern == "*" || pattern == &event_kind)
253            {
254                trigger.fire_count += 1;
255                matched.push(trigger.id);
256                debug!(
257                    trigger_id = %trigger.id,
258                    name = %trigger.name,
259                    event_kind = %event_kind,
260                    "event trigger fired"
261                );
262            }
263        }
264
265        matched
266    }
267
268    /// Get all schedule-type triggers with their intervals.
269    pub fn get_schedule_triggers(&self) -> Vec<(TriggerId, Duration)> {
270        self.triggers
271            .iter()
272            .filter_map(|entry| {
273                let t = entry.value();
274                if !t.enabled {
275                    return None;
276                }
277                if let TriggerCondition::Schedule { interval_secs } = &t.condition {
278                    Some((*entry.key(), Duration::from_secs(*interval_secs)))
279                } else {
280                    None
281                }
282            })
283            .collect()
284    }
285
286    /// Get a trigger by ID.
287    pub fn get_trigger(&self, id: &TriggerId) -> Option<Trigger> {
288        self.triggers.get(id).map(|t| t.clone())
289    }
290
291    /// Check if a webhook trigger exists and return its action.
292    pub fn check_webhook(&self, id: &TriggerId) -> Option<TriggerAction> {
293        let mut entry = self.triggers.get_mut(id)?;
294        let trigger = entry.value_mut();
295
296        if !trigger.enabled {
297            return None;
298        }
299        if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
300            trigger.enabled = false;
301            return None;
302        }
303
304        if matches!(trigger.condition, TriggerCondition::Webhook { .. }) {
305            trigger.fire_count += 1;
306            debug!(
307                trigger_id = %trigger.id,
308                name = %trigger.name,
309                "webhook trigger fired"
310            );
311            Some(trigger.action.clone())
312        } else {
313            None
314        }
315    }
316}
317
318impl Default for TriggerEngine {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Helpers
326// ---------------------------------------------------------------------------
327
328/// Map a [`PunchEvent`] to a string kind for matching.
329fn event_kind_string(event: &PunchEvent) -> String {
330    match event {
331        PunchEvent::FighterSpawned { .. } => "fighter_spawned".to_string(),
332        PunchEvent::FighterMessage { .. } => "fighter_message".to_string(),
333        PunchEvent::GorillaUnleashed { .. } => "gorilla_unleashed".to_string(),
334        PunchEvent::GorillaPaused { .. } => "gorilla_paused".to_string(),
335        PunchEvent::ToolExecuted { .. } => "tool_executed".to_string(),
336        PunchEvent::BoutStarted { .. } => "bout_started".to_string(),
337        PunchEvent::BoutEnded { .. } => "bout_ended".to_string(),
338        PunchEvent::ComboTriggered { .. } => "combo_triggered".to_string(),
339        PunchEvent::TroopFormed { .. } => "troop_formed".to_string(),
340        PunchEvent::TroopDisbanded { .. } => "troop_disbanded".to_string(),
341        PunchEvent::McpServerStarted { .. } => "mcp_server_started".to_string(),
342        PunchEvent::McpServerStopped { .. } => "mcp_server_stopped".to_string(),
343        PunchEvent::HeartbeatExecuted { .. } => "heartbeat_executed".to_string(),
344        PunchEvent::Error { .. } => "error".to_string(),
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use punch_types::FighterId;
356
357    fn make_keyword_trigger(keywords: Vec<&str>) -> Trigger {
358        Trigger {
359            id: TriggerId::new(),
360            name: "test-keyword".to_string(),
361            condition: TriggerCondition::Keyword {
362                keywords: keywords.into_iter().map(String::from).collect(),
363            },
364            action: TriggerAction::Log {
365                message: "keyword matched".to_string(),
366            },
367            enabled: true,
368            created_at: Utc::now(),
369            fire_count: 0,
370            max_fires: 0,
371        }
372    }
373
374    fn make_event_trigger(event_kind: &str) -> Trigger {
375        Trigger {
376            id: TriggerId::new(),
377            name: "test-event".to_string(),
378            condition: TriggerCondition::Event {
379                event_kind: event_kind.to_string(),
380            },
381            action: TriggerAction::Log {
382                message: "event matched".to_string(),
383            },
384            enabled: true,
385            created_at: Utc::now(),
386            fire_count: 0,
387            max_fires: 0,
388        }
389    }
390
391    fn make_schedule_trigger(interval_secs: u64) -> Trigger {
392        Trigger {
393            id: TriggerId::new(),
394            name: "test-schedule".to_string(),
395            condition: TriggerCondition::Schedule { interval_secs },
396            action: TriggerAction::Log {
397                message: "schedule fired".to_string(),
398            },
399            enabled: true,
400            created_at: Utc::now(),
401            fire_count: 0,
402            max_fires: 0,
403        }
404    }
405
406    #[tokio::test]
407    async fn test_keyword_trigger_matching() {
408        let engine = TriggerEngine::new();
409        let trigger = make_keyword_trigger(vec!["deploy", "release"]);
410        let id = engine.register_trigger(trigger);
411
412        // Should match.
413        let matches = engine.check_keyword("please deploy the app").await;
414        assert_eq!(matches.len(), 1);
415        assert_eq!(matches[0], id);
416
417        // Should match (case-insensitive).
418        let matches = engine.check_keyword("DEPLOY now!").await;
419        assert_eq!(matches.len(), 1);
420
421        // Should not match.
422        let matches = engine.check_keyword("hello world").await;
423        assert!(matches.is_empty());
424    }
425
426    #[tokio::test]
427    async fn test_keyword_trigger_multiple_keywords() {
428        let engine = TriggerEngine::new();
429        let trigger = make_keyword_trigger(vec!["help", "assist"]);
430        engine.register_trigger(trigger);
431
432        let matches = engine.check_keyword("I need help").await;
433        assert_eq!(matches.len(), 1);
434
435        let matches = engine.check_keyword("please assist me").await;
436        assert_eq!(matches.len(), 1);
437    }
438
439    #[tokio::test]
440    async fn test_event_trigger_firing() {
441        let engine = TriggerEngine::new();
442        let trigger = make_event_trigger("fighter_spawned");
443        let id = engine.register_trigger(trigger);
444
445        let event = PunchEvent::FighterSpawned {
446            fighter_id: FighterId::new(),
447            name: "test".to_string(),
448        };
449
450        let matches = engine.check_event(&event).await;
451        assert_eq!(matches.len(), 1);
452        assert_eq!(matches[0], id);
453
454        // Different event type should not match.
455        let event2 = PunchEvent::Error {
456            source: "test".to_string(),
457            message: "oops".to_string(),
458        };
459        let matches2 = engine.check_event(&event2).await;
460        assert!(matches2.is_empty());
461    }
462
463    #[tokio::test]
464    async fn test_event_trigger_wildcard() {
465        let engine = TriggerEngine::new();
466        let trigger = make_event_trigger("*");
467        engine.register_trigger(trigger);
468
469        let event = PunchEvent::Error {
470            source: "test".to_string(),
471            message: "anything".to_string(),
472        };
473        let matches = engine.check_event(&event).await;
474        assert_eq!(matches.len(), 1);
475    }
476
477    #[test]
478    fn test_schedule_trigger_listing() {
479        let engine = TriggerEngine::new();
480        let t1 = make_schedule_trigger(60);
481        let t2 = make_schedule_trigger(300);
482        engine.register_trigger(t1);
483        engine.register_trigger(t2);
484
485        // Also add a non-schedule trigger to verify it's excluded.
486        let t3 = make_keyword_trigger(vec!["hello"]);
487        engine.register_trigger(t3);
488
489        let schedules = engine.get_schedule_triggers();
490        assert_eq!(schedules.len(), 2);
491    }
492
493    #[test]
494    fn test_trigger_registration_and_removal() {
495        let engine = TriggerEngine::new();
496        let trigger = make_keyword_trigger(vec!["test"]);
497        let id = engine.register_trigger(trigger);
498
499        assert!(engine.get_trigger(&id).is_some());
500        assert_eq!(engine.list_triggers().len(), 1);
501
502        engine.remove_trigger(&id);
503        assert!(engine.get_trigger(&id).is_none());
504        assert_eq!(engine.list_triggers().len(), 0);
505    }
506
507    #[tokio::test]
508    async fn test_trigger_max_fires() {
509        let engine = TriggerEngine::new();
510        let mut trigger = make_keyword_trigger(vec!["fire"]);
511        trigger.max_fires = 2;
512        engine.register_trigger(trigger);
513
514        // First two should match.
515        assert_eq!(engine.check_keyword("fire").await.len(), 1);
516        assert_eq!(engine.check_keyword("fire").await.len(), 1);
517        // Third should not.
518        assert_eq!(engine.check_keyword("fire").await.len(), 0);
519    }
520
521    #[tokio::test]
522    async fn test_disabled_trigger_does_not_fire() {
523        let engine = TriggerEngine::new();
524        let mut trigger = make_keyword_trigger(vec!["test"]);
525        trigger.enabled = false;
526        engine.register_trigger(trigger);
527
528        let matches = engine.check_keyword("test message").await;
529        assert!(matches.is_empty());
530    }
531
532    #[test]
533    fn test_webhook_trigger() {
534        let engine = TriggerEngine::new();
535        let trigger = Trigger {
536            id: TriggerId::new(),
537            name: "webhook-test".to_string(),
538            condition: TriggerCondition::Webhook { secret: None },
539            action: TriggerAction::Log {
540                message: "webhook received".to_string(),
541            },
542            enabled: true,
543            created_at: Utc::now(),
544            fire_count: 0,
545            max_fires: 0,
546        };
547        let id = engine.register_trigger(trigger);
548
549        let action = engine.check_webhook(&id);
550        assert!(action.is_some());
551
552        // Non-existent ID should return None.
553        let fake_id = TriggerId::new();
554        assert!(engine.check_webhook(&fake_id).is_none());
555    }
556
557    #[test]
558    fn trigger_engine_default() {
559        let engine = TriggerEngine::default();
560        assert!(engine.list_triggers().is_empty());
561    }
562
563    #[test]
564    fn trigger_id_display() {
565        let id = TriggerId::new();
566        let s = format!("{}", id);
567        assert!(!s.is_empty());
568    }
569
570    #[test]
571    fn trigger_id_default() {
572        let id = TriggerId::default();
573        assert!(!id.0.is_nil());
574    }
575
576    #[test]
577    fn get_trigger_returns_correct_data() {
578        let engine = TriggerEngine::new();
579        let trigger = make_keyword_trigger(vec!["hello"]);
580        let id = engine.register_trigger(trigger);
581
582        let retrieved = engine.get_trigger(&id).unwrap();
583        assert_eq!(retrieved.name, "test-keyword");
584        assert!(retrieved.enabled);
585        assert_eq!(retrieved.fire_count, 0);
586    }
587
588    #[test]
589    fn get_trigger_nonexistent_returns_none() {
590        let engine = TriggerEngine::new();
591        let id = TriggerId::new();
592        assert!(engine.get_trigger(&id).is_none());
593    }
594
595    #[test]
596    fn remove_nonexistent_trigger_does_not_panic() {
597        let engine = TriggerEngine::new();
598        let id = TriggerId::new();
599        engine.remove_trigger(&id); // Should not panic.
600    }
601
602    #[tokio::test]
603    async fn keyword_trigger_fire_count_increments() {
604        let engine = TriggerEngine::new();
605        let trigger = make_keyword_trigger(vec!["count"]);
606        let id = engine.register_trigger(trigger);
607
608        engine.check_keyword("count me").await;
609        engine.check_keyword("count again").await;
610        engine.check_keyword("count three").await;
611
612        let t = engine.get_trigger(&id).unwrap();
613        assert_eq!(t.fire_count, 3);
614    }
615
616    #[tokio::test]
617    async fn event_trigger_fire_count_increments() {
618        let engine = TriggerEngine::new();
619        let trigger = make_event_trigger("error");
620        let id = engine.register_trigger(trigger);
621
622        let event = PunchEvent::Error {
623            source: "test".to_string(),
624            message: "oops".to_string(),
625        };
626        engine.check_event(&event).await;
627        engine.check_event(&event).await;
628
629        let t = engine.get_trigger(&id).unwrap();
630        assert_eq!(t.fire_count, 2);
631    }
632
633    #[test]
634    fn webhook_trigger_fire_count_increments() {
635        let engine = TriggerEngine::new();
636        let trigger = Trigger {
637            id: TriggerId::new(),
638            name: "webhook-count".to_string(),
639            condition: TriggerCondition::Webhook {
640                secret: Some("secret".to_string()),
641            },
642            action: TriggerAction::Log {
643                message: "fired".to_string(),
644            },
645            enabled: true,
646            created_at: Utc::now(),
647            fire_count: 0,
648            max_fires: 0,
649        };
650        let id = engine.register_trigger(trigger);
651
652        engine.check_webhook(&id);
653        engine.check_webhook(&id);
654
655        let t = engine.get_trigger(&id).unwrap();
656        assert_eq!(t.fire_count, 2);
657    }
658
659    #[test]
660    fn webhook_trigger_disabled_returns_none() {
661        let engine = TriggerEngine::new();
662        let trigger = Trigger {
663            id: TriggerId::new(),
664            name: "disabled-webhook".to_string(),
665            condition: TriggerCondition::Webhook { secret: None },
666            action: TriggerAction::Log {
667                message: "nope".to_string(),
668            },
669            enabled: false,
670            created_at: Utc::now(),
671            fire_count: 0,
672            max_fires: 0,
673        };
674        let id = trigger.id;
675        engine.register_trigger(trigger);
676
677        assert!(engine.check_webhook(&id).is_none());
678    }
679
680    #[test]
681    fn webhook_trigger_max_fires_reached() {
682        let engine = TriggerEngine::new();
683        let trigger = Trigger {
684            id: TriggerId::new(),
685            name: "limited-webhook".to_string(),
686            condition: TriggerCondition::Webhook { secret: None },
687            action: TriggerAction::Log {
688                message: "limited".to_string(),
689            },
690            enabled: true,
691            created_at: Utc::now(),
692            fire_count: 0,
693            max_fires: 1,
694        };
695        let id = engine.register_trigger(trigger);
696
697        assert!(engine.check_webhook(&id).is_some());
698        // Second should fail (max_fires=1 reached).
699        assert!(engine.check_webhook(&id).is_none());
700    }
701
702    #[test]
703    fn check_webhook_on_non_webhook_trigger_returns_none() {
704        let engine = TriggerEngine::new();
705        let trigger = make_keyword_trigger(vec!["test"]);
706        let id = engine.register_trigger(trigger);
707
708        assert!(engine.check_webhook(&id).is_none());
709    }
710
711    #[test]
712    fn disabled_schedule_trigger_excluded() {
713        let engine = TriggerEngine::new();
714        let mut trigger = make_schedule_trigger(60);
715        trigger.enabled = false;
716        engine.register_trigger(trigger);
717
718        let schedules = engine.get_schedule_triggers();
719        assert!(schedules.is_empty());
720    }
721
722    #[test]
723    fn list_triggers_returns_summaries() {
724        let engine = TriggerEngine::new();
725        let t1 = make_keyword_trigger(vec!["a", "b"]);
726        let t2 = make_event_trigger("fighter_spawned");
727        let t3 = make_schedule_trigger(120);
728        let t4 = Trigger {
729            id: TriggerId::new(),
730            name: "webhook".to_string(),
731            condition: TriggerCondition::Webhook { secret: None },
732            action: TriggerAction::Log {
733                message: "wh".to_string(),
734            },
735            enabled: true,
736            created_at: Utc::now(),
737            fire_count: 0,
738            max_fires: 0,
739        };
740
741        engine.register_trigger(t1);
742        engine.register_trigger(t2);
743        engine.register_trigger(t3);
744        engine.register_trigger(t4);
745
746        let summaries = engine.list_triggers();
747        assert_eq!(summaries.len(), 4);
748
749        // Check condition_type descriptions.
750        let types: Vec<String> = summaries
751            .iter()
752            .map(|(_, s)| s.condition_type.clone())
753            .collect();
754        assert!(types.iter().any(|t| t.contains("keyword")));
755        assert!(types.iter().any(|t| t.contains("event")));
756        assert!(types.iter().any(|t| t.contains("schedule")));
757        assert!(types.iter().any(|t| t == "webhook"));
758    }
759
760    #[tokio::test]
761    async fn multiple_keyword_triggers_fire_independently() {
762        let engine = TriggerEngine::new();
763        let t1 = make_keyword_trigger(vec!["alpha"]);
764        let t2 = make_keyword_trigger(vec!["beta"]);
765        let id1 = engine.register_trigger(t1);
766        let id2 = engine.register_trigger(t2);
767
768        // Only alpha matches.
769        let matches = engine.check_keyword("alpha is here").await;
770        assert_eq!(matches.len(), 1);
771        assert_eq!(matches[0], id1);
772
773        // Only beta matches.
774        let matches = engine.check_keyword("beta is here").await;
775        assert_eq!(matches.len(), 1);
776        assert_eq!(matches[0], id2);
777
778        // Both match.
779        let matches = engine.check_keyword("alpha and beta together").await;
780        assert_eq!(matches.len(), 2);
781    }
782}