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