From c76e59072f8dca453456a5c7fa822cdc80525edb Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Fri, 19 Jun 2026 09:46:34 +0800 Subject: [PATCH 1/5] fix(dashboard): scope .modal-body label to direct children The descendant selector `.modal-body label` leaked into nested labels (e.g. the filter builder's inputs), restyling controls it shouldn't. Scope it to `.modal-body > label` so only top-level modal field labels are affected. --- dashboard/src/index.css | 2 +- dashboard/src/pages/Sessions.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 5e913aad..694be2aa 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -203,7 +203,7 @@ select:disabled { padding: 1.5rem; } -.modal-body label { +.modal-body > label { display: block; font-size: 0.875rem; font-weight: 600; diff --git a/dashboard/src/pages/Sessions.css b/dashboard/src/pages/Sessions.css index 0840af5d..3a9dafde 100644 --- a/dashboard/src/pages/Sessions.css +++ b/dashboard/src/pages/Sessions.css @@ -377,7 +377,7 @@ padding: 1.5rem; } -.modal-body label { +.modal-body > label { display: block; font-size: 0.875rem; font-weight: 600; From 9995e253cec7df3a62b3b62269b990103b287ddd Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Sat, 20 Jun 2026 12:56:28 +0800 Subject: [PATCH 2/5] feat(webhook): smart pre-dispatch filters Add optional smart filters that refine WHETHER a subscribed webhook fires, evaluated per event before delivery. Conditions (AND-combined) match on sender/recipient/body/type/mentions/fromMe/hasMedia/isGroup with is/isNot/contains/equals/matches operators; message-only conditions are skipped for non-message events, so a webhook subscribed to several families behaves sanely without per-event filter sets. A webhook with no filters behaves exactly as before (additive, zero-config). - filters/: family-aware field registry, evaluator, class-validator validation - entity column + DTO field + migration for the persisted filters - wire evaluateFilters into dispatch matching - dashboard FilterBuilder UI on the webhooks page (sender picker backed by a session-chats query) + i18n strings - unit coverage for the evaluator/validation and dispatch, plus a webhooks e2e suite --- dashboard/src/components/FilterBuilder.css | 289 +++++++++++++++ dashboard/src/components/FilterBuilder.tsx | 294 +++++++++++++++ dashboard/src/hooks/queries.ts | 15 +- dashboard/src/i18n/locales/ar.json | 32 ++ dashboard/src/i18n/locales/en.json | 32 ++ dashboard/src/i18n/locales/es.json | 32 ++ dashboard/src/i18n/locales/fr.json | 32 ++ dashboard/src/i18n/locales/he.json | 32 ++ dashboard/src/i18n/locales/it.json | 32 ++ dashboard/src/i18n/locales/te.json | 32 ++ dashboard/src/i18n/locales/zh-CN.json | 32 ++ dashboard/src/i18n/locales/zh-HK.json | 32 ++ dashboard/src/pages/Webhooks.css | 84 +++++ dashboard/src/pages/Webhooks.tsx | 102 +++++- dashboard/src/services/api.ts | 16 +- .../1781500000000-AddWebhookFilters.ts | 23 ++ src/modules/webhook/dto/webhook.dto.spec.ts | 55 +++ src/modules/webhook/dto/webhook.dto.ts | 31 +- .../webhook/entities/webhook.entity.ts | 5 + .../webhook/filters/filter-evaluator.spec.ts | 116 ++++++ .../webhook/filters/filter-evaluator.ts | 90 +++++ src/modules/webhook/filters/filter-types.ts | 153 ++++++++ .../webhook/filters/filter-validation.ts | 119 ++++++ .../webhook/webhook.controller.spec.ts | 1 + src/modules/webhook/webhook.service.spec.ts | 110 ++++++ src/modules/webhook/webhook.service.ts | 7 +- test/setup-e2e.ts | 5 + test/webhooks.e2e-spec.ts | 346 ++++++++++++++++++ 28 files changed, 2137 insertions(+), 12 deletions(-) create mode 100644 dashboard/src/components/FilterBuilder.css create mode 100644 dashboard/src/components/FilterBuilder.tsx create mode 100644 src/database/migrations/1781500000000-AddWebhookFilters.ts create mode 100644 src/modules/webhook/filters/filter-evaluator.spec.ts create mode 100644 src/modules/webhook/filters/filter-evaluator.ts create mode 100644 src/modules/webhook/filters/filter-types.ts create mode 100644 src/modules/webhook/filters/filter-validation.ts create mode 100644 test/webhooks.e2e-spec.ts diff --git a/dashboard/src/components/FilterBuilder.css b/dashboard/src/components/FilterBuilder.css new file mode 100644 index 00000000..ddc41854 --- /dev/null +++ b/dashboard/src/components/FilterBuilder.css @@ -0,0 +1,289 @@ +.filter-builder { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.filter-builder-head { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.filter-builder-title { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #64748b); +} + +.filter-builder-hint { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.filter-row { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 8px; +} + +.filter-row select { + height: 2.25rem; + padding: 0 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-primary); + font-size: 0.8125rem; +} + +/* field + operator: fixed width; value flexes to fill the rest */ +.filter-row > select { + width: 100%; +} + +.filter-field { + flex: 0 0 8.5rem; +} + +.filter-operator { + flex: 0 0 7rem; +} + +.filter-value { + flex: 1 1 auto; + min-width: 0; +} + +.filter-remove { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + height: 2.25rem; + width: 2.25rem; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.filter-remove svg { + width: 16px; + height: 16px; + flex-shrink: 0; + stroke: var(--text-secondary); +} + +.filter-remove:hover { + border-color: var(--error); + color: var(--error); +} + +.filter-remove:hover svg { + stroke: var(--error); +} + +.filter-add { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + color: var(--primary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; +} + +.filter-add:hover { + border-color: var(--primary); +} + +/* Text operator */ +.filter-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; +} + +/* The global `.modal-body input` (full width + large padding + bg) leaks into the + case-sensitive checkbox; reset it (2-class selector beats `.modal-body input`). */ +.filter-case input { + width: auto; + margin: 0; + padding: 0; +} + +.filter-text input[type='text'] { + width: 100%; + height: 2.25rem; + padding: 0 0.625rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-primary); + font-size: 0.8125rem; +} + +.filter-case { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--text-secondary); + cursor: pointer; +} + +/* Enum multi-select */ +.filter-enum { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.enum-tag { + font-size: 0.75rem; + padding: 0.3125rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.enum-tag:hover { + border-color: var(--primary); + color: var(--primary); +} + +.enum-tag.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +/* Chips input with autocomplete */ +.chips-input { + position: relative; +} + +.chips-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + min-height: 2.25rem; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-white); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + background: rgba(37, 211, 102, 0.15); + border: 1px solid rgba(37, 211, 102, 0.25); + color: var(--primary); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + max-width: 100%; +} + +.chip-remove { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + padding: 0; +} + +/* `.chips-row` prefix raises specificity above the global `.modal-body input`, which + would otherwise force width:100% (dropping the field onto its own line) + bg + padding. */ +.chips-row .chips-text { + flex: 1 1 6rem; + min-width: 6rem; + width: auto; + height: 26px; + padding: 2px 8px; + border: none; + outline: none; + background: var(--bg-light); + color: var(--text-primary); + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; +} + +/* keep the yes/no select compact rather than filling the value column */ +.filter-bool { + width: auto; +} + +.chips-suggestions { + position: absolute; + z-index: 20; + top: calc(100% + 2px); + left: 0; + right: 0; + max-height: 14rem; + overflow-y: auto; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); +} + +.chips-suggestion { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 0.5rem 0.625rem; + border: none; + background: transparent; + cursor: pointer; + text-align: left; +} + +.chips-suggestion:hover { + background: var(--bg-light); +} + +.chips-suggestion-name { + font-size: 0.8125rem; + color: var(--text-primary); +} + +.chips-suggestion-id { + font-size: 0.6875rem; + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; +} + +.chips-suggestion-add { + color: var(--primary); + font-weight: 600; + font-size: 0.8125rem; + border-top: 1px solid var(--border); +} diff --git a/dashboard/src/components/FilterBuilder.tsx b/dashboard/src/components/FilterBuilder.tsx new file mode 100644 index 00000000..4d000ebb --- /dev/null +++ b/dashboard/src/components/FilterBuilder.tsx @@ -0,0 +1,294 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, X } from 'lucide-react'; +import { + type Chat, + type WebhookFilters, + type WebhookFilterCondition, + type WebhookFilterOperator, +} from '../services/api'; +import './FilterBuilder.css'; + +type FieldKind = 'id' | 'idArray' | 'text' | 'enum' | 'boolean'; + +interface FieldDescriptor { + field: string; + kind: FieldKind; + operators: WebhookFilterOperator[]; + enumValues?: string[]; +} + +const MESSAGE_TYPES = [ + 'text', + 'image', + 'video', + 'audio', + 'voice', + 'document', + 'sticker', + 'location', + 'contact', + 'revoked', + 'unknown', +]; + +// Mirrors the backend message-family field registry (src/modules/webhook/filters/filter-types.ts). +const MESSAGE_FIELDS: FieldDescriptor[] = [ + { field: 'sender', kind: 'id', operators: ['is', 'isNot'] }, + { field: 'recipient', kind: 'id', operators: ['is', 'isNot'] }, + { field: 'body', kind: 'text', operators: ['contains', 'equals', 'matches'] }, + { field: 'type', kind: 'enum', operators: ['is', 'isNot'], enumValues: MESSAGE_TYPES }, + { field: 'isGroup', kind: 'boolean', operators: ['is'] }, + { field: 'fromMe', kind: 'boolean', operators: ['is'] }, + { field: 'hasMedia', kind: 'boolean', operators: ['is'] }, + { field: 'mentions', kind: 'idArray', operators: ['is', 'isNot'] }, +]; + +const descriptorFor = (field: string): FieldDescriptor => + MESSAGE_FIELDS.find(f => f.field === field) ?? MESSAGE_FIELDS[0]; + +function defaultValueFor(kind: FieldKind): WebhookFilterCondition['value'] { + if (kind === 'boolean') return true; + if (kind === 'text') return ''; + return []; +} + +/** Accepts a full JID or a phone number; normalizes bare numbers to `@c.us`. */ +function normalizeToJid(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + if (value.includes('@')) return value.toLowerCase(); + const digits = value.replace(/[^0-9]/g, ''); + return digits ? `${digits}@c.us` : null; +} + +interface ContactChipsInputProps { + value: string[]; + onChange: (next: string[]) => void; + chats: Chat[]; +} + +function ContactChipsInput({ value, onChange, chats }: ContactChipsInputProps) { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const [open, setOpen] = useState(false); + + const suggestions = useMemo(() => { + const query = text.trim().toLowerCase(); + const chosen = new Set(value); + return chats + .filter(c => !chosen.has(c.id)) + .filter(c => !query || c.name.toLowerCase().includes(query) || c.id.toLowerCase().includes(query)) + .slice(0, 8); + }, [text, chats, value]); + + const labelFor = (jid: string) => chats.find(c => c.id === jid)?.name ?? jid; + const add = (jid: string) => { + if (jid && !value.includes(jid)) onChange([...value, jid]); + setText(''); + }; + const addTyped = () => { + const jid = normalizeToJid(text); + if (jid) add(jid); + }; + + return ( +
+
+ {value.map(jid => ( + + {labelFor(jid)} + + + ))} + setText(e.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 120)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addTyped(); + } else if (e.key === 'Backspace' && !text && value.length) { + onChange(value.slice(0, -1)); + } + }} + /> +
+ {open && (suggestions.length > 0 || text.trim()) && ( +
+ {suggestions.map(c => ( + + ))} + {text.trim() && normalizeToJid(text) && ( + + )} +
+ )} +
+ ); +} + +interface FilterBuilderProps { + filters: WebhookFilters | null | undefined; + onChange: (filters: WebhookFilters | null) => void; + chats: Chat[]; +} + +export function FilterBuilder({ filters, onChange, chats }: FilterBuilderProps) { + const { t } = useTranslation(); + const conditions = filters?.conditions ?? []; + + const emit = (next: WebhookFilterCondition[]) => onChange(next.length ? { conditions: next } : null); + + const updateAt = (index: number, patch: Partial) => + emit(conditions.map((c, i) => (i === index ? { ...c, ...patch } : c))); + + const addCondition = () => { + const def = MESSAGE_FIELDS[0]; + emit([...conditions, { field: def.field, operator: def.operators[0], value: defaultValueFor(def.kind) }]); + }; + + const changeField = (index: number, field: string) => { + const def = descriptorFor(field); + updateAt(index, { field, operator: def.operators[0], value: defaultValueFor(def.kind), caseSensitive: undefined }); + }; + + return ( +
+
+ {t('webhooks.filters.title')} + {t('webhooks.filters.hint')} +
+ + {conditions.map((condition, index) => { + const def = descriptorFor(condition.field); + return ( +
+ + + + +
+ {(def.kind === 'id' || def.kind === 'idArray') && ( + updateAt(index, { value: next })} + chats={chats} + /> + )} + + {def.kind === 'enum' && ( +
+ {def.enumValues?.map(option => { + const selected = Array.isArray(condition.value) && (condition.value as string[]).includes(option); + return ( + + ); + })} +
+ )} + + {def.kind === 'text' && ( +
+ updateAt(index, { value: e.target.value })} + /> + +
+ )} + + {def.kind === 'boolean' && ( + + )} +
+ + +
+ ); + })} + + +
+ ); +} diff --git a/dashboard/src/hooks/queries.ts b/dashboard/src/hooks/queries.ts index 5b644aa4..9406d001 100644 --- a/dashboard/src/hooks/queries.ts +++ b/dashboard/src/hooks/queries.ts @@ -8,6 +8,7 @@ import { infraApi, pluginsApi, type Webhook, + type WebhookFilters, type TemplatePayload, } from '../services/api'; @@ -17,6 +18,7 @@ export const queryKeys = { sessions: ['sessions'] as const, sessionStats: ['sessions', 'stats'] as const, sessionGroups: (sessionId: string) => ['sessions', sessionId, 'groups'] as const, + sessionChats: (sessionId: string) => ['sessions', sessionId, 'chats'] as const, webhooks: ['webhooks'] as const, templates: (sessionId: string) => ['sessions', sessionId, 'templates'] as const, apiKeys: ['apiKeys'] as const, @@ -55,6 +57,15 @@ export function useSessionGroupsQuery(sessionId: string, enabled: boolean) { }); } +export function useSessionChatsQuery(sessionId: string, enabled: boolean) { + return useQuery({ + queryKey: queryKeys.sessionChats(sessionId), + queryFn: () => sessionApi.getChats(sessionId), + enabled: enabled && !!sessionId, + staleTime: 60_000, + }); +} + export function useStopSessionMutation() { const queryClient = useQueryClient(); return useMutation({ @@ -78,8 +89,8 @@ export function useWebhooksQuery() { export function useCreateWebhookMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: { sessionId: string; url: string; events: string[] }) => - webhookApi.create(params.sessionId, { url: params.url, events: params.events }), + mutationFn: (params: { sessionId: string; url: string; events: string[]; filters?: WebhookFilters | null }) => + webhookApi.create(params.sessionId, { url: params.url, events: params.events, filters: params.filters }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.webhooks }); }, diff --git a/dashboard/src/i18n/locales/ar.json b/dashboard/src/i18n/locales/ar.json index 5aaae46b..893ec920 100644 --- a/dashboard/src/i18n/locales/ar.json +++ b/dashboard/src/i18n/locales/ar.json @@ -267,6 +267,38 @@ "events": "الأحداث", "available": "الأحداث المتاحة", "saveChanges": "حفظ التغييرات", + "filters": { + "title": "عوامل التصفية (اختياري)", + "hint": "يتم التفعيل فقط عند تطابق جميع الشروط. ينطبق على أحداث الرسائل.", + "badge": "{{count}} عامل تصفية", + "badge_other": "{{count}} عوامل تصفية", + "addCondition": "إضافة شرط", + "removeCondition": "إزالة الشرط", + "caseSensitive": "حساس لحالة الأحرف", + "contactPlaceholder": "رقم أو معرّف واتساب...", + "textPlaceholder": "نص للمطابقة...", + "regexPlaceholder": "تعبير نمطي...", + "addValue": "إضافة \"{{value}}\"", + "yes": "نعم", + "no": "لا", + "fields": { + "sender": "المُرسِل", + "recipient": "المُستلِم", + "body": "نص الرسالة", + "type": "نوع الرسالة", + "isGroup": "محادثة جماعية", + "fromMe": "مُرسَلة مني", + "hasMedia": "تحتوي على وسائط", + "mentions": "الإشارات" + }, + "operators": { + "is": "هو", + "isNot": "ليس", + "contains": "يحتوي على", + "equals": "يساوي", + "matches": "يطابق التعبير النمطي" + } + }, "columns": { "url": "الرابط", "events": "الأحداث", diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index c7a36f37..05af0bbc 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -267,6 +267,38 @@ "events": "Events", "available": "Available Events", "saveChanges": "Save Changes", + "filters": { + "title": "Filters (optional)", + "hint": "Only fire when all conditions match. Applies to message events.", + "badge": "{{count}} filter", + "badge_other": "{{count}} filters", + "addCondition": "Add condition", + "removeCondition": "Remove condition", + "caseSensitive": "Case sensitive", + "contactPlaceholder": "Number or WhatsApp ID...", + "textPlaceholder": "Text to match...", + "regexPlaceholder": "Regular expression...", + "addValue": "Add \"{{value}}\"", + "yes": "Yes", + "no": "No", + "fields": { + "sender": "Sender", + "recipient": "Recipient", + "body": "Message body", + "type": "Message type", + "isGroup": "Is group chat", + "fromMe": "Sent by me", + "hasMedia": "Has media", + "mentions": "Mentions" + }, + "operators": { + "is": "is", + "isNot": "is not", + "contains": "contains", + "equals": "equals", + "matches": "matches regex" + } + }, "columns": { "url": "URL", "events": "Events", diff --git a/dashboard/src/i18n/locales/es.json b/dashboard/src/i18n/locales/es.json index b885ca5f..e14c7d54 100644 --- a/dashboard/src/i18n/locales/es.json +++ b/dashboard/src/i18n/locales/es.json @@ -267,6 +267,38 @@ "events": "Eventos", "available": "Eventos disponibles", "saveChanges": "Guardar cambios", + "filters": { + "title": "Filtros (opcional)", + "hint": "Activar solo cuando se cumplan todas las condiciones. Se aplica a eventos de mensajes.", + "badge": "{{count}} filtro", + "badge_other": "{{count}} filtros", + "addCondition": "Añadir condición", + "removeCondition": "Eliminar condición", + "caseSensitive": "Distinguir mayúsculas", + "contactPlaceholder": "Número o ID de WhatsApp...", + "textPlaceholder": "Texto a buscar...", + "regexPlaceholder": "Expresión regular...", + "addValue": "Añadir \"{{value}}\"", + "yes": "Sí", + "no": "No", + "fields": { + "sender": "Remitente", + "recipient": "Destinatario", + "body": "Cuerpo del mensaje", + "type": "Tipo de mensaje", + "isGroup": "Es chat de grupo", + "fromMe": "Enviado por mí", + "hasMedia": "Tiene multimedia", + "mentions": "Menciones" + }, + "operators": { + "is": "es", + "isNot": "no es", + "contains": "contiene", + "equals": "es igual a", + "matches": "coincide con regex" + } + }, "columns": { "url": "URL", "events": "Eventos", diff --git a/dashboard/src/i18n/locales/fr.json b/dashboard/src/i18n/locales/fr.json index e8d82293..c2e2404b 100644 --- a/dashboard/src/i18n/locales/fr.json +++ b/dashboard/src/i18n/locales/fr.json @@ -267,6 +267,38 @@ "events": "Événements", "available": "Événements disponibles", "saveChanges": "Enregistrer les modifications", + "filters": { + "title": "Filtres (facultatif)", + "hint": "Déclencher uniquement lorsque toutes les conditions sont remplies. S'applique aux événements de message.", + "badge": "{{count}} filtre", + "badge_other": "{{count}} filtres", + "addCondition": "Ajouter une condition", + "removeCondition": "Supprimer la condition", + "caseSensitive": "Sensible à la casse", + "contactPlaceholder": "Numéro ou identifiant WhatsApp...", + "textPlaceholder": "Texte à rechercher...", + "regexPlaceholder": "Expression régulière...", + "addValue": "Ajouter « {{value}} »", + "yes": "Oui", + "no": "Non", + "fields": { + "sender": "Expéditeur", + "recipient": "Destinataire", + "body": "Corps du message", + "type": "Type de message", + "isGroup": "Est une discussion de groupe", + "fromMe": "Envoyé par moi", + "hasMedia": "Contient un média", + "mentions": "Mentions" + }, + "operators": { + "is": "est", + "isNot": "n'est pas", + "contains": "contient", + "equals": "est égal à", + "matches": "correspond à la regex" + } + }, "columns": { "url": "URL", "events": "Événements", diff --git a/dashboard/src/i18n/locales/he.json b/dashboard/src/i18n/locales/he.json index 2ada3b55..191bd31d 100644 --- a/dashboard/src/i18n/locales/he.json +++ b/dashboard/src/i18n/locales/he.json @@ -267,6 +267,38 @@ "events": "אירועים", "available": "אירועים זמינים", "saveChanges": "שמירת שינויים", + "filters": { + "title": "מסננים (אופציונלי)", + "hint": "הפעלה רק כאשר כל התנאים מתקיימים. חל על אירועי הודעות.", + "badge": "{{count}} מסנן", + "badge_other": "{{count}} מסננים", + "addCondition": "הוספת תנאי", + "removeCondition": "הסרת תנאי", + "caseSensitive": "תלוי רישיות", + "contactPlaceholder": "מספר או מזהה WhatsApp...", + "textPlaceholder": "טקסט להתאמה...", + "regexPlaceholder": "ביטוי רגולרי...", + "addValue": "הוספת \"{{value}}\"", + "yes": "כן", + "no": "לא", + "fields": { + "sender": "שולח", + "recipient": "נמען", + "body": "גוף ההודעה", + "type": "סוג הודעה", + "isGroup": "צ'אט קבוצתי", + "fromMe": "נשלח על ידי", + "hasMedia": "מכיל מדיה", + "mentions": "אזכורים" + }, + "operators": { + "is": "הוא", + "isNot": "אינו", + "contains": "מכיל", + "equals": "שווה ל", + "matches": "תואם ביטוי רגולרי" + } + }, "columns": { "url": "כתובת URL", "events": "אירועים", diff --git a/dashboard/src/i18n/locales/it.json b/dashboard/src/i18n/locales/it.json index 963b9e1c..5fe5c8db 100644 --- a/dashboard/src/i18n/locales/it.json +++ b/dashboard/src/i18n/locales/it.json @@ -267,6 +267,38 @@ "events": "Eventi", "available": "Eventi Disponibili", "saveChanges": "Salva Modifiche", + "filters": { + "title": "Filtri (opzionale)", + "hint": "Attiva solo quando tutte le condizioni corrispondono. Si applica agli eventi dei messaggi.", + "badge": "{{count}} filtro", + "badge_other": "{{count}} filtri", + "addCondition": "Aggiungi condizione", + "removeCondition": "Rimuovi condizione", + "caseSensitive": "Distingui maiuscole", + "contactPlaceholder": "Numero o ID WhatsApp...", + "textPlaceholder": "Testo da cercare...", + "regexPlaceholder": "Espressione regolare...", + "addValue": "Aggiungi \"{{value}}\"", + "yes": "Sì", + "no": "No", + "fields": { + "sender": "Mittente", + "recipient": "Destinatario", + "body": "Corpo del messaggio", + "type": "Tipo di messaggio", + "isGroup": "È una chat di gruppo", + "fromMe": "Inviato da me", + "hasMedia": "Contiene media", + "mentions": "Menzioni" + }, + "operators": { + "is": "è", + "isNot": "non è", + "contains": "contiene", + "equals": "è uguale a", + "matches": "corrisponde a regex" + } + }, "columns": { "url": "URL", "events": "Eventi", diff --git a/dashboard/src/i18n/locales/te.json b/dashboard/src/i18n/locales/te.json index 9c4ebe91..8415f0df 100644 --- a/dashboard/src/i18n/locales/te.json +++ b/dashboard/src/i18n/locales/te.json @@ -267,6 +267,38 @@ "events": "ఈవెంట్స్", "available": "అందుబాటులో ఉన్న ఈవెంట్స్", "saveChanges": "మార్పులను సేవ్ చేయి", + "filters": { + "title": "ఫిల్టర్‌లు (ఐచ్ఛికం)", + "hint": "అన్ని షరతులు సరిపోలినప్పుడు మాత్రమే ట్రిగ్గర్ అవుతుంది. సందేశ ఈవెంట్‌లకు వర్తిస్తుంది.", + "badge": "{{count}} ఫిల్టర్", + "badge_other": "{{count}} ఫిల్టర్‌లు", + "addCondition": "షరతును జోడించు", + "removeCondition": "షరతును తొలగించు", + "caseSensitive": "కేస్ సెన్సిటివ్", + "contactPlaceholder": "నంబర్ లేదా WhatsApp ID...", + "textPlaceholder": "సరిపోల్చాల్సిన టెక్స్ట్...", + "regexPlaceholder": "రెగ్యులర్ ఎక్స్‌ప్రెషన్...", + "addValue": "\"{{value}}\" జోడించు", + "yes": "అవును", + "no": "కాదు", + "fields": { + "sender": "పంపినవారు", + "recipient": "స్వీకర్త", + "body": "సందేశ విషయం", + "type": "సందేశ రకం", + "isGroup": "గ్రూప్ చాట్", + "fromMe": "నేను పంపినది", + "hasMedia": "మీడియా ఉంది", + "mentions": "ప్రస్తావనలు" + }, + "operators": { + "is": "అనేది", + "isNot": "కాదు", + "contains": "కలిగి ఉంది", + "equals": "సమానం", + "matches": "రెగ్యెక్స్‌తో సరిపోలుతుంది" + } + }, "columns": { "url": "URL", "events": "ఈవెంట్స్", diff --git a/dashboard/src/i18n/locales/zh-CN.json b/dashboard/src/i18n/locales/zh-CN.json index a0de1dde..29c1eb67 100644 --- a/dashboard/src/i18n/locales/zh-CN.json +++ b/dashboard/src/i18n/locales/zh-CN.json @@ -267,6 +267,38 @@ "events": "事件", "available": "可用事件", "saveChanges": "保存更改", + "filters": { + "title": "筛选条件(可选)", + "hint": "仅当所有条件都匹配时才触发。适用于消息事件。", + "badge": "{{count}} 个筛选条件", + "badge_other": "{{count}} 个筛选条件", + "addCondition": "添加条件", + "removeCondition": "移除条件", + "caseSensitive": "区分大小写", + "contactPlaceholder": "号码或 WhatsApp ID...", + "textPlaceholder": "要匹配的文本...", + "regexPlaceholder": "正则表达式...", + "addValue": "添加 \"{{value}}\"", + "yes": "是", + "no": "否", + "fields": { + "sender": "发送者", + "recipient": "接收者", + "body": "消息正文", + "type": "消息类型", + "isGroup": "是群聊", + "fromMe": "由我发送", + "hasMedia": "包含媒体", + "mentions": "提及" + }, + "operators": { + "is": "是", + "isNot": "不是", + "contains": "包含", + "equals": "等于", + "matches": "匹配正则" + } + }, "columns": { "url": "URL", "events": "事件", diff --git a/dashboard/src/i18n/locales/zh-HK.json b/dashboard/src/i18n/locales/zh-HK.json index cd176394..1b1b08b9 100644 --- a/dashboard/src/i18n/locales/zh-HK.json +++ b/dashboard/src/i18n/locales/zh-HK.json @@ -267,6 +267,38 @@ "events": "事件", "available": "可用事件", "saveChanges": "儲存變更", + "filters": { + "title": "篩選條件(選填)", + "hint": "僅當所有條件都符合時才觸發。適用於訊息事件。", + "badge": "{{count}} 個篩選條件", + "badge_other": "{{count}} 個篩選條件", + "addCondition": "新增條件", + "removeCondition": "移除條件", + "caseSensitive": "區分大小寫", + "contactPlaceholder": "號碼或 WhatsApp ID...", + "textPlaceholder": "要符合的文字...", + "regexPlaceholder": "正規表達式...", + "addValue": "新增 \"{{value}}\"", + "yes": "是", + "no": "否", + "fields": { + "sender": "傳送者", + "recipient": "接收者", + "body": "訊息內容", + "type": "訊息類型", + "isGroup": "是群組聊天", + "fromMe": "由我傳送", + "hasMedia": "包含媒體", + "mentions": "提及" + }, + "operators": { + "is": "是", + "isNot": "不是", + "contains": "包含", + "equals": "等於", + "matches": "符合正規表達式" + } + }, "columns": { "url": "URL", "events": "事件", diff --git a/dashboard/src/pages/Webhooks.css b/dashboard/src/pages/Webhooks.css index f4d180af..ce513214 100644 --- a/dashboard/src/pages/Webhooks.css +++ b/dashboard/src/pages/Webhooks.css @@ -5,6 +5,25 @@ overflow-x: hidden; } +/* Webhook create/edit modal: roomier than the default 480px (filters need width). */ +.webhooks-page .modal:not(.modal-sm) { + max-width: 640px; +} + +/* Consistent vertical rhythm between top-level form blocks in the webhook modals. + Direct-child only, so nested FilterBuilder inputs/selects are untouched. */ +.webhooks-page .modal-body > input, +.webhooks-page .modal-body > select, +.webhooks-page .modal-body > .event-tags { + margin-bottom: 0.75rem; +} + +.webhooks-page .modal-body > .event-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + .page-header { margin-bottom: 2rem; } @@ -216,6 +235,71 @@ border-color: var(--primary); } +.filter-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; + background: var(--bg-light); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.filter-badge-interactive { + position: relative; + cursor: default; + outline: none; +} + +.filter-badge-interactive:hover, +.filter-badge-interactive:focus-visible { + border-color: var(--primary); + color: var(--text-primary); +} + +/* Fixed-positioned (escapes the card's overflow:hidden); coordinates set inline from the badge rect. */ +.filter-popover { + position: fixed; + z-index: 100; + min-width: 200px; + max-width: 340px; + padding: 0.5rem 0.625rem; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16); + display: flex; + flex-direction: column; + gap: 0.25rem; + font-weight: 500; + white-space: normal; + cursor: default; +} + +.filter-popover-title { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + margin-bottom: 0.125rem; +} + +.filter-popover-row { + font-size: 0.78rem; + color: var(--text-primary); + word-break: break-word; +} + +.filter-popover-row + .filter-popover-row { + padding-top: 0.25rem; + border-top: 1px solid var(--border); +} + .status-badge { display: inline-flex; align-items: center; diff --git a/dashboard/src/pages/Webhooks.tsx b/dashboard/src/pages/Webhooks.tsx index b5e5016b..e0dcfa63 100644 --- a/dashboard/src/pages/Webhooks.tsx +++ b/dashboard/src/pages/Webhooks.tsx @@ -12,20 +12,80 @@ import { Check, AlertTriangle, AlertCircle, + Filter, } from 'lucide-react'; -import { webhookApi, type Webhook } from '../services/api'; +import { webhookApi, type Webhook, type WebhookFilters, type WebhookFilterCondition } from '../services/api'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; import { useRole } from '../hooks/useRole'; import { useWebhooksQuery, useSessionsQuery, + useSessionChatsQuery, useCreateWebhookMutation, useUpdateWebhookMutation, useDeleteWebhookMutation, } from '../hooks/queries'; import { PageHeader } from '../components/PageHeader'; +import { FilterBuilder } from '../components/FilterBuilder'; import './Webhooks.css'; +// Filters only apply to message.* events (the wildcard subscribes to them too). +const supportsFilters = (events: string[]) => events.some(e => e === '*' || e.startsWith('message.')); + +type TFn = ReturnType['t']; + +// One-line, human-readable summary of a condition for the badge popover, reusing the FilterBuilder labels. +function conditionSummary(c: WebhookFilterCondition, t: TFn): string { + const field = t(`webhooks.filters.fields.${c.field}`, { defaultValue: c.field }); + const operator = t(`webhooks.filters.operators.${c.operator}`, { defaultValue: c.operator }); + let value: string; + if (typeof c.value === 'boolean') { + value = c.value ? t('webhooks.filters.yes') : t('webhooks.filters.no'); + } else if (Array.isArray(c.value)) { + value = c.value.join(', '); + } else { + value = `"${c.value}"`; + } + const caseNote = c.caseSensitive ? ` · ${t('webhooks.filters.caseSensitive')}` : ''; + return `${field} ${operator} ${value}${caseNote}`; +} + +// Filters badge with a hover/focus popover listing the configured conditions. The popover is +// fixed-positioned from the badge's rect so the card's `overflow: hidden` doesn't clip it. +function FilterBadge({ filters }: { filters: WebhookFilters }) { + const { t } = useTranslation(); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + const openAt = (el: HTMLElement) => { + const r = el.getBoundingClientRect(); + setCoords({ top: r.bottom + 6, left: r.left }); + }; + const close = () => setCoords(null); + + return ( + openAt(e.currentTarget)} + onMouseLeave={close} + onFocus={e => openAt(e.currentTarget)} + onBlur={close} + > + + {t('webhooks.filters.badge', { count: filters.conditions.length })} + {coords && ( +
+
{t('webhooks.filters.title')}
+ {filters.conditions.map((condition, i) => ( +
+ {conditionSummary(condition, t)} +
+ ))} +
+ )} +
+ ); +} + // Must stay aligned with the backend WEBHOOK_EVENTS: the API now rejects unknown // event names, so offering e.g. the never-emitted 'session.connected' would 400 on save. const availableEventNames = [ @@ -59,10 +119,19 @@ export function Webhooks() { const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ sessionId: string; id: string; url: string } | null>(null); const [editWebhook, setEditWebhook] = useState(null); - const [newWebhook, setNewWebhook] = useState({ url: '', events: ['message.received'], sessionId: '' }); + const [newWebhook, setNewWebhook] = useState<{ + url: string; + events: string[]; + sessionId: string; + filters: WebhookFilters | null; + }>({ url: '', events: ['message.received'], sessionId: '', filters: null }); const [testingId, setTestingId] = useState(null); const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + // Single source for the contact/group autocomplete in whichever modal is open. + const activeSessionId = showEditModal ? editWebhook?.sessionId ?? '' : newWebhook.sessionId; + const { data: chats = [] } = useSessionChatsQuery(activeSessionId, showCreateModal || showEditModal); + const eventDescription = (name: string) => { if (name === '*') return t('webhooks.eventDescriptions.all'); return t(`webhooks.eventDescriptions.${name}`, { defaultValue: name }); @@ -82,9 +151,10 @@ export function Webhooks() { sessionId: newWebhook.sessionId, url: newWebhook.url, events: newWebhook.events, + filters: newWebhook.filters, }); setShowCreateModal(false); - setNewWebhook({ url: '', events: ['message.received'], sessionId: '' }); + setNewWebhook({ url: '', events: ['message.received'], sessionId: '', filters: null }); setToast({ type: 'success', message: t('webhooks.toasts.created') }); } catch (err) { setToast({ @@ -153,7 +223,12 @@ export function Webhooks() { await updateMutation.mutateAsync({ sessionId: editWebhook.sessionId, id: editWebhook.id, - data: { url: editWebhook.url, events: editWebhook.events, active: editWebhook.active }, + data: { + url: editWebhook.url, + events: editWebhook.events, + active: editWebhook.active, + filters: editWebhook.filters ?? null, + }, }); setShowEditModal(false); setEditWebhook(null); @@ -258,7 +333,7 @@ export function Webhooks() { onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })} /> -
+
{availableEventNames.map(name => (
+ {supportsFilters(newWebhook.events) && ( + setNewWebhook(prev => ({ ...prev, filters }))} + chats={chats} + /> + )}
+ {supportsFilters(editWebhook.events) && ( + setEditWebhook(prev => (prev ? { ...prev, filters } : prev))} + chats={chats} + /> + )}
{t('common.status')}
diff --git a/dashboard/src/services/api.ts b/dashboard/src/services/api.ts index b7bb1e40..eeba2a56 100644 --- a/dashboard/src/services/api.ts +++ b/dashboard/src/services/api.ts @@ -38,11 +38,25 @@ export interface SessionStats { memoryUsage: { heapUsed: number; heapTotal: number; rss: number }; } +export type WebhookFilterOperator = 'is' | 'isNot' | 'contains' | 'equals' | 'matches'; + +export interface WebhookFilterCondition { + field: string; + operator: WebhookFilterOperator; + value: string | string[] | boolean; + caseSensitive?: boolean; +} + +export interface WebhookFilters { + conditions: WebhookFilterCondition[]; +} + export interface Webhook { id: string; sessionId: string; url: string; events: string[]; + filters?: WebhookFilters | null; active: boolean; secret?: string; createdAt: string; @@ -342,7 +356,7 @@ export const webhookApi = { listBySession: (sessionId: string) => request(`/sessions/${sessionId}/webhooks`), listAll: () => request('/webhooks'), get: (sessionId: string, id: string) => request(`/sessions/${sessionId}/webhooks/${id}`), - create: (sessionId: string, data: { url: string; events: string[] }) => + create: (sessionId: string, data: { url: string; events: string[]; filters?: WebhookFilters | null }) => request(`/sessions/${sessionId}/webhooks`, { method: 'POST', body: JSON.stringify(data), diff --git a/src/database/migrations/1781500000000-AddWebhookFilters.ts b/src/database/migrations/1781500000000-AddWebhookFilters.ts new file mode 100644 index 00000000..9655729d --- /dev/null +++ b/src/database/migrations/1781500000000-AddWebhookFilters.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds an optional `filters` column to `webhooks` (webhook pre-filters). + * Nullable JSON: null/absent means no filtering. Hand-authored because `synchronize` + * is off for the `data` connection on Postgres (and optional on SQLite). `jsonb` on + * Postgres, `text` on SQLite (where `simple-json` serializes to text). + */ +export class AddWebhookFilters1781500000000 implements MigrationInterface { + name = 'AddWebhookFilters1781500000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('webhooks', 'filters')) return; + const isPostgres = queryRunner.connection.options.type === 'postgres'; + const columnType = isPostgres ? 'jsonb' : 'text'; + await queryRunner.query(`ALTER TABLE "webhooks" ADD COLUMN "filters" ${columnType}`); + } + + public async down(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn('webhooks', 'filters'))) return; + await queryRunner.query(`ALTER TABLE "webhooks" DROP COLUMN "filters"`); + } +} diff --git a/src/modules/webhook/dto/webhook.dto.spec.ts b/src/modules/webhook/dto/webhook.dto.spec.ts index 6791c74e..8683de68 100644 --- a/src/modules/webhook/dto/webhook.dto.spec.ts +++ b/src/modules/webhook/dto/webhook.dto.spec.ts @@ -33,3 +33,58 @@ describe('webhook DTO event validation', () => { expect(errs.some(e => e.property === 'events')).toBe(true); }); }); + +describe('webhook DTO filter validation', () => { + const withFilters = (conditions: unknown) => ({ url: 'https://x.example/hook', filters: { conditions } }); + + it('accepts a webhook with no filters (optional)', async () => { + expect(await errorsFor(CreateWebhookDto, { url: 'https://x.example/hook' })).toHaveLength(0); + }); + + it('accepts valid sender + body conditions', async () => { + const errs = await errorsFor( + CreateWebhookDto, + withFilters([ + { field: 'sender', operator: 'is', value: ['123@c.us'] }, + { field: 'body', operator: 'contains', value: 'invoice' }, + ]), + ); + expect(errs).toHaveLength(0); + }); + + it('rejects an unknown field', async () => { + const errs = await errorsFor(CreateWebhookDto, withFilters([{ field: 'nope', operator: 'is', value: ['x'] }])); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); + + it('rejects an operator not allowed for the field', async () => { + const errs = await errorsFor( + CreateWebhookDto, + withFilters([{ field: 'sender', operator: 'contains', value: ['x'] }]), + ); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); + + it('rejects an invalid message type value', async () => { + const errs = await errorsFor(CreateWebhookDto, withFilters([{ field: 'type', operator: 'is', value: ['gif'] }])); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); + + it('rejects an uncompilable regex', async () => { + const errs = await errorsFor(CreateWebhookDto, withFilters([{ field: 'body', operator: 'matches', value: '(' }])); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); + + it('rejects a catastrophic-backtracking regex pattern', async () => { + const errs = await errorsFor( + CreateWebhookDto, + withFilters([{ field: 'body', operator: 'matches', value: '(a+)+' }]), + ); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); + + it('rejects a non-boolean value for a boolean field', async () => { + const errs = await errorsFor(CreateWebhookDto, withFilters([{ field: 'isGroup', operator: 'is', value: 'yes' }])); + expect(errs.some(e => e.property === 'filters')).toBe(true); + }); +}); diff --git a/src/modules/webhook/dto/webhook.dto.ts b/src/modules/webhook/dto/webhook.dto.ts index 94d7229e..e3bdae69 100644 --- a/src/modules/webhook/dto/webhook.dto.ts +++ b/src/modules/webhook/dto/webhook.dto.ts @@ -15,6 +15,17 @@ import { } from 'class-validator'; import { Expose, plainToInstance } from 'class-transformer'; import { Webhook } from '../entities/webhook.entity'; +import { WebhookFilters } from '../filters/filter-types'; +import { IsValidWebhookFilters } from '../filters/filter-validation'; + +const FILTERS_API_DESCRIPTION = + 'Optional smart pre-filter. When set, every condition must match (AND) for the webhook to fire. Omit or null to fire on every subscribed event.'; +const FILTERS_API_EXAMPLE = { + conditions: [ + { field: 'sender', operator: 'is', value: ['1234567890@c.us'] }, + { field: 'body', operator: 'contains', value: 'invoice' }, + ], +}; export const WEBHOOK_EVENTS = [ 'message.received', @@ -39,7 +50,9 @@ export class CreateWebhookDto { description: 'Webhook URL to receive events', example: 'https://your-server.com/webhook', }) - @IsUrl() + // require_tld:false allows hostnames without a dot (e.g. http://localhost:3000); the SSRF + // guard still decides whether the host is actually allowed to be delivered to. + @IsUrl({ require_tld: false }) url: string; @ApiPropertyOptional({ @@ -71,6 +84,11 @@ export class CreateWebhookDto { @IsObject() headers?: Record; + @ApiPropertyOptional({ description: FILTERS_API_DESCRIPTION, example: FILTERS_API_EXAMPLE }) + @IsOptional() + @IsValidWebhookFilters() + filters?: WebhookFilters | null; + @ApiPropertyOptional({ description: 'Number of retry attempts on failure', example: 3, @@ -87,7 +105,7 @@ export class CreateWebhookDto { export class UpdateWebhookDto { @ApiPropertyOptional({ description: 'Webhook URL' }) @IsOptional() - @IsUrl() + @IsUrl({ require_tld: false }) url?: string; @ApiPropertyOptional({ description: "Event types to subscribe to. '*' subscribes to all events." }) @@ -108,6 +126,11 @@ export class UpdateWebhookDto { @IsObject() headers?: Record; + @ApiPropertyOptional({ description: FILTERS_API_DESCRIPTION, example: FILTERS_API_EXAMPLE }) + @IsOptional() + @IsValidWebhookFilters() + filters?: WebhookFilters | null; + @ApiPropertyOptional({ description: 'Enable/disable webhook' }) @IsOptional() @IsBoolean() @@ -147,6 +170,10 @@ export class WebhookResponseDto { @ApiProperty() events: string[]; + @Expose() + @ApiPropertyOptional({ description: FILTERS_API_DESCRIPTION, example: FILTERS_API_EXAMPLE }) + filters?: WebhookFilters | null; + @Expose() @ApiProperty() active: boolean; diff --git a/src/modules/webhook/entities/webhook.entity.ts b/src/modules/webhook/entities/webhook.entity.ts index d6f07c55..ca8fd94a 100644 --- a/src/modules/webhook/entities/webhook.entity.ts +++ b/src/modules/webhook/entities/webhook.entity.ts @@ -10,6 +10,7 @@ import { import { Session } from '../../session/entities/session.entity'; import { DateTransformer } from '../../../common/transformers/date.transformer'; import { jsonColumnType, dateColumnType } from '../../../common/utils/column-types'; +import { WebhookFilters } from '../filters/filter-types'; @Entity('webhooks') export class Webhook { @@ -35,6 +36,10 @@ export class Webhook { @Column({ type: jsonColumnType(), default: '{}' }) headers: Record; + // Optional smart pre-filter. Null/absent means "no filtering" (fire on every subscribed event). + @Column({ type: jsonColumnType(), nullable: true }) + filters: WebhookFilters | null; + @Column({ type: 'boolean', default: true }) active: boolean; diff --git a/src/modules/webhook/filters/filter-evaluator.spec.ts b/src/modules/webhook/filters/filter-evaluator.spec.ts new file mode 100644 index 00000000..3c8a6712 --- /dev/null +++ b/src/modules/webhook/filters/filter-evaluator.spec.ts @@ -0,0 +1,116 @@ +import { evaluateFilters } from './filter-evaluator'; +import { WebhookFilters } from './filter-types'; + +const msg = (over: Record = {}): Record => ({ + from: '111@c.us', + to: '999@c.us', + body: 'Hello World', + type: 'text', + fromMe: false, + isGroup: false, + ...over, +}); + +const filters = (...conditions: WebhookFilters['conditions']): WebhookFilters => ({ conditions }); + +describe('evaluateFilters', () => { + it('passes when filters are absent or empty (additive/optional)', () => { + expect(evaluateFilters(null, 'message.received', msg())).toBe(true); + expect(evaluateFilters(undefined, 'message.received', msg())).toBe(true); + expect(evaluateFilters(filters(), 'message.received', msg())).toBe(true); + }); + + it('matches sender by JID, case-insensitively', () => { + const f = filters({ field: 'sender', operator: 'is', value: ['111@C.US'] }); + expect(evaluateFilters(f, 'message.received', msg())).toBe(true); + expect(evaluateFilters(f, 'message.received', msg({ from: '222@c.us' }))).toBe(false); + }); + + it('resolves sender to author in group messages', () => { + const f = filters({ field: 'sender', operator: 'is', value: ['part@c.us'] }); + const groupMsg = msg({ from: '120@g.us', author: 'part@c.us', isGroup: true }); + expect(evaluateFilters(f, 'message.received', groupMsg)).toBe(true); + }); + + it('supports isNot (negation), including unknown sender', () => { + const f = filters({ field: 'sender', operator: 'isNot', value: ['111@c.us'] }); + expect(evaluateFilters(f, 'message.received', msg())).toBe(false); + expect(evaluateFilters(f, 'message.received', msg({ from: '222@c.us' }))).toBe(true); + expect(evaluateFilters(f, 'message.received', msg({ from: undefined }))).toBe(true); + }); + + it('ANDs all conditions', () => { + const f = filters( + { field: 'sender', operator: 'is', value: ['111@c.us'] }, + { field: 'type', operator: 'is', value: ['image'] }, + ); + expect(evaluateFilters(f, 'message.received', msg({ type: 'image' }))).toBe(true); + expect(evaluateFilters(f, 'message.received', msg({ type: 'text' }))).toBe(false); + }); + + it('body contains is case-insensitive by default and case-sensitive when set', () => { + expect( + evaluateFilters(filters({ field: 'body', operator: 'contains', value: 'hello' }), 'message.received', msg()), + ).toBe(true); + expect( + evaluateFilters( + filters({ field: 'body', operator: 'contains', value: 'hello', caseSensitive: true }), + 'message.received', + msg(), + ), + ).toBe(false); + }); + + it('body equals and regex matches', () => { + expect( + evaluateFilters(filters({ field: 'body', operator: 'equals', value: 'Hello World' }), 'message.received', msg()), + ).toBe(true); + expect( + evaluateFilters(filters({ field: 'body', operator: 'matches', value: '^hello' }), 'message.received', msg()), + ).toBe(true); + expect( + evaluateFilters(filters({ field: 'body', operator: 'matches', value: '^world' }), 'message.received', msg()), + ).toBe(false); + }); + + it('rejects an invalid regex by failing the match, not throwing', () => { + expect( + evaluateFilters(filters({ field: 'body', operator: 'matches', value: '(' }), 'message.received', msg()), + ).toBe(false); + }); + + it('boolean fields (isGroup, fromMe, hasMedia)', () => { + expect( + evaluateFilters( + filters({ field: 'isGroup', operator: 'is', value: true }), + 'message.received', + msg({ isGroup: true }), + ), + ).toBe(true); + expect(evaluateFilters(filters({ field: 'fromMe', operator: 'is', value: false }), 'message.received', msg())).toBe( + true, + ); + expect( + evaluateFilters(filters({ field: 'hasMedia', operator: 'is', value: true }), 'message.received', msg()), + ).toBe(false); + expect( + evaluateFilters( + filters({ field: 'hasMedia', operator: 'is', value: true }), + 'message.received', + msg({ media: { mimetype: 'image/png' } }), + ), + ).toBe(true); + }); + + it('mentions (idArray) intersects', () => { + const f = filters({ field: 'mentions', operator: 'is', value: ['boss@c.us'] }); + expect(evaluateFilters(f, 'message.received', msg({ mentionedIds: ['boss@c.us', 'x@c.us'] }))).toBe(true); + expect(evaluateFilters(f, 'message.received', msg({ mentionedIds: ['x@c.us'] }))).toBe(false); + }); + + it('skips conditions whose field is not registered for the event family', () => { + // A message-family field carried on a (future) session event is ignored, not failed. + const f = filters({ field: 'sender', operator: 'is', value: ['nobody@c.us'] }); + expect(evaluateFilters(f, 'session.status', msg())).toBe(true); + }); +}); diff --git a/src/modules/webhook/filters/filter-evaluator.ts b/src/modules/webhook/filters/filter-evaluator.ts new file mode 100644 index 00000000..38106007 --- /dev/null +++ b/src/modules/webhook/filters/filter-evaluator.ts @@ -0,0 +1,90 @@ +import { + WebhookFilters, + WebhookFilterCondition, + FieldDefinition, + MAX_REGEX_LENGTH, + MAX_REGEX_INPUT_LENGTH, + eventFamily, + getFieldDefinition, +} from './filter-types'; + +const normalizeJid = (value: string): string => value.trim().toLowerCase(); + +const toStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + +function safeRegexTest(pattern: string, input: string, caseSensitive: boolean): boolean { + if (pattern.length > MAX_REGEX_LENGTH) return false; + const text = input.length > MAX_REGEX_INPUT_LENGTH ? input.slice(0, MAX_REGEX_INPUT_LENGTH) : input; + try { + return new RegExp(pattern, caseSensitive ? '' : 'i').test(text); + } catch { + return false; + } +} + +function evaluateCondition( + def: FieldDefinition, + condition: WebhookFilterCondition, + data: Record, +): boolean { + const { operator, value, caseSensitive = false } = condition; + const resolved = def.resolve(data); + + switch (def.kind) { + case 'id': + case 'enum': { + const candidates = toStringArray(value); + const actual = typeof resolved === 'string' ? resolved : undefined; + const normalize = def.kind === 'id' ? normalizeJid : (s: string): string => s; + const set = new Set(candidates.map(normalize)); + const isMatch = actual != null && set.has(normalize(actual)); + return operator === 'isNot' ? !isMatch : isMatch; + } + + case 'idArray': { + const candidates = new Set(toStringArray(value).map(normalizeJid)); + const actual = toStringArray(resolved).map(normalizeJid); + const intersects = actual.some(v => candidates.has(v)); + return operator === 'isNot' ? !intersects : intersects; + } + + case 'boolean': + return resolved === (value === true); + + case 'text': { + if (typeof value !== 'string') return true; // malformed; validated on save + const haystackRaw = typeof resolved === 'string' ? resolved : ''; + const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase(); + const needle = caseSensitive ? value : value.toLowerCase(); + if (operator === 'equals') return haystack === needle; + if (operator === 'matches') return safeRegexTest(value, haystackRaw, caseSensitive); + return haystack.includes(needle); // contains + } + + default: + return true; + } +} + +/** + * Returns true when the webhook should fire for this event. Absent or empty filters + * always pass (additive/optional). All conditions must match (AND). Conditions whose + * field is not registered for the fired event's family are skipped. + */ +export function evaluateFilters( + filters: WebhookFilters | null | undefined, + event: string, + data: Record, +): boolean { + if (!filters || !Array.isArray(filters.conditions) || filters.conditions.length === 0) { + return true; + } + const family = eventFamily(event); + for (const condition of filters.conditions) { + const def = getFieldDefinition(family, condition.field); + if (!def) continue; + if (!evaluateCondition(def, condition, data)) return false; + } + return true; +} diff --git a/src/modules/webhook/filters/filter-types.ts b/src/modules/webhook/filters/filter-types.ts new file mode 100644 index 00000000..e7893b30 --- /dev/null +++ b/src/modules/webhook/filters/filter-types.ts @@ -0,0 +1,153 @@ +/** + * Smart webhook filters: an optional, additive pre-filter layer applied to webhook + * triggers. A webhook with no filters behaves exactly as before. When filters are + * present, every condition must match (logical AND) for the webhook to fire. + * + * The design is intentionally event-family aware so it can grow beyond messages: + * fields are registered per family (`message`, later `session`/`group`). A condition + * whose field is unknown for the fired event's family is skipped, so a webhook + * subscribed to several families behaves sanely without per-event filter sets. + */ + +export type FilterOperator = 'is' | 'isNot' | 'contains' | 'equals' | 'matches'; + +/** Value shape a field resolves to, which decides how operators are applied. */ +export type FieldKind = 'id' | 'idArray' | 'text' | 'enum' | 'boolean'; + +export interface WebhookFilterCondition { + field: string; + operator: FilterOperator; + value: string | string[] | boolean; + /** Only meaningful for `text` fields (`contains`/`equals`/`matches`). Defaults to false. */ + caseSensitive?: boolean; +} + +export interface WebhookFilters { + conditions: WebhookFilterCondition[]; +} + +export interface FieldDefinition { + field: string; + kind: FieldKind; + operators: FilterOperator[]; + resolve: (data: Record) => unknown; + /** Allowed values for `enum` fields (used by validation + dashboard). */ + enumValues?: readonly string[]; +} + +export const MESSAGE_TYPES = [ + 'text', + 'image', + 'video', + 'audio', + 'voice', + 'document', + 'sticker', + 'location', + 'contact', + 'revoked', + 'unknown', +] as const; + +// Guard rails. These bound both stored config size and per-event evaluation cost. +export const MAX_CONDITIONS = 20; +export const MAX_VALUES_PER_CONDITION = 100; +export const MAX_TEXT_VALUE_LENGTH = 1000; +export const MAX_REGEX_LENGTH = 200; +/** Inputs longer than this are truncated before regex matching to bound backtracking cost. */ +export const MAX_REGEX_INPUT_LENGTH = 4096; + +const ID_OPERATORS: FilterOperator[] = ['is', 'isNot']; +const TEXT_OPERATORS: FilterOperator[] = ['contains', 'equals', 'matches']; +const ENUM_OPERATORS: FilterOperator[] = ['is', 'isNot']; +const BOOLEAN_OPERATORS: FilterOperator[] = ['is']; + +const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + +/** + * Field registry keyed by event family. v1 ships `message`; `session`/`group` + * slot in later by adding entries here, with no change to the evaluator. + */ +export const FILTER_FIELDS: Record = { + message: [ + { + field: 'sender', + kind: 'id', + operators: ID_OPERATORS, + // In groups `from` is the group JID; `author` is the real participant. + resolve: data => str(data.author) ?? str(data.from), + }, + { + field: 'recipient', + kind: 'id', + operators: ID_OPERATORS, + resolve: data => str(data.to), + }, + { + field: 'body', + kind: 'text', + operators: TEXT_OPERATORS, + resolve: data => str(data.body) ?? '', + }, + { + field: 'type', + kind: 'enum', + operators: ENUM_OPERATORS, + enumValues: MESSAGE_TYPES, + resolve: data => str(data.type), + }, + { + field: 'isGroup', + kind: 'boolean', + operators: BOOLEAN_OPERATORS, + resolve: data => data.isGroup === true, + }, + { + field: 'fromMe', + kind: 'boolean', + operators: BOOLEAN_OPERATORS, + resolve: data => data.fromMe === true, + }, + { + field: 'hasMedia', + kind: 'boolean', + operators: BOOLEAN_OPERATORS, + resolve: data => data.media != null, + }, + { + field: 'mentions', + kind: 'idArray', + operators: ID_OPERATORS, + resolve: data => (Array.isArray(data.mentionedIds) ? (data.mentionedIds as unknown[]) : []), + }, + ], +}; + +/** `message.received` -> `message`. */ +export function eventFamily(event: string): string { + const dot = event.indexOf('.'); + return dot === -1 ? event : event.slice(0, dot); +} + +export function getFieldDefinition(family: string, field: string): FieldDefinition | undefined { + return FILTER_FIELDS[family]?.find(f => f.field === field); +} + +/** Find a field across all families (used by validation, which is family-agnostic). */ +export function findFieldDefinition(field: string): FieldDefinition | undefined { + for (const defs of Object.values(FILTER_FIELDS)) { + const found = defs.find(f => f.field === field); + if (found) return found; + } + return undefined; +} + +/** + * Heuristic guard against catastrophic-backtracking patterns (e.g. `(a+)+`). It is a + * best-effort net, not a proof of safety; the hard bound is the input-length cap applied + * at match time plus {@link MAX_REGEX_LENGTH}. Flags a quantified group whose body itself + * contains an unbounded quantifier. + */ +export function isPotentiallyCatastrophicRegex(pattern: string): boolean { + return /\([^()]*[+*{][^()]*\)[+*]/.test(pattern) || /\([^()]*[+*{][^()]*\)\{/.test(pattern); +} diff --git a/src/modules/webhook/filters/filter-validation.ts b/src/modules/webhook/filters/filter-validation.ts new file mode 100644 index 00000000..4edf41e9 --- /dev/null +++ b/src/modules/webhook/filters/filter-validation.ts @@ -0,0 +1,119 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; +import { + FilterOperator, + findFieldDefinition, + isPotentiallyCatastrophicRegex, + MAX_CONDITIONS, + MAX_REGEX_LENGTH, + MAX_TEXT_VALUE_LENGTH, + MAX_VALUES_PER_CONDITION, +} from './filter-types'; + +const OPERATORS: FilterOperator[] = ['is', 'isNot', 'contains', 'equals', 'matches']; + +function validateCondition(condition: unknown, index: number): string | null { + const where = `conditions[${index}]`; + if (typeof condition !== 'object' || condition === null) return `${where} must be an object`; + const { field, operator, value, caseSensitive } = condition as Record; + + if (typeof field !== 'string') return `${where}.field must be a string`; + const def = findFieldDefinition(field); + if (!def) return `${where}.field "${field}" is not a recognized filter field`; + + if (typeof operator !== 'string' || !OPERATORS.includes(operator as FilterOperator)) { + return `${where}.operator "${String(operator)}" is invalid`; + } + if (!def.operators.includes(operator as FilterOperator)) { + return `${where}.operator "${operator}" is not allowed for field "${field}"`; + } + if (caseSensitive !== undefined && typeof caseSensitive !== 'boolean') { + return `${where}.caseSensitive must be a boolean`; + } + + switch (def.kind) { + case 'boolean': + if (typeof value !== 'boolean') return `${where}.value must be a boolean for "${field}"`; + return null; + + case 'text': { + if (typeof value !== 'string') return `${where}.value must be a string for "${field}"`; + if (operator === 'matches') { + if (value.length > MAX_REGEX_LENGTH) return `${where}.value regex exceeds ${MAX_REGEX_LENGTH} chars`; + if (isPotentiallyCatastrophicRegex(value)) return `${where}.value regex is rejected as potentially unsafe`; + try { + new RegExp(value); + } catch { + return `${where}.value is not a valid regular expression`; + } + } else if (value.length > MAX_TEXT_VALUE_LENGTH) { + return `${where}.value exceeds ${MAX_TEXT_VALUE_LENGTH} chars`; + } + return null; + } + + case 'id': + case 'idArray': + case 'enum': { + if (!Array.isArray(value) || value.length === 0) { + return `${where}.value must be a non-empty array for "${field}"`; + } + if (value.length > MAX_VALUES_PER_CONDITION) { + return `${where}.value exceeds ${MAX_VALUES_PER_CONDITION} entries`; + } + for (const v of value) { + if (typeof v !== 'string' || v.length === 0) return `${where}.value entries must be non-empty strings`; + if (def.kind === 'enum' && def.enumValues && !def.enumValues.includes(v)) { + return `${where}.value "${v}" is not a valid ${field}`; + } + } + return null; + } + + default: + return `${where} has an unsupported field kind`; + } +} + +/** Pure validator: returns a list of human-readable problems (empty when valid). */ +export function collectFilterErrors(value: unknown): string[] { + if (value === null || value === undefined) return []; + if (typeof value !== 'object') return ['filters must be an object']; + const conditions = (value as Record).conditions; + if (!Array.isArray(conditions)) return ['filters.conditions must be an array']; + if (conditions.length > MAX_CONDITIONS) return [`filters.conditions exceeds ${MAX_CONDITIONS} entries`]; + + const errors: string[] = []; + conditions.forEach((condition, index) => { + const error = validateCondition(condition, index); + if (error) errors.push(error); + }); + return errors; +} + +@ValidatorConstraint({ name: 'isValidWebhookFilters', async: false }) +class IsValidWebhookFiltersConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return collectFilterErrors(value).length === 0; + } + defaultMessage(args: ValidationArguments): string { + return collectFilterErrors(args.value).join('; ') || 'Invalid webhook filters'; + } +} + +export function IsValidWebhookFilters(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsValidWebhookFiltersConstraint, + }); + }; +} diff --git a/src/modules/webhook/webhook.controller.spec.ts b/src/modules/webhook/webhook.controller.spec.ts index 6e70c957..e1cb5f3d 100644 --- a/src/modules/webhook/webhook.controller.spec.ts +++ b/src/modules/webhook/webhook.controller.spec.ts @@ -22,6 +22,7 @@ function createSecretWebhook(overrides: Partial = {}): Webhook { events: ['message.received'], secret: 's3cr3t-hmac-key', headers: { Authorization: 'Bearer receiver-token' }, + filters: null, active: true, retryCount: 3, lastTriggeredAt: null, diff --git a/src/modules/webhook/webhook.service.spec.ts b/src/modules/webhook/webhook.service.spec.ts index 114b77b2..e242559e 100644 --- a/src/modules/webhook/webhook.service.spec.ts +++ b/src/modules/webhook/webhook.service.spec.ts @@ -20,6 +20,7 @@ import * as crypto from 'crypto'; import { fetch as undiciFetch } from 'undici'; import { WebhookService, WebhookPayload } from './webhook.service'; import { Webhook } from './entities/webhook.entity'; +import { WebhookFilters } from './filters/filter-types'; import { HookManager } from '../../core/hooks'; import { QUEUE_NAMES } from '../queue/queue-names'; import { Session } from '../session/entities/session.entity'; @@ -32,6 +33,7 @@ function createMockWebhook(overrides: Partial = {}): Webhook { events: ['message.received'], secret: null, headers: {}, + filters: null, active: true, retryCount: 3, lastTriggeredAt: null, @@ -351,6 +353,114 @@ describe('WebhookService', () => { }); }); + // ── dispatch (smart filters) ────────────────────────────────────── + // The event still has to match `events[]`; filters then refine WHETHER it fires based + // on the payload. A webhook with no filters behaves exactly as before (fires on match). + + describe('dispatch (smart filters)', () => { + const mockFetch = undiciFetch as jest.Mock; + + beforeEach(() => { + mockFetch.mockResolvedValue({ ok: true, status: 200 }); + (repository.update as jest.Mock).mockResolvedValue({ affected: 1 }); + }); + + afterEach(() => mockFetch.mockReset()); + + const conds = (...conditions: WebhookFilters['conditions']): WebhookFilters => ({ conditions }); + + // events:['*'] isolates the filter logic from event-name matching. Returns the number + // of outbound HTTP deliveries the dispatch performed (1 = fired, 0 = filtered out). + async function deliveries( + filters: WebhookFilters | null, + event: string, + data: Record, + ): Promise { + mockFetch.mockClear(); + const webhook = createMockWebhook({ events: ['*'], filters }); + (repository.find as jest.Mock).mockResolvedValue([webhook]); + await service.dispatch('sess-1', event, data); + return mockFetch.mock.calls.length; + } + + it('fires with no filters (additive: zero-config behaviour is unchanged)', async () => { + expect(await deliveries(null, 'message.received', { from: '111@c.us' })).toBe(1); + expect(await deliveries(conds(), 'message.received', { from: '111@c.us' })).toBe(1); + }); + + it('sender "is": fires on a match, filters out a mismatch', async () => { + const f = conds({ field: 'sender', operator: 'is', value: ['111@c.us'] }); + expect(await deliveries(f, 'message.received', { from: '111@c.us' })).toBe(1); + expect(await deliveries(f, 'message.received', { from: '222@c.us' })).toBe(0); + }); + + it('sender "isNot": filters out the named sender, fires for everyone else', async () => { + const f = conds({ field: 'sender', operator: 'isNot', value: ['spammer@c.us'] }); + expect(await deliveries(f, 'message.received', { from: 'spammer@c.us' })).toBe(0); + expect(await deliveries(f, 'message.received', { from: 'friend@c.us' })).toBe(1); + }); + + it('resolves sender to the group participant (author), not the group JID', async () => { + const f = conds({ field: 'sender', operator: 'is', value: ['part@c.us'] }); + const data = { from: '120@g.us', author: 'part@c.us', isGroup: true }; + expect(await deliveries(f, 'message.received', data)).toBe(1); + }); + + it('ANDs multiple conditions (all must match)', async () => { + const f = conds( + { field: 'sender', operator: 'is', value: ['boss@c.us'] }, + { field: 'body', operator: 'contains', value: 'invoice' }, + ); + expect(await deliveries(f, 'message.received', { from: 'boss@c.us', body: 'the invoice is ready' })).toBe(1); + expect(await deliveries(f, 'message.received', { from: 'boss@c.us', body: 'lunch?' })).toBe(0); + expect(await deliveries(f, 'message.received', { from: 'other@c.us', body: 'invoice' })).toBe(0); + }); + + it('body "contains" is case-insensitive by default and respects caseSensitive', async () => { + const ci = conds({ field: 'body', operator: 'contains', value: 'ping' }); + expect(await deliveries(ci, 'message.received', { body: 'PING me' })).toBe(1); + const cs = conds({ field: 'body', operator: 'contains', value: 'ping', caseSensitive: true }); + expect(await deliveries(cs, 'message.received', { body: 'PING me' })).toBe(0); + }); + + it('body "matches" applies the regex; an invalid regex filters out (never throws)', async () => { + const f = conds({ field: 'body', operator: 'matches', value: '^order\\s+\\d+' }); + expect(await deliveries(f, 'message.received', { body: 'order 42' })).toBe(1); + expect(await deliveries(f, 'message.received', { body: 'hello' })).toBe(0); + const bad = conds({ field: 'body', operator: 'matches', value: '(' }); + await expect(deliveries(bad, 'message.received', { body: '(' })).resolves.toBe(0); + }); + + it('type "is" matches one of the listed message types', async () => { + const f = conds({ field: 'type', operator: 'is', value: ['image', 'video'] }); + expect(await deliveries(f, 'message.received', { type: 'image' })).toBe(1); + expect(await deliveries(f, 'message.received', { type: 'text' })).toBe(0); + }); + + it('boolean fields: fromMe and hasMedia', async () => { + const fromMe = conds({ field: 'fromMe', operator: 'is', value: true }); + expect(await deliveries(fromMe, 'message.received', { fromMe: true })).toBe(1); + expect(await deliveries(fromMe, 'message.received', { fromMe: false })).toBe(0); + + const hasMedia = conds({ field: 'hasMedia', operator: 'is', value: true }); + expect(await deliveries(hasMedia, 'message.received', { media: { mimetype: 'image/png' } })).toBe(1); + expect(await deliveries(hasMedia, 'message.received', { body: 'just text' })).toBe(0); + }); + + it('mentions: fires when the message mentions one of the listed JIDs', async () => { + const f = conds({ field: 'mentions', operator: 'is', value: ['boss@c.us'] }); + expect(await deliveries(f, 'message.received', { mentionedIds: ['boss@c.us', 'x@c.us'] })).toBe(1); + expect(await deliveries(f, 'message.received', { mentionedIds: ['x@c.us'] })).toBe(0); + }); + + it('skips message-only conditions on a non-message event (so it still fires)', async () => { + // A webhook subscribed to '*' with message filters must not suppress non-message events. + const f = conds({ field: 'sender', operator: 'is', value: ['nobody@c.us'] }); + expect(await deliveries(f, 'session.status', { status: 'connected' })).toBe(1); + expect(await deliveries(f, 'message.received', { from: 'someone@c.us' })).toBe(0); + }); + }); + // ── custom-header sanitization ─────────────────────────────── describe('custom header merge', () => { diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts index bea14766..5c31ae96 100644 --- a/src/modules/webhook/webhook.service.ts +++ b/src/modules/webhook/webhook.service.ts @@ -10,6 +10,7 @@ import { CreateWebhookDto, UpdateWebhookDto } from './dto'; import { createLogger } from '../../common/services/logger.service'; import { QUEUE_NAMES } from '../queue/queue-names'; import { generateIdempotencyKey, generateDeliveryId } from './utils/idempotency.util'; +import { evaluateFilters } from './filters/filter-evaluator'; import { assertSafeFetchUrl, withSafeFetch, @@ -80,6 +81,7 @@ export class WebhookService { events: dto.events || ['message.received'], secret: dto.secret || null, headers: dto.headers || {}, + filters: dto.filters ?? null, retryCount: dto.retryCount ?? 3, }); @@ -125,6 +127,7 @@ export class WebhookService { // not a stored blank that silently disables signing while looking configured. if (dto.secret !== undefined) webhook.secret = dto.secret || null; if (dto.headers !== undefined) webhook.headers = dto.headers; + if (dto.filters !== undefined) webhook.filters = dto.filters; if (dto.active !== undefined) webhook.active = dto.active; if (dto.retryCount !== undefined) webhook.retryCount = dto.retryCount; @@ -205,7 +208,9 @@ export class WebhookService { return; } - const matchingWebhooks = webhooks.filter(w => w.events.includes(event) || w.events.includes('*')); + const matchingWebhooks = webhooks.filter( + w => (w.events.includes(event) || w.events.includes('*')) && evaluateFilters(w.filters, event, data), + ); // Generate idempotency key (same for all webhooks receiving this event). occurredAt is captured // once here and reused for every retry of this dispatch, so recurring lifecycle events get a diff --git a/test/setup-e2e.ts b/test/setup-e2e.ts index cfaec0fa..c25a4531 100644 --- a/test/setup-e2e.ts +++ b/test/setup-e2e.ts @@ -7,3 +7,8 @@ process.env.AUTO_START_SESSIONS = 'false'; // Keep the auth/audit + data schema zero-config for the test boot. process.env.MAIN_DATABASE_SYNCHRONIZE = 'true'; process.env.DATABASE_SYNCHRONIZE = 'true'; +// e2e suites burst many requests at the API; relax the per-second rate limit so the +// ThrottlerGuard (still wired) doesn't 429 a normal test run. +process.env.RATE_LIMIT_SHORT_LIMIT = '100000'; +process.env.RATE_LIMIT_MEDIUM_LIMIT = '100000'; +process.env.RATE_LIMIT_LONG_LIMIT = '100000'; diff --git a/test/webhooks.e2e-spec.ts b/test/webhooks.e2e-spec.ts new file mode 100644 index 00000000..090ea567 --- /dev/null +++ b/test/webhooks.e2e-spec.ts @@ -0,0 +1,346 @@ +// archiver v8 is ESM-only (pulled in transitively via @Global StorageModule); stub for ts-jest CJS. +jest.mock('archiver', () => ({ TarArchive: jest.fn() })); + +import http from 'http'; +import crypto from 'crypto'; +import { AddressInfo } from 'net'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; +import { AuthService } from './../src/modules/auth/auth.service'; +import { ApiKeyRole } from './../src/modules/auth/entities/api-key.entity'; +import { Session } from './../src/modules/session/entities/session.entity'; +import { WebhookService } from './../src/modules/webhook/webhook.service'; + +/** + * End-to-end coverage for the webhooks module across the seam the unit specs can't reach: the REST + * surface (CRUD + auth + validation, with the global ValidationPipe), persistence, and real + * HMAC-signed HTTP delivery to a live local receiver. Dispatch is the one thing the WhatsApp socket + * would normally trigger, so we call WebhookService.dispatch() directly - that boundary isn't + * webhook logic. + * + * SSRF protection is ON by default and would reject the 127.0.0.1 receiver at both registration and + * delivery, so the suite runs with WEBHOOK_SSRF_PROTECT=false (one test flips it back on locally). + */ +describe('Webhooks (e2e)', () => { + let app: INestApplication; + let webhookService: WebhookService; + let sessionRepo: Repository; + let apiKey: string; + let viewerKey: string; + let received: Array<{ headers: http.IncomingHttpHeaders; raw: string; body: Record }>; + let receiver: http.Server; + let receiverUrl: string; + const prevSsrf = process.env.WEBHOOK_SSRF_PROTECT; + + // Webhooks carry a CASCADE foreign key to a session row, and dispatch looks them up by sessionId. + // Persisting one real session per test both satisfies the FK and isolates each case (and prior + // runs) from the others, since dispatch and the session-scoped routes only see that session's id. + let sessionSeq = 0; + const nextSession = async (): Promise => { + const session = await sessionRepo.save(sessionRepo.create({ name: `e2e-webhooks-${Date.now()}-${sessionSeq++}` })); + return session.id; + }; + + const waitFor = async (predicate: () => boolean, timeoutMs = 1000): Promise => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition'); + await new Promise(r => setTimeout(r, 10)); + } + }; + + // Create a webhook via the REST API (exercises CreateWebhookDto + filter validation) and return the + // response DTO. Defaults to the receiver URL + a subscribe-all event so dispatch tests can refine + // behaviour purely through filters/overrides. + const createWebhook = async ( + session: string, + overrides: Record = {}, + ): Promise> => { + const res = await request(app.getHttpServer()) + .post(`/api/sessions/${session}/webhooks`) + .set('X-API-Key', apiKey) + .send({ url: receiverUrl, events: ['*'], ...overrides }) + .expect(201); + return res.body as Record; + }; + + beforeAll(async () => { + process.env.WEBHOOK_SSRF_PROTECT = 'false'; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + + webhookService = app.get(WebhookService); + sessionRepo = app.get(getRepositoryToken(Session, 'data')); + + // Mint real keys so the suite doesn't depend on seed/DB state; ADMIN covers the OPERATOR routes. + const authService = app.get(AuthService); + apiKey = (await authService.createApiKey({ name: 'e2e-admin', role: ApiKeyRole.ADMIN })).rawKey; + viewerKey = (await authService.createApiKey({ name: 'e2e-viewer', role: ApiKeyRole.VIEWER })).rawKey; + + received = []; + receiver = http.createServer((req, res) => { + let raw = ''; + req.on('data', chunk => (raw += chunk)); + req.on('end', () => { + received.push({ headers: req.headers, raw, body: JSON.parse(raw || '{}') as Record }); + res.writeHead(200).end(); + }); + }); + await new Promise(resolve => receiver.listen(0, '127.0.0.1', resolve)); + receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/hook`; + }); + + afterAll(async () => { + await new Promise(resolve => receiver.close(() => resolve())); + if (prevSsrf === undefined) delete process.env.WEBHOOK_SSRF_PROTECT; + else process.env.WEBHOOK_SSRF_PROTECT = prevSsrf; + try { + await app?.close(); + } catch { + /* ignore teardown-only multi-datasource quirk */ + } + }); + + beforeEach(() => { + received = []; + }); + + // ── CRUD over the REST surface ──────────────────────────────────── + + describe('CRUD over REST', () => { + it('creates a webhook and never leaks the secret or headers in the response', async () => { + const session = await nextSession(); + const dto = await createWebhook(session, { + secret: 'top-secret', + headers: { 'X-Custom': 'v' }, + filters: { conditions: [{ field: 'sender', operator: 'is', value: ['a@c.us'] }] }, + }); + + expect(dto.id).toBeDefined(); + expect(dto.sessionId).toBe(session); + expect(dto.active).toBe(true); + // Write-only fields must never appear in any API response. + expect(dto.secret).toBeUndefined(); + expect(dto.headers).toBeUndefined(); + }); + + it('lists webhooks for a session', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + const res = await request(app.getHttpServer()) + .get(`/api/sessions/${session}/webhooks`) + .set('X-API-Key', apiKey) + .expect(200); + + const list = res.body as Array<{ id: string }>; + expect(list).toHaveLength(1); + expect(list[0].id).toBe(created.id); + }); + + it('gets a webhook by id, and returns 404 for an unknown id', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + await request(app.getHttpServer()) + .get(`/api/sessions/${session}/webhooks/${created.id as string}`) + .set('X-API-Key', apiKey) + .expect(200) + .expect(res => { + if ((res.body as { id: string }).id !== created.id) throw new Error('wrong webhook returned'); + }); + + await request(app.getHttpServer()) + .get(`/api/sessions/${session}/webhooks/00000000-0000-0000-0000-000000000000`) + .set('X-API-Key', apiKey) + .expect(404); + }); + + it('updates fields and persists them', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + const res = await request(app.getHttpServer()) + .put(`/api/sessions/${session}/webhooks/${created.id as string}`) + .set('X-API-Key', apiKey) + .send({ events: ['message.received', 'session.status'], active: false }) + .expect(200); + + const updated = res.body as { events: string[]; active: boolean }; + expect(updated.events).toEqual(['message.received', 'session.status']); + expect(updated.active).toBe(false); + }); + + it('lists the webhook across all sessions via GET /api/webhooks', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + const res = await request(app.getHttpServer()).get('/api/webhooks').set('X-API-Key', apiKey).expect(200); + + const ids = (res.body as Array<{ id: string }>).map(w => w.id); + expect(ids).toContain(created.id); + }); + + it('deletes a webhook (204) and then it is gone (404)', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + await request(app.getHttpServer()) + .delete(`/api/sessions/${session}/webhooks/${created.id as string}`) + .set('X-API-Key', apiKey) + .expect(204); + + await request(app.getHttpServer()) + .get(`/api/sessions/${session}/webhooks/${created.id as string}`) + .set('X-API-Key', apiKey) + .expect(404); + }); + }); + + // ── auth boundaries ─────────────────────────────────────────────── + + describe('auth', () => { + it('rejects a request with no API key (401)', async () => { + const session = await nextSession(); + await request(app.getHttpServer()).get(`/api/sessions/${session}/webhooks`).expect(401); + }); + + it('forbids a viewer-role key from creating a webhook (403)', async () => { + const session = await nextSession(); + await request(app.getHttpServer()) + .post(`/api/sessions/${session}/webhooks`) + .set('X-API-Key', viewerKey) + .send({ url: receiverUrl }) + .expect(403); + }); + }); + + // ── registration validation ─────────────────────────────────────── + + describe('registration validation', () => { + it('rejects an internal URL with 400 when SSRF protection is on', async () => { + const session = await nextSession(); + // Self-contained: turn protection on and clear any ambient SSRF_ALLOWED_HOSTS (a dev .env may + // allowlist 127.0.0.1) so the assertion holds regardless of the local environment. + const prevAllow = process.env.SSRF_ALLOWED_HOSTS; + process.env.WEBHOOK_SSRF_PROTECT = 'true'; + process.env.SSRF_ALLOWED_HOSTS = ''; + try { + await request(app.getHttpServer()) + .post(`/api/sessions/${session}/webhooks`) + .set('X-API-Key', apiKey) + .send({ url: 'http://169.254.169.254/hook' }) // link-local cloud metadata, a canonical SSRF target + .expect(400); + } finally { + process.env.WEBHOOK_SSRF_PROTECT = 'false'; + if (prevAllow === undefined) delete process.env.SSRF_ALLOWED_HOSTS; + else process.env.SSRF_ALLOWED_HOSTS = prevAllow; + } + }); + }); + + // ── dispatch over real HTTP ─────────────────────────────────────── + + describe('dispatch over real HTTP', () => { + it('delivers a correctly HMAC-signed POST when a filter matches', async () => { + const session = await nextSession(); + const secret = 'sig-secret'; + await createWebhook(session, { + secret, + filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] }, + }); + + await webhookService.dispatch(session, 'message.received', { from: 'boss@c.us', body: 'hi' }); + await waitFor(() => received.length === 1); + + const { headers, raw, body } = received[0]; + expect(headers['x-openwa-event']).toBe('message.received'); + // Verify the signature over the exact bytes that were sent, not a re-serialization. + const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`; + expect(headers['x-openwa-signature']).toBe(expected); + expect((body as { data: { from: string } }).data.from).toBe('boss@c.us'); + }); + + it('does not deliver when the filter does not match', async () => { + const session = await nextSession(); + await createWebhook(session, { + filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] }, + }); + + await webhookService.dispatch(session, 'message.received', { from: 'spammer@c.us', body: 'spam' }); + // No way to await a non-event; give dispatch a real chance to (not) deliver, then assert silence. + await new Promise(r => setTimeout(r, 100)); + expect(received).toHaveLength(0); + }); + + it('passes a non-message event through a message-only filter', async () => { + const session = await nextSession(); + await createWebhook(session, { + filters: { conditions: [{ field: 'sender', operator: 'is', value: ['nobody@c.us'] }] }, + }); + + await webhookService.dispatch(session, 'session.status', { status: 'connected' }); + await waitFor(() => received.length === 1); + expect(received[0].headers['x-openwa-event']).toBe('session.status'); + }); + + it('does not deliver to an inactive webhook', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + await request(app.getHttpServer()) + .put(`/api/sessions/${session}/webhooks/${created.id as string}`) + .set('X-API-Key', apiKey) + .send({ active: false }) + .expect(200); + + await webhookService.dispatch(session, 'message.received', { from: 'a@c.us' }); + await new Promise(r => setTimeout(r, 100)); + expect(received).toHaveLength(0); + }); + + it('drops forged reserved headers but keeps custom ones on the wire', async () => { + const session = await nextSession(); + await createWebhook(session, { + headers: { 'X-OpenWA-Event': 'forged', 'Content-Type': 'text/plain', 'X-Custom': 'ok' }, + }); + + await webhookService.dispatch(session, 'message.received', {}); + await waitFor(() => received.length === 1); + + const { headers } = received[0]; + expect(headers['x-openwa-event']).toBe('message.received'); // system value wins, not 'forged' + expect(headers['content-type']).toBe('application/json'); + expect(headers['x-custom']).toBe('ok'); // legitimate custom header preserved + }); + }); + + // ── the test endpoint ───────────────────────────────────────────── + + describe('test endpoint', () => { + it('POST /:id/test delivers a test event to the receiver and reports success', async () => { + const session = await nextSession(); + const created = await createWebhook(session); + + const res = await request(app.getHttpServer()) + .post(`/api/sessions/${session}/webhooks/${created.id as string}/test`) + .set('X-API-Key', apiKey) + .expect(201); + + expect((res.body as { success: boolean }).success).toBe(true); + await waitFor(() => received.length === 1); + expect(received[0].headers['x-openwa-event']).toBe('test'); + }); + }); +}); From 08d2bdaf8a6ff920c60a3e2415abc63d82cb6b4e Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Sat, 20 Jun 2026 13:00:39 +0800 Subject: [PATCH 3/5] feat(webhook): WaId-aware id matching via the lid->phone table Match id filters (sender/recipient/mentions) by the engine-neutral WaId key instead of the raw JID, reusing the same identity primitives as the engine and MessageService. An engine-emitted id (any of @c.us / @s.whatsapp.net / @lid, optional :device suffix, a lid resolved to its phone) and a user-typed filter value (bare digits or a JID) both collapse to one neutral key, so: - a phone filter matches the same person across user dialects, and - a lid-addressed actor (e.g. an unresolved @lid group participant) now matches a phone filter once the persistent lid->phone table knows the mapping - previously a silent miss. WebhookService takes the cross-session LidMappingStore (optional) and threads a resolver into evaluateFilters at dispatch; the evaluator stays a pure function (resolver optional) so unit contexts need no store. No new filter fields, no UI change - the dashboard keeps storing the neutral @c.us value. --- CHANGELOG.md | 12 +++++ .../webhook/filters/filter-evaluator.spec.ts | 46 +++++++++++++++++++ .../webhook/filters/filter-evaluator.ts | 42 +++++++++++++---- src/modules/webhook/webhook.module.ts | 3 +- src/modules/webhook/webhook.service.spec.ts | 18 ++++++++ src/modules/webhook/webhook.service.ts | 9 +++- 6 files changed, 118 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9d1327..06ccc2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Smart webhook filters (optional, additive).** A webhook trigger can now carry an optional set of + pre-dispatch conditions, evaluated per event before delivery: it fires only when **all** conditions + match (AND). Conditions match on `sender` / `recipient` / `body` / `type` / `mentions` / `fromMe` / + `hasMedia` / `isGroup` with `is` / `isNot` / `contains` / `equals` / `matches` (regex) operators; + message-only conditions are skipped for non-message events, so a `*`-subscribed webhook still fires on + session events. A webhook with no filters behaves exactly as before. Contact-id conditions + (`sender`/`recipient`/`mentions`) match by the engine-neutral `WaId` key, so a filter written as a + plain number or in any dialect (`@c.us` / `@s.whatsapp.net` / `@lid`) matches the same person - and a + lid-addressed sender (e.g. an unresolved `@lid` group participant) matches a phone filter once the + persistent `lid -> phone` table knows the mapping. Configurable via the API (`filters` on create/update) + and a new FilterBuilder UI on the dashboard's Webhooks page. + - **Persistent, cross-session `lid -> phone` resolution + a `from` filter on message history.** A new `lid_mappings` table (on the `data` connection) records the `lid -> phone` mappings WhatsApp pushes us (history sync, contacts) so resolution is shared across sessions and survives restarts, instead of diff --git a/src/modules/webhook/filters/filter-evaluator.spec.ts b/src/modules/webhook/filters/filter-evaluator.spec.ts index 3c8a6712..c58aae01 100644 --- a/src/modules/webhook/filters/filter-evaluator.spec.ts +++ b/src/modules/webhook/filters/filter-evaluator.spec.ts @@ -113,4 +113,50 @@ describe('evaluateFilters', () => { const f = filters({ field: 'sender', operator: 'is', value: ['nobody@c.us'] }); expect(evaluateFilters(f, 'session.status', msg())).toBe(true); }); + + // ── WaId-aware id matching (engine-neutral) ─────────────────────── + // Ids are compared by their neutral WaId key, so a contact matches regardless of the dialect the + // engine emits and regardless of how the filter is written (bare digits or a JID). + + describe('engine-neutral id matching', () => { + it('matches the same user across @c.us and @s.whatsapp.net', () => { + const f = filters({ field: 'sender', operator: 'is', value: ['111@c.us'] }); + expect(evaluateFilters(f, 'message.received', msg({ from: '111@s.whatsapp.net' }))).toBe(true); + }); + + it('matches a JID filter against a bare-number actor and vice versa', () => { + expect( + evaluateFilters( + filters({ field: 'sender', operator: 'is', value: ['111'] }), + 'message.received', + msg({ from: '111@c.us' }), + ), + ).toBe(true); + }); + + it('ignores a :device suffix', () => { + const f = filters({ field: 'sender', operator: 'is', value: ['111@c.us'] }); + expect(evaluateFilters(f, 'message.received', msg({ from: '111:12@s.whatsapp.net' }))).toBe(true); + }); + + it('resolves a lid actor to its phone via the resolver (the lid->phone table)', () => { + // Group author arrives as an unresolved @lid; the resolver maps it to the phone the filter names. + const resolve = (jid: string): string | null => (jid.startsWith('111@lid') ? '628999' : null); + const f = filters({ field: 'sender', operator: 'is', value: ['628999'] }); + const data = msg({ from: '120@g.us', author: '111@lid', isGroup: true }); + expect(evaluateFilters(f, 'message.received', data, resolve)).toBe(true); + }); + + it('control: without a resolver the same lid actor does NOT match the phone filter', () => { + const f = filters({ field: 'sender', operator: 'is', value: ['628999'] }); + const data = msg({ from: '120@g.us', author: '111@lid', isGroup: true }); + expect(evaluateFilters(f, 'message.received', data)).toBe(false); + }); + + it('resolves lid actors inside mentions (idArray) too', () => { + const resolve = (jid: string): string | null => (jid.startsWith('111@lid') ? '628999' : null); + const f = filters({ field: 'mentions', operator: 'is', value: ['628999'] }); + expect(evaluateFilters(f, 'message.received', msg({ mentionedIds: ['111@lid', 'x@c.us'] }), resolve)).toBe(true); + }); + }); }); diff --git a/src/modules/webhook/filters/filter-evaluator.ts b/src/modules/webhook/filters/filter-evaluator.ts index 38106007..ced73e46 100644 --- a/src/modules/webhook/filters/filter-evaluator.ts +++ b/src/modules/webhook/filters/filter-evaluator.ts @@ -7,8 +7,23 @@ import { eventFamily, getFieldDefinition, } from './filter-types'; +import { WaId } from '../../../engine/identity/wa-id.value'; -const normalizeJid = (value: string): string => value.trim().toLowerCase(); +/** + * Resolves a lid JID to its phone user-part when the mapping is known (mirrors the engine adapter's + * `resolvePhone`). The dispatcher supplies one backed by the persistent lid->phone table; it is absent + * in pure/unit contexts, where an unresolved lid simply stays a lid. + */ +export type LidResolver = (jid: string) => string | null; + +// Reduce an id to its engine-neutral canonical key so the same contact matches regardless of dialect. +// An engine-emitted JID (any of @c.us / @s.whatsapp.net / @lid, an optional :device suffix, a lid the +// table resolves to its phone) and a user-typed filter value (bare digits or a JID) both collapse to +// the same neutral string (`@c.us` / `@g.us` / `@lid`). So a phone filter now matches +// the person across user dialects AND any lid resolving to that phone - previously a silent miss. +const canonicalActor = (jid: string, resolve?: LidResolver): string => + WaId.fromEngineJid(jid, resolve).toNeutral().toLowerCase(); +const canonicalInput = (value: string): string => WaId.fromUserInput(value).toNeutral().toLowerCase(); const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; @@ -27,24 +42,29 @@ function evaluateCondition( def: FieldDefinition, condition: WebhookFilterCondition, data: Record, + resolve?: LidResolver, ): boolean { const { operator, value, caseSensitive = false } = condition; const resolved = def.resolve(data); switch (def.kind) { - case 'id': + case 'id': { + const candidates = new Set(toStringArray(value).map(canonicalInput)); + const actual = typeof resolved === 'string' ? resolved : undefined; + const isMatch = actual != null && candidates.has(canonicalActor(actual, resolve)); + return operator === 'isNot' ? !isMatch : isMatch; + } + case 'enum': { - const candidates = toStringArray(value); + const candidates = new Set(toStringArray(value)); const actual = typeof resolved === 'string' ? resolved : undefined; - const normalize = def.kind === 'id' ? normalizeJid : (s: string): string => s; - const set = new Set(candidates.map(normalize)); - const isMatch = actual != null && set.has(normalize(actual)); + const isMatch = actual != null && candidates.has(actual); return operator === 'isNot' ? !isMatch : isMatch; } case 'idArray': { - const candidates = new Set(toStringArray(value).map(normalizeJid)); - const actual = toStringArray(resolved).map(normalizeJid); + const candidates = new Set(toStringArray(value).map(canonicalInput)); + const actual = toStringArray(resolved).map(jid => canonicalActor(jid, resolve)); const intersects = actual.some(v => candidates.has(v)); return operator === 'isNot' ? !intersects : intersects; } @@ -70,12 +90,14 @@ function evaluateCondition( /** * Returns true when the webhook should fire for this event. Absent or empty filters * always pass (additive/optional). All conditions must match (AND). Conditions whose - * field is not registered for the fired event's family are skipped. + * field is not registered for the fired event's family are skipped. `resolve` (optional) + * maps a lid to its phone so id conditions match a lid-addressed actor by phone. */ export function evaluateFilters( filters: WebhookFilters | null | undefined, event: string, data: Record, + resolve?: LidResolver, ): boolean { if (!filters || !Array.isArray(filters.conditions) || filters.conditions.length === 0) { return true; @@ -84,7 +106,7 @@ export function evaluateFilters( for (const condition of filters.conditions) { const def = getFieldDefinition(family, condition.field); if (!def) continue; - if (!evaluateCondition(def, condition, data)) return false; + if (!evaluateCondition(def, condition, data, resolve)) return false; } return true; } diff --git a/src/modules/webhook/webhook.module.ts b/src/modules/webhook/webhook.module.ts index 34bfc85f..db524284 100644 --- a/src/modules/webhook/webhook.module.ts +++ b/src/modules/webhook/webhook.module.ts @@ -4,6 +4,7 @@ import { Webhook } from './entities/webhook.entity'; import { WebhookService } from './webhook.service'; import { WebhookController } from './webhook.controller'; import { WebhooksListController } from './webhooks-list.controller'; +import { EngineModule } from '../../engine/engine.module'; // Only import QueueModule if explicitly enabled to avoid Redis connection errors const queueModules: Array = []; @@ -16,7 +17,7 @@ if (process.env.QUEUE_ENABLED === 'true') { } @Module({ - imports: [TypeOrmModule.forFeature([Webhook], 'data'), ...queueModules], + imports: [TypeOrmModule.forFeature([Webhook], 'data'), EngineModule, ...queueModules], controllers: [WebhookController, WebhooksListController], providers: [WebhookService], exports: [WebhookService], diff --git a/src/modules/webhook/webhook.service.spec.ts b/src/modules/webhook/webhook.service.spec.ts index e242559e..39e31992 100644 --- a/src/modules/webhook/webhook.service.spec.ts +++ b/src/modules/webhook/webhook.service.spec.ts @@ -21,6 +21,7 @@ import { fetch as undiciFetch } from 'undici'; import { WebhookService, WebhookPayload } from './webhook.service'; import { Webhook } from './entities/webhook.entity'; import { WebhookFilters } from './filters/filter-types'; +import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; import { HookManager } from '../../core/hooks'; import { QUEUE_NAMES } from '../queue/queue-names'; import { Session } from '../session/entities/session.entity'; @@ -50,6 +51,7 @@ describe('WebhookService', () => { let configService: jest.Mocked>; let hookManager: jest.Mocked>; let webhookQueue: jest.Mocked>; + let lidStore: { getCached: jest.Mock }; beforeEach(async () => { repository = { @@ -82,12 +84,15 @@ describe('WebhookService', () => { add: jest.fn().mockResolvedValue(undefined), }; + lidStore = { getCached: jest.fn().mockReturnValue(null) }; + const module: TestingModule = await Test.createTestingModule({ providers: [ WebhookService, { provide: getRepositoryToken(Webhook, 'data'), useValue: repository }, { provide: ConfigService, useValue: configService }, { provide: HookManager, useValue: hookManager }, + { provide: LidMappingStoreService, useValue: lidStore }, { provide: getQueueToken(QUEUE_NAMES.WEBHOOK), useValue: webhookQueue }, ], }).compile(); @@ -459,6 +464,19 @@ describe('WebhookService', () => { expect(await deliveries(f, 'session.status', { status: 'connected' })).toBe(1); expect(await deliveries(f, 'message.received', { from: 'someone@c.us' })).toBe(0); }); + + it('resolves a lid sender to its phone via the table, so a phone filter fires (else a silent miss)', async () => { + const f = conds({ field: 'sender', operator: 'is', value: ['628999'] }); + const data = { from: '120@g.us', author: '111@lid', isGroup: true }; + + // No mapping yet -> the lid author never matches the phone filter. + lidStore.getCached.mockReturnValue(null); + expect(await deliveries(f, 'message.received', data)).toBe(0); + + // Table maps lid 111 -> 628999 -> the same message now fires. + lidStore.getCached.mockImplementation((lid: string) => (lid === '111' ? '628999' : null)); + expect(await deliveries(f, 'message.received', data)).toBe(1); + }); }); // ── custom-header sanitization ─────────────────────────────── diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts index 5c31ae96..6a76f4ba 100644 --- a/src/modules/webhook/webhook.service.ts +++ b/src/modules/webhook/webhook.service.ts @@ -11,6 +11,8 @@ import { createLogger } from '../../common/services/logger.service'; import { QUEUE_NAMES } from '../queue/queue-names'; import { generateIdempotencyKey, generateDeliveryId } from './utils/idempotency.util'; import { evaluateFilters } from './filters/filter-evaluator'; +import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; +import { userPart } from '../../engine/identity/wa-id'; import { assertSafeFetchUrl, withSafeFetch, @@ -50,6 +52,8 @@ export class WebhookService { private readonly configService: ConfigService, private readonly hookManager: HookManager, @Optional() + private readonly lidMappingStore?: LidMappingStoreService, + @Optional() @InjectQueue(QUEUE_NAMES.WEBHOOK) private readonly webhookQueue?: Queue, ) { @@ -208,8 +212,11 @@ export class WebhookService { return; } + // Resolve a lid actor to its phone through the persistent table so a phone filter matches a + // lid-addressed sender (e.g. an unresolved @lid group participant). Absent store -> no resolution. + const resolveLid = (jid: string): string | null => this.lidMappingStore?.getCached(userPart(jid)) ?? null; const matchingWebhooks = webhooks.filter( - w => (w.events.includes(event) || w.events.includes('*')) && evaluateFilters(w.filters, event, data), + w => (w.events.includes(event) || w.events.includes('*')) && evaluateFilters(w.filters, event, data, resolveLid), ); // Generate idempotency key (same for all webhooks receiving this event). occurredAt is captured From 560f7b12f38bad1380f776a1cf6fd696f60d27e5 Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Sun, 21 Jun 2026 05:30:11 +0800 Subject: [PATCH 4/5] fix(webhook): drop ReDoS-prone regex `matches` filter operator The `matches` operator compiled a user-supplied regex and ran it synchronously on the event loop for every inbound message. The `isPotentiallyCatastrophicRegex` heuristic only caught nested-quantifier shapes like `(a+)+`; alternation-overlap (`(a|a)+b`) and polynomial (`.*.*.*x$`) patterns sailed through and froze the whole process on a single message. Since webhook config isn't admin-gated, any session- managing key could plant such a filter - a remote DoS. Remove `matches` entirely: `contains`/`equals` cover the real use cases at zero ReDoS surface and no new dependency. Validation now rejects the operator outright, and the regex length/input caps and heuristic are gone. Drops the operator from the dashboard FilterBuilder, the API client type, and the `matches`/`regexPlaceholder` i18n keys across all 9 locales. --- CHANGELOG.md | 2 +- dashboard/package-lock.json | 4 +- dashboard/src/components/FilterBuilder.tsx | 8 +- dashboard/src/i18n/locales/ar.json | 4 +- dashboard/src/i18n/locales/en.json | 4 +- dashboard/src/i18n/locales/es.json | 4 +- dashboard/src/i18n/locales/fr.json | 4 +- dashboard/src/i18n/locales/he.json | 4 +- dashboard/src/i18n/locales/it.json | 4 +- dashboard/src/i18n/locales/te.json | 4 +- dashboard/src/i18n/locales/zh-CN.json | 4 +- dashboard/src/i18n/locales/zh-HK.json | 4 +- dashboard/src/services/api.ts | 2 +- package-lock.json | 79 ++++++++----------- src/modules/webhook/dto/webhook.dto.spec.ts | 9 +-- .../webhook/filters/filter-evaluator.spec.ts | 12 +-- .../webhook/filters/filter-evaluator.ts | 13 --- src/modules/webhook/filters/filter-types.ts | 19 +---- .../webhook/filters/filter-validation.ts | 16 +--- src/modules/webhook/webhook.service.spec.ts | 8 +- 20 files changed, 63 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ccc2c5..08803bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smart webhook filters (optional, additive).** A webhook trigger can now carry an optional set of pre-dispatch conditions, evaluated per event before delivery: it fires only when **all** conditions match (AND). Conditions match on `sender` / `recipient` / `body` / `type` / `mentions` / `fromMe` / - `hasMedia` / `isGroup` with `is` / `isNot` / `contains` / `equals` / `matches` (regex) operators; + `hasMedia` / `isGroup` with `is` / `isNot` / `contains` / `equals` operators; message-only conditions are skipped for non-message events, so a `*`-subscribed webhook still fires on session events. A webhook with no filters behaves exactly as before. Contact-id conditions (`sender`/`recipient`/`mentions`) match by the engine-neutral `WaId` key, so a filter written as a diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index bee3baec..88780e99 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "dashboard", - "version": "0.4.1", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dashboard", - "version": "0.4.1", + "version": "0.4.5", "dependencies": { "@tanstack/react-query": "^5.101.0", "@tanstack/react-table": "^8.21.3", diff --git a/dashboard/src/components/FilterBuilder.tsx b/dashboard/src/components/FilterBuilder.tsx index 4d000ebb..2d827ab7 100644 --- a/dashboard/src/components/FilterBuilder.tsx +++ b/dashboard/src/components/FilterBuilder.tsx @@ -36,7 +36,7 @@ const MESSAGE_TYPES = [ const MESSAGE_FIELDS: FieldDescriptor[] = [ { field: 'sender', kind: 'id', operators: ['is', 'isNot'] }, { field: 'recipient', kind: 'id', operators: ['is', 'isNot'] }, - { field: 'body', kind: 'text', operators: ['contains', 'equals', 'matches'] }, + { field: 'body', kind: 'text', operators: ['contains', 'equals'] }, { field: 'type', kind: 'enum', operators: ['is', 'isNot'], enumValues: MESSAGE_TYPES }, { field: 'isGroup', kind: 'boolean', operators: ['is'] }, { field: 'fromMe', kind: 'boolean', operators: ['is'] }, @@ -243,11 +243,7 @@ export function FilterBuilder({ filters, onChange, chats }: FilterBuilderProps) updateAt(index, { value: e.target.value })} />