diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ef169d..8cfbcad0 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` 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. (#379) + - **Configurable first-boot init timeout for the whatsapp-web.js engine (`WWEBJS_AUTH_TIMEOUT_MS`).** On slow first boots (e.g. WSL2 or low-resource containers) the engine's fixed 30s wait for WhatsApp Web to finish loading could expire before the QR code was generated, aborting startup. Set 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.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..2d827ab7 --- /dev/null +++ b/dashboard/src/components/FilterBuilder.tsx @@ -0,0 +1,290 @@ +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'] }, + { 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 205fd859..e7714ced 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({ @@ -82,8 +93,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 1d37aeb0..cea8ded0 100644 --- a/dashboard/src/i18n/locales/ar.json +++ b/dashboard/src/i18n/locales/ar.json @@ -276,6 +276,36 @@ "events": "الأحداث", "available": "الأحداث المتاحة", "saveChanges": "حفظ التغييرات", + "filters": { + "title": "عوامل التصفية (اختياري)", + "hint": "يتم التفعيل فقط عند تطابق جميع الشروط. ينطبق على أحداث الرسائل.", + "badge": "{{count}} عامل تصفية", + "badge_other": "{{count}} عوامل تصفية", + "addCondition": "إضافة شرط", + "removeCondition": "إزالة الشرط", + "caseSensitive": "حساس لحالة الأحرف", + "contactPlaceholder": "رقم أو معرّف واتساب...", + "textPlaceholder": "نص للمطابقة...", + "addValue": "إضافة \"{{value}}\"", + "yes": "نعم", + "no": "لا", + "fields": { + "sender": "المُرسِل", + "recipient": "المُستلِم", + "body": "نص الرسالة", + "type": "نوع الرسالة", + "isGroup": "محادثة جماعية", + "fromMe": "مُرسَلة مني", + "hasMedia": "تحتوي على وسائط", + "mentions": "الإشارات" + }, + "operators": { + "is": "هو", + "isNot": "ليس", + "contains": "يحتوي على", + "equals": "يساوي" + } + }, "columns": { "url": "الرابط", "events": "الأحداث", diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index acc7a217..6196c2cd 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -276,6 +276,36 @@ "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...", + "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" + } + }, "columns": { "url": "URL", "events": "Events", diff --git a/dashboard/src/i18n/locales/es.json b/dashboard/src/i18n/locales/es.json index 1fef3c3e..8194a12e 100644 --- a/dashboard/src/i18n/locales/es.json +++ b/dashboard/src/i18n/locales/es.json @@ -276,6 +276,36 @@ "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...", + "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" + } + }, "columns": { "url": "URL", "events": "Eventos", diff --git a/dashboard/src/i18n/locales/fr.json b/dashboard/src/i18n/locales/fr.json index 3dbd6379..0115d6db 100644 --- a/dashboard/src/i18n/locales/fr.json +++ b/dashboard/src/i18n/locales/fr.json @@ -276,6 +276,36 @@ "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...", + "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 à" + } + }, "columns": { "url": "URL", "events": "Événements", diff --git a/dashboard/src/i18n/locales/he.json b/dashboard/src/i18n/locales/he.json index 3707454b..544c2e5f 100644 --- a/dashboard/src/i18n/locales/he.json +++ b/dashboard/src/i18n/locales/he.json @@ -276,6 +276,36 @@ "events": "אירועים", "available": "אירועים זמינים", "saveChanges": "שמירת שינויים", + "filters": { + "title": "מסננים (אופציונלי)", + "hint": "הפעלה רק כאשר כל התנאים מתקיימים. חל על אירועי הודעות.", + "badge": "{{count}} מסנן", + "badge_other": "{{count}} מסננים", + "addCondition": "הוספת תנאי", + "removeCondition": "הסרת תנאי", + "caseSensitive": "תלוי רישיות", + "contactPlaceholder": "מספר או מזהה WhatsApp...", + "textPlaceholder": "טקסט להתאמה...", + "addValue": "הוספת \"{{value}}\"", + "yes": "כן", + "no": "לא", + "fields": { + "sender": "שולח", + "recipient": "נמען", + "body": "גוף ההודעה", + "type": "סוג הודעה", + "isGroup": "צ'אט קבוצתי", + "fromMe": "נשלח על ידי", + "hasMedia": "מכיל מדיה", + "mentions": "אזכורים" + }, + "operators": { + "is": "הוא", + "isNot": "אינו", + "contains": "מכיל", + "equals": "שווה ל" + } + }, "columns": { "url": "כתובת URL", "events": "אירועים", diff --git a/dashboard/src/i18n/locales/it.json b/dashboard/src/i18n/locales/it.json index 71f0ad8f..cf8d7658 100644 --- a/dashboard/src/i18n/locales/it.json +++ b/dashboard/src/i18n/locales/it.json @@ -276,6 +276,36 @@ "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...", + "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" + } + }, "columns": { "url": "URL", "events": "Eventi", diff --git a/dashboard/src/i18n/locales/te.json b/dashboard/src/i18n/locales/te.json index 4714871f..190b47f5 100644 --- a/dashboard/src/i18n/locales/te.json +++ b/dashboard/src/i18n/locales/te.json @@ -276,6 +276,36 @@ "events": "ఈవెంట్స్", "available": "అందుబాటులో ఉన్న ఈవెంట్స్", "saveChanges": "మార్పులను సేవ్ చేయి", + "filters": { + "title": "ఫిల్టర్‌లు (ఐచ్ఛికం)", + "hint": "అన్ని షరతులు సరిపోలినప్పుడు మాత్రమే ట్రిగ్గర్ అవుతుంది. సందేశ ఈవెంట్‌లకు వర్తిస్తుంది.", + "badge": "{{count}} ఫిల్టర్", + "badge_other": "{{count}} ఫిల్టర్‌లు", + "addCondition": "షరతును జోడించు", + "removeCondition": "షరతును తొలగించు", + "caseSensitive": "కేస్ సెన్సిటివ్", + "contactPlaceholder": "నంబర్ లేదా WhatsApp ID...", + "textPlaceholder": "సరిపోల్చాల్సిన టెక్స్ట్...", + "addValue": "\"{{value}}\" జోడించు", + "yes": "అవును", + "no": "కాదు", + "fields": { + "sender": "పంపినవారు", + "recipient": "స్వీకర్త", + "body": "సందేశ విషయం", + "type": "సందేశ రకం", + "isGroup": "గ్రూప్ చాట్", + "fromMe": "నేను పంపినది", + "hasMedia": "మీడియా ఉంది", + "mentions": "ప్రస్తావనలు" + }, + "operators": { + "is": "అనేది", + "isNot": "కాదు", + "contains": "కలిగి ఉంది", + "equals": "సమానం" + } + }, "columns": { "url": "URL", "events": "ఈవెంట్స్", diff --git a/dashboard/src/i18n/locales/zh-CN.json b/dashboard/src/i18n/locales/zh-CN.json index fc6e1836..10602dbb 100644 --- a/dashboard/src/i18n/locales/zh-CN.json +++ b/dashboard/src/i18n/locales/zh-CN.json @@ -276,6 +276,36 @@ "events": "事件", "available": "可用事件", "saveChanges": "保存更改", + "filters": { + "title": "筛选条件(可选)", + "hint": "仅当所有条件都匹配时才触发。适用于消息事件。", + "badge": "{{count}} 个筛选条件", + "badge_other": "{{count}} 个筛选条件", + "addCondition": "添加条件", + "removeCondition": "移除条件", + "caseSensitive": "区分大小写", + "contactPlaceholder": "号码或 WhatsApp ID...", + "textPlaceholder": "要匹配的文本...", + "addValue": "添加 \"{{value}}\"", + "yes": "是", + "no": "否", + "fields": { + "sender": "发送者", + "recipient": "接收者", + "body": "消息正文", + "type": "消息类型", + "isGroup": "是群聊", + "fromMe": "由我发送", + "hasMedia": "包含媒体", + "mentions": "提及" + }, + "operators": { + "is": "是", + "isNot": "不是", + "contains": "包含", + "equals": "等于" + } + }, "columns": { "url": "URL", "events": "事件", diff --git a/dashboard/src/i18n/locales/zh-HK.json b/dashboard/src/i18n/locales/zh-HK.json index 45f5b831..47970a4f 100644 --- a/dashboard/src/i18n/locales/zh-HK.json +++ b/dashboard/src/i18n/locales/zh-HK.json @@ -276,6 +276,36 @@ "events": "事件", "available": "可用事件", "saveChanges": "儲存變更", + "filters": { + "title": "篩選條件(選填)", + "hint": "僅當所有條件都符合時才觸發。適用於訊息事件。", + "badge": "{{count}} 個篩選條件", + "badge_other": "{{count}} 個篩選條件", + "addCondition": "新增條件", + "removeCondition": "移除條件", + "caseSensitive": "區分大小寫", + "contactPlaceholder": "號碼或 WhatsApp ID...", + "textPlaceholder": "要符合的文字...", + "addValue": "新增 \"{{value}}\"", + "yes": "是", + "no": "否", + "fields": { + "sender": "傳送者", + "recipient": "接收者", + "body": "訊息內容", + "type": "訊息類型", + "isGroup": "是群組聊天", + "fromMe": "由我傳送", + "hasMedia": "包含媒體", + "mentions": "提及" + }, + "operators": { + "is": "是", + "isNot": "不是", + "contains": "包含", + "equals": "等於" + } + }, "columns": { "url": "URL", "events": "事件", 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; 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 593103aa..65b4726f 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 = [ @@ -60,10 +120,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 }); @@ -83,9 +152,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({ @@ -154,7 +224,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); @@ -259,7 +334,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..962b081b 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'; + +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/package-lock.json b/package-lock.json index 6f5afd2f..8052eec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openwa", - "version": "0.4.2", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openwa", - "version": "0.4.2", + "version": "0.4.5", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -762,7 +762,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1268,7 +1267,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.0.0.tgz", "integrity": "sha512-GYXJNJclgm9H5Tt/tmuNWfjyF2BuygMwl8xrRIfxxOTgcK4SB8zNUPveTcHGAOoKKtPDVotHNZCBRrFCcOAXMA==", "license": "MIT", - "peer": true, "dependencies": { "redis-info": "^3.1.0" }, @@ -1307,7 +1305,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.0.0.tgz", "integrity": "sha512-X/F256CmpBj9oj+fK2wOzjygkhTtjgWnc3f/SxPiUs3rQFvgYCplJLEaURh13J+Tpq46/1drAxq4Ukt4e5LelA==", "license": "MIT", - "peer": true, "dependencies": { "@bull-board/api": "8.0.0" } @@ -1345,7 +1342,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1676,7 +1672,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1708,7 +1703,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -1808,6 +1802,7 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1824,6 +1819,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -1846,6 +1842,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -1865,6 +1862,7 @@ "os": [ "freebsd" ], + "peer": true, "dependencies": { "@img/sharp-wasm32": "0.35.1" }, @@ -1887,6 +1885,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1903,6 +1902,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1919,6 +1919,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1935,6 +1936,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1951,6 +1953,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1967,6 +1970,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1983,6 +1987,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -1999,6 +2004,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2015,6 +2021,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2031,6 +2038,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2047,6 +2055,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2069,6 +2078,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2091,6 +2101,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2113,6 +2124,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2135,6 +2147,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2157,6 +2170,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2179,6 +2193,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2201,6 +2216,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2217,6 +2233,7 @@ "integrity": "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/runtime": "^1.11.0" }, @@ -2236,6 +2253,7 @@ ], "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@img/sharp-wasm32": "0.35.1" }, @@ -2258,6 +2276,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -2277,6 +2296,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.9.0" }, @@ -2296,6 +2316,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=20.9.0" }, @@ -3497,7 +3518,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -3652,7 +3672,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3699,7 +3718,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -3759,7 +3777,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3781,7 +3798,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.27.tgz", "integrity": "sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3994,7 +4010,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -4575,7 +4590,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4695,7 +4709,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -4921,7 +4934,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -5673,7 +5685,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5762,7 +5773,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6360,7 +6370,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6663,7 +6672,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6754,7 +6762,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.1.tgz", "integrity": "sha512-zD5IT+qMqbMgPFPdL9FwnZka1bz6nckM+5lXj4N0vsXqdzoVO6wizmXpwsg/4GnHmXJsL7XOKeWA64tYUdPrOA==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -7072,7 +7079,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7142,15 +7148,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8003,8 +8007,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -8502,7 +8505,6 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -8562,7 +8564,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10074,7 +10075,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", @@ -10357,7 +10357,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -12349,6 +12348,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -12737,7 +12737,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -13067,7 +13066,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13783,8 +13781,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -13934,7 +13931,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14104,6 +14100,7 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.1.tgz", "integrity": "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -14148,6 +14145,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -14535,7 +14533,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -15061,7 +15058,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15430,7 +15426,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15615,7 +15610,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15835,7 +15829,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16176,7 +16169,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16245,7 +16237,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/src/database/migrations/1781500000000-AddWebhookFilters.ts b/src/database/migrations/1781500000000-AddWebhookFilters.ts new file mode 100644 index 00000000..e57c26db --- /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). Stored as `text` on BOTH dialects: the + * entity column is `simple-json` (jsonColumnType resolves to `simple-json` everywhere — see + * column-types.ts), which serializes to/parses from text in JS. A `jsonb` column on Postgres would + * re-introduce the entity/column type drift that crashed the dashboard (#385/#384). + */ +export class AddWebhookFilters1781500000000 implements MigrationInterface { + name = 'AddWebhookFilters1781500000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('webhooks', 'filters')) return; + await queryRunner.query(`ALTER TABLE "webhooks" ADD COLUMN "filters" text`); + } + + 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..c1477a40 100644 --- a/src/modules/webhook/dto/webhook.dto.spec.ts +++ b/src/modules/webhook/dto/webhook.dto.spec.ts @@ -33,3 +33,53 @@ 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 the removed "matches" (regex) operator', async () => { + const errs = await errorsFor( + CreateWebhookDto, + withFilters([{ field: 'body', operator: 'matches', value: '^order' }]), + ); + 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 2f8c6099..22cc217b 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', @@ -40,7 +51,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({ @@ -72,6 +85,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, @@ -88,7 +106,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." }) @@ -109,6 +127,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() @@ -148,6 +171,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..1ea005fa --- /dev/null +++ b/src/modules/webhook/filters/filter-evaluator.spec.ts @@ -0,0 +1,156 @@ +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 is exact and case-insensitive by default', () => { + expect( + evaluateFilters(filters({ field: 'body', operator: 'equals', value: 'Hello World' }), 'message.received', msg()), + ).toBe(true); + expect( + evaluateFilters(filters({ field: 'body', operator: 'equals', value: 'hello world' }), 'message.received', msg()), + ).toBe(true); + expect( + evaluateFilters(filters({ field: 'body', operator: 'equals', value: 'Hello' }), '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); + }); + + // ── 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 new file mode 100644 index 00000000..10b57052 --- /dev/null +++ b/src/modules/webhook/filters/filter-evaluator.ts @@ -0,0 +1,99 @@ +import { + WebhookFilters, + WebhookFilterCondition, + FieldDefinition, + eventFamily, + getFieldDefinition, +} from './filter-types'; +import { WaId } from '../../../engine/identity/wa-id.value'; + +/** + * 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') : []; + +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': { + 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 = new Set(toStringArray(value)); + const actual = typeof resolved === 'string' ? resolved : undefined; + const isMatch = actual != null && candidates.has(actual); + return operator === 'isNot' ? !isMatch : isMatch; + } + + case 'idArray': { + 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; + } + + 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; + 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. `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; + } + const family = eventFamily(event); + for (const condition of filters.conditions) { + const def = getFieldDefinition(family, condition.field); + if (!def) continue; + if (!evaluateCondition(def, condition, data, resolve)) 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..4b005f63 --- /dev/null +++ b/src/modules/webhook/filters/filter-types.ts @@ -0,0 +1,140 @@ +/** + * 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'; + +/** 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`). 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; + +const ID_OPERATORS: FilterOperator[] = ['is', 'isNot']; +const TEXT_OPERATORS: FilterOperator[] = ['contains', 'equals']; +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; +} diff --git a/src/modules/webhook/filters/filter-validation.ts b/src/modules/webhook/filters/filter-validation.ts new file mode 100644 index 00000000..d0329056 --- /dev/null +++ b/src/modules/webhook/filters/filter-validation.ts @@ -0,0 +1,107 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; +import { + FilterOperator, + findFieldDefinition, + MAX_CONDITIONS, + MAX_TEXT_VALUE_LENGTH, + MAX_VALUES_PER_CONDITION, +} from './filter-types'; + +const OPERATORS: FilterOperator[] = ['is', 'isNot', 'contains', 'equals']; + +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 (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.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 114b77b2..6cbef2ea 100644 --- a/src/modules/webhook/webhook.service.spec.ts +++ b/src/modules/webhook/webhook.service.spec.ts @@ -20,6 +20,8 @@ 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 { 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'; @@ -32,6 +34,7 @@ function createMockWebhook(overrides: Partial = {}): Webhook { events: ['message.received'], secret: null, headers: {}, + filters: null, active: true, retryCount: 3, lastTriggeredAt: null, @@ -48,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 = { @@ -80,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(); @@ -351,6 +358,125 @@ 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 "equals" fires only on an exact match', async () => { + const f = conds({ field: 'body', operator: 'equals', value: 'order 42' }); + expect(await deliveries(f, 'message.received', { body: 'order 42' })).toBe(1); + expect(await deliveries(f, 'message.received', { body: 'order 4242' })).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); + }); + + 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 ─────────────────────────────── describe('custom header merge', () => { diff --git a/src/modules/webhook/webhook.service.ts b/src/modules/webhook/webhook.service.ts index bea14766..6a76f4ba 100644 --- a/src/modules/webhook/webhook.service.ts +++ b/src/modules/webhook/webhook.service.ts @@ -10,6 +10,9 @@ 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 { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; +import { userPart } from '../../engine/identity/wa-id'; import { assertSafeFetchUrl, withSafeFetch, @@ -49,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, ) { @@ -80,6 +85,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 +131,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 +212,12 @@ export class WebhookService { return; } - const matchingWebhooks = webhooks.filter(w => w.events.includes(event) || w.events.includes('*')); + // 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, resolveLid), + ); // 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'); + }); + }); +});