1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", tag = "type")]
52pub enum TriggerCondition {
53 Schedule {
55 interval_secs: u64,
57 },
58 Keyword {
60 keywords: Vec<String>,
62 },
63 Event {
65 event_kind: String,
67 },
68 Webhook {
70 secret: Option<String>,
72 },
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "action")]
78pub enum TriggerAction {
79 SpawnFighter { template_name: String },
81 SendMessage {
83 fighter_id: FighterId,
84 message: String,
85 },
86 ExecuteWorkflow { workflow_id: String, input: String },
88 RunGorilla { gorilla_id: GorillaId },
90 Log { message: String },
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Trigger {
97 pub id: TriggerId,
99 pub name: String,
101 pub condition: TriggerCondition,
103 pub action: TriggerAction,
105 pub enabled: bool,
107 pub created_at: DateTime<Utc>,
109 pub fire_count: u64,
111 pub max_fires: u64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TriggerSummary {
118 pub name: String,
120 pub condition_type: String,
122 pub enabled: bool,
124 pub fire_count: u64,
126 pub created_at: DateTime<Utc>,
128}
129
130pub struct TriggerEngine {
136 triggers: DashMap<TriggerId, Trigger>,
138}
139
140impl TriggerEngine {
141 pub fn new() -> Self {
143 Self {
144 triggers: DashMap::new(),
145 }
146 }
147
148 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 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 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 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 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 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 pub fn get_trigger(&self, id: &TriggerId) -> Option<Trigger> {
288 self.triggers.get(id).map(|t| t.clone())
289 }
290
291 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
324fn 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#[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 let matches = engine.check_keyword("please deploy the app").await;
414 assert_eq!(matches.len(), 1);
415 assert_eq!(matches[0], id);
416
417 let matches = engine.check_keyword("DEPLOY now!").await;
419 assert_eq!(matches.len(), 1);
420
421 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 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 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 assert_eq!(engine.check_keyword("fire").await.len(), 1);
516 assert_eq!(engine.check_keyword("fire").await.len(), 1);
517 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 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); }
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 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 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 let matches = engine.check_keyword("alpha is here").await;
770 assert_eq!(matches.len(), 1);
771 assert_eq!(matches[0], id1);
772
773 let matches = engine.check_keyword("beta is here").await;
775 assert_eq!(matches.len(), 1);
776 assert_eq!(matches[0], id2);
777
778 let matches = engine.check_keyword("alpha and beta together").await;
780 assert_eq!(matches.len(), 2);
781 }
782}