Merge branch 'feat/webhook-text-wrap-stellix-pattern' into 'main'

feat(webhook): text-wrap payload для chat-bot receivers (stellix pattern)

See merge request 2-6/2-6-4/terravault/ordinis!216
This commit is contained in:
Александр Зимин
2026-05-15 16:24:15 +00:00
@@ -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": "<rendered>"} если 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.
*
* <p>Format: {@code [<eventType>] <дictionary>/<businessKey> by <userId>}
* + 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();
}
}