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