package vip.mate.trigger.ingest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.trigger.model.TriggerEntity; import java.util.Map; /** * Decides whether a trigger's stored {@code pattern_json} actually matches * an inbound envelope, beyond the coarse {@code (workspaceId, patternType)} * filter the SQL query already does. * *

Without this layer the ingest service broadcasts every event to every * trigger in the same workspace that happens to share a {@code patternType}, * which is the event-storm hazard the design has warned about — one channel * message would fire every channel-message trigger regardless of intent. * *

v0 supports four pattern shapes: *

* *

Unknown pattern types fail closed (no match) so a typo'd or future * pattern type can't silently fire every workspace trigger. */ @Slf4j @Component public class TriggerPatternMatcher { private final ObjectMapper objectMapper; public TriggerPatternMatcher(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } public boolean matches(TriggerEntity trigger, TriggerEventEnvelope envelope) { String type = trigger.getPatternType(); if (type == null) return false; JsonNode pattern = parsePattern(trigger); return switch (type) { case "cron" -> false; // scheduler-driven, not ingested case "channel_message" -> matchesChannelMessage(pattern, envelope); case "agent_lifecycle" -> matchesAgentLifecycle(pattern, envelope); case "content_match" -> matchesContent(pattern, envelope); case "workflow_completion" -> matchesWorkflowCompletion(pattern, envelope); case "webhook" -> true; // pass-through; secret check happens at the HTTP boundary default -> { log.warn("Trigger {} uses unknown patternType '{}' — failing closed", trigger.getId(), type); yield false; } }; } private JsonNode parsePattern(TriggerEntity trigger) { String json = trigger.getPatternJson(); if (json == null || json.isBlank()) return objectMapper.nullNode(); try { return objectMapper.readTree(json); } catch (Exception e) { // A trigger with malformed pattern_json should never have been // accepted at create / update time; fail closed at fire time. log.warn("Trigger {} pattern_json parse failed: {}", trigger.getId(), e.getMessage()); return objectMapper.nullNode(); } } private boolean matchesChannelMessage(JsonNode pattern, TriggerEventEnvelope envelope) { if (envelope == null) return false; // channelType lives in envelope.data ("channelType" key) — the upstream // ChannelWebhookController stuffs it there. envelope itself is generic // and doesn't have a typed channel field. String wantChannel = textOrNull(pattern, "channelType"); if (wantChannel != null) { Object actual = envelope.data() == null ? null : envelope.data().get("channelType"); if (!(actual instanceof String s) || !wantChannel.equalsIgnoreCase(s)) return false; } String wantSender = textOrNull(pattern, "senderEquals"); if (wantSender != null && !wantSender.equals(envelope.senderId())) { return false; } return true; } private boolean matchesAgentLifecycle(JsonNode pattern, TriggerEventEnvelope envelope) { Map data = envelope.data(); if (data == null) return false; Long wantAgent = longOrNull(pattern, "agentId"); if (wantAgent != null) { Object actual = data.get("agentId"); if (!(actual instanceof Number n) || n.longValue() != wantAgent) return false; } String wantPhase = textOrNull(pattern, "phase"); if (wantPhase != null) { Object phase = data.get("phase"); if (!(phase instanceof String s) || !wantPhase.equalsIgnoreCase(s)) return false; } return true; } private boolean matchesContent(JsonNode pattern, TriggerEventEnvelope envelope) { String needle = textOrNull(pattern, "substring"); if (needle == null || needle.isBlank()) { // content_match without a substring is a misconfiguration — refuse // to fire blanket-on-every-event rather than acting as a wildcard. return false; } Map data = envelope.data(); if (data == null) return false; Object content = data.get("content"); if (!(content instanceof String s)) return false; return s.toLowerCase().contains(needle.toLowerCase()); } private boolean matchesWorkflowCompletion(JsonNode pattern, TriggerEventEnvelope envelope) { Map data = envelope.data(); if (data == null) return false; Long wantSource = longOrNull(pattern, "sourceWorkflowId"); if (wantSource != null) { Object actual = data.get("sourceWorkflowId"); if (!(actual instanceof Number n) || n.longValue() != wantSource) return false; } String wantState = textOrNull(pattern, "stateFilter"); if (wantState != null && !"any".equalsIgnoreCase(wantState)) { Object state = data.get("state"); if (!(state instanceof String s) || !wantState.equalsIgnoreCase(s)) return false; } return true; } private static String textOrNull(JsonNode node, String key) { if (node == null || !node.hasNonNull(key)) return null; String s = node.get(key).asText(null); return (s == null || s.isBlank()) ? null : s; } private static Long longOrNull(JsonNode node, String key) { if (node == null || !node.hasNonNull(key)) return null; JsonNode v = node.get(key); if (v.isNumber()) return v.asLong(); try { return Long.parseLong(v.asText()); } catch (NumberFormatException e) { return null; } } }