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::Error { .. } => "error".to_string(),
344 }
345}
346
347#[cfg(test)]
352mod tests {
353 use super::*;
354 use punch_types::FighterId;
355
356 fn make_keyword_trigger(keywords: Vec<&str>) -> Trigger {
357 Trigger {
358 id: TriggerId::new(),
359 name: "test-keyword".to_string(),
360 condition: TriggerCondition::Keyword {
361 keywords: keywords.into_iter().map(String::from).collect(),
362 },
363 action: TriggerAction::Log {
364 message: "keyword matched".to_string(),
365 },
366 enabled: true,
367 created_at: Utc::now(),
368 fire_count: 0,
369 max_fires: 0,
370 }
371 }
372
373 fn make_event_trigger(event_kind: &str) -> Trigger {
374 Trigger {
375 id: TriggerId::new(),
376 name: "test-event".to_string(),
377 condition: TriggerCondition::Event {
378 event_kind: event_kind.to_string(),
379 },
380 action: TriggerAction::Log {
381 message: "event matched".to_string(),
382 },
383 enabled: true,
384 created_at: Utc::now(),
385 fire_count: 0,
386 max_fires: 0,
387 }
388 }
389
390 fn make_schedule_trigger(interval_secs: u64) -> Trigger {
391 Trigger {
392 id: TriggerId::new(),
393 name: "test-schedule".to_string(),
394 condition: TriggerCondition::Schedule { interval_secs },
395 action: TriggerAction::Log {
396 message: "schedule fired".to_string(),
397 },
398 enabled: true,
399 created_at: Utc::now(),
400 fire_count: 0,
401 max_fires: 0,
402 }
403 }
404
405 #[tokio::test]
406 async fn test_keyword_trigger_matching() {
407 let engine = TriggerEngine::new();
408 let trigger = make_keyword_trigger(vec!["deploy", "release"]);
409 let id = engine.register_trigger(trigger);
410
411 let matches = engine.check_keyword("please deploy the app").await;
413 assert_eq!(matches.len(), 1);
414 assert_eq!(matches[0], id);
415
416 let matches = engine.check_keyword("DEPLOY now!").await;
418 assert_eq!(matches.len(), 1);
419
420 let matches = engine.check_keyword("hello world").await;
422 assert!(matches.is_empty());
423 }
424
425 #[tokio::test]
426 async fn test_keyword_trigger_multiple_keywords() {
427 let engine = TriggerEngine::new();
428 let trigger = make_keyword_trigger(vec!["help", "assist"]);
429 engine.register_trigger(trigger);
430
431 let matches = engine.check_keyword("I need help").await;
432 assert_eq!(matches.len(), 1);
433
434 let matches = engine.check_keyword("please assist me").await;
435 assert_eq!(matches.len(), 1);
436 }
437
438 #[tokio::test]
439 async fn test_event_trigger_firing() {
440 let engine = TriggerEngine::new();
441 let trigger = make_event_trigger("fighter_spawned");
442 let id = engine.register_trigger(trigger);
443
444 let event = PunchEvent::FighterSpawned {
445 fighter_id: FighterId::new(),
446 name: "test".to_string(),
447 };
448
449 let matches = engine.check_event(&event).await;
450 assert_eq!(matches.len(), 1);
451 assert_eq!(matches[0], id);
452
453 let event2 = PunchEvent::Error {
455 source: "test".to_string(),
456 message: "oops".to_string(),
457 };
458 let matches2 = engine.check_event(&event2).await;
459 assert!(matches2.is_empty());
460 }
461
462 #[tokio::test]
463 async fn test_event_trigger_wildcard() {
464 let engine = TriggerEngine::new();
465 let trigger = make_event_trigger("*");
466 engine.register_trigger(trigger);
467
468 let event = PunchEvent::Error {
469 source: "test".to_string(),
470 message: "anything".to_string(),
471 };
472 let matches = engine.check_event(&event).await;
473 assert_eq!(matches.len(), 1);
474 }
475
476 #[test]
477 fn test_schedule_trigger_listing() {
478 let engine = TriggerEngine::new();
479 let t1 = make_schedule_trigger(60);
480 let t2 = make_schedule_trigger(300);
481 engine.register_trigger(t1);
482 engine.register_trigger(t2);
483
484 let t3 = make_keyword_trigger(vec!["hello"]);
486 engine.register_trigger(t3);
487
488 let schedules = engine.get_schedule_triggers();
489 assert_eq!(schedules.len(), 2);
490 }
491
492 #[test]
493 fn test_trigger_registration_and_removal() {
494 let engine = TriggerEngine::new();
495 let trigger = make_keyword_trigger(vec!["test"]);
496 let id = engine.register_trigger(trigger);
497
498 assert!(engine.get_trigger(&id).is_some());
499 assert_eq!(engine.list_triggers().len(), 1);
500
501 engine.remove_trigger(&id);
502 assert!(engine.get_trigger(&id).is_none());
503 assert_eq!(engine.list_triggers().len(), 0);
504 }
505
506 #[tokio::test]
507 async fn test_trigger_max_fires() {
508 let engine = TriggerEngine::new();
509 let mut trigger = make_keyword_trigger(vec!["fire"]);
510 trigger.max_fires = 2;
511 engine.register_trigger(trigger);
512
513 assert_eq!(engine.check_keyword("fire").await.len(), 1);
515 assert_eq!(engine.check_keyword("fire").await.len(), 1);
516 assert_eq!(engine.check_keyword("fire").await.len(), 0);
518 }
519
520 #[tokio::test]
521 async fn test_disabled_trigger_does_not_fire() {
522 let engine = TriggerEngine::new();
523 let mut trigger = make_keyword_trigger(vec!["test"]);
524 trigger.enabled = false;
525 engine.register_trigger(trigger);
526
527 let matches = engine.check_keyword("test message").await;
528 assert!(matches.is_empty());
529 }
530
531 #[test]
532 fn test_webhook_trigger() {
533 let engine = TriggerEngine::new();
534 let trigger = Trigger {
535 id: TriggerId::new(),
536 name: "webhook-test".to_string(),
537 condition: TriggerCondition::Webhook { secret: None },
538 action: TriggerAction::Log {
539 message: "webhook received".to_string(),
540 },
541 enabled: true,
542 created_at: Utc::now(),
543 fire_count: 0,
544 max_fires: 0,
545 };
546 let id = engine.register_trigger(trigger);
547
548 let action = engine.check_webhook(&id);
549 assert!(action.is_some());
550
551 let fake_id = TriggerId::new();
553 assert!(engine.check_webhook(&fake_id).is_none());
554 }
555
556 #[test]
557 fn trigger_engine_default() {
558 let engine = TriggerEngine::default();
559 assert!(engine.list_triggers().is_empty());
560 }
561
562 #[test]
563 fn trigger_id_display() {
564 let id = TriggerId::new();
565 let s = format!("{}", id);
566 assert!(!s.is_empty());
567 }
568
569 #[test]
570 fn trigger_id_default() {
571 let id = TriggerId::default();
572 assert!(!id.0.is_nil());
573 }
574
575 #[test]
576 fn get_trigger_returns_correct_data() {
577 let engine = TriggerEngine::new();
578 let trigger = make_keyword_trigger(vec!["hello"]);
579 let id = engine.register_trigger(trigger);
580
581 let retrieved = engine.get_trigger(&id).unwrap();
582 assert_eq!(retrieved.name, "test-keyword");
583 assert!(retrieved.enabled);
584 assert_eq!(retrieved.fire_count, 0);
585 }
586
587 #[test]
588 fn get_trigger_nonexistent_returns_none() {
589 let engine = TriggerEngine::new();
590 let id = TriggerId::new();
591 assert!(engine.get_trigger(&id).is_none());
592 }
593
594 #[test]
595 fn remove_nonexistent_trigger_does_not_panic() {
596 let engine = TriggerEngine::new();
597 let id = TriggerId::new();
598 engine.remove_trigger(&id); }
600
601 #[tokio::test]
602 async fn keyword_trigger_fire_count_increments() {
603 let engine = TriggerEngine::new();
604 let trigger = make_keyword_trigger(vec!["count"]);
605 let id = engine.register_trigger(trigger);
606
607 engine.check_keyword("count me").await;
608 engine.check_keyword("count again").await;
609 engine.check_keyword("count three").await;
610
611 let t = engine.get_trigger(&id).unwrap();
612 assert_eq!(t.fire_count, 3);
613 }
614
615 #[tokio::test]
616 async fn event_trigger_fire_count_increments() {
617 let engine = TriggerEngine::new();
618 let trigger = make_event_trigger("error");
619 let id = engine.register_trigger(trigger);
620
621 let event = PunchEvent::Error {
622 source: "test".to_string(),
623 message: "oops".to_string(),
624 };
625 engine.check_event(&event).await;
626 engine.check_event(&event).await;
627
628 let t = engine.get_trigger(&id).unwrap();
629 assert_eq!(t.fire_count, 2);
630 }
631
632 #[test]
633 fn webhook_trigger_fire_count_increments() {
634 let engine = TriggerEngine::new();
635 let trigger = Trigger {
636 id: TriggerId::new(),
637 name: "webhook-count".to_string(),
638 condition: TriggerCondition::Webhook {
639 secret: Some("secret".to_string()),
640 },
641 action: TriggerAction::Log {
642 message: "fired".to_string(),
643 },
644 enabled: true,
645 created_at: Utc::now(),
646 fire_count: 0,
647 max_fires: 0,
648 };
649 let id = engine.register_trigger(trigger);
650
651 engine.check_webhook(&id);
652 engine.check_webhook(&id);
653
654 let t = engine.get_trigger(&id).unwrap();
655 assert_eq!(t.fire_count, 2);
656 }
657
658 #[test]
659 fn webhook_trigger_disabled_returns_none() {
660 let engine = TriggerEngine::new();
661 let trigger = Trigger {
662 id: TriggerId::new(),
663 name: "disabled-webhook".to_string(),
664 condition: TriggerCondition::Webhook { secret: None },
665 action: TriggerAction::Log {
666 message: "nope".to_string(),
667 },
668 enabled: false,
669 created_at: Utc::now(),
670 fire_count: 0,
671 max_fires: 0,
672 };
673 let id = trigger.id;
674 engine.register_trigger(trigger);
675
676 assert!(engine.check_webhook(&id).is_none());
677 }
678
679 #[test]
680 fn webhook_trigger_max_fires_reached() {
681 let engine = TriggerEngine::new();
682 let trigger = Trigger {
683 id: TriggerId::new(),
684 name: "limited-webhook".to_string(),
685 condition: TriggerCondition::Webhook { secret: None },
686 action: TriggerAction::Log {
687 message: "limited".to_string(),
688 },
689 enabled: true,
690 created_at: Utc::now(),
691 fire_count: 0,
692 max_fires: 1,
693 };
694 let id = engine.register_trigger(trigger);
695
696 assert!(engine.check_webhook(&id).is_some());
697 assert!(engine.check_webhook(&id).is_none());
699 }
700
701 #[test]
702 fn check_webhook_on_non_webhook_trigger_returns_none() {
703 let engine = TriggerEngine::new();
704 let trigger = make_keyword_trigger(vec!["test"]);
705 let id = engine.register_trigger(trigger);
706
707 assert!(engine.check_webhook(&id).is_none());
708 }
709
710 #[test]
711 fn disabled_schedule_trigger_excluded() {
712 let engine = TriggerEngine::new();
713 let mut trigger = make_schedule_trigger(60);
714 trigger.enabled = false;
715 engine.register_trigger(trigger);
716
717 let schedules = engine.get_schedule_triggers();
718 assert!(schedules.is_empty());
719 }
720
721 #[test]
722 fn list_triggers_returns_summaries() {
723 let engine = TriggerEngine::new();
724 let t1 = make_keyword_trigger(vec!["a", "b"]);
725 let t2 = make_event_trigger("fighter_spawned");
726 let t3 = make_schedule_trigger(120);
727 let t4 = Trigger {
728 id: TriggerId::new(),
729 name: "webhook".to_string(),
730 condition: TriggerCondition::Webhook { secret: None },
731 action: TriggerAction::Log {
732 message: "wh".to_string(),
733 },
734 enabled: true,
735 created_at: Utc::now(),
736 fire_count: 0,
737 max_fires: 0,
738 };
739
740 engine.register_trigger(t1);
741 engine.register_trigger(t2);
742 engine.register_trigger(t3);
743 engine.register_trigger(t4);
744
745 let summaries = engine.list_triggers();
746 assert_eq!(summaries.len(), 4);
747
748 let types: Vec<String> = summaries
750 .iter()
751 .map(|(_, s)| s.condition_type.clone())
752 .collect();
753 assert!(types.iter().any(|t| t.contains("keyword")));
754 assert!(types.iter().any(|t| t.contains("event")));
755 assert!(types.iter().any(|t| t.contains("schedule")));
756 assert!(types.iter().any(|t| t == "webhook"));
757 }
758
759 #[tokio::test]
760 async fn multiple_keyword_triggers_fire_independently() {
761 let engine = TriggerEngine::new();
762 let t1 = make_keyword_trigger(vec!["alpha"]);
763 let t2 = make_keyword_trigger(vec!["beta"]);
764 let id1 = engine.register_trigger(t1);
765 let id2 = engine.register_trigger(t2);
766
767 let matches = engine.check_keyword("alpha is here").await;
769 assert_eq!(matches.len(), 1);
770 assert_eq!(matches[0], id1);
771
772 let matches = engine.check_keyword("beta is here").await;
774 assert_eq!(matches.len(), 1);
775 assert_eq!(matches[0], id2);
776
777 let matches = engine.check_keyword("alpha and beta together").await;
779 assert_eq!(matches.len(), 2);
780 }
781}