diff --git a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcher.java b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcher.java index e6bd265..ffd7e90 100644 --- a/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcher.java +++ b/ordinis-outbox/src/main/java/cloud/nstart/terravault/ordinis/outbox/webhook/WebhookDispatcher.java @@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery; import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDeliveryRepository; import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription; import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; @@ -76,6 +77,17 @@ public class WebhookDispatcher { @Value("${ordinis.webhook.allowed-hosts:}") private String allowedHostsCsv; + /** + * Hosts что ожидают text-shaped payload {"text": "..."} вместо raw event JSON + * (пример: chat.nstart.cloud Webhook Bot, Mattermost-style incoming hooks). + * CSV. Default empty — все subscriptions получают raw JSON. Для chat-bot + * URLs выставить hosts через ORDINIS_WEBHOOK_TEXT_WRAP_HOSTS env. Pattern + * учится у stellix alert-bridge: receivers что показывают alerts человеку + * требуют human-readable text, не структурированный JSON. + */ + @Value("${ordinis.webhook.text-wrap-hosts:}") + private String textWrapHostsCsv; + public WebhookDispatcher( OutboxEventRepository outboxRepository, WebhookSubscriptionRepository subscriptionRepository, @@ -219,7 +231,26 @@ public class WebhookDispatcher { Timer.Sample sample = Timer.start(); try { - byte[] body = objectMapper.writeValueAsBytes(event.getPayload()); + // Text-wrap для chat-bot receivers (Mattermost @WebhookBot и подобные). + // Stellix pattern: alert получатель видит human-readable string, не raw + // JSON. Wrap'аем как {"text": ""} если URL host в allowlist. + // Без wrap чат-бот получает structured JSON, не показывает в чат + // ничего интересного OR вообще rejects. + byte[] body; + try { + URI uri = URI.create(sub.getUrl()); + String host = uri.getHost() != null ? uri.getHost().toLowerCase() : ""; + if (shouldTextWrap(host)) { + String rendered = renderEventAsText(event); + var wrapped = java.util.Map.of("text", rendered); + body = objectMapper.writeValueAsBytes(wrapped); + } else { + body = objectMapper.writeValueAsBytes(event.getPayload()); + } + } catch (IllegalArgumentException uriEx) { + // Bad URL — fallback to raw, dispatcher logged elsewhere. + body = objectMapper.writeValueAsBytes(event.getPayload()); + } String signature = HmacSigner.sign(sub.getHmacSecret(), body); HttpRequest req = HttpRequest.newBuilder() @@ -271,4 +302,49 @@ public class WebhookDispatcher { } deliveryRepository.save(delivery); } + + /** + * @return true если URL host матчит {@code ordinis.webhook.text-wrap-hosts} + * CSV — payload завернётся в {@code {"text": "..."}} как ожидают + * Mattermost-style chat bots. См. stellix/monitoring/alert-bridge + * для prior art. + */ + private boolean shouldTextWrap(String host) { + if (host == null || host.isBlank() || textWrapHostsCsv == null + || textWrapHostsCsv.isBlank()) { + return false; + } + for (String h : textWrapHostsCsv.split(",")) { + if (host.equalsIgnoreCase(h.trim())) { + return true; + } + } + return false; + } + + /** + * Render OutboxEvent в human-readable single-line/multi-line text для + * chat-bot delivery. Не задействует i18n templates (которые в + * ordinis-notifications) — webhook bridge должен быть self-contained + * на случай если notifications module disabled. + * + *

Format: {@code [] <дictionary>/ by } + * + payload preview if present. Mirrors stellix renderAlert() shape. + */ + private String renderEventAsText(OutboxEvent event) { + StringBuilder sb = new StringBuilder(); + sb.append("[").append(event.getEventType()).append("]"); + JsonNode p = event.getPayload(); + if (p != null && !p.isMissingNode()) { + JsonNode dict = p.get("dictionary"); + JsonNode bk = p.get("businessKey"); + JsonNode op = p.get("operation"); + JsonNode maker = p.get("maker"); + if (dict != null) sb.append(" ").append(dict.asText()); + if (bk != null) sb.append("/").append(bk.asText()); + if (op != null) sb.append(" op=").append(op.asText()); + if (maker != null) sb.append(" by=").append(maker.asText()); + } + return sb.toString(); + } }