diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index f1bc8772..f2b9c98f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -1,6 +1,8 @@ package vip.mate.channel; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import vip.mate.agent.AgentService; @@ -8,6 +10,7 @@ import vip.mate.agent.context.ChatOrigin; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ResolveOutcome; import vip.mate.approval.PendingApproval; +import vip.mate.channel.event.ChannelMessageReceivedEvent; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.service.ChannelService; @@ -59,6 +62,11 @@ public class ChannelMessageRouter { private final ChatStreamTracker streamTracker; private final ChannelChatOriginFactory chatOriginFactory; private final ChannelErrorClassifier errorClassifier; + /** Field-injected (rather than constructor) to avoid a signature + * change that would ripple through every test that constructs the + * router directly. Spring's stock publisher is always available. */ + @Autowired(required = false) + private ApplicationEventPublisher events; /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} @@ -189,6 +197,14 @@ public class ChannelMessageRouter { * @param channelEntity 渠道配置(含关联 agentId) */ public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) { + // Fan out to the trigger pipeline FIRST — channel_message and + // content_match triggers fire on every received message regardless + // of whether the channel has an agent attached. If we returned + // early on a missing agent below without publishing, the workflow + // side would silently lose every channel-event that doesn't also + // route to a chat agent. + publishChannelEvent(message, adapter, channelEntity); + Long agentId = channelEntity.getAgentId(); if (agentId == null) { log.warn("Channel {} has no associated agent, ignoring message from {}", @@ -231,6 +247,43 @@ public class ChannelMessageRouter { } } + /** + * Publish a {@link ChannelMessageReceivedEvent} so the trigger module's + * bridge can fan the message out to channel_message + content_match + * triggers. Best-effort — a publish failure must never block the + * primary chat-routing path. {@code messageId} is used as the dedup + * key downstream so repeated webhook deliveries can't double-fire + * the same trigger. + */ + private void publishChannelEvent(ChannelMessage message, ChannelAdapter adapter, + ChannelEntity channelEntity) { + if (events == null || message == null || adapter == null || channelEntity == null) return; + try { + long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId(); + String channelType = adapter.getChannelType(); + // messageId may be null for adapters that don't surface one; + // fall back to a sender+timestamp composite so the dedup key + // is at least deterministic-ish per webhook delivery. + String messageId = message.getMessageId(); + if (messageId == null || messageId.isBlank()) { + messageId = channelType + ":" + message.getSenderId() + ":" + + (message.getTimestamp() == null ? System.currentTimeMillis() + : message.getTimestamp()); + } + events.publishEvent(new ChannelMessageReceivedEvent( + ws, + channelType, + messageId, + message.getSenderId(), + message.getSenderName(), + message.getChatId(), + message.getContent())); + } catch (Exception e) { + log.warn("[ChannelMessageRouter] event publish failed for sender {}: {}", + message.getSenderId(), e.getMessage()); + } + } + /** * 防抖到期:将合并后的消息真正放入渠道队列 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/event/ChannelMessageReceivedEvent.java b/mateclaw-server/src/main/java/vip/mate/channel/event/ChannelMessageReceivedEvent.java new file mode 100644 index 00000000..926855ec --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/event/ChannelMessageReceivedEvent.java @@ -0,0 +1,26 @@ +package vip.mate.channel.event; + +/** + * Spring application event fired when a channel adapter accepts an + * inbound message and hands it off to {@code ChannelMessageRouter}. + * The trigger module subscribes via {@code @EventListener} and forwards + * the payload through {@code TriggerEventIngestService} so triggers of + * pattern type {@code channel_message} or {@code content_match} can fan + * out to workflows. Going through the event bus instead of injecting + * the trigger service directly into the channel module keeps the two + * worlds decoupled and dodges the construction cycle. + * + *
{@code messageId} doubles as the dedup key — repeated webhook + * deliveries of the same message can't double-fire downstream triggers + * because the {@code mate_trigger_event} unique constraint catches the + * second insert. + */ +public record ChannelMessageReceivedEvent( + long workspaceId, + String channelType, + String messageId, + String senderId, + String senderName, + String chatId, + String content +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java new file mode 100644 index 00000000..bd868192 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/ChannelMessageEventBridge.java @@ -0,0 +1,70 @@ +package vip.mate.trigger.dispatch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import vip.mate.channel.event.ChannelMessageReceivedEvent; +import vip.mate.trigger.ingest.TriggerEventEnvelope; +import vip.mate.trigger.ingest.TriggerEventIngestService; + +import java.util.HashMap; +import java.util.Map; + +/** + * Bridges {@link ChannelMessageReceivedEvent} from the channel module + * into two trigger pattern types — {@code channel_message} (matches by + * {@code channelType} / {@code senderEquals}) and {@code content_match} + * (matches by substring inside the message content). The same envelope + * fans out to both since the matcher's per-pattern key on the SQL + * candidate query selects which triggers actually run. + * + *
Lives in the trigger module so the channel runtime stays free of
+ * trigger / ingest dependencies. Failures inside ingest are logged and
+ * swallowed — a bad downstream trigger MUST NOT corrupt the primary
+ * chat-routing path that just published the event.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ChannelMessageEventBridge {
+
+ private final TriggerEventIngestService ingestService;
+
+ @EventListener
+ public void onChannelMessage(ChannelMessageReceivedEvent event) {
+ if (event == null) return;
+ try {
+ Map