From c2f01faf6c6bf4d832bb6bdef0a07483381d7f7e Mon Sep 17 00:00:00 2001 From: JuanBifrost Date: Sun, 13 Sep 2026 11:32:45 -0500 Subject: [PATCH] feat(chat): add inline system activity and automation event logs in inbox - Add messageService.createActivity for system activity messages and PartyKit realtime broadcast - Record activity events in worker during flow execution (custom fields, tags, sequences, flow triggers, conversation archive/unarchive) - Add ActivityMessageItem component in builder UI with centered layout and clean dashed event dividers - Add chat-store showSystemEvents state and header toggle button in MessageHead - Add activity translation keys across all 20 locales for complete i18n key parity - Add unit test for createActivity in message-service.test.ts --- apps/builder/messages/ar.json | 24 +- apps/builder/messages/da.json | 24 +- apps/builder/messages/de.json | 24 +- apps/builder/messages/en.json | 22 ++ apps/builder/messages/es.json | 22 ++ apps/builder/messages/fi.json | 24 +- apps/builder/messages/fr.json | 24 +- apps/builder/messages/he.json | 24 +- apps/builder/messages/id.json | 24 +- apps/builder/messages/it.json | 24 +- apps/builder/messages/ja.json | 24 +- apps/builder/messages/nl.json | 24 +- apps/builder/messages/pt-BR.json | 24 +- apps/builder/messages/pt-PT.json | 24 +- apps/builder/messages/ro.json | 24 +- apps/builder/messages/sv.json | 24 +- apps/builder/messages/tr.json | 24 +- apps/builder/messages/vi.json | 24 +- apps/builder/messages/zh-CN.json | 24 +- apps/builder/messages/zh-TW.json | 24 +- .../src/features/chat/store/chat-store.ts | 10 + .../components/activity-message-item.tsx | 218 ++++++++++++++++++ .../messages/components/message-item.tsx | 5 + .../src/features/messages/message-head.tsx | 38 ++- .../src/features/messages/message-list.tsx | 7 +- .../src/integration/handlers/contact.ts | 107 +++++++++ apps/worker/src/integration/handlers/flow.ts | 33 ++- .../src/integration/handlers/step-handlers.ts | 38 +++ .../__tests__/message-service.test.ts | 59 ++++- packages/business/src/message/service.ts | 51 ++++ 30 files changed, 1020 insertions(+), 22 deletions(-) create mode 100644 apps/builder/src/features/messages/components/activity-message-item.tsx diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json index 03bb117fd7..046baeb34c 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "اختر تاريخ انتهاء العرض المحدود المدة قبل الإرسال.", "waTemplateMpmIncompleteProducts": "يحتاج كل قسم إلى منتج واحد محدد على الأقل — احذف الصفوف الفارغة أو اختر منتجًا لها.", "mediaStepUnsupported": "لا يمكن لهذه القناة إرسال هذا النوع من الوسائط. أزِل الخطوة أو وجِّه هذه العقدة إلى قناة تدعمها.", - "mediaButtonsUnsupported": "لا يمكن لهذه القناة إرسال أزرار مع رسالة وسائط. أزِل الأزرار أو انقلها إلى خطوة نصية بعد هذه الخطوة." + "mediaButtonsUnsupported": "لا يمكن لهذه القناة إرسال أزرار مع رسالة وسائط. أزِل الأزرار أو انقلها إلى خطوة نصية بعد هذه الخطوة.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "يتيح لك هذا التكامل إنشاء بوتات Instagram.", diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json index ad41c93cf0..24ff7f04c7 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Vælg en udløbsdato for tilbuddet, før du sender.", "waTemplateMpmIncompleteProducts": "Hver sektion skal have mindst ét valgt produkt — fjern tomme rækker, eller vælg et produkt til dem.", "mediaStepUnsupported": "Denne kanal kan ikke sende denne medietype. Fjern trinnet, eller peg noden mod en kanal der understøtter det.", - "mediaButtonsUnsupported": "Denne kanal kan ikke sende knapper sammen med en mediebesked. Fjern knapperne, eller flyt dem til et teksttrin efter dette." + "mediaButtonsUnsupported": "Denne kanal kan ikke sende knapper sammen med en mediebesked. Fjern knapperne, eller flyt dem til et teksttrin efter dette.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Denne integration lets du opret Instagram bots.", diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json index 2650d82d70..cf0ad1c850 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Wähle ein Ablaufdatum für das zeitlich begrenzte Angebot, bevor du sendest.", "waTemplateMpmIncompleteProducts": "Jeder Abschnitt braucht mindestens ein ausgewähltes Produkt — entferne leere Zeilen oder wähle ein Produkt aus.", "mediaStepUnsupported": "Dieser Kanal kann diesen Medientyp nicht senden. Entferne den Schritt oder richte diesen Node auf einen Kanal aus, der ihn unterstützt.", - "mediaButtonsUnsupported": "Dieser Kanal kann keine Buttons mit einer Mediennachricht senden. Entferne die Buttons oder verschiebe sie in einen Textschritt nach diesem." + "mediaButtonsUnsupported": "Dieser Kanal kann keine Buttons mit einer Mediennachricht senden. Entferne die Buttons oder verschiebe sie in einen Textschritt nach diesem.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Mit dieser Integration können Sie Instagram-Bots erstellen.", diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json index 2480c2c252..ae90b71c4d 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -3246,6 +3246,28 @@ "title": "Edit Profile" }, "messages": { + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + }, "moveFeature": "Move {feature}", "poweredBy": "Powered by {name}", "noGiphyApiKey": "No GIPHY API key found. Please add one in Platform Settings.", diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json index 758d519f41..aff527cdc8 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -3059,6 +3059,28 @@ "title": "Editar perfil" }, "messages": { + "activity": { + "customFieldChanged": "Campo personalizado cambiado: {name}", + "customFieldCleared": "Campo personalizado eliminado: {name}", + "previousValue": "Valor anterior", + "newValue": "Nuevo valor", + "unset": "unset", + "tagAdded": "Etiqueta añadida: {tag}", + "tagAddedLabel": "Etiqueta añadida", + "tagRemoved": "Etiqueta eliminada: {tag}", + "tagRemovedLabel": "Etiqueta eliminada", + "flowTriggered": "La automatización {name} se activó", + "flowTriggeredPrefix": "La automatización", + "flowTriggeredSuffix": "se activó", + "sequenceSubscribed": "Suscrito a secuencia: {name}", + "sequenceSubscribedPrefix": "Suscrito a secuencia:", + "sequenceUnsubscribed": "Desuscrito de secuencia: {name}", + "sequenceUnsubscribedPrefix": "Desuscrito de secuencia:", + "conversationClosed": "La conversación fue cerrada", + "conversationReopened": "La conversación fue reabierta", + "toggleHideEvents": "Ocultar eventos del sistema", + "toggleShowEvents": "Mostrar eventos del sistema" + }, "moveFeature": "Mover {feature}", "poweredBy": "Con tecnología de {name}", "noGiphyApiKey": "No GIPHY API clave found. Por favor, Añadir one en Platform Ajustes.", diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json index cfd0a650fe..dfa30212b6 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Valitse määräaikaisen tarjouksen päättymisaika ennen lähettämistä.", "waTemplateMpmIncompleteProducts": "Jokaisessa osiossa on oltava vähintään yksi valittu tuote — poista tyhjät rivit tai valitse niille tuote.", "mediaStepUnsupported": "Tämä kanava ei voi lähettää tätä mediatyyppiä. Poista vaihe tai osoita tämä solmu kanavalle, joka tukee sitä.", - "mediaButtonsUnsupported": "Tämä kanava ei voi lähettää painikkeita mediaviestin mukana. Poista painikkeet tai siirrä ne tämän jälkeiseen tekstivaiheeseen." + "mediaButtonsUnsupported": "Tämä kanava ei voi lähettää painikkeita mediaviestin mukana. Poista painikkeet tai siirrä ne tämän jälkeiseen tekstivaiheeseen.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Tämän integraation avulla voit luoda Instagram-botteja.", diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json index 23abc21593..f24c4f352f 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Choisissez une date d'expiration pour l'offre à durée limitée avant l'envoi.", "waTemplateMpmIncompleteProducts": "Chaque section doit contenir au moins un produit sélectionné — supprimez les lignes vides ou choisissez un produit.", "mediaStepUnsupported": "Ce canal ne peut pas envoyer ce type de média. Supprimez l'étape ou orientez ce nœud vers un canal qui le prend en charge.", - "mediaButtonsUnsupported": "Ce canal ne peut pas envoyer de boutons avec un message média. Supprimez les boutons ou déplacez-les vers une étape de texte suivante." + "mediaButtonsUnsupported": "Ce canal ne peut pas envoyer de boutons avec un message média. Supprimez les boutons ou déplacez-les vers une étape de texte suivante.", + "activity": { + "customFieldChanged": "Champ personnalisé modifié : {name}", + "customFieldCleared": "Champ personnalisé supprimé : {name}", + "previousValue": "Valeur précédente", + "newValue": "Nouvelle valeur", + "unset": "unset", + "tagAdded": "Tag ajouté : {tag}", + "tagAddedLabel": "Tag ajouté", + "tagRemoved": "Tag supprimé : {tag}", + "tagRemovedLabel": "Tag supprimé", + "flowTriggered": "L'automatisation {name} a été déclenchée", + "flowTriggeredPrefix": "L'automatisation", + "flowTriggeredSuffix": "a été déclenchée", + "sequenceSubscribed": "Abonné à la séquence : {name}", + "sequenceSubscribedPrefix": "Abonné à la séquence :", + "sequenceUnsubscribed": "Désabonné de la séquence : {name}", + "sequenceUnsubscribedPrefix": "Désabonné de la séquence :", + "conversationClosed": "La conversation a été fermée", + "conversationReopened": "La conversation a été rouverte", + "toggleHideEvents": "Masquer les événements système", + "toggleShowEvents": "Afficher les événements système" + } }, "instagram": { "description": "Cette intégration permet de créer des bots Instagram.", diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json index 640fdc6b02..d891dd1278 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -1328,7 +1328,29 @@ "waTemplateLtoExpirationRequired": "בחרו תאריך תפוגה למבצע המוגבל בזמן לפני השליחה.", "waTemplateMpmIncompleteProducts": "כל מקטע צריך לפחות מוצר אחד שנבחר — הסירו שורות ריקות או בחרו להן מוצר.", "mediaStepUnsupported": "ערוץ זה לא יכול לשלוח סוג מדיה זה. הסר את השלב או הפנה את הצומת לערוץ שתומך בכך.", - "mediaButtonsUnsupported": "ערוץ זה לא יכול לשלוח כפתורים עם הודעת מדיה. הסר את הכפתורים או העבר אותם לשלב טקסט שלאחר שלב זה." + "mediaButtonsUnsupported": "ערוץ זה לא יכול לשלוח כפתורים עם הודעת מדיה. הסר את הכפתורים או העבר אותם לשלב טקסט שלאחר שלב זה.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "שילוב זה מאפשר ליצור בוטים ל-Instagram.", diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index 73a650a651..3f5a459f16 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Pilih tanggal berakhir untuk penawaran waktu terbatas sebelum mengirim.", "waTemplateMpmIncompleteProducts": "Setiap bagian membutuhkan minimal satu produk terpilih — hapus baris kosong atau pilih produknya.", "mediaStepUnsupported": "Saluran ini tidak dapat mengirim jenis media ini. Hapus langkahnya, atau arahkan node ini ke saluran yang mendukungnya.", - "mediaButtonsUnsupported": "Saluran ini tidak dapat mengirim tombol bersama pesan media. Hapus tombolnya, atau pindahkan ke langkah teks setelah langkah ini." + "mediaButtonsUnsupported": "Saluran ini tidak dapat mengirim tombol bersama pesan media. Hapus tombolnya, atau pindahkan ke langkah teks setelah langkah ini.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Integrasi ini memungkinkan Anda membuat bot Instagram.", diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json index 9d19b079d3..4f04bae358 100644 --- a/apps/builder/messages/it.json +++ b/apps/builder/messages/it.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Scegli una data di scadenza per l'offerta a tempo limitato prima di inviare.", "waTemplateMpmIncompleteProducts": "Ogni sezione richiede almeno un prodotto selezionato: rimuovi le righe vuote o scegli un prodotto.", "mediaStepUnsupported": "Questo canale non può inviare questo tipo di contenuto. Rimuovi il passaggio o imposta questo nodo su un canale che lo supporta.", - "mediaButtonsUnsupported": "Questo canale non può inviare pulsanti con un messaggio multimediale. Rimuovi i pulsanti o spostali in un passaggio di testo successivo." + "mediaButtonsUnsupported": "Questo canale non può inviare pulsanti con un messaggio multimediale. Rimuovi i pulsanti o spostali in un passaggio di testo successivo.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Questa integrazione ti consente di creare bot Instagram.", diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json index 57481216b4..8c8cb60f84 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -3164,7 +3164,29 @@ "waTemplateLtoExpirationRequired": "送信する前に期間限定オファーの有効期限を選択してください。", "waTemplateMpmIncompleteProducts": "各セクションには選択済みの商品が少なくとも1つ必要です。空の行を削除するか、商品を選択してください。", "mediaStepUnsupported": "このチャネルではこの種類のメディアを送信できません。ステップを削除するか、対応しているチャネルにこのノードを向けてください。", - "mediaButtonsUnsupported": "このチャネルではメディアメッセージと一緒にボタンを送信できません。ボタンを削除するか、この後のテキストステップに移動してください。" + "mediaButtonsUnsupported": "このチャネルではメディアメッセージと一緒にボタンを送信できません。ボタンを削除するか、この後のテキストステップに移動してください。", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "この連携を使用すると、Instagramボットを作成できます。", diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json index 43f5949080..f82137ea6f 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Kies een vervaldatum voor de tijdelijke aanbieding voordat je verzendt.", "waTemplateMpmIncompleteProducts": "Elke sectie heeft minstens één geselecteerd product nodig — verwijder lege rijen of kies er een product voor.", "mediaStepUnsupported": "Dit kanaal kan dit mediatype niet verzenden. Verwijder de stap of richt deze node op een kanaal dat het ondersteunt.", - "mediaButtonsUnsupported": "Dit kanaal kan geen knoppen bij een mediabericht verzenden. Verwijder de knoppen of verplaats ze naar een tekststap hierna." + "mediaButtonsUnsupported": "Dit kanaal kan geen knoppen bij een mediabericht verzenden. Verwijder de knoppen of verplaats ze naar een tekststap hierna.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Met deze integratie kun je Instagram-bots maken.", diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json index 915121ba24..9672139a0e 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -3164,7 +3164,29 @@ "waTemplateLtoExpirationRequired": "Escolha uma data de expiração para a oferta por tempo limitado antes de enviar.", "waTemplateMpmIncompleteProducts": "Cada seção precisa de pelo menos um produto selecionado — remova linhas vazias ou escolha um produto.", "mediaStepUnsupported": "Este canal não consegue enviar esse tipo de mídia. Remova a etapa ou aponte este nó para um canal compatível.", - "mediaButtonsUnsupported": "Este canal não consegue enviar botões junto com uma mensagem de mídia. Remova os botões ou mova-os para uma etapa de texto depois desta." + "mediaButtonsUnsupported": "Este canal não consegue enviar botões junto com uma mensagem de mídia. Remova os botões ou mova-os para uma etapa de texto depois desta.", + "activity": { + "customFieldChanged": "Campo personalizado alterado: {name}", + "customFieldCleared": "Campo personalizado removido: {name}", + "previousValue": "Valor anterior", + "newValue": "Novo valor", + "unset": "unset", + "tagAdded": "Tag adicionada: {tag}", + "tagAddedLabel": "Tag adicionada", + "tagRemoved": "Tag removida: {tag}", + "tagRemovedLabel": "Tag removida", + "flowTriggered": "A automação {name} foi iniciada", + "flowTriggeredPrefix": "A automação", + "flowTriggeredSuffix": "foi iniciada", + "sequenceSubscribed": "Inscrito na sequência: {name}", + "sequenceSubscribedPrefix": "Inscrito na sequência:", + "sequenceUnsubscribed": "Cancelada inscrição da sequência: {name}", + "sequenceUnsubscribedPrefix": "Cancelada inscrição da sequência:", + "conversationClosed": "A conversa foi encerrada", + "conversationReopened": "A conversa foi reaberta", + "toggleHideEvents": "Ocultar eventos do sistema", + "toggleShowEvents": "Mostrar eventos do sistema" + } }, "instagram": { "description": "Esta integração permite criar bots do Instagram.", diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json index 1ea43581f9..5eca50247b 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Escolha uma data de expiração para a oferta por tempo limitado antes de enviar.", "waTemplateMpmIncompleteProducts": "Cada secção precisa de pelo menos um produto selecionado — remova linhas vazias ou escolha um produto.", "mediaStepUnsupported": "Este canal não consegue enviar este tipo de multimédia. Remova o passo ou aponte este nó para um canal compatível.", - "mediaButtonsUnsupported": "Este canal não consegue enviar botões juntamente com uma mensagem multimédia. Remova os botões ou mova-os para um passo de texto depois deste." + "mediaButtonsUnsupported": "Este canal não consegue enviar botões juntamente com uma mensagem multimédia. Remova os botões ou mova-os para um passo de texto depois deste.", + "activity": { + "customFieldChanged": "Campo personalizado alterado: {name}", + "customFieldCleared": "Campo personalizado removido: {name}", + "previousValue": "Valor anterior", + "newValue": "Novo valor", + "unset": "unset", + "tagAdded": "Tag adicionada: {tag}", + "tagAddedLabel": "Tag adicionada", + "tagRemoved": "Tag removida: {tag}", + "tagRemovedLabel": "Tag removida", + "flowTriggered": "A automação {name} foi iniciada", + "flowTriggeredPrefix": "A automação", + "flowTriggeredSuffix": "foi iniciada", + "sequenceSubscribed": "Inscrito na sequência: {name}", + "sequenceSubscribedPrefix": "Inscrito na sequência:", + "sequenceUnsubscribed": "Cancelada inscrição da sequência: {name}", + "sequenceUnsubscribedPrefix": "Cancelada inscrição da sequência:", + "conversationClosed": "A conversa foi encerrada", + "conversationReopened": "A conversa foi reaberta", + "toggleHideEvents": "Ocultar eventos do sistema", + "toggleShowEvents": "Mostrar eventos do sistema" + } }, "instagram": { "description": "Esta integração permite-lhe criar bots do Instagram.", diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json index 1a14de9a76..07b7e5a18a 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Alege o dată de expirare pentru oferta pe timp limitat înainte de a trimite.", "waTemplateMpmIncompleteProducts": "Fiecare secțiune are nevoie de cel puțin un produs selectat — elimină rândurile goale sau alege un produs.", "mediaStepUnsupported": "Acest canal nu poate trimite acest tip de conținut media. Elimină pasul sau îndreaptă acest nod spre un canal compatibil.", - "mediaButtonsUnsupported": "Acest canal nu poate trimite butoane împreună cu un mesaj media. Elimină butoanele sau mută-le într-un pas text de după acesta." + "mediaButtonsUnsupported": "Acest canal nu poate trimite butoane împreună cu un mesaj media. Elimină butoanele sau mută-le într-un pas text de după acesta.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Această integrare îți permite să creezi boți Instagram.", diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json index cb8b661602..c365e8805d 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -3772,7 +3772,29 @@ "waTemplateLtoExpirationRequired": "Välj ett utgångsdatum för det tidsbegränsade erbjudandet innan du skickar.", "waTemplateMpmIncompleteProducts": "Varje sektion behöver minst en vald produkt — ta bort tomma rader eller välj en produkt.", "mediaStepUnsupported": "Den här kanalen kan inte skicka den här medietypen. Ta bort steget eller peka noden mot en kanal som stöder det.", - "mediaButtonsUnsupported": "Den här kanalen kan inte skicka knappar tillsammans med ett mediemeddelande. Ta bort knapparna eller flytta dem till ett textsteg efter det här." + "mediaButtonsUnsupported": "Den här kanalen kan inte skicka knappar tillsammans med ett mediemeddelande. Ta bort knapparna eller flytta dem till ett textsteg efter det här.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "messenger": { "botProfile": "Botprofil", diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json index c3963dd513..d573a18703 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Göndermeden önce sınırlı süreli teklif için bir bitiş tarihi seçin.", "waTemplateMpmIncompleteProducts": "Her bölümde en az bir seçili ürün olmalı — boş satırları silin veya onlara ürün seçin.", "mediaStepUnsupported": "Bu kanal bu medya türünü gönderemez. Adımı kaldırın veya bu düğümü destekleyen bir kanala yönlendirin.", - "mediaButtonsUnsupported": "Bu kanal, düğmeleri bir medya mesajıyla birlikte gönderemez. Düğmeleri kaldırın veya bundan sonraki bir metin adımına taşıyın." + "mediaButtonsUnsupported": "Bu kanal, düğmeleri bir medya mesajıyla birlikte gönderemez. Düğmeleri kaldırın veya bundan sonraki bir metin adımına taşıyın.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Bu entegrasyon, Instagram botları oluşturmanızı sağlar.", diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json index fec2770470..9025ac92b5 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -3185,7 +3185,29 @@ "waTemplateLtoExpirationRequired": "Chọn thời điểm hết hạn cho ưu đãi có thời hạn trước khi gửi.", "waTemplateMpmIncompleteProducts": "Mỗi nhóm cần ít nhất một sản phẩm đã chọn — xóa dòng trống hoặc chọn sản phẩm cho dòng đó.", "mediaStepUnsupported": "Kênh này không gửi được loại media này. Hãy xoá bước, hoặc trỏ node này sang kênh có hỗ trợ.", - "mediaButtonsUnsupported": "Kênh này không gửi được nút kèm tin nhắn media. Hãy xoá các nút, hoặc chuyển sang một bước văn bản ngay sau bước này." + "mediaButtonsUnsupported": "Kênh này không gửi được nút kèm tin nhắn media. Hãy xoá các nút, hoặc chuyển sang một bước văn bản ngay sau bước này.", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "instagram": { "description": "Tích hợp này cho phép bạn tạo bot Instagram.", diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json index 6ed74b47bc..7e1081e10d 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -3772,7 +3772,29 @@ "waTemplateLtoExpirationRequired": "发送前请为限时优惠选择到期时间。", "waTemplateMpmIncompleteProducts": "每个分组至少需要一个已选择的商品——请删除空行或为其选择商品。", "mediaStepUnsupported": "此渠道无法发送该类型的媒体。请删除该步骤,或将此节点指向支持的渠道。", - "mediaButtonsUnsupported": "此渠道无法随媒体消息发送按钮。请删除按钮,或将其移到此步骤之后的文本步骤中。" + "mediaButtonsUnsupported": "此渠道无法随媒体消息发送按钮。请删除按钮,或将其移到此步骤之后的文本步骤中。", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "messenger": { "botProfile": "机器人个人数据", diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json index 70bfae8367..083bb40a90 100644 --- a/apps/builder/messages/zh-TW.json +++ b/apps/builder/messages/zh-TW.json @@ -1577,7 +1577,29 @@ "waTemplateLtoExpirationRequired": "傳送前請為限時優惠選擇到期時間。", "waTemplateMpmIncompleteProducts": "每個分組至少需要一個已選擇的商品——請刪除空白列或為其選擇商品。", "mediaStepUnsupported": "此管道無法傳送該類型的媒體。請刪除該步驟,或將此節點指向支援的管道。", - "mediaButtonsUnsupported": "此管道無法隨媒體訊息傳送按鈕。請刪除按鈕,或移到此步驟之後的文字步驟中。" + "mediaButtonsUnsupported": "此管道無法隨媒體訊息傳送按鈕。請刪除按鈕,或移到此步驟之後的文字步驟中。", + "activity": { + "customFieldChanged": "Custom field changed: {name}", + "customFieldCleared": "Custom field cleared: {name}", + "previousValue": "Previous value", + "newValue": "New value", + "unset": "unset", + "tagAdded": "Tag added: {tag}", + "tagAddedLabel": "Tag added", + "tagRemoved": "Tag removed: {tag}", + "tagRemovedLabel": "Tag removed", + "flowTriggered": "Automation {name} was triggered", + "flowTriggeredPrefix": "Automation", + "flowTriggeredSuffix": "was triggered", + "sequenceSubscribed": "Subscribed to sequence: {name}", + "sequenceSubscribedPrefix": "Subscribed to sequence:", + "sequenceUnsubscribed": "Unsubscribed from sequence: {name}", + "sequenceUnsubscribedPrefix": "Unsubscribed from sequence:", + "conversationClosed": "Conversation was closed", + "conversationReopened": "Conversation was reopened", + "toggleHideEvents": "Hide system events", + "toggleShowEvents": "Show system events" + } }, "productCategories": { "deleteDescription": "{count, plural, =0 {此分類尚未指派任何商品。} one {# 項商品將移至未分類。} other {# 項商品將移至未分類。}}", diff --git a/apps/builder/src/features/chat/store/chat-store.ts b/apps/builder/src/features/chat/store/chat-store.ts index 110aa104fe..7858b929d2 100644 --- a/apps/builder/src/features/chat/store/chat-store.ts +++ b/apps/builder/src/features/chat/store/chat-store.ts @@ -66,6 +66,9 @@ export type ChatState = { // active facebook post (for comment conversations) activePost: PostDetails | null + + // show/hide system activity events in chat + showSystemEvents: boolean } export type ChatActions = { @@ -133,6 +136,9 @@ export type ChatActions = { // Contact actions updateContact: (contactId: string, data: Partial) => void + + // Activity events toggle + toggleSystemEvents: () => void } export type ChatStore = ChatState & ChatActions @@ -184,6 +190,10 @@ export const createChatStore = () => { replyToMessage: null, isPrivateReply: false, activePost: null, + showSystemEvents: true, + + toggleSystemEvents: () => + set((state) => ({ showSystemEvents: !state.showSystemEvents })), prependConversation: (newConversation: ListConversationItemResource) => set((state) => ({ diff --git a/apps/builder/src/features/messages/components/activity-message-item.tsx b/apps/builder/src/features/messages/components/activity-message-item.tsx new file mode 100644 index 0000000000..6b68a0109f --- /dev/null +++ b/apps/builder/src/features/messages/components/activity-message-item.tsx @@ -0,0 +1,218 @@ +"use client" + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@chatbotx.io/ui/components/ui/tooltip" +import { format } from "date-fns" +import { useTranslations } from "next-intl" +import type { MessageResourceWithRelations } from "../schema/resource" + +type ActivityContentAttributes = { + activityType?: + | "custom_field_changed" + | "custom_field_cleared" + | "tag_added" + | "tag_removed" + | "flow_triggered" + | "sequence_subscribed" + | "sequence_unsubscribed" + | "conversation_status_changed" + customFieldId?: string + customFieldName?: string + fieldKeyword?: string + oldValue?: string | null + newValue?: string + tags?: string[] + flowId?: string + flowName?: string + sequenceId?: string + sequenceName?: string + status?: string +} + +export const ActivityMessageItem = ({ + message, +}: { + message: MessageResourceWithRelations +}) => { + const t = useTranslations("messages.activity") + const attrs = (message.contentAttributes ?? {}) as ActivityContentAttributes + const formattedTime = format( + new Date(message.createdAt), + "yyyy/MM/dd HH:mm:ss", + ) + + const renderContent = () => { + switch (attrs.activityType) { + case "custom_field_changed": + return ( +
+
+ {t("customFieldChanged", { + name: + attrs.customFieldName ?? + attrs.fieldKeyword ?? + attrs.customFieldId ?? + "", + })} +
+
+ {attrs.oldValue === undefined ? null : ( +
+ + {t("previousValue")}: + {" "} + {attrs.oldValue || t("unset")} +
+ )} + {attrs.newValue ? ( +
+ + {t("newValue")}: + {" "} + {attrs.newValue} +
+ ) : null} +
+
+ ) + + case "custom_field_cleared": + return ( +
+ {t("customFieldCleared", { + name: attrs.fieldKeyword ?? attrs.customFieldName ?? "", + })} +
+ ) + + case "tag_added": + return ( +
+ {t("tagAddedLabel")}: + + {(attrs.tags ?? []).join(", ")} + +
+ ) + + case "tag_removed": + return ( +
+ {t("tagRemovedLabel")}: + + {(attrs.tags ?? []).join(", ")} + +
+ ) + + case "flow_triggered": { + const flowName = attrs.flowName ?? attrs.flowId ?? "" + return ( +
+ {t("flowTriggeredPrefix")} + + {flowName} + + {t("flowTriggeredSuffix")} +
+ ) + } + + case "sequence_subscribed": { + const seqName = attrs.sequenceName ?? attrs.sequenceId ?? "" + return ( +
+ {t("sequenceSubscribedPrefix")} + + {seqName} + +
+ ) + } + + case "sequence_unsubscribed": { + const seqName = attrs.sequenceName ?? attrs.sequenceId ?? "" + return ( +
+ {t("sequenceUnsubscribedPrefix")} + + {seqName} + +
+ ) + } + + case "conversation_status_changed": + return ( +
+ {attrs.status === "closed" + ? t("conversationClosed") + : t("conversationReopened")} +
+ ) + + default: { + const lines = (message.text ?? "").split("\n") + return ( +
+ {lines.map((line, idx) => { + const colonIdx = line.indexOf(":") + const lower = line.toLowerCase() + if ( + colonIdx !== -1 && + (lower.startsWith("previous value:") || + lower.startsWith("valor anterior:") || + lower.startsWith("new value:") || + lower.startsWith("nuevo valor:")) + ) { + const label = line.slice(0, colonIdx + 1) + const val = line.slice(colonIdx + 1) + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: lines are static text chunks +
+ + {label} + + {val} +
+ ) + } + return ( +
+ {line} +
+ ) + })} +
+ ) + } + } + } + + return ( +
+ + + {renderContent()} +
+ } + /> + +

{formattedTime}

+
+ + + ) +} diff --git a/apps/builder/src/features/messages/components/message-item.tsx b/apps/builder/src/features/messages/components/message-item.tsx index a01776dad7..219ee787e2 100644 --- a/apps/builder/src/features/messages/components/message-item.tsx +++ b/apps/builder/src/features/messages/components/message-item.tsx @@ -43,6 +43,7 @@ import { useState } from "react" import type { AttachmentResource } from "@/features/attachments/schema/resource" import { useAttachmentUrl } from "@/features/attachments/utils" import type { MessageResourceWithRelations } from "../schema/resource" +import { ActivityMessageItem } from "./activity-message-item" import { MessageActions, MessageActionsEditor } from "./message-actions" import { MessageBubble } from "./message-bubble" @@ -90,6 +91,10 @@ export const MessageItem = (props: MessageItemProps) => { const t = useTranslations("messages") const [isEditing, setIsEditing] = useState(false) + if (message.messageType === "activity") { + return + } + const variants: Record<"left" | "right" | "full", string> = { left: "px-4 py-3 rounded-xl bg-secondary", right: "px-4 py-3 rounded-xl bg-primary text-primary-foreground", diff --git a/apps/builder/src/features/messages/message-head.tsx b/apps/builder/src/features/messages/message-head.tsx index d30f633586..09f34139f5 100644 --- a/apps/builder/src/features/messages/message-head.tsx +++ b/apps/builder/src/features/messages/message-head.tsx @@ -6,7 +6,13 @@ import { TooltipContent, TooltipTrigger, } from "@chatbotx.io/ui/components/ui/tooltip" -import { ArrowLeftIcon, BotIcon, UserRoundIcon } from "lucide-react" +import { cn } from "@chatbotx.io/ui/lib/utils" +import { + ActivityIcon, + ArrowLeftIcon, + BotIcon, + UserRoundIcon, +} from "lucide-react" import { useTranslations } from "next-intl" import { useAction } from "next-safe-action/hooks" import { toast } from "sonner" @@ -37,6 +43,8 @@ export default function MessageHead({ activeConversationId, setAssignee, updateConversation, + showSystemEvents, + toggleSystemEvents, } = useChatStore((state) => state) const activeConversation = conversations.find( @@ -116,6 +124,34 @@ export default function MessageHead({ )} + + + + + } + /> + +

+ {showSystemEvents + ? t("messages.activity.toggleHideEvents") + : t("messages.activity.toggleShowEvents")} +

+
+
) diff --git a/apps/builder/src/features/messages/message-list.tsx b/apps/builder/src/features/messages/message-list.tsx index 48583d255f..61aaf27ecb 100644 --- a/apps/builder/src/features/messages/message-list.tsx +++ b/apps/builder/src/features/messages/message-list.tsx @@ -24,6 +24,7 @@ export function MessageList() { const { messages, + showSystemEvents, loadMoreMessages, isLoadMoreMessage, hasNextMessagePage, @@ -235,6 +236,10 @@ export function MessageList() { loadMoreMessages(workspaceId, MESSAGE_LIST_PER_PAGE) } + const filteredMessages = showSystemEvents + ? messages + : messages.filter((m) => m.messageType !== "activity") + return ( // `min-h-0`: Virtuoso's scroller is `height: 100%`, so without it this item // is floored at the full list height and pushes the composer below the fold @@ -246,7 +251,7 @@ export function MessageList() { List: MessageComponentList, Header: MessageComponentHeader, }} - data={messages} + data={filteredMessages} firstItemIndex={firstItemIndex} followOutput initialTopMostItemIndex={{ index: "LAST" }} diff --git a/apps/worker/src/integration/handlers/contact.ts b/apps/worker/src/integration/handlers/contact.ts index d2f50ed18c..2145412a83 100644 --- a/apps/worker/src/integration/handlers/contact.ts +++ b/apps/worker/src/integration/handlers/contact.ts @@ -2,6 +2,7 @@ import { contactCustomFieldService, contactNoteService, contactService, + messageService, tagService, } from "@chatbotx.io/business" import { contactSequenceService } from "@chatbotx.io/business/contact-sequence" @@ -61,6 +62,24 @@ export async function setContactCustomField({ allowBotFields: true, operation: step.operation, }) + + if (conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: `Custom field changed: ${step.inputFieldId}\nNew value: ${resolvedValue}`, + contentAttributes: { + activityType: "custom_field_changed", + fieldKeyword: step.inputFieldId, + newValue: resolvedValue, + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create custom field activity message") + }) + } } catch (error: unknown) { // Steps run one-per-BullMQ-job and the next step is only enqueued after // this one returns — a thrown error (e.g. an invalid number value) would @@ -91,6 +110,23 @@ export async function clearContactCustomField({ contactInboxId: contactInbox.id, allowBotFields: true, }) + + if (conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: `Custom field cleared: ${step.inputFieldId}`, + contentAttributes: { + activityType: "custom_field_cleared", + fieldKeyword: step.inputFieldId, + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create clear field activity message") + }) + } } export async function addContactNotes({ @@ -143,6 +179,23 @@ export async function addContactTag({ step.tags, contactInbox, ) + + if (step.tags.length > 0 && conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: `Tag added: ${step.tags.join(", ")}`, + contentAttributes: { + activityType: "tag_added", + tags: step.tags, + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create tag added activity message") + }) + } } /** @@ -185,6 +238,23 @@ export async function removeContactTag({ step.tags, contactInbox, ) + + if (step.tags.length > 0 && conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: `Tag removed: ${step.tags.join(", ")}`, + contentAttributes: { + activityType: "tag_removed", + tags: step.tags, + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create tag removed activity message") + }) + } } export async function detachTagsByNames( @@ -226,6 +296,23 @@ export async function addContactSequence({ sequenceId: step.sequenceId, contactInboxId: contactInbox.id, }) + + if (conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: "Subscribed to sequence", + contentAttributes: { + activityType: "sequence_subscribed", + sequenceId: step.sequenceId, + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create sequence activity message") + }) + } } export async function removeContactSequence({ @@ -244,6 +331,26 @@ export async function removeContactSequence({ reason: "unsubscribed_via_flow", contactInboxId: contactInbox.id, }) + + if (conversation.id && contactInbox.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: "Unsubscribed from sequence", + contentAttributes: { + activityType: "sequence_unsubscribed", + sequenceId: step.sequenceId, + }, + }) + .catch((err) => { + logger.warn( + { err }, + "Failed to create unsubscribe sequence activity message", + ) + }) + } } export async function subscribeBroadcast({ diff --git a/apps/worker/src/integration/handlers/flow.ts b/apps/worker/src/integration/handlers/flow.ts index 2bd8293a1a..6261df5d7a 100644 --- a/apps/worker/src/integration/handlers/flow.ts +++ b/apps/worker/src/integration/handlers/flow.ts @@ -1,5 +1,9 @@ import { automatedResponseService } from "@chatbotx.io/automated-response" -import { broadcastService } from "@chatbotx.io/business" +import { + broadcastService, + flowService, + messageService, +} from "@chatbotx.io/business" import { and, db, eq } from "@chatbotx.io/database/client" import { createMessageRepository } from "@chatbotx.io/database/repositories" import { contactsOnBroadcastsModel } from "@chatbotx.io/database/schema" @@ -277,6 +281,33 @@ export const runFlowNode = async ( targetType = "node" targetId = targetNode.id targetNodeId = targetNode.id + + const isInitialFlowStart = + !props.startFromStepId && (!props.nodeId || targetNode.data.isStartNode) + + if (isInitialFlowStart && conversation.id && contactInbox.id) { + try { + const flow = await flowService.findBy({ + id: props.flowId, + workspaceId: conversation.workspaceId, + }) + if (flow?.name) { + await messageService.createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: `Automation "${flow.name}" was triggered`, + contentAttributes: { + activityType: "flow_triggered", + flowId: flow.id, + flowName: flow.name, + }, + }) + } + } catch (err) { + logger.warn({ err }, "Failed to create flow_triggered activity message") + } + } } try { diff --git a/apps/worker/src/integration/handlers/step-handlers.ts b/apps/worker/src/integration/handlers/step-handlers.ts index 189c6d5bf6..bb772b4978 100644 --- a/apps/worker/src/integration/handlers/step-handlers.ts +++ b/apps/worker/src/integration/handlers/step-handlers.ts @@ -3,6 +3,7 @@ import { contactService, conversationService, inboxTeamService, + messageService, workspaceMemberService, } from "@chatbotx.io/business" import { gte, type SQL } from "@chatbotx.io/database/client" @@ -22,6 +23,7 @@ import { type UnfollowConversationStepSchema, } from "@chatbotx.io/flow-config" import { subHours } from "date-fns" +import { logger } from "../../lib/logger" import { allIntegrations, resolveIntegrationContextFromContactInbox, @@ -43,6 +45,7 @@ export async function stepBlockContact({ export async function stepArchiveConversation({ conversation, + contactInbox, }: ExecuteStepProps) { await conversationService.updateArchived({ workspaceId: conversation.workspaceId, @@ -54,10 +57,28 @@ export async function stepArchiveConversation({ triggerType: "flow_action", }, }) + + if (conversation.id && contactInbox?.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: "Conversation was closed", + contentAttributes: { + activityType: "conversation_status_changed", + status: "closed", + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create archive activity message") + }) + } } export async function stepUnarchiveConversation({ conversation, + contactInbox, }: ExecuteStepProps) { await conversationService.updateArchived({ workspaceId: conversation.workspaceId, @@ -69,6 +90,23 @@ export async function stepUnarchiveConversation({ triggerType: "flow_action", }, }) + + if (conversation.id && contactInbox?.id) { + await messageService + .createActivity({ + workspaceId: conversation.workspaceId, + conversationId: conversation.id, + contactInboxId: contactInbox.id, + text: "Conversation was reopened", + contentAttributes: { + activityType: "conversation_status_changed", + status: "open", + }, + }) + .catch((err) => { + logger.warn({ err }, "Failed to create unarchive activity message") + }) + } } export async function stepAssignConversation({ diff --git a/packages/business/__tests__/message-service.test.ts b/packages/business/__tests__/message-service.test.ts index ca9c888c83..b8e5941f4f 100644 --- a/packages/business/__tests__/message-service.test.ts +++ b/packages/business/__tests__/message-service.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const mocks = vi.hoisted(() => { const repo = { + create: vi.fn(), findById: vi.fn(), findLastByConversation: vi.fn(), hardDeleteAllByContactInbox: vi.fn(), @@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => { db: { query: { messageModel: { findFirst: vi.fn(), findMany: vi.fn() } } }, repo, createMessageRepository: vi.fn().mockResolvedValue(repo), + broadcastToWorkspaceParty: vi.fn(async () => undefined), withCache: vi.fn( async ( _key: string, @@ -38,11 +40,16 @@ vi.mock("@chatbotx.io/redis", () => ({ // wired in as a class field (module-scope import), not because any test here // exercises it — this file only covers the pre-existing read/delete methods. vi.mock("@chatbotx.io/filesystem", () => ({})) -vi.mock("@chatbotx.io/partysocket-config", () => ({})) +vi.mock("@chatbotx.io/partysocket-config", () => ({ + RealtimeEventType: { messageCreated: "messageCreated" }, +})) vi.mock("@chatbotx.io/worker-config", () => ({})) vi.mock("../src/contact-inbox/service", () => ({ contactInboxService: {} })) vi.mock("../src/conversation/service", () => ({ conversationService: {} })) vi.mock("../src/platform/settings", () => ({ resolveTenantSettings: vi.fn() })) +vi.mock("../src/platform/realtime-broadcast", () => ({ + broadcastToWorkspaceParty: mocks.broadcastToWorkspaceParty, +})) const { messageService } = await import("../src/message/service") @@ -187,4 +194,54 @@ describe("messageService", () => { }) expect(result).toEqual({ attachmentPaths: ["origin.jpg", "thumb.jpg"] }) }) + + test("createActivity persists an activity message and broadcasts it", async () => { + const createdMessage = { + id: "msg-act-1", + text: "Custom field changed: city", + messageType: "activity", + senderType: "system", + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + createdAt: new Date(), + } + mocks.repo.create.mockResolvedValue(createdMessage) + + const result = await messageService.createActivity({ + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + text: "Custom field changed: city", + contentAttributes: { + activityType: "custom_field_changed", + fieldName: "city", + }, + }) + + expect(mocks.createMessageRepository).toHaveBeenCalledTimes(1) + expect(mocks.repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Custom field changed: city", + messageType: "activity", + senderType: "system", + workspaceId: "ws-1", + conversationId: "conv-1", + contactInboxId: "ci-1", + contentType: "text", + contentAttributes: { + activityType: "custom_field_changed", + fieldName: "city", + }, + }), + ) + expect(mocks.broadcastToWorkspaceParty).toHaveBeenCalledWith("ws-1", { + eventType: "messageCreated", + data: { + ...createdMessage, + attachments: [], + }, + }) + expect(result).toBe(createdMessage) + }) }) diff --git a/packages/business/src/message/service.ts b/packages/business/src/message/service.ts index 58ae56961e..5ba770cd71 100644 --- a/packages/business/src/message/service.ts +++ b/packages/business/src/message/service.ts @@ -5,8 +5,11 @@ import { type MessageWithAttachments, } from "@chatbotx.io/database/repositories" import type { MessageModel } from "@chatbotx.io/database/types" +import { RealtimeEventType } from "@chatbotx.io/partysocket-config" import { withCache } from "@chatbotx.io/redis" import { BaseService } from "../base.service" +import { logger } from "../logger" +import { broadcastToWorkspaceParty } from "../platform/realtime-broadcast" import { createOutgoing } from "./create-outgoing" import { findByIdWithUrls, @@ -160,6 +163,54 @@ class MessageService extends BaseService { const repo = await createMessageRepository(tx) return await repo.hardDeleteAllByContactInbox(params) } + + async createActivity(props: { + tx?: DatabaseClient + workspaceId: string + conversationId: string + contactInboxId: string + text: string + contentAttributes?: Record + }): Promise { + const { + tx = db, + workspaceId, + conversationId, + contactInboxId, + text, + contentAttributes, + } = props + const repo = await createMessageRepository(tx) + const createdAt = new Date() + + const message = await repo.create({ + text, + messageType: "activity", + workspaceId, + conversationId, + senderType: "system", + senderId: null, + contactInboxId, + contentType: "text", + createdAt, + contentAttributes: contentAttributes ?? null, + }) + + broadcastToWorkspaceParty(workspaceId, { + eventType: RealtimeEventType.messageCreated, + data: { + ...message, + attachments: [], + }, + }).catch((err) => { + logger.warn( + { err, workspaceId, conversationId }, + "Failed to broadcast activity messageCreated", + ) + }) + + return message + } } export const messageService = new MessageService()