fix: preserve custom files during upstream sync

This commit is contained in:
Rodribm10 2026-05-06 06:48:26 -03:00
parent cf7338a6e2
commit db9b451eeb
27 changed files with 1848 additions and 2 deletions

229
META-WEBHOOK-PROXY.md Normal file
View File

@ -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 1520% 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/<upstream_host>/webhooks/whatsapp/%2B<phone>
```
This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts `<upstream_host>`, checks it against an allowlist, and forwards the request to `https://<upstream_host>/webhooks/whatsapp/%2B<phone>`.
### 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/<your-chatwoot-domain>/webhooks/whatsapp/%2B<phone>
```
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<phone>
```
## 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.

View File

@ -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;
}
}

View File

@ -0,0 +1,87 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,31 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>

View File

@ -0,0 +1,54 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>

View File

@ -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 () => {
</label>
<div v-if="!alertAllInboxes">
<Multiselect
<multiselect
v-model="selectedAlertInboxes"
:options="inboxes || []"
track-by="id"

View File

@ -0,0 +1,443 @@
<script>
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);
},
},
};
</script>
<template>
<div class="mx-8">
<div class="flex p-2.5">
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
}}
</label>
<Avatar
:src="avatarUrl"
:size="72"
icon-name="i-ri-global-fill"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="websiteName"
:class="{ error: v$.websiteName.$error }"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.PLACE_HOLDER'
)
"
:error="websiteNameValidationErrorMsg"
@blur="v$.websiteName.$touch"
/>
<woot-input
v-model="welcomeHeading"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.PLACE_HOLDER'
)
"
/>
<Editor
v-model="welcomeTagline"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
)
"
:max-length="255"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>
{{
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.LABEL')
}}
<select v-model="replyTime">
<option
v-for="option in getReplyTimeOptions"
:key="option.key"
:value="option.value"
>
{{ option.text }}
</option>
</select>
</label>
<label>
{{
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_COLOR_LABEL'
)
}}
<woot-color-picker v-model="color" />
</label>
<InputRadioGroup
name="widget-bubble-position"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION_LABEL'
)
"
:items="widgetBubblePositions"
:action="handleWidgetBubblePositionChange"
/>
<InputRadioGroup
name="widget-bubble-type"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE_LABEL'
)
"
:items="widgetBubbleTypes"
:action="handleWidgetBubbleTypeChange"
/>
<woot-input
v-model="widgetBubbleLauncherTitle"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.PLACE_HOLDER'
)
"
/>
<NextButton
type="submit"
class="mt-4"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.BUTTON_TEXT'
)
"
:is-loading="uiFlags.isUpdating"
:disabled="v$.$invalid || uiFlags.isUpdating"
/>
</form>
</div>
</div>
<div class="w-100 lg:w-3/5">
<InputRadioGroup
name="widget-view-options"
class="text-center"
:items="getWidgetViewOptions"
:action="handleWidgetViewChange"
/>
<div
v-if="isWidgetPreview"
class="flex flex-col items-center justify-end min-h-[40.625rem] mx-5 mb-5 p-2.5 bg-n-slate-3 rounded-lg"
>
<Widget
:welcome-heading="welcomeHeading"
:welcome-tagline="welcomeTagline"
:website-name="websiteName"
:logo="avatarUrl"
is-online
:reply-time="replyTime"
:color="color"
:widget-bubble-position="widgetBubblePosition"
:widget-bubble-launcher-title="widgetBubbleLauncherTitle"
:widget-bubble-type="widgetBubbleType"
/>
</div>
<div
v-else
class="mx-5 p-2.5 bg-n-slate-3 rounded-lg dark:bg-n-solid-3"
>
<woot-code :script="widgetScript" />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
<script setup>
import SLAListItem from './SLAListItem.vue';
</script>
<template>
<div class="w-full min-h-[12rem] relative">
<div class="w-full space-y-3">
<SLAListItem
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_1')"
:description="$t('SLA.LIST.EMPTY.DESC_1')"
first-response="20m"
next-response="1h"
resolution-time="24h"
has-business-hours
/>
<SLAListItem
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_2')"
:description="$t('SLA.LIST.EMPTY.DESC_2')"
first-response="2h"
next-response="4h"
resolution-time="4d"
has-business-hours
/>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
>
<slot />
</div>
</div>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
defineProps({
hasBusinessHours: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div
class="inline-flex items-center min-w-0 gap-1 px-1.5 sm:px-2 py-1 border border-solid rounded-lg border-n-weak"
>
<fluent-icon
size="14"
:icon="hasBusinessHours ? 'alarm-on' : 'alarm-off'"
type="outline"
class="flex-shrink-0"
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
/>
<span
class="hidden text-xs tracking-[0.2%] font-normal truncate sm:block"
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
>
{{
hasBusinessHours
? $t('SLA.LIST.BUSINESS_HOURS_ON')
: $t('SLA.LIST.BUSINESS_HOURS_OFF')
}}
</span>
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
import BaseEmptyState from './BaseEmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['primaryAction']);
const primaryAction = () => emit('primaryAction');
</script>
<template>
<BaseEmptyState>
<p class="max-w-xs text-sm font-medium text-center">
{{ $t('SLA.LIST.404') }}
</p>
<NextButton
icon="i-lucide-plus"
class="mt-4"
:label="$t('SLA.ADD_ACTION_LONG')"
@click="primaryAction"
/>
</BaseEmptyState>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
defineProps({
showActions: {
type: Boolean,
default: true,
},
});
defineEmits(['add']);
</script>
<template>
<BaseSettingsHeader
:title="$t('SLA.HEADER')"
:description="$t('SLA.DESCRIPTION')"
:link-text="$t('SLA.LEARN_MORE')"
feature-name="sla"
>
<template v-if="showActions" #actions>
<Button
:label="$t('SLA.ADD_ACTION')"
icon="i-lucide-circle-plus"
@click="$emit('add')"
/>
</template>
</BaseSettingsHeader>
</template>

View File

@ -0,0 +1,71 @@
<script setup>
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
import SLAResponseTime from './SLAResponseTime.vue';
import SLABusinessHoursLabel from './SLABusinessHoursLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
slaName: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
firstResponse: {
type: String,
required: true,
},
nextResponse: {
type: String,
required: true,
},
resolutionTime: {
type: String,
required: true,
},
hasBusinessHours: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete']);
</script>
<template>
<BaseSettingsListItem
class="sm:divide-x sm:divide-n-weak"
:title="slaName"
:description="description"
>
<template #label>
<SLABusinessHoursLabel :has-business-hours="hasBusinessHours" />
</template>
<template #rightSection>
<div
class="flex items-center divide-x rtl:divide-x-reverse sm:rtl:!border-l-0 sm:rtl:!border-r sm:rtl:border-solid sm:rtl:border-n-weak gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between divide-n-weak"
>
<SLAResponseTime response-type="FRT" :response-time="firstResponse" />
<SLAResponseTime response-type="NRT" :response-time="nextResponse" />
<SLAResponseTime response-type="RT" :response-time="resolutionTime" />
</div>
</template>
<template #actions>
<Button
v-tooltip.top="$t('SLA.FORM.DELETE')"
faded
ruby
xs
icon="i-lucide-trash-2"
:is-loading="isLoading"
@click="emit('delete')"
/>
</template>
</BaseSettingsListItem>
</template>

View File

@ -0,0 +1,41 @@
<script setup>
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
</script>
<template>
<BaseSettingsListItem class="opacity-50">
<template #title>
<div
class="w-24 h-[26px] rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
/>
</template>
<template #description>
<div
class="w-64 h-4 mb-0.5 rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
/>
<div
class="w-48 h-4 rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
/>
</template>
<template #label>
<div
class="w-32 h-[26px] bg-n-slate-4 dark:bg-n-slate-6 animate-pulse rounded-md"
/>
</template>
<template #rightSection>
<div
class="flex items-center sm:rtl:!border-l-0 sm:rtl:!border-r border-n-weak gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between"
>
<div
v-for="ii in 3"
:key="ii"
class="flex justify-end w-1/3 h-full px-4"
>
<div
class="w-32 h-full rounded-md bg-n-slate-4 dark:bg-n-slate-6 animate-pulse"
/>
</div>
</div>
</template>
</BaseSettingsListItem>
</template>

View File

@ -0,0 +1,37 @@
<script setup>
defineProps({
responseType: {
type: String,
default: '',
},
responseTime: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex flex-row items-start w-full h-full gap-1 sm:items-end sm:px-6 sm:py-2 sm:gap-2 sm:flex-col"
>
<span
class="inline-flex items-center gap-1 tracking-[-0.6%] text-sm ltr:pl-1.5 sm:ltr:pl-0 rtl:pr-1.5 sm:rtl:pr-0 text-n-slate-11"
>
<fluent-icon
v-tooltip.left="$t(`SLA.LIST.RESPONSE_TYPES.${responseType}`)"
size="14"
icon="information"
type="outline"
class="flex-shrink-0 hidden text-sm font-normal sm:flex sm:font-medium text-n-slate-11"
/>
{{ $t(`SLA.LIST.RESPONSE_TYPES.SHORT_HAND.${responseType}`) }}
<span class="flex sm:hidden">:</span>
</span>
<span
class="text-sm sm:text-2xl font-medium tracking-[-1.5%] text-n-slate-12"
>
{{ responseTime }}
</span>
</div>
</template>

View File

@ -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

View File

@ -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

170
docs/BAILEYS_VPN_SETUP.md Normal file
View File

@ -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 <https://mullvad.net/en/account/create> 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 <https://mullvad.net/en/account/wireguard-config>, 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=<your-private-key>
- WIREGUARD_ADDRESSES=<your-ipv4-address>/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 <GLUETUN_CONTAINER> 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.

View File

@ -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

12
lib/tasks/companies.rake Normal file
View File

@ -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

View File

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M9.7642 8L9.62358 14.1619H8.25142L8.11506 8H9.7642ZM8.9375 16.821C8.67898 16.821 8.45739 16.7301 8.27273 16.5483C8.09091 16.3665 8 16.1449 8 15.8835C8 15.6278 8.09091 15.4091 8.27273 15.2273C8.45739 15.0455 8.67898 14.9545 8.9375 14.9545C9.19034 14.9545 9.40909 15.0455 9.59375 15.2273C9.78125 15.4091 9.875 15.6278 9.875 15.8835C9.875 16.0568 9.83097 16.2145 9.7429 16.3565C9.65767 16.4986 9.54403 16.6122 9.40199 16.6974C9.26278 16.7798 9.10795 16.821 8.9375 16.821Z" fill="#446888"/>
<path d="M13.1073 8L12.9667 14.1619H11.5945L11.4582 8H13.1073ZM12.2806 16.821C12.0221 16.821 11.8005 16.7301 11.6159 16.5483C11.434 16.3665 11.3431 16.1449 11.3431 15.8835C11.3431 15.6278 11.434 15.4091 11.6159 15.2273C11.8005 15.0455 12.0221 14.9545 12.2806 14.9545C12.5335 14.9545 12.7522 15.0455 12.9369 15.2273C13.1244 15.4091 13.2181 15.6278 13.2181 15.8835C13.2181 16.0568 13.1741 16.2145 13.086 16.3565C13.0008 16.4986 12.8872 16.6122 12.7451 16.6974C12.6059 16.7798 12.4511 16.821 12.2806 16.821Z" fill="#446888"/>
<path d="M16.4505 8L16.3098 14.1619H14.9377L14.8013 8H16.4505ZM15.6237 16.821C15.3652 16.821 15.1436 16.7301 14.959 16.5483C14.7772 16.3665 14.6862 16.1449 14.6862 15.8835C14.6862 15.6278 14.7772 15.4091 14.959 15.2273C15.1436 15.0455 15.3652 14.9545 15.6237 14.9545C15.8766 14.9545 16.0953 15.0455 16.28 15.2273C16.4675 15.4091 16.5612 15.6278 16.5612 15.8835C16.5612 16.0568 16.5172 16.2145 16.4291 16.3565C16.3439 16.4986 16.2303 16.6122 16.0882 16.6974C15.949 16.7798 15.7942 16.821 15.6237 16.821Z" fill="#446888"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M12.7642 8L12.6236 14.1619H11.2514L11.1151 8H12.7642ZM11.9375 16.821C11.679 16.821 11.4574 16.7301 11.2727 16.5483C11.0909 16.3665 11 16.1449 11 15.8835C11 15.6278 11.0909 15.4091 11.2727 15.2273C11.4574 15.0455 11.679 14.9545 11.9375 14.9545C12.1903 14.9545 12.4091 15.0455 12.5938 15.2273C12.7812 15.4091 12.875 15.6278 12.875 15.8835C12.875 16.0568 12.831 16.2145 12.7429 16.3565C12.6577 16.4986 12.544 16.6122 12.402 16.6974C12.2628 16.7798 12.108 16.821 11.9375 16.821Z" fill="#446888"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M10.7642 8L10.6236 14.1619H9.25142L9.11506 8H10.7642ZM9.9375 16.821C9.67898 16.821 9.45739 16.7301 9.27273 16.5483C9.09091 16.3665 9 16.1449 9 15.8835C9 15.6278 9.09091 15.4091 9.27273 15.2273C9.45739 15.0455 9.67898 14.9545 9.9375 14.9545C10.1903 14.9545 10.4091 15.0455 10.5938 15.2273C10.7812 15.4091 10.875 15.6278 10.875 15.8835C10.875 16.0568 10.831 16.2145 10.7429 16.3565C10.6577 16.4986 10.544 16.6122 10.402 16.6974C10.2628 16.7798 10.108 16.821 9.9375 16.821Z" fill="#446888"/>
<path d="M14.1073 8L13.9667 14.1619H12.5945L12.4582 8H14.1073ZM13.2806 16.821C13.0221 16.821 12.8005 16.7301 12.6159 16.5483C12.434 16.3665 12.3431 16.1449 12.3431 15.8835C12.3431 15.6278 12.434 15.4091 12.6159 15.2273C12.8005 15.0455 13.0221 14.9545 13.2806 14.9545C13.5335 14.9545 13.7522 15.0455 13.9369 15.2273C14.1244 15.4091 14.2181 15.6278 14.2181 15.8835C14.2181 16.0568 14.1741 16.2145 14.086 16.3565C14.0008 16.4986 13.8872 16.6122 13.7451 16.6974C13.6059 16.7798 13.4511 16.821 13.2806 16.821Z" fill="#446888"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M13.5686 8L11.1579 16.9562H10L12.4107 8H13.5686Z" fill="#446888"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#FFEBEE"/>
<path d="M8 8.5C8 7.94772 8.44772 7.5 9 7.5C9.55228 7.5 10 7.94772 10 8.5V13C10 13.5523 9.55228 14 9 14C8.44772 14 8 13.5523 8 13V8.5Z" fill="#FF382D"/>
<path d="M8 15.5C8 14.9477 8.44772 14.5 9 14.5C9.55228 14.5 10 14.9477 10 15.5C10 16.0523 9.55228 16.5 9 16.5C8.44772 16.5 8 16.0523 8 15.5Z" fill="#FF382D"/>
<path d="M11 8.5C11 7.94772 11.4477 7.5 12 7.5C12.5523 7.5 13 7.94772 13 8.5V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8.5Z" fill="#FF382D"/>
<path d="M11 15.5C11 14.9477 11.4477 14.5 12 14.5C12.5523 14.5 13 14.9477 13 15.5C13 16.0523 12.5523 16.5 12 16.5C11.4477 16.5 11 16.0523 11 15.5Z" fill="#FF382D"/>
<path d="M14 8.5C14 7.94772 14.4477 7.5 15 7.5C15.5523 7.5 16 7.94772 16 8.5V13C16 13.5523 15.5523 14 15 14C14.4477 14 14 13.5523 14 13V8.5Z" fill="#FF382D"/>
<path d="M14 15.5C14 14.9477 14.4477 14.5 15 14.5C15.5523 14.5 16 14.9477 16 15.5C16 16.0523 15.5523 16.5 15 16.5C14.4477 16.5 14 16.0523 14 15.5Z" fill="#FF382D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

44
public/manifest.json Normal file
View File

@ -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"
}

View File

@ -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

View File

@ -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