diff --git a/META-WEBHOOK-PROXY.md b/META-WEBHOOK-PROXY.md new file mode 100644 index 000000000..9062d14d5 --- /dev/null +++ b/META-WEBHOOK-PROXY.md @@ -0,0 +1,229 @@ +# Meta Webhook Proxy + +## Problem + +Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 15–20% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely. + +``` +Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance +``` + +## Architecture + +The proxy is a single nginx server that routes requests based on the first path segment: + +``` +https://proxy.example.com//webhooks/whatsapp/%2B +``` + +This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts ``, checks it against an allowlist, and forwards the request to `https:///webhooks/whatsapp/%2B`. + +### Multi-tenant + +One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist. + +## Setup + +### Prerequisites + +- A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.) +- A DNS A record pointing your proxy domain to the server IP (e.g., `proxy.example.com → 1.2.3.4`) +- SSH root access to the server + +### 1. Install nginx and certbot + +```bash +ssh root@proxy.example.com + +apt-get update +apt-get install -y nginx certbot python3-certbot-nginx +``` + +### 2. Create a temporary HTTP-only config + +Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config: + +```bash +cat > /etc/nginx/sites-available/cw-proxy << 'EOF' +server { + listen 80; + server_name proxy.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 404; + } +} +EOF +``` + +Enable the site and reload: + +```bash +rm -f /etc/nginx/sites-enabled/default +ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy +nginx -t && systemctl reload nginx +``` + +### 3. Obtain the SSL certificate + +```bash +certbot certonly --webroot -w /var/www/html -d proxy.example.com \ + --non-interactive --agree-tos -m your-email@example.com +``` + +Certbot installs a systemd timer that auto-renews the certificate before it expires. + +### 4. Deploy the full proxy config + +Replace the temporary config with the full proxy configuration: + +```bash +cat > /etc/nginx/sites-available/cw-proxy << 'EOF' +# Allowlist of upstream Chatwoot hosts +# Add new hosts here to enable proxying +map $upstream_host $upstream_allowed { + default 0; + chatwoot.example.com 1; + # chatwoot.other.com 1; +} + +server { + listen 80; + server_name proxy.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name proxy.example.com; + + resolver 1.1.1.1 8.8.8.8 valid=300s; + resolver_timeout 5s; + + ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem; + + # Extract upstream host from first path segment, proxy the rest + location ~ ^/([^/]+)(/.*)$ { + set $upstream_host $1; + set $upstream_path $2; + + # Reject hosts not in the allowlist + if ($upstream_allowed = 0) { + return 403; + } + + proxy_pass https://$upstream_host$upstream_path$is_args$args; + proxy_set_header Host $upstream_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_ssl_server_name on; + } + + location / { + return 404; + } +} +EOF +``` + +Test and reload: + +```bash +nginx -t && systemctl reload nginx +``` + +### 5. Verify + +```bash +# Root path → 404 +curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/ +# Expected: 404 + +# Unknown host → 403 +curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test +# Expected: 403 + +# Allowed host → proxied (502 if upstream is unreachable, 200 if live) +curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test +# Expected: 502 or 200 +``` + +### 6. Configure Meta webhook URL + +In the Meta App Dashboard, set the webhook callback URL to: + +``` +https://proxy.example.com//webhooks/whatsapp/%2B +``` + +For example, if your Chatwoot is at `chatwoot.example.com` and the phone number is `+5511999999999`: + +``` +https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999 +``` + +Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently. + +## Adding a new upstream + +1. SSH into the proxy server +2. Edit `/etc/nginx/sites-available/cw-proxy` +3. Add the new host to the `map` block: + ```nginx + map $upstream_host $upstream_allowed { + default 0; + chatwoot.example.com 1; + chatwoot.newclient.com 1; # ← add this line + } + ``` +4. Test and reload: + ```bash + nginx -t && systemctl reload nginx + ``` +5. Set the Meta webhook callback URL for the new instance to: + ``` + https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B + ``` + +## Removing an upstream + +1. Remove or comment out the host from the `map` block +2. `nginx -t && systemctl reload nginx` +3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy) + +## Key nginx directives + +| Directive | Purpose | +|-----------|---------| +| `map $upstream_host $upstream_allowed` | Allowlist of permitted upstream hosts. Only hosts set to `1` are proxied; all others get 403. | +| `proxy_ssl_server_name on` | Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate. | +| `resolver 1.1.1.1 8.8.8.8 valid=300s` | Required because `proxy_pass` uses a variable (`$upstream_host`), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS. | +| `proxy_set_header Host $upstream_host` | Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly. | + +## Failure modes + +All recoverable — Meta retries with exponential backoff for up to 36 hours: + +| Failure | What happens | Recovery | +|---------|-------------|----------| +| Proxy down | Connection refused | Meta retries | +| Upstream down | 502 Bad Gateway | Meta retries | +| SSL expired | TLS handshake error | Meta retries | + +## Important notes + +- **Do not rate-limit.** Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal. +- **SSL auto-renewal** is handled by the certbot systemd timer. Verify with `systemctl status certbot.timer`. +- The `%2B` in the URL is the URL-encoded `+` sign for the phone number's country code. diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss new file mode 100644 index 000000000..9567d66c3 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -0,0 +1,273 @@ +@mixin label-multiselect-hover { + &::after { + @apply text-n-brand; + } + + &:hover { + @apply bg-n-slate-3; + + &::after { + @apply text-n-blue-11; + } + } +} + +.multiselect { + &:not(.no-margin) { + @apply mb-4; + } + + &.invalid .multiselect__tags { + @apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8; + } + + &.multiselect--disabled { + @apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto; + + .multiselect__select { + @apply cursor-not-allowed bg-transparent rounded-lg; + } + } + + .multiselect--active { + > .multiselect__tags { + @apply outline-n-blue-border; + } + } + + .multiselect__select { + @apply min-h-[2.875rem] p-0 right-0 top-0; + + &::before { + @apply right-0; + } + } + + .multiselect__content-wrapper { + @apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg; + } + + .multiselect__content { + @apply max-w-full; + + .multiselect__option { + @apply text-sm font-normal flex justify-between items-center; + + span { + @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; + } + + p { + @apply mb-0; + } + + &::after { + @apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; + } + + &.multiselect__option--highlight { + @apply bg-n-alpha-black2 text-n-slate-12; + } + + &.multiselect__option--highlight:hover { + @apply bg-n-brand/10 text-n-slate-12; + + &::after { + @apply bg-transparent text-center text-n-slate-12; + } + } + + &.multiselect__option--highlight::after { + @apply bg-transparent text-n-slate-12; + } + + &.multiselect__option--selected { + @apply bg-n-brand/20 text-n-slate-12; + + &::after { + @apply bg-transparent; + } + + &.multiselect__option--highlight:hover { + @apply bg-transparent; + + &::after:hover { + @apply text-n-slate-12 bg-transparent; + } + } + } + } + } + + .multiselect__tags { + @apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0; + + input { + @apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10; + } + } + + .multiselect__spinner { + background-color: transparent; + } + + .multiselect__tags-wrap { + @apply inline-block leading-none mt-1; + } + + .multiselect__placeholder { + @apply text-n-slate-10 font-normal pt-3; + } + + .multiselect__tag { + @apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5; + } + + .multiselect__tag-icon { + @include label-multiselect-hover; + } + + .multiselect__input { + @apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none; + } + + .multiselect__single { + @apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis; + } +} + +.sidebar-labels-wrap { + &.has-edited, + &:hover { + .multiselect { + @apply cursor-pointer; + } + } + + .multiselect { + > .multiselect__select { + @apply invisible; + } + + > .multiselect__tags { + @apply outline-transparent; + } + + &.multiselect--active > .multiselect__tags { + @apply outline-n-blue-border; + } + } +} + +.multiselect-wrap--small { + // To be removed one SLA reports date picker is created + &.tiny { + .multiselect.no-margin { + @apply min-h-[32px]; + } + + .multiselect__select { + @apply min-h-[32px] h-8; + + &::before { + @apply top-[60%]; + } + } + + .multiselect__tags { + @apply min-h-[32px] max-h-[32px]; + + .multiselect__single { + @apply pt-1 pb-1; + } + } + } + + .multiselect__tags, + .multiselect__input, + .multiselect { + @apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem]; + } + + .multiselect__input { + @apply h-[2.375rem] min-h-[2.375rem]; + } + + .multiselect__single { + @apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5; + } + + .multiselect__placeholder { + @apply m-0 py-2 px-0.5; + } + + .multiselect__tag { + @apply py-[6px] my-[1px]; + } + + .multiselect__select { + @apply min-h-[2.5rem]; + } + + .multiselect--disabled .multiselect__current, + .multiselect--disabled .multiselect__select { + @apply bg-transparent; + } +} + +.multiselect--disabled { + background-color: rgba(var(--black-alpha-2)) !important; + + .multiselect__tags { + @apply hover:outline-n-weak; + } +} + +.multiselect--active { + .multiselect__select::before { + @apply top-[62%]; + } +} + +.multiselect__select::before { + top: 60% !important; +} + +.multiselect-wrap--medium { + .multiselect__tags, + .multiselect__input { + @apply items-center flex; + } + + .multiselect__tags, + .multiselect__input, + .multiselect { + @apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem]; + } + + .multiselect__input { + @apply h-[2.875rem] min-h-[2.875rem]; + margin-bottom: 0 !important; + } + + .multiselect__single { + @apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12; + } + + .multiselect__placeholder { + @apply m-0 py-1 px-0.5; + } + + .multiselect__select { + @apply min-h-[3rem]; + } + + .multiselect--disabled .multiselect__current, + .multiselect--disabled .multiselect__select { + @apply bg-transparent; + } + + .multiselect__tags-wrap { + @apply flex-shrink-0; + } +} diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue new file mode 100644 index 000000000..ded6cb930 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/javascript/dashboard/components/FormSection.vue b/app/javascript/dashboard/components/FormSection.vue new file mode 100644 index 000000000..d622d470d --- /dev/null +++ b/app/javascript/dashboard/components/FormSection.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/PriorityMark.vue b/app/javascript/dashboard/components/widgets/conversation/PriorityMark.vue new file mode 100644 index 000000000..3a47da9c6 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/PriorityMark.vue @@ -0,0 +1,54 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/agents/EditAgent.vue b/app/javascript/dashboard/routes/dashboard/settings/agents/EditAgent.vue index d477cab32..c9e9cb978 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agents/EditAgent.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/agents/EditAgent.vue @@ -6,7 +6,6 @@ import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import Button from 'dashboard/components-next/button/Button.vue'; -import Multiselect from 'vue-multiselect'; import Auth from '../../../../api/auth'; import wootConstants from 'dashboard/constants/globals'; @@ -253,7 +252,7 @@ const resetPassword = async () => {
- +import { mapGetters } from 'vuex'; +import { useAlert } from 'dashboard/composables'; +import Widget from 'dashboard/modules/widget-preview/components/Widget.vue'; +import InputRadioGroup from './components/InputRadioGroup.vue'; +import { useVuelidate } from '@vuelidate/core'; +import { required } from '@vuelidate/validators'; +import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; +import { LocalStorage } from 'shared/helpers/localStorage'; +import NextButton from 'dashboard/components-next/button/Button.vue'; +import Avatar from 'next/avatar/Avatar.vue'; +import Editor from 'dashboard/components-next/Editor/Editor.vue'; + +export default { + components: { + Widget, + InputRadioGroup, + NextButton, + Editor, + Avatar, + }, + props: { + inbox: { + type: Object, + default: () => {}, + }, + }, + setup() { + return { v$: useVuelidate() }; + }, + data() { + return { + isWidgetPreview: true, + color: '#1f93ff', + websiteName: '', + welcomeHeading: '', + welcomeTagline: '', + replyTime: 'in_a_few_minutes', + avatarFile: null, + avatarUrl: '', + widgetBubblePosition: 'right', + widgetBubbleLauncherTitle: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.DEFAULT' + ), + widgetBubbleType: 'standard', + widgetBubblePositions: [ + { + id: 'left', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.LEFT' + ), + checked: false, + }, + { + id: 'right', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.RIGHT' + ), + checked: true, + }, + ], + widgetBubbleTypes: [ + { + id: 'standard', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.STANDARD' + ), + checked: true, + }, + { + id: 'expanded_bubble', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.EXPANDED_BUBBLE' + ), + checked: false, + }, + ], + }; + }, + computed: { + ...mapGetters({ + uiFlags: 'inboxes/getUIFlags', + }), + storageKey() { + return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`; + }, + widgetScript() { + let options = { + position: this.widgetBubblePosition, + type: this.widgetBubbleType, + launcherTitle: this.widgetBubbleLauncherTitle, + }; + let script = this.inbox.web_widget_script; + return ( + script.substring(0, 13) + + this.$t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', { + options: JSON.stringify(options), + }) + + script.substring(13) + ); + }, + getWidgetViewOptions() { + return [ + { + id: 'preview', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW' + ), + checked: true, + }, + { + id: 'script', + title: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT' + ), + checked: false, + }, + ]; + }, + getReplyTimeOptions() { + return [ + { + key: 'in_a_few_minutes', + value: 'in_a_few_minutes', + text: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_MINUTES' + ), + }, + { + key: 'in_a_few_hours', + value: 'in_a_few_hours', + text: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_HOURS' + ), + }, + { + key: 'in_a_day', + value: 'in_a_day', + text: this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_DAY' + ), + }, + ]; + }, + websiteNameValidationErrorMsg() { + return this.v$.websiteName.$error + ? this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.ERROR') + : ''; + }, + }, + mounted() { + this.setDefaults(); + }, + validations: { + websiteName: { required }, + }, + methods: { + setDefaults() { + // Widget Settings + const { + name, + welcome_title, + welcome_tagline, + widget_color, + reply_time, + avatar_url, + } = this.inbox; + this.websiteName = name; + this.welcomeHeading = welcome_title; + this.welcomeTagline = welcome_tagline; + this.color = widget_color; + this.replyTime = reply_time; + this.avatarUrl = avatar_url; + + const savedInformation = this.getSavedInboxInformation(); + if (savedInformation) { + this.widgetBubblePositions = this.widgetBubblePositions.map(item => { + if (item.id === savedInformation.position) { + item.checked = true; + this.widgetBubblePosition = item.id; + } + return item; + }); + this.widgetBubbleTypes = this.widgetBubbleTypes.map(item => { + if (item.id === savedInformation.type) { + item.checked = true; + this.widgetBubbleType = item.id; + } + return item; + }); + this.widgetBubbleLauncherTitle = + savedInformation.launcherTitle || 'Chat with us'; + } + }, + handleWidgetBubblePositionChange(item) { + this.widgetBubblePosition = item.id; + }, + handleWidgetBubbleTypeChange(item) { + this.widgetBubbleType = item.id; + }, + handleWidgetViewChange(item) { + this.isWidgetPreview = item.id === 'preview'; + }, + handleImageUpload({ file, url }) { + this.avatarFile = file; + this.avatarUrl = url; + }, + async handleAvatarDelete() { + try { + await this.$store.dispatch('inboxes/deleteInboxAvatar', this.inbox.id); + this.avatarFile = null; + this.avatarUrl = ''; + useAlert( + this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.SUCCESS_MESSAGE' + ) + ); + } catch (error) { + useAlert( + error.message + ? error.message + : this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.ERROR_MESSAGE' + ) + ); + } + }, + async updateWidget() { + const bubbleSettings = { + position: this.widgetBubblePosition, + launcherTitle: this.widgetBubbleLauncherTitle, + type: this.widgetBubbleType, + }; + + LocalStorage.set(this.storageKey, bubbleSettings); + + try { + const payload = { + id: this.inbox.id, + name: this.websiteName, + channel: { + widget_color: this.color, + welcome_title: this.welcomeHeading, + welcome_tagline: this.welcomeTagline, + reply_time: this.replyTime, + }, + }; + if (this.avatarFile) { + payload.avatar = this.avatarFile; + } + await this.$store.dispatch('inboxes/updateInbox', payload); + useAlert( + this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.SUCCESS_MESSAGE' + ) + ); + } catch (error) { + useAlert( + error.message || + this.$t( + 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.ERROR_MESSAGE' + ) + ); + } + }, + getSavedInboxInformation() { + return LocalStorage.get(this.storageKey); + }, + }, +}; + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/BaseEmptyState.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/BaseEmptyState.vue new file mode 100644 index 000000000..2711658ad --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/BaseEmptyState.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLABusinessHoursLabel.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLABusinessHoursLabel.vue new file mode 100644 index 000000000..983ff38b2 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLABusinessHoursLabel.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAEmptyState.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAEmptyState.vue new file mode 100644 index 000000000..90915d251 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAEmptyState.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue new file mode 100644 index 000000000..8eb80eeb0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue new file mode 100644 index 000000000..085860efa --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue new file mode 100644 index 000000000..7282f73d1 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue new file mode 100644 index 000000000..87860110a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/jobs/inboxes/bulk_auto_assignment_job.rb b/app/jobs/inboxes/bulk_auto_assignment_job.rb new file mode 100644 index 000000000..9e808648b --- /dev/null +++ b/app/jobs/inboxes/bulk_auto_assignment_job.rb @@ -0,0 +1,47 @@ +class Inboxes::BulkAutoAssignmentJob < ApplicationJob + queue_as :scheduled_jobs + include BillingHelper + + def perform + Account.feature_assignment_v2.find_each do |account| + if should_skip_auto_assignment?(account) + Rails.logger.info("Skipping auto assignment for account #{account.id}") + next + end + + account.inboxes.where(enable_auto_assignment: true).find_each do |inbox| + process_assignment(inbox) + end + end + end + + private + + def process_assignment(inbox) + allowed_agent_ids = inbox.member_ids_with_assignment_capacity + + if allowed_agent_ids.blank? + Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}") + return + end + + assign_conversations(inbox, allowed_agent_ids) + end + + def assign_conversations(inbox, allowed_agent_ids) + unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT) + unassigned_conversations.find_each do |conversation| + ::AutoAssignment::AgentAssignmentService.new( + conversation: conversation, + allowed_agent_ids: allowed_agent_ids + ).perform + Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}") + end + end + + def should_skip_auto_assignment?(account) + return false unless ChatwootApp.chatwoot_cloud? + + default_plan?(account) + end +end diff --git a/app/services/whatsapp/message_dedup_lock.rb b/app/services/whatsapp/message_dedup_lock.rb new file mode 100644 index 000000000..4a863a4a4 --- /dev/null +++ b/app/services/whatsapp/message_dedup_lock.rb @@ -0,0 +1,19 @@ +# Atomic dedup lock for WhatsApp incoming messages. +# +# Meta can deliver the same webhook event multiple times. This lock uses +# Redis SET NX EX to ensure only one worker processes a given source_id. +class Whatsapp::MessageDedupLock + KEY_PREFIX = Redis::RedisKeys::MESSAGE_SOURCE_KEY + DEFAULT_TTL = 1.day.to_i + + def initialize(source_id, ttl: DEFAULT_TTL) + @key = format(KEY_PREFIX, id: source_id) + @ttl = ttl + end + + # Returns true when the lock is acquired (caller should proceed). + # Returns false when another worker already holds the lock. + def acquire! + ::Redis::Alfred.set(@key, true, nx: true, ex: @ttl) + end +end diff --git a/docs/BAILEYS_VPN_SETUP.md b/docs/BAILEYS_VPN_SETUP.md new file mode 100644 index 000000000..f9f160c87 --- /dev/null +++ b/docs/BAILEYS_VPN_SETUP.md @@ -0,0 +1,170 @@ +# VPN Setup for Baileys API (Gluetun + Mullvad) + +## Problem + +Hosting providers like Hostinger have their IP ranges mass-blocked by Meta. This causes WhatsApp connections through Baileys to fail. The solution is to route **only** Baileys traffic through a VPN, keeping everything else (Rails, Sidekiq, Postgres, Redis) on the regular network. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Network (coolify) │ +│ │ +│ ┌──────────┐ ┌──────────────────────────────┐ │ +│ │ Rails │ │ Gluetun (VPN tunnel) │ │ +│ │ Sidekiq │───▶│ :3025 ──▶ Baileys API │ │ +│ └──────────┘ │ (network_mode: service) │ │ +│ │ │ │ │ │ +│ │ │ │ VPN (WireGuard) │ │ +│ │ │ ▼ │ │ +│ │ │ Mullvad SP (Brazil) │ │ +│ │ └──────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Redis │◀───────│ Baileys │ │ +│ │ Postgres│ │ (via VPN)│ │ +│ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**Gluetun** creates a WireGuard VPN tunnel. Baileys shares Gluetun's network via `network_mode: "service:gluetun"`, so all WhatsApp traffic exits through the VPN IP. Internal Docker traffic (Redis, etc.) is exempted via firewall subnet rules. + +--- + +## Step 1 — Get Mullvad Credentials + +1. Go to and generate an account (no email needed — save the 16-digit account number). +2. Add credit (€5/month, accepts card, PayPal, crypto). +3. Go to , log in, select **Linux**, click **Generate key**, choose **Brazil > São Paulo**, and download the `.conf` file. +4. From the downloaded file, note: + - `PrivateKey` — e.g. `KLaIt4oAaI6Iz4iQhSS9/0UBlbvfmG1LC/NWGXW/DH4=` + - `Address` — use **only the IPv4** address, e.g. `10.67.152.85/32` (discard the IPv6 address) + +> **Important:** Gluetun does not support IPv6. Only use the IPv4 address from the `.conf` file. + +--- + +## Step 2 — Docker Compose Changes + +### 2.1 Add the `gluetun` service (baileys-api compose) + +```yaml +gluetun: + image: qmcgaw/gluetun + restart: always + cap_add: + - NET_ADMIN + ports: + - '3025:3025' + environment: + - VPN_SERVICE_PROVIDER=mullvad + - VPN_TYPE=wireguard + - WIREGUARD_PRIVATE_KEY= + - WIREGUARD_ADDRESSES=/32 + - SERVER_COUNTRIES=Brazil + - SERVER_CITIES=Sao Paulo + - FIREWALL_OUTBOUND_SUBNETS=172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 + - DNS_KEEP_NAMESERVER=on + healthcheck: + test: + - CMD-SHELL + - 'wget -qO- https://ipinfo.io/ip' + interval: 30s + timeout: 10s + retries: 5 + networks: + - coolify +``` + +Key environment variables: + +| Variable | Purpose | +|---|---| +| `FIREWALL_OUTBOUND_SUBNETS` | Allows internal Docker traffic (Redis, etc.) to bypass the VPN. Must cover all private subnets used by Docker. | +| `DNS_KEEP_NAMESERVER` | Keeps Docker's internal DNS so containers can resolve hostnames like `redis`. Without this, you get `getaddrinfo ENOTFOUND` errors. | + +> **Do NOT set `OWNED_ONLY=yes`** — Mullvad does not have owned servers in São Paulo, only rented ones. This filter would match zero servers. + +### 2.2 Modify the `baileys-api` service + +Apply these changes to the existing baileys-api service: + +```yaml +baileys-api: + # ... existing config ... + network_mode: 'service:gluetun' # Route all traffic through Gluetun + depends_on: + gluetun: + condition: service_healthy # Wait for VPN to be up + # REMOVE any 'ports' section — port 3025 is now exposed by gluetun + # REMOVE any 'networks' section — network_mode is incompatible with networks +``` + +> **`condition: service_healthy`** is critical. Without it, baileys-api starts before the VPN tunnel is established, causing Redis connection timeouts. + +### 2.3 Update `BAILEYS_PROVIDER_DEFAULT_URL` in Chatwoot + +In the Rails and Sidekiq services (or Coolify environment variables), change: + +``` +# Before +BAILEYS_PROVIDER_DEFAULT_URL=http://baileys-api:3025 + +# After +BAILEYS_PROVIDER_DEFAULT_URL=http://gluetun:3025 +``` + +Since baileys-api now shares Gluetun's network, external services must address it via `gluetun` hostname. + +### 2.4 Declare the shared network + +If the baileys-api compose is a separate stack, declare the shared network: + +```yaml +networks: + coolify: + external: true + name: coolify +``` + +--- + +## Step 3 — Verify + +After deploying, run from the server's SSH terminal: + +```bash +# Check container health +docker ps | grep -E "gluetun|baileys" + +# Get the VPN exit IP (replace with your actual gluetun container name) +docker exec wget -qO- https://ipinfo.io/ip + +# Compare with the server's real IP +curl -s https://ipinfo.io/ip +``` + +If the two IPs are **different**, the VPN is working correctly. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `Redis client error` / connection timeout on Redis | Baileys starts before VPN is ready | Add `depends_on` with `condition: service_healthy` | +| `Redis client error` / connection refused | Internal Docker traffic blocked by VPN firewall | Add `192.168.0.0/16` to `FIREWALL_OUTBOUND_SUBNETS` | +| `getaddrinfo ENOTFOUND redis` | Docker DNS not working inside VPN | Set `DNS_KEEP_NAMESERVER=on` in gluetun | +| `no server found: ... city sao paulo; owned servers only` | Mullvad has no owned servers in São Paulo | Remove `OWNED_ONLY=yes` from gluetun env | +| `interface address is IPv6 but IPv6 is not supported` | IPv6 address in `WIREGUARD_ADDRESSES` | Use only the IPv4 address (remove the `fc00:...` part) | +| `REDIS_URL` with hardcoded IP (e.g. `172.19.0.2`) | Docker internal IPs change on restart | Always use hostnames (e.g. `redis://redis:6379`) | + +--- + +## VPN Expiration + +If the Mullvad subscription expires: +- **WhatsApp stops working** (Baileys can't connect through the VPN). +- **Everything else keeps running** normally (Rails, Sidekiq, Redis, Postgres are not affected). +- To restore: renew Mullvad, or revert the docker-compose changes to bypass the VPN entirely. diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake new file mode 100644 index 000000000..9e092c225 --- /dev/null +++ b/lib/tasks/auto_annotate_models.rake @@ -0,0 +1,9 @@ +# NOTE: only doing this in development as some production environments (Heroku) +# NOTE: are sensitive to local FS writes, and besides -- it's just not proper +# NOTE: to have a dev-mode tool do its thing in production. +if Rails.env.development? + require 'annotate_rb' + + # Configuration is in .annotaterb.yml + AnnotateRb::Core.load_rake_tasks +end diff --git a/lib/tasks/companies.rake b/lib/tasks/companies.rake new file mode 100644 index 000000000..11fb5dc10 --- /dev/null +++ b/lib/tasks/companies.rake @@ -0,0 +1,12 @@ +namespace :companies do + desc 'Backfill companies from existing contact email domains' + task backfill: :environment do + puts 'Starting company backfill migration...' + puts 'This will process all accounts and create companies from contact email domains.' + puts 'The job will run in the background via Sidekiq' + puts '' + Migration::CompanyBackfillJob.perform_later + puts 'Company backfill job has been enqueued.' + puts 'Monitor progress in logs or Sidekiq dashboard.' + end +end diff --git a/public/assets/images/dashboard/priority/high.svg b/public/assets/images/dashboard/priority/high.svg new file mode 100644 index 000000000..9196db1b7 --- /dev/null +++ b/public/assets/images/dashboard/priority/high.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/images/dashboard/priority/low.svg b/public/assets/images/dashboard/priority/low.svg new file mode 100644 index 000000000..5b6d70a69 --- /dev/null +++ b/public/assets/images/dashboard/priority/low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/images/dashboard/priority/medium.svg b/public/assets/images/dashboard/priority/medium.svg new file mode 100644 index 000000000..ffb17d44c --- /dev/null +++ b/public/assets/images/dashboard/priority/medium.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/images/dashboard/priority/none.svg b/public/assets/images/dashboard/priority/none.svg new file mode 100644 index 000000000..e215fda39 --- /dev/null +++ b/public/assets/images/dashboard/priority/none.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/images/dashboard/priority/urgent.svg b/public/assets/images/dashboard/priority/urgent.svg new file mode 100644 index 000000000..fc4f16b50 --- /dev/null +++ b/public/assets/images/dashboard/priority/urgent.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..78fcc4755 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "Chatwoot", + "short_name": "Chatwoot", + "icons": [{ + "src": "\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + }], + "start_url": "/", + "display": "standalone", + "background_color": "#1f93ff", + "theme_color": "#1f93ff" +} diff --git a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb b/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb new file mode 100644 index 000000000..3ad12d488 --- /dev/null +++ b/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Inboxes::BulkAutoAssignmentJob do + let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) } + let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) } + let(:inbox) { create(:inbox, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) } + let(:assignment_service) { double } + + describe '#perform' do + before do + allow(assignment_service).to receive(:perform) + end + + context 'when inbox has inbox members' do + before do + create(:inbox_member, user: agent, inbox: inbox) + account.enable_features!('assignment_v2') + inbox.update!(enable_auto_assignment: true) + end + + it 'assigns unassigned conversations in enabled inboxes' do + allow(AutoAssignment::AgentAssignmentService).to receive(:new).with( + conversation: conversation, + allowed_agent_ids: [agent.id] + ).and_return(assignment_service) + + described_class.perform_now + expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with( + conversation: conversation, + allowed_agent_ids: [agent.id] + ) + end + + it 'skips inboxes with auto assignment disabled' do + inbox.update!(enable_auto_assignment: false) + allow(AutoAssignment::AgentAssignmentService).to receive(:new) + + described_class.perform_now + + expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with( + conversation: conversation, + allowed_agent_ids: [agent.id] + ) + end + + context 'when account is on default plan in chatwoot cloud' do + before do + account.update!(custom_attributes: {}) + InstallationConfig.create!(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }]) + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + end + + it 'skips auto assignment' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}") + + allow(AutoAssignment::AgentAssignmentService).to receive(:new) + expect(AutoAssignment::AgentAssignmentService).not_to receive(:new) + + described_class.perform_now + end + end + end + + context 'when inbox has no members' do + before do + account.enable_features!('assignment_v2') + inbox.update!(enable_auto_assignment: true) + end + + it 'does not assign conversations' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}") + + described_class.perform_now + end + end + + context 'when assignment_v2 feature is disabled' do + before do + account.disable_features!('assignment_v2') + end + + it 'skips auto assignment' do + allow(AutoAssignment::AgentAssignmentService).to receive(:new) + expect(AutoAssignment::AgentAssignmentService).not_to receive(:new) + + described_class.perform_now + end + end + end +end diff --git a/spec/services/whatsapp/message_dedup_lock_spec.rb b/spec/services/whatsapp/message_dedup_lock_spec.rb new file mode 100644 index 000000000..f3009b2ff --- /dev/null +++ b/spec/services/whatsapp/message_dedup_lock_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe Whatsapp::MessageDedupLock do + let(:source_id) { "wamid.test_#{SecureRandom.hex(8)}" } + let(:lock) { described_class.new(source_id) } + let(:redis_key) { format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: source_id) } + + after { Redis::Alfred.delete(redis_key) } + + describe '#acquire!' do + it 'returns truthy on first acquire' do + expect(lock.acquire!).to be_truthy + end + + it 'returns falsy on second acquire for the same source_id' do + lock.acquire! + expect(described_class.new(source_id).acquire!).to be_falsy + end + + it 'allows different source_ids to acquire independently' do + lock.acquire! + other = described_class.new("wamid.other_#{SecureRandom.hex(8)}") + expect(other.acquire!).to be_truthy + end + + it 'lets exactly one thread win when two race for the same source_id' do + results = Concurrent::Array.new + barrier = Concurrent::CyclicBarrier.new(2) + + threads = Array.new(2) do + Thread.new do + barrier.wait + results << described_class.new(source_id).acquire! + end + end + + threads.each(&:join) + + wins = results.count { |r| r } + expect(wins).to eq(1), "Expected exactly 1 winner but got #{wins}. Results: #{results.inspect}" + end + end +end