8842c71a90a8bb61598061bf4fa2a56b6e6fdcb4
1941 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8842c71a90 |
fix: Handle NUL bytes in canned response search (#15397)
Canned response search now removes NUL bytes from user-provided search text before passing it to PostgreSQL, so malformed input returns normal search results instead of a database encoding error. ## Closes - [CW-7922](https://linear.app/chatwoot/issue/CW-7922/harden-backend-paths-causing-production-sentry-errors) - [Sentry 7663466064](https://chatwoot-p3.sentry.io/issues/7663466064/) ## How to reproduce Call the canned responses endpoint with a search parameter containing a NUL byte. PostgreSQL previously raised `PG::UntranslatableCharacter` while evaluating the search query. ## What changed - Strip NUL bytes once at the controller boundary. - Reuse the sanitized value for matching and result ranking. - Add request coverage for a search term containing a NUL byte. |
||
|
|
eaecc43ca4 |
fix: search returning 500 when a conversation has no messages (#15328)
## Description
`SearchService#filter_conversations` matches conversations on the
display id and on the contact name, email, phone number and identifier.
It never looks at message content, so a conversation with no messages is
a valid result whenever its contact matches.
The search views did not account for that. They rendered
`conversation.messages.try(:first)`, which is `nil` for such a
conversation, and `api/v1/models/_message` calls `message.id` on it:
```
ActionView::Template::Error (undefined method 'id' for nil):
1: json.id message.id
app/views/api/v1/models/_message.json.jbuilder:1
app/views/api/v1/accounts/search/_message.json.jbuilder:1
app/views/api/v1/accounts/search/conversations.json.jbuilder:8
```
A single conversation without messages is enough to turn the whole
search request into a 500 for that query, so the agent loses
conversation search entirely until that conversation gets a message.
Both views that render a conversation search result were affected, so
this applies to `GET /search/conversations` and to the combined `GET
/search`.
The fix guards the message partial the same way the neighbouring
`contact`, `inbox` and `agent` partials in those same views are already
guarded. When there is no message the key is rendered as an empty
object, which is what already happens for a missing contact, inbox or
assignee.
**How to reproduce**
1. Create a conversation without any message (for example via `POST
/api/v1/accounts/{id}/conversations` without a `message`).
2. Search for the contact's name or phone number: `GET
/api/v1/accounts/{id}/search/conversations?q=<phone>`.
3. The request returns 500.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Added one spec per affected endpoint in
`spec/controllers/api/v1/accounts/search_controller_spec.rb`, each
creating a conversation with no messages whose contact matches the query
and asserting that it is returned. Both fail with a 500 before the
change.
Also reproduced manually on a running instance: searching a contact that
had a conversation with no messages returned 500, and returns 200 with
`"message": {}` after the change.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Any dependent changes have been merged and published in downstream
modules
---
Related: #15289 documents the atomic `POST /conversations` with an
inline `message`, which avoids creating conversations without messages
in the first place. This fix is independent of it — it protects the
search regardless of how the conversation ended up without messages
(created by an agent before replying, by an API integration, by campaign
tooling, or by a flow that did not complete).
Co-authored-by: Sojan Jose <sojan@pepalo.com>
|
||
|
|
0a2293f921 |
feat: record Captain conversation outcome episodes from lifecycle events [CW-7792] (#15316)
Records Captain conversation outcomes at episode grain so reporting can distinguish initial demand from reopened conversations and measure replies, handoffs, resolutions, human follow-up, and CSAT. Eligibility creates the episode at demand time. Message-derived fields are snapshotted from persisted messages at handoff or resolution, keeping terminal analytics accurate without writing outcomes for every message. Outcome tracking remains reporting-only and fail-open. Builds on the episode-grain schema from #15315. ## Closes - https://linear.app/chatwoot/issue/CW-7792 ## How to test 1. Enable `captain_integration_v2` and connect a Captain assistant to an inbox. 2. Send an inbound customer message and confirm an initial outcome episode is created at the message timestamp. 3. Let Captain reply and then resolve or hand off the conversation. Confirm the episode records Captain reply counts and timestamps, the outcome timestamp, and the handoff category where applicable. 4. Reply after resolution and confirm a `reopen` episode is created while preserving the previous episode. 5. Resolve the reopened conversation and submit CSAT. Confirm the response is attributed to the episode that issued the survey. ## What changed - Creates the initial episode from demand-level eligibility and appends a new episode when a resolved conversation reopens. - Snapshots Captain replies and the first qualifying human reply from persisted messages at handoff and resolution. - Attributes asynchronous resolution events using the episode active at the event timestamp. - Records later CSAT responses using the survey message timestamp. - Keeps boundary writes transactional and fail-open without retries, advisory locks, late-boundary repair, or handoff self-healing. - Adds schema-constrained handoff reason categories, including lifecycle coverage for incomplete V2 tool fallback handoffs. Open, non-terminal episodes may retain empty or stale message-derived fields until handoff or resolution. |
||
|
|
1693525124 |
fix(captain): scope copilot conversation access (#15249)
## Description Captain Copilot's `get_conversation` tool returned any conversation in the account, without checking whether the agent asking for it could open that conversation from the inbox view. An agent who belongs to a single inbox, or who holds a narrow custom role, could therefore read the message history of conversations outside their access, including the private notes on them. The tool now runs the same permission filter that the conversation list endpoint and Copilot's own `search_conversation` tool already use, so it returns only the conversations the agent can already open. Administrators see no change, because the filter returns the whole account scope for them. The Copilot chat service had the same gap. When an agent opened Copilot while viewing a conversation, the service looked that conversation up by account alone and wrote its ID and contact ID into the system prompt. It now resolves the conversation through the same filter, and leaves the context out when the agent cannot access it. ## Closes https://linear.app/chatwoot/issue/CW-7768 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Create two inboxes in one account, for example Inbox A and Inbox B. 2. Add an agent to Inbox A only, then start a conversation in Inbox B and leave a private note on it. 3. Sign in as that agent, open Copilot, and ask it for the Inbox B conversation by its ID. 4. Before the change Copilot returns the full message history including the private note. After the change it reports that the conversation was not found. Opening the same conversation from the inbox view as that agent is rejected both before and after the change, so the inbox view and Copilot now agree on what the agent can read. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
bdbbaa38de |
feat(captain): add inactivity timer backend (2/5) (#15303)
Captain V2 assistants can now persist a configurable inactivity timer and choose whether inactivity resolution sends the saved closing message or resolves silently. This PR contains only the API, persistence, runtime behavior, and backend specs. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## Depends on Stack 2 of 5. Based on the assistant-policy foundation in #15299. The frontend follows in #15308. ## What changed - Added per-assistant inactivity duration and resolution-message settings with safe defaults. - Restricted the Part 2 settings API to Captain V2 while keeping the Part 1 policy mode available without V2. - Updated inactivity handling to use the assistant timer and skip the public resolution message when disabled. - Serialized the effective timer and message settings for the frontend. - Added model, request, and job coverage, including the explicit Captain V2 boundary. ## How to test 1. Enable Captain V2 and update `auto_resolve_after` and `send_inactivity_resolution_message` through the assistant API. 2. Run the inactivity job and confirm it uses the assistant timer. 3. Disable the resolution message and confirm the conversation resolves silently. 4. Disable Captain V2 and confirm timer/message updates are ignored while `auto_resolve_mode` remains updateable. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
81fc35e9e6 |
chore: upgrade Rails to 7.2.3.1 (#13437)
This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current Rails 7.0 framework defaults, so the runtime upgrade can be deployed and observed independently from default-behavior changes. ## What changed - Upgrade Rails and the compatible dependency set to Rails 7.2.3.1. - Keep `config.load_defaults 7.0` for a staged, lower-risk rollout. - Replace the unmaintained Azure Active Storage fork with the maintained `azure-blob` adapter while preserving the `microsoft` service name. - Pin Sidekiq 7.3.10 with `connection_pool` 2.x after validating scheduled-job execution against Redis. - Update Rails 7.2 compatibility surfaces in Active Record, strong parameters, migrations, storage, and tests. - Add read-only production preflight checks, an opt-in Active Storage smoke script, a deployment runbook, and the full Rails 7.2/8.0/8.1 assessment. ## How to test 1. Sign in and verify the dashboard and conversation UI load normally. 2. Open the agent-management modal and confirm agent data is rendered. 3. Create an API inbox and wait for the asynchronous deletion flow to complete. 4. Open Super Admin pages, including instance status and account-user management. 5. Upload and download an attachment using the configured Active Storage service. 6. Confirm recurring Sidekiq Cron jobs register and execute after startup. ## Rollout Follow `docs/rails_upgrades/7_2.md` for pre-deploy checks, deployment order, smoke tests, monitoring, and rollback. Run `bundle exec rails runner script/rails_upgrade/preflight.rb` against a production-equivalent environment before rollout. ## Tracking - [CW-5863 — Upgrade Rails to 8+](https://linear.app/chatwoot/issue/CW-5863/upgrade-rails-to-8) - [Rails 7.2 to 8.1 upgrade and production rollout plan](https://linear.app/chatwoot/document/chatwoot-rails-72-to-81-upgrade-and-production-rollout-plan-44e9f4964cb2) --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
f12529105b |
fix: align AgentBot ownership with conversation counts (#15343)
AgentBot-owned conversations are now treated as assigned across conversation lists, counts, pagination, permissions, unread membership, human auto-assignment, and advanced assignee filters. ## Closes - https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination ## Follow-ups - https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot tracks ownership refresh during AgentBot deletion. - https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes tracks the existing saved-filter header count refresh gap. ## Why The backend treated every conversation without a human assignee as unassigned, even when an AgentBot owned it. The frontend already hid AgentBot-owned conversations from the Unassigned list, so counts, pagination, filters, unread membership, direct-access permissions, and auto-assignment could disagree with the visible queue. ## What changed - Treat conversations with either a human assignee or AgentBot owner as assigned. - Keep conversation counts, pagination, unread memberships, advanced filters, and automation assignee conditions aligned with the shared ownership semantics. - Keep human assignee equality and not-equality filters human-only, even when a human and AgentBot have the same numeric ID. - Exclude AgentBot-owned conversations from both legacy and V2 human auto-assignment, and from unassigned-only Enterprise access. - Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse or accept an assigned conversation. - Emit ownership-change updates when only the AgentBot owner changes, so connected clients refresh queue state. ## Validation - AgentBot assignment changed ownership to the bot, moved the conversation to pending, and removed it from the open queue. - Assignee "is present" returned human- and AgentBot-owned conversations; "is not present" returned only genuinely unassigned conversations. - Automation assignee presence conditions treated AgentBot ownership as present and did not execute the absent-owner path. - Live human-assignee equality and not-equality filters excluded AgentBot-owned conversations, including numeric ID collisions. - A 32-conversation pending queue loaded across pagination with matching totals and no missing or duplicate rows. - AgentBot ownership changes and human takeover updated filtered rows immediately without a reload. - Human takeover opened the conversation and restored the public reply composer; subsequent unassignment kept the conversation open. - An unassigned-only custom-role agent saw only genuinely unassigned conversations and could not see AgentBot-owned conversations. - AgentBot-owned conversations showed the handled-by-bot banner, Take over action, and disabled public reply composer. - New conversations in the connected inbox were assigned to the AgentBot and excluded from human auto-assignment. - Opening an AgentBot-owned conversation did not let the legacy assignment callback or its locked recheck replace the bot. - Moving an AgentBot-owned conversation to an auto-assigning team preserved the bot and did not create a second human owner. - Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound reuse, and inbound WhatsApp acceptance preserved existing AgentBot owners. - Focused ownership, filters, pagination, permissions, unread-count, auto-assignment, frontend, and lint checks passed locally. - GitHub Actions, Docker builds, CircleCI, security checks, and the final Codex review are green on the final head. |
||
|
|
35a5f3390c |
feat: update status on agent bot assignment (#14870)
Assigning a conversation to an Agent Bot now moves it to pending. Assigning a bot-owned pending conversation to a human opens it again, while other assignment changes preserve the existing status. This makes existing Agent Bot ownership behave like an AI handoff without depending on the assignment dropdown UI work. Closes: https://linear.app/chatwoot/issue/CW-7448/apply-agent-bot-assignment-behavior ## Why Agent Bot ownership should remove conversations from the main open queue while the bot is handling them. Explicit human takeover should bring a bot-owned pending conversation back to the open queue and clear the bot owner. ## What changed - Agent Bot assignment clears the human assignee and marks the conversation pending. - Human assignment clears the Agent Bot owner and opens the conversation only when it was bot-owned and pending. - Ordinary human assignment, non-pending bot takeover, and unassignment preserve the existing conversation status. - Manual human takeover uses the existing assignment and status events. Bot-initiated handoffs continue to use the existing bot-handoff event path. ## Validation - Assign an open conversation to an Agent Bot through the assignment API and verify it becomes pending. - Assign that bot-owned pending conversation to a human and verify it becomes open. - Verify ordinary human assignment, non-pending bot takeover, and unassignment do not force a status change. |
||
|
|
0f3bb640f5 |
feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls, so you can decide *who* an assistant replies to and *when* it's on duty. By default nothing changes, an assistant still responds to every conversation in its connected inboxes but you can now narrow that down. - **Audience**: build a condition tree (contact attributes, conversation attributes, and custom attributes) with and/or groups, mirroring the contact-segment filter semantics. Only conversations whose contact matches the audience get a Captain reply. - **Schedule**: choose when Captain replies — *Anytime*, *During business hours*, or *Outside business hours* (based on each inbox's configured working hours; inboxes without business hours are always covered). When an assistant opts out of a conversation (contact outside the audience, or off-schedule), the conversation is routed to the human queue instead of being parked pending on a silent bot — both on initial creation and on reopen. Fixes https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls |Audience|Availability| |--|--| | <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52 09 PM" src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688" /> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52 13 PM" src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483" /> | ## How to test 1. Open **Captain → Assistants → (an assistant) → Settings**. 2. Under **Audience**, add a condition or condition group (e.g. `Contact language equal_to en`) and save. Start a conversation from a contact that does *not* match — Captain should stay silent and the conversation should land in the human (open) queue instead of pending. 3. With a matching contact, Captain should respond as before. 4. Under **Schedule**, pick **During business hours** (or **Outside business hours**) on an inbox that has working hours configured, and confirm Captain only engages within/outside that window. An empty/`Anytime` schedule always responds. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
834e9a4044 | fix(whatsapp): correctly sync Twilio template approval status (#15353) | ||
|
|
0df9508893 |
fix(security): keep inbox access filtering on the participating scope (#15207)
An agent who is removed from an inbox could still see that inbox's conversations under the **Participating** filter. Removing an agent from an inbox does not delete the conversation participant records they already had, and the participating filter was ignoring inbox access entirely — so those conversations stayed visible indefinitely. The filter now respects inbox access like every other conversation filter. ## Linear Ticket - https://linear.app/chatwoot/issue/CW-6923 ## How to reproduce 1. Add an agent to two inboxes, A and B. 2. As that agent, become a participant on a conversation in inbox B (open it, or get added as a participant). 3. Remove the agent from inbox B in Settings → Inboxes → Collaborators. 4. Log in as the agent and open Conversations → Participating. 5. The inbox B conversation is still listed, and is openable. ## What changed `ConversationFinder#filter_by_conversation_type` **replaced** `@conversations` with `current_user.participating_conversations` for the `participating` type, discarding the inbox/permission-filtered scope built up by `Conversations::PermissionFilterService` immediately before it. It now narrows the existing scope by participating ids instead, so permission filtering survives. |
||
|
|
17d927554d |
fix: block the unused Active Storage direct-upload route (#15329)
## Description **Problem.** The default Active Storage upload route, `POST /rails/active_storage/direct_uploads`, is mounted automatically by Rails and requires no authentication. Chatwoot doesn't rely on it, our dashboard and widget uploads all use scoped, authenticated endpoints, so the route just sits there letting anyone create blobs anonymously. **Fix.** Block the built-in route so it returns `403`. Chatwoot's own upload controllers inherit from the same Rails class but are left working, an `instance_of?` check makes the block apply only to the bare route, not to the subclasses that call `super`. Fixes https://linear.app/chatwoot/issue/INF-94 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Added a request spec asserting the bare route returns `403` and creates no blob. Existing widget and conversation direct-upload specs still pass, confirming the scoped endpoints are unaffected. 13 examples, 0 failures across the three direct-upload specs; rubocop clean. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
430c5cfef0 |
fix(whatsapp): refresh inboxes when opening new conversation composer (#15337)
When an agent starts a new conversation, the WhatsApp template picker in the composer only shows templates that were already loaded into the frontend store — it doesn't refresh when the composer opens. If a template sync completed after the store was last populated, the newly synced template shows up on the inbox's Settings > Templates page (which always refetches on load) but not in the New Conversation composer, since that view relied solely on the account-cache-invalidated websocket event, which doesn't always reach an already-open session in time. ## What changed - `ComposeConversation.vue` now dispatches a cache-aware `inboxes/get` refetch every time the composer popover opens, so the WhatsApp template list is current before an agent picks a template to message a customer. The refetch checks the account's cache key first and only re-pulls the full inbox list when it's actually stale, so it stays cheap in the common case. ## How to reproduce 1. Sync/update WhatsApp templates for an inbox (e.g. via Settings > Inboxes > [WhatsApp inbox] > Sync Templates). 2. Without reloading the page, open the New Conversation composer for that inbox and check the WhatsApp template picker — a newly synced template may be missing until this fix. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
a72b49a981 |
fix: validate days_before filter values before date arithmetic (#15331)
## Description Follow-up to #15319, addressing the non-blocking review notes and the Ito QA finding there. The `days_before` filter value went straight through `to_i`, which never fails: `-1` moves the cutoff into the future and matches every conversation, and a non-numeric string becomes `0`. The value is now parsed as a base-10 integer and must fall within the UI-supported `1..998` range; anything else raises the existing `CustomExceptions::CustomFilter::InvalidValue`, which the controller already turns into a client error. Also corrects an existing weak spec that sent `3` days but computed its expectation with `2` days, passing only because the seeded data made both counts equal. It now sends `2` and exercises the exclusive boundary. Refs https://linear.app/chatwoot/issue/CW-7832 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - New specs: invalid values (`-1`, `abc`, `0`, `999`) raise `InvalidValue`; string values parse as base 10 (`'02'` means 2 days, not octal). - `bundle exec rspec spec/services/conversations/filter_service_spec.rb` (36 examples, 0 failures). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
3b74f9c359 |
fix: prevent Captain bot collisions (#15324)
Prevents Captain from being scheduled for replies or inactive-conversation resolution when an inbox already has an active AgentBot or Dialogflow integration. ## Closes [CW-7834](https://linear.app/chatwoot/issue/CW-7834/prevent-captain-from-processing-agentbot-and-dialogflow-conversations) ## Why Captain and an external inbox bot could both process conversations from the same inbox. ## What this change does - Distinguishes external inbox bots from Captain in the existing bot predicate. - Skips Captain reply scheduling when an external bot is active. - Skips Captain inactive-resolution scheduling when an external bot is active. ## Validation - Connect an AgentBot to a Captain-enabled inbox and confirm Captain does not reply or schedule inactive resolution. - Enable Dialogflow on a Captain-enabled inbox and confirm Captain does not reply. - Remove the external bot integration and confirm Captain resumes normal processing. --------- Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> |
||
|
|
781943867b |
fix(whatsapp): preserve and display flow responses (#15279)
WhatsApp Flow submissions currently reach Chatwoot as `interactive.nfm_reply` messages, but their structured answers are discarded. Agents see an empty message and outbound webhooks cannot access the submitted data. This change preserves the Flow response in message content attributes and renders a readable response card in the conversation. Existing WhatsApp messages and other interactive message types remain unchanged. Fixes https://github.com/chatwoot/chatwoot/issues/13970 <img width="1234" height="1350" alt="CleanShot 2026-08-04 at 00 40 14@2x" src="https://github.com/user-attachments/assets/884b3a5c-bab3-4196-8037-776e6c41ab2a" /> ### How to reproduce 1. Send an approved WhatsApp template containing a Flow button. 2. Complete and submit the Flow from WhatsApp. 3. Open the conversation in Chatwoot. 4. Observe that the incoming message has no visible content and the submitted fields are absent from the message webhook. ### What changed - Parse `interactive.nfm_reply.response_json` for incoming WhatsApp Cloud messages. - Store the Flow name, body, and structured response under `content_attributes.whatsapp_flow_response`. - Add a dedicated conversation bubble that formats submitted field names and values. - Preserve original response keys while converting message attributes to the frontend shape. - Keep `flow_token` available in stored/webhook data while omitting it from the agent-facing card. - Add focused backend, helper, and component regression coverage. ### How to test 1. Send a WhatsApp Flow template to a contact. 2. Submit the Flow with multiple field types, including free text and a selection. 3. Confirm the incoming conversation message displays each submitted field and value. 4. Confirm an empty-answer Flow still renders a visible “Submitted a flow response” message. 5. Inspect the `message_created` webhook and confirm the structured response is present in `content_attributes.whatsapp_flow_response.response_json`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
56be133bb0 |
feat(data-imports): add Freshdesk migration (1/3) (#15261)
## Description Adds Freshdesk as an integration import source so administrators can validate a Freshdesk domain and API key, then import contacts, tickets, public replies, customer replies, and private notes while tracking progress from Data Imports. The integration has now been validated against a live Freshdesk trial tenant with contacts, Web Chat and phone tickets, public replies, customer replies, a private note, pagination, requester expansion, and attachment metadata. That validation found and fixed the current Web Chat source mapping and prevented the ticket description from duplicating the initial Web Chat message. Related: #15116 ## Closes Closes [CW-7639](https://linear.app/chatwoot/issue/CW-7639/freshdesk-freshworks-migration) ## Type of change - [x] New feature (non-breaking change which adds functionality) ## What changed - Added a shared source adapter, importer, job, retry, restart, creation, and placeholder inbox contract used by Intercom and Freshdesk. - Added Freshdesk API authentication, contact and ticket pagination, requester expansion, conversation retrieval, normalization, channel grouping, and error handling. - Added current Freshdesk source identifiers through SMS, including Web Chat source `15`, and grouped equivalent sources into placeholder inboxes. - Used Web Chat conversation events as the complete message history so the generated ticket description does not duplicate the initial customer message. - Preserved Freshdesk ticket subjects in source metadata and added a sanitized live-derived Web Chat fixture with structured bodies and attachment metadata. - Added Freshdesk selection, domain and API key validation, and provider-neutral import status handling in the Data Imports UI. ## How to test 1. Enable the data_import feature for an account and open Settings > Data > New import. 2. Select Freshdesk and enter a Freshdesk domain and API key. 3. Select contacts and/or conversations, validate the credentials, and start the import. 4. Confirm progress is displayed and imported tickets appear as resolved conversations in Freshdesk placeholder inboxes with public replies and private notes preserved. 5. Verify Web Chat tickets appear in the Chat placeholder inbox and the initial customer message is imported once. 6. Verify an abandoned import can be restarted and a stalled import can be retried. ## Current scope - **Product decision:** Attachment binaries are intentionally not imported in the current migration scope. Attachment metadata is preserved and messages include a skipped-attachment marker. - Adaptive Retry-After scheduling and handling the 30,000-ticket listing ceiling are covered by stacked follow-up PRs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
bab2f99004 |
perf: reduce per-request work in conversation filter endpoint (#15321)
## Description Every conversation filter request runs three separate unbounded COUNT queries over the filtered set (mine, unassigned, all) and eager-loads every message of every conversation on the page. On large accounts this adds a fixed 1-2s of latency per request regardless of the filter. This PR trims both: - The three counts are now computed in a single pass using `COUNT(*) FILTER (...)` aggregates. Response shape and count semantics are unchanged. - The `:messages` eager-load is removed from the filter base relation. The list payload fetches messages through scoped queries (last message, last non-activity message, unread messages), which never read the preloaded collection, so it was loaded and discarded on every request. Fixes https://linear.app/chatwoot/issue/CW-7830 |
||
|
|
d01ad1fc97 |
fix: handle ActionController::Parameters in days_before filter (#15319)
## Description The `days_before` filter operator in `FilterService#days_before_filter_query` calls `with_indifferent_access` directly on the query hash. In production the filter payload arrives as `ActionController::Parameters` (the controller passes `params.permit!`), which does not respond to `with_indifferent_access`, so every conversation filter request using `days_before` raises `NoMethodError` and returns a 500. Existing specs pass plain hashes with `with_indifferent_access`, which is why this was never caught. This PR normalizes the query hash via `to_h` first (permitted parameters convert to a `HashWithIndifferentAccess`, plain hashes are unaffected) and adds a regression spec that builds the payload as `ActionController::Parameters`, mirroring the controller. Fixes https://linear.app/chatwoot/issue/CW-7832 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Added a regression spec that passes the filter payload as permitted `ActionController::Parameters` with the `days_before` operator. It reproduces the `NoMethodError` on the current develop branch and passes with this change. - `bundle exec rspec spec/services/conversations/filter_service_spec.rb` (31 examples, 0 failures) - `bundle exec rspec spec/services/conversations/filter_service_frontend_alignment_spec.rb` (10 examples, 0 failures) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
c82b75dcce |
feat: add manage-notification-preferences footer to agent notification emails (#15192)
## Description Agent notification emails (new conversation, assignment, mention, new message, SLA misses) currently carry no indication of why the recipient received them or how to turn them off. When recipients cannot easily manage these emails, some mark them as spam, which hurts sender reputation and overall deliverability. This adds a short footer line to agent notification emails only: > You're receiving this email because email notifications are enabled for your account. **Manage notification preferences**. The link points the recipient to their dashboard profile notification settings page (`/app/accounts/:account_id/profile/settings`) so they can disable notifications instead of marking the email as spam. This is a navigational link only, not a one-click unsubscribe (a real unsubscribe flow is a separate future change). Scoping: the mailer layout (`app/views/layouts/mailer/base.liquid`) is shared by all mailers via `ApplicationMailer`. To keep the footer on agent notification emails only, `AgentNotifications::ConversationNotificationsMailer` exposes a `notification_settings_url` liquid local (built from the existing `app_account_url` route helper), and the layout renders the footer line only when that local is present. Transactional and other emails do not set it, so they are unaffected. The enterprise SLA notification methods prepend into the same mailer class, so they inherit the footer automatically. Part of https://linear.app/chatwoot/issue/CW-7752 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - `bundle exec rspec spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb spec/mailers/confirmation_instructions_spec.rb` — 26 examples, 0 failures. Asserts the footer link and settings URL are present in an agent notification email and absent from a non-notification (confirmation) email. - `bundle exec rspec spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb` — 6 examples, 0 failures (SLA notification emails). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
6f6ddc5636 |
fix: flaky AppliedSla scope spec after Rails reloads (#15327)
CI no longer fails when the AppliedSla scope spec runs after Rails reloads model classes. The spec now compares record IDs, so it checks which SLA records the scope returns without depending on Ruby class identity. ## How to reproduce Run the report builder and assignment policy controller specs before `spec/enterprise/models/applied_sla_spec.rb` in the same RSpec process. Before the fix, the scope returns records with the expected IDs, but the assertion rejects them because FactoryBot and Rails use different `AppliedSla` class objects after the reload. ## What changed The spec reads the IDs returned by `with_sla_applicable_conversation` and checks the normal conversation, missing contact, and blocked contact cases with those IDs. The affected CircleCI shard passes locally after the change. |
||
|
|
d1a6ea5798 |
fix: normalize mexico whatsapp phone numbers (#14174)
## Summary This fixes WhatsApp phone number normalization for Mexico numbers when using Twilio as a provider. Twilio may send incoming Mexico WhatsApp phone numbers with an extra `1` after the country code, for example: - stored contact inbox source: `whatsapp:+525512345678` - incoming message source: `whatsapp:+5215512345678` Before this change, Chatwoot could treat these as different source IDs and create a duplicate contact/conversation path instead of matching the existing one. ## What changed - added a Mexico-specific phone normalizer - normalize `+521...` to `+52...` for contact matching - kept the original incoming source ID when creating a brand new contact inbox ## Why this approach Chatwoot already has country-specific normalization for WhatsApp numbers in the normalization service. This follows the same pattern and keeps the change small and isolated. ## Tests Added specs covering: - matching an existing Mexico WhatsApp contact inbox when Twilio sends `+521...` - preserving the original source ID when no matching contact inbox exists and a new one is created Fixes https://linear.app/chatwoot/issue/CW-6844/fix-normalize-mexico-whatsapp-phone-numbers-on-twilio-to-prevent --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
342f0a399c |
fix(captain): resolve V2 FAQ citations from trusted sources (#15159)
Captain V2 now adds FAQ citations from a structured model response. The model returns ordered response parts with citation indexes, and Chatwoot turns only trusted indexes into customer links. ## Before Captain V2 asked the model to copy text markers such as `[[faq:1]]`. Chatwoot used one regular expression to replace those markers with links in the outgoing message and another regular expression to remove the rendered links before the next model turn. Long conversations depended on parsing the customer message to recover plain model context. ## After The FAQ lookup tool now gives each eligible source document a numeric index and never gives the model a URL. FAQ results from the same document reuse the same index. The model returns `response_parts`, where each part contains customer text and the supporting citation indexes. Chatwoot checks every index against the document IDs registered during the current run. Only stored HTTP or HTTPS web-document links without embedded credentials can appear in the customer reply. Blank links, PDF sources, attachments, non-HTTP storage links, and unknown indexes do not create links. Sources receive display numbers in the order they first appear, and repeated sources keep the same display number. Chatwoot saves the structured response parts with each newly generated Captain message. Later Captain V2 turns use the saved plain text for those messages, so they never need to parse rendered links. Existing messages remain unchanged and continue to use their stored content. When citations are disabled, Chatwoot clears citation indexes before it returns or saves the response. Captain V1, Copilot, legacy prompts, legacy tools, and the playground response contract are unchanged. The playground continues to show the plain `response` field. ## Closes [AI-138](https://linear.app/chatwoot/issue/AI-138/faq-citation-fix) ## How to test 1. Open a conversation handled by a Captain V2 assistant and turn citations off. Ask a greeting, an FAQ question, a code question, and a follow up question. Confirm that the assistant answers normally and shows no source links. 2. Turn citations on and ask a question that matches one public web document. Confirm that the reply shows the stored public link after the supported text. 3. Ask a question that needs two public web documents. Confirm that the response order stays correct, each link appears after the supported text, and repeated sources keep the same display number. 4. Ask a question that retrieves several FAQ results from one document. Confirm that the reply shows the document once at each supported response part rather than exposing separate FAQ sources. 5. Ask a question supported by a PDF, attachment, blank link, or non-HTTP storage link. Confirm that Captain can use the information but does not show a customer link. 6. Ask for a fenced code example with a citation. Confirm that the code block stays complete and the citation appears after the closing fence. 7. Continue the conversation with a follow up question. Confirm that Captain uses the earlier plain response text and does not receive or repeat rendered citation links. 8. Test a scenario handoff in a conversation. Confirm that the handoff and final response still work. |
||
|
|
9177fffa71 | fix: enforce required conversation attributes on resolve for macros (#15232) | ||
|
|
8448001fdc |
fix(security): gate SLA APIs and automation on the sla feature (#15209)
SLA is a premium feature, but several SLA surfaces never checked the
account's `sla` flag. An account without SLA — or one whose plan was
downgraded and had the flag revoked — could still read SLA breach
reporting, attach an SLA policy to a conversation through the
conversation update endpoint, and keep applying SLA policies from
automation rules. All three paths now require the feature, matching the
SLA policies API which already checked it.
## How to reproduce
1. Disable the `sla` feature for an account that has SLA policies and
applied SLAs.
2. `GET /api/v1/accounts/{id}/applied_slas/metrics` (also `index` and
`download`) — returns the account's SLA breach data instead of denying.
3. `PATCH /api/v1/accounts/{id}/conversations/{display_id}` with
`sla_policy_id` — the policy is attached.
4. Trigger an automation rule with an "Add SLA" action — the SLA is
applied and starts tracking.
## What changed
- `Api::V1::Accounts::AppliedSlasController` gains the same
`ensure_sla_feature_enabled` guard the SLA policies controller uses,
covering `index`, `metrics` and `download`.
-
`Enterprise::Api::V1::Accounts::ConversationsController#permitted_update_params`
only permits `sla_policy_id` when the feature is enabled. When it is off
the parameter is dropped, so an existing SLA association is preserved
rather than cleared.
- `Enterprise::ActionService#add_sla` returns early when the feature is
off, so automation rules stop applying SLAs on revoked accounts.
|
||
|
|
e7ded47753 |
feat: move Captain auto-resolve policy to assistants (1/5) (#15299)
Captain auto-resolve policy is now owned by each assistant. Existing assistants first read an assistant-level setting, fall back to the current account setting during rollout, and are backfilled asynchronously so behavior is preserved. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## How to test 1. Configure different auto-resolve modes on two assistants in the same account through the API. 2. Confirm each assistant follows its own mode when the inactivity job runs. 3. Confirm an assistant without the new setting follows the existing account mode. 4. Run the migration job and confirm the account mode is copied without overwriting an existing assistant mode. 5. Confirm evaluated mode falls back to time-based resolution when the `captain_tasks` capability is unavailable. ## What changed - Added assistant-level `disabled`, `legacy`, and `evaluated` policy storage and validation. - Updated scheduling and resolution runtime reads to use the assistant policy. - Added a compatibility fallback and asynchronous backfill from the account setting. - Preserved the account-level `captain_tasks` capability gate for evaluation. - Kept this foundation independent of the Captain V2 guard; the timer and advanced settings in stacks 2–5 are Captain V2-only. Stack 1 of 5. This is the base for #15303. |
||
|
|
0ad2780972 |
fix(conversations): prevent duplicate assignment activity from concurrent assignment writes (#15275)
Conversations could get reassigned repeatedly in quick succession — bouncing between agents with several "Assigned to X" activity messages in a row — whenever an assignment write raced against another assignment write on the same conversation (an automation rule vs. Assignment V2's auto-assign, two manual assignment clicks, two concurrent status transitions triggering legacy round-robin, etc). This was reproducible with or without Assignment V2 enabled; it wasn't a V2-specific issue, just more visible under message bursts. Assignment V2's own job-vs-job race was already fixed separately (#14495) — this PR covers the other writers that don't check the conversation's current state before overwriting it. ## How to reproduce Configure an automation rule that assigns an agent/team on `message_created`, then send a burst of messages on one conversation from a channel/inbox with concurrent message delivery (or race it against another assignment source — a manual assignment click, or a status change that triggers legacy auto-assignment). The conversation's assignee flips between agents multiple times, each producing its own activity message, even though later writes were often redundant (setting the assignee to a value it already had, from the writer's stale point of view). ## What changed Three assignment write paths now take a row lock (`with_lock`) before writing, so a concurrent writer re-reads the conversation's true current state before deciding whether a write is actually needed: - `ActionService#assign_agent`/`#assign_team` (and the corresponding unassign methods) — used by automation rules, macros, and delayed automations. - `Conversations::AssignmentService#assign_agent`/`#assign_agent_bot` — the dashboard's assignee dropdown (human agent or AgentBot). - `AutoAssignment::AgentAssignmentService#perform` — the legacy (non-V2) round-robin auto-assignment path. A write that would just reapply an already-current value becomes a genuine no-op instead of producing a redundant `UPDATE` and duplicate activity message. Legitimate reassignment (a real change in target, or reassignment away from an agent who lost inbox access) is unaffected. |
||
|
|
94e9727eb4 |
feat: move Captain conversation outcomes to episode grain [CW-7792] (#15315)
Moves Captain conversation outcomes from one row per conversation to one row per **engagement episode**: a new row each time demand for Captain (re)starts - first eligible message, a reopen after resolution, or (reserved) explicit assignment. Each episode has its own demand anchor, window, and trigger, so returning customers count as new demand and later cycles can't overwrite an earlier episode's handoff reason, resolution, or CSAT. Also adds `conversation_outcomes` associations on Account, Inbox, Conversation, and Captain::Assistant. ## Why this wasn't in #15233 The episode design came out of reviewing the wiring PR: per-conversation grain couldn't answer per-cycle questions without a patch per field. The table is unreleased with no writers, so changing the grain now is a pure schema swap - and landing it first means the tracker gets reviewed against the final model. ## What changed - Adds `episode_trigger`, `started_at`, `ended_at` - Drops `reopen_count` and `last_reopened_at` - reopens are episode rows now - Uniqueness moves from `(account, assistant, conversation)` to per-boundary `(account, conversation, started_at)` - Two partial unique indexes: one open episode per conversation, one initial episode per stream - Model: trigger enum, `started_at` uniqueness validation, `chronological`/`covering` scopes |
||
|
|
1c28df49e5 |
feat(whatsapp): expose cached Twilio templates (#15311)
The cached inbox template endpoint currently serves only native WhatsApp channels. This draft extends the same read-only endpoint to Twilio WhatsApp inboxes and adds last-sync metadata for both providers, so the template listing can use one stable contract without making provider requests. Non-WhatsApp inbox behavior remains unchanged. Related: https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings ### Things to know - This is stack 1 of 2 and contains only the API contract needed by the settings UI in #15312. - Template data remains cache-only; the endpoint does not call Meta or Twilio. - Native WhatsApp templates are filtered by `name`; Twilio Content Templates are filtered by `friendly_name`. ### How to test 1. Request the message templates endpoint for a native WhatsApp inbox and confirm it returns the cached templates plus `meta.last_updated_at`. 2. Request it for a Twilio WhatsApp inbox and confirm it returns cached Content Templates plus `meta.last_updated_at`. 3. Pass a template name and confirm only the matching provider template is returned. 4. Request it for a non-WhatsApp inbox and confirm the endpoint returns an unprocessable entity response. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
e3ce2772fe |
fix: preserve original filenames for Telegram inbound attachments (#15071)
## Description Telegram inbound attachments were stored under a generic, renamed filename (e.g. `file_5.pdf`) instead of the name the sender actually uploaded (e.g. `Quarterly-Report-2025.pdf`). **Root cause:** In `Telegram::IncomingMessageService#attach_files`, the attachment filename was taken from the `Down`-downloaded file's `original_filename`, which `Down` derives from the Telegram file-server download URL. That URL is built from Telegram's `getFile` response, whose `file_path` is Telegram's *internal* storage path — not the sender's chosen name. The real name is present in the webhook payload as `file_name` on the `document` / `audio` / `video` object, but it was never read. Because `Attachment#set_extension` derives the stored `extension` from the filename, this also corrupted the recorded extension for documents whose Telegram path lacked a proper one. **Fix:** Prefer the payload's `file_name`, falling back to the downloaded name when it's absent (photos, stickers, voice notes — which Telegram sends without a `file_name`). This mirrors how the Line channel (`message['fileName']`) and WhatsApp Cloud channel already handle inbound attachment filenames. ```ruby filename: file[:file_name].presence || attachment_file.original_filename, ``` Fixes #15070 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Added/updated specs in `spec/services/telegram/incoming_message_service_spec.rb`: - Extended the existing document-message example to assert the stored attachment retains the original payload filename (`Screenshot 2021-09-27 at 2.01.14 PM.png`) rather than Telegram's internal download name. - Added a fallback example (a `photo` payload, which carries no `file_name`) asserting the attachment is still created and the filename falls back to the downloaded name — guarding the `.presence || …` branch and confirming no regression for media types without a `file_name`. > Note: my local machine does not have the Ruby/Redis toolchain to run the suite, so I have not run RSpec/RuboCop locally — I'm relying on CI to validate. Suggested command for reviewers: `bundle exec rspec spec/services/telegram/incoming_message_service_spec.rb`. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have added tests that prove my fix is effective - [ ] New and existing unit tests pass locally with my changes (unable to run locally — see note above; relying on CI) |
||
|
|
3633dd44fb |
perf: keep planner off misestimated inbox scan for label filter queries (#15264)
## Description Label-based conversation filters (custom views / folders) can time out for non-admin agents on large accounts. The permission scoping adds `conversations.inbox_id IN (subquery)` to the filter query, and Postgres misestimates the row count for the account+inbox combination by several orders of magnitude. It then drives the query through an inbox index scan over the account's entire conversation set instead of starting from the label's taggings, which are often only a handful of rows. The query exceeds the request timeout and the folder never loads. This change adds a planner hint to `Conversations::PermissionFilterService`: when enabled, the inbox scoping condition is written as `(conversations.inbox_id + 0) IN (subquery)`, which returns identical rows but is not indexable, so the planner cannot choose the misestimated path. `Conversations::FilterService` enables the hint only when the filter payload contains a `labels` condition. All other callers of the permission service are unchanged, since the bare condition is the right plan for unfiltered conversation lists. Observed on a ~365k-conversation account (worst case, cold cache): label filter for an agent went from exceeding 60s to ~2ms. Popular labels (~154k taggings) show no regression. Admin queries are untouched. Fixes https://linear.app/chatwoot/issue/CW-7787 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Specs for row-equivalence of the hinted scoping, admin behavior, and hint application scoped to label filters only - `EXPLAIN (ANALYZE, BUFFERS)` comparison on a production-scale dataset for rare-label, popular-label, and no-label query shapes, agent and admin, with generic plans matching prepared-statement behavior |
||
|
|
871130f566 |
feat: add Captain conversation outcome model [CW-7792] (#15233)
Adds the `Captain::ConversationOutcome` model and its table: one row per (account, assistant, conversation) that folds Captain lifecycle events into flat facts for the upcoming value metrics report. This is the second PR in the outcomes stack, on top of the lifecycle event layer (#15213); the tracker and listener that populate it come next. The table deliberately stores only timestamps, counts, and the handoff reason. Every classification and duration (coverage, autonomous vs assisted, durable resolution, resolution time) is derived at query time in the stats layer, so definitions can change later without backfills. The row is created on the first qualifying customer message, making `created_at` the demand-start anchor. Commit messages carry the field-by-field rationale for what was left out. | Field | Meaning | | --- | --- | | `account_id`, `assistant_id`, `conversation_id`, `inbox_id` | Reporting dimensions; unique on the first three | | `first_captain_reply_at` / `last_captain_reply_at` | First and latest public Captain reply | | `captain_reply_count` | Public Captain replies in the conversation | | `first_human_reply_at` | First public human agent reply, used to classify assisted resolutions | | `handoff_at` | When Captain handed the conversation to a human | | `handoff_reason_category` | Why it handed off (customer_request, missing_knowledge, unsupported_request, policy_restriction, tool_failure, pending_clarification, usage_limit) | | `resolved_at` | When the conversation was resolved | | `last_reopened_at` / `reopen_count` | Reopen facts backing reopen rate and durable-resolution checks | | `csat_rating` / `csat_received_at` | CSAT for Captain-involved conversations | |
||
|
|
94d7ccf5e9 |
fix(security): gate custom role APIs on the custom_roles feature (#15208)
Custom Roles is a premium feature, but the account's `custom_roles` flag
was never checked by the API. An account on a plan without Custom Roles
— or one whose plan was downgraded and had the flag revoked — could
still list, create, edit and delete custom roles, and could still attach
a `custom_role_id` to an agent through the agents API. Both paths now
require the feature.
## How to reproduce
1. Disable the `custom_roles` feature for an account (Super Admin →
Account → Features, or a plan downgrade).
2. `GET /api/v1/accounts/{id}/custom_roles` — returns 200 with the
account's roles instead of denying.
3. `PATCH /api/v1/accounts/{id}/agents/{agent_id}` with `custom_role_id`
— the role is assigned.
## What changed
- `Api::V1::Accounts::CustomRolesController` gains a
`ensure_custom_roles_feature_enabled` guard, matching the shape already
used by the SLA policies controller.
-
`Enterprise::Api::V1::Accounts::AgentsController#associate_agent_with_custom_role`
ignores `custom_role_id` when the feature is off, rather than writing
it.
Existing custom role assignments are deliberately left untouched — they
are evaluated at request time by `Enterprise::AccountUser#permissions`
and the conversation policy, where custom roles mostly *narrow* an
agent's scope. Dropping them on revocation would widen conversation
visibility for restricted agents, which is a separate product decision.
---------
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
|
||
|
|
7142bc43a5 |
feat(whatsapp): expose message templates through the API (#15277)
External integrations can send WhatsApp template messages, but they do not have a focused API to discover the templates available for an inbox. This adds an authenticated endpoint that lists the inbox's cached WhatsApp templates and optionally filters them by exact template name. Existing inbox responses and template synchronization behavior remain unchanged. Fixes https://github.com/chatwoot/chatwoot/issues/13959 ### Things to know - The endpoint returns templates already synchronized and cached for a direct WhatsApp inbox; it does not make a new provider request. - Non-WhatsApp inboxes return an unprocessable entity response. ### How to test 1. Authenticate as a user assigned to a WhatsApp inbox. 2. Request `GET /api/v1/accounts/:account_id/inboxes/:id/message_templates` and verify the response contains the inbox's templates under `payload`. 3. Repeat the request with `?name=<template_name>` and verify only the exact matching template is returned. 4. Request the endpoint for a non-WhatsApp inbox and verify it returns an unprocessable entity response. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
e1270a4ef8 |
fix(whatsapp): render template values in conversation transcript (#15255)
WhatsApp template messages can be delivered with the correct variable
values while the Chatwoot conversation shows numeric placeholders
instead. This affects template sends where the client submits the raw
template body together with valid processed parameters; WhatsApp
delivery itself remains unchanged.
The message model currently passes outgoing content through Liquid
before saving it. Liquid interprets positional WhatsApp placeholders
such as `{{1}}` and `{{2}}` as numeric expressions, even though the
WhatsApp sender independently uses `processed_params.body` to build the
provider payload.
Fixes
https://linear.app/chatwoot/issue/PLA-192/render-whatsapp-template-values-in-conversation-transcripts
### What changed
For WhatsApp template messages, Chatwoot now substitutes positional or
named body placeholders from `processed_params.body` before saving the
transcript. Missing values remain visible as placeholders, and ordinary
outgoing Liquid messages continue through the existing rendering path.
The existing message model regression suite remains green, and the
reported payload now persists as `Hello Ahmad, Furqan is your contact.`
### Things to know
This corrects newly created messages. Existing conversation messages
that were already stored as `1`, `2`, and so on are not backfilled.
### How to reproduce
1. Open a WhatsApp conversation outside the 24-hour messaging window.
2. Send a positional template whose body contains `{{1}}` and `{{2}}`.
3. Supply values for both parameters while submitting the original
template body as the message content.
4. Observe that WhatsApp delivers the substituted values, while the
Chatwoot transcript shows `1` and `2`.
### How to test
1. Select a WhatsApp template with two body variables.
2. Enter `Ahmad` and `Furqan` as the values and send the message.
3. Confirm the recipient receives the substituted template.
4. Confirm the Chatwoot conversation also displays `Ahmad` and `Furqan`
instead of the numeric placeholders.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
|
||
|
|
6347ad1926 | feat: add analytics providers to help center (#15124) | ||
|
|
7981e2cc75 |
refactor: introduce normalized Captain lifecycle events [CW-7792] (#15213)
This introduces a small event layer for the Captain V2 conversation lifecycle. A new `Captain::ConversationEvents` facade dispatches five normalized events (`captain.conversation.engaged`, `captain.conversation.handed_off`, `captain.conversation.resolved`, `captain.response.completed`, `captain.response.failed`) from the points where Captain engages a conversation, replies, fails, hands off, or auto-resolves. Each event carries the conversation, assistant, timestamp, and a `source`/`reason_category` where relevant. ## Why this, why now The Captain V2 flow is about to gain several observers at once: conversation outcome tracking, agent session capture, and analytics all need to know when Captain engages, replies, fails, hands off, or resolves. Wiring each of them directly into `ResponseBuilderJob`, `HookExecutionService`, and the tools would tangle secondary bookkeeping into the paths that deliver customer-facing behavior, and every future consumer would deepen that. Landing the event layer first as its own PR means the flow announces these moments once and stays otherwise untouched: customer-visible behavior (messages, status changes, handoffs, usage enforcement) remains synchronous, while secondary effects subscribe through listeners. The upcoming conversation outcomes PR then reduces to a listener plus a model instead of another round of edits to the core flow, which is why this ships now, before that work merges. The existing inference reporting behavior is folded into this layer: the `conversation.captain_inference_*` events and their dispatch helpers on `Enterprise::Conversation` are removed, and a dedicated `Captain::ReportingEventListener` (registered on the enterprise async dispatcher) maps `source: 'inference'` events to the same stored reporting event names, so recorded analytics and the assistant stats builder are unaffected. ## What changed - New `Captain::ConversationEvents` facade and event type constants - Event emission from `HookExecutionService` (engagement, usage-limit handoff), `ResponseBuilderJob` (response completed/failed, generation-failure handoff), `HandoffTool` (tool handoff), and `InboxPendingConversationsResolutionJob` (inference resolved/handoff) - A dedicated `Captain::ReportingEventListener` preserves inference reporting events through the new event names, removing captain logic from the OSS listener |
||
|
|
b05f22da8d |
fix(captain): default paid accounts to V2 (#15262)
Paid Chatwoot Cloud accounts now receive Captain V2 during plan reconciliation unless they are explicitly held on Captain V1. Older accounts could otherwise start on V1 when they became paid after the V2 rollout because only newly created accounts carried the rollout eligibility value. ## Closes No linked issue. ## How to reproduce 1. Start with a cloud account created before the Captain V2 rollout. 2. Upgrade the account from the default plan to a paid plan. 3. Reconcile the Stripe subscription. 4. Confirm that Captain is enabled but Captain V2 remains disabled. ## What changed 1. Treat a missing rollout eligibility value as eligible for Captain V2 on paid plans. 2. Keep Captain V2 disabled when the rollout eligibility value is explicitly set to false. 3. Keep the default plan behavior unchanged. 4. Update the billing reconciliation specs to cover existing paid accounts, new accounts, and explicit V1 exceptions. |
||
|
|
ecb7a44f07 |
fix(email): resolve reply recipients from the message being sent (#15194)
When an agent loops a new address into an ongoing email thread and then adds a private note, the reply currently goes out with no Cc, and with To falling back to the conversation contact. The newly added person never receives the mail. This change makes an outgoing email keep the To/Cc/Bcc that were entered on that reply, no matter what is added to the conversation afterwards. Closes #15193 ## How to reproduce 1. Open a conversation on an Email inbox. 2. Add a new address to **Cc** and send a reply. 3. Immediately add a private note to the same conversation. 4. Inspect the delivered mail: `Cc` is empty and `To` is the conversation contact instead of the addresses entered on the reply. ## What changed `ConversationReplyMailer#cc_bcc_emails` and `#to_emails_from_content_attributes` read the addresses from `@conversation.messages.outgoing.last` rather than from the message they were handed. Replies are delivered asynchronously (`SendReplyJob.perform_later`, and `wait: 2.seconds` when the message has attachments), so any outgoing message created in that window replaces the recipients of a mail that is already queued. A private note is the easiest way to hit it: private notes are outgoing messages, and on an Email inbox `Messages::MessageBuilder#process_emails` stores them with empty `to_emails` / `cc_emails` / `bcc_emails`. Both lookups now go through the existing `current_message` helper (`@message || @conversation.messages.outgoing.last`), which the `from` and `reply_to` builders already use. `email_reply` sets `@message`, so it resolves the recipients of the message being delivered; `reply_with_summary` and `reply_without_summary` leave `@message` nil and keep their current behaviour. Added a spec covering the private-note case in `spec/mailers/conversation_reply_mailer_spec.rb`. ## Note I could not run the Ruby test suite locally — there is no Ruby toolchain in the environment I worked in, so the new spec has not been executed on my side. The change and the spec were verified by reading the code paths (`Message#send_reply`, `Email::SendOnEmailService`, `Messages::MessageBuilder#process_emails`, `ConversationReplyMailerHelper#prepare_mail`). Deferring to CI for the actual run. |
||
|
|
0c606babea |
feat: time based automation (#15022)
## Description Add automations that trigger based on how long a conversation has been in a given state. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - UI flows - Specs (https://github.com/chatwoot/chatwoot/pull/15021) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
38a317962b |
fix: Captain handles message bursts with a single reply (#15133)
Captain now handles a burst of customer messages with one reply. If more messages arrive before Captain replies, the latest job uses the full conversation history. The change applies only to Captain V2 and works across every channel that Captain supports. Blocked on: https://github.com/chatwoot/chatwoot/pull/15212 ## Closes Closes https://github.com/chatwoot/chatwoot/issues/14545 ## How to reproduce 1. Start a pending conversation with Captain V2. 2. Send several messages while Captain is preparing a reply. 3. Captain can generate and send a separate reply for each message. ## What changed * Each Captain V2 job records the incoming message that started it. * A job stops before generation if a newer message already exists. * Captain discards a generated reply if a newer message arrived during generation. * The latest job replies using the full conversation history. * Langfuse records whether a generation was discarded and whether a customer credit was used. * Captain V1 keeps its existing behavior. ## Tradeoffs Discarded generations still cost money. Message bursts can also increase background job work and model provider load. A continuous stream of incoming messages can delay the reply until one generation finishes without a newer message. A small timing window remains if a message arrives after the final check and before Captain saves the reply. A handoff also cannot be undone if a newer message arrives after Captain has already changed the conversation status. ## How to test 1. Enable Captain V2 and start a pending Captain conversation. 2. Send several messages while Captain is preparing a reply. 3. Confirm that Captain sends one reply based on the full message history. 4. Confirm that Langfuse marks discarded runs with `discarded=true` and `credit_used=false`. 5. Disable Captain V2 and confirm that Captain V1 behavior is unchanged. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
9d769dfcdd |
fix(whatsapp): collect text header parameters (#15199)
WhatsApp templates with variables in both a text header and body currently show inputs only for the body. Agents therefore cannot provide the header value in the template composer, even though the API and backend support the corresponding `processed_params.header` payload. The composer now displays text-header variables separately, previews their substituted values, and sends them alongside body parameters. Templates using media headers remain unchanged. Related: https://github.com/chatwoot/utils/pull/65 ### Things to know This PR consumes the released `@chatwoot/utils@0.0.57`, which adds text-header parameter construction and completeness validation. ### How to reproduce 1. Open a WhatsApp template containing text header `Welcome {{1}}` and body variables `{{1}}` and `{{2}}`. 2. Observe that the current composer displays only two body inputs and omits the header input. ### How to test 1. Open the same template in the conversation composer. 2. Confirm one header input and two body inputs are displayed. 3. Fill the values and confirm both the header and body previews update. 4. Send the template and confirm `processed_params` contains `header.1`, `body.1`, and `body.2`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
502c45f73b |
fix: freeze SLA misses after resolution (#15024)
## Description Resolved conversations now preserve historical SLA misses without allowing their displayed duration to keep growing. Applied SLAs record a stable completion timestamp that is shared through REST and realtime payloads, and the dashboard freezes FRT, NRT, and RT misses at that point. Legacy completed SLAs without a reliable timestamp remain visible as a static missed state. Terminal SLAs remain frozen when a conversation is reopened; a reopen before finalization continues the same SLA without resetting its deadlines. ### Closes [CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Apply an SLA with a resolution-time threshold to a conversation. 2. Let the threshold breach, then resolve the conversation. 3. Observe that the recorded miss duration continues increasing every minute even though the conversation is resolved. ## What changed - Added nullable `applied_slas.completed_at` and exposed it as `sla_completed_at` in conversation, report, and websocket payloads. - Captured completion before broadcasting resolution and preserved it for terminal applied SLAs. - Frozen recorded FRT, NRT, and RT durations in classic and next-generation conversation labels, including a static fallback for legacy rows. - Added a dry-run-first, resumable Rails runner for account-scoped or explicitly global historical repair without enqueuing jobs or touching `updated_at`. Account-scoped production rollout starts with: ```sh ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb ``` ## How Has This Been Tested? - Verified resolution stamping, nonterminal reopen clearing, and terminal reopen preservation. - Verified dry-run, apply, account/global scope, resume, skip, idempotency, and timestamp-preserving backfill behavior. - Verified all three miss types freeze and existing conversation-card behavior remains intact. - 71 focused RSpec examples and 37 focused Vitest examples pass. - RuboCop, ESLint, and diff checks pass. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> |
||
|
|
651db39765 |
fix: harden article author updates (#15229)
## Description Article edits now ignore an `author_id` that does not belong to the current account, retain the existing author, and still apply other valid article changes. Article creation continues to reject cross-account authors with a generic validation error and without creating a record. The authenticated article serializers still omit authors without a current-account membership so existing forged or stale records cannot expose agent profile fields. The guard now checks `current_account_user` directly to make that intent explicit. ## Closes Follow-up to [CW-7665](https://linear.app/chatwoot/issue/CW-7665) and [#15191](https://github.com/chatwoot/chatwoot/pull/15191). ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Creating an article with a cross-account author returns `422` and does not create an article. - Updating an article with a cross-account author retains the previous author while applying other valid attributes. - Articles whose previous author is no longer an account member can still be edited without exposing that author's agent profile. - Existing OSS and Enterprise article request coverage passes locally. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> |
||
|
|
bc9839ed38 |
fix: stabilize conversation FAQ lock spec (#15236)
## Description Backend CI no longer flakes when the conversation FAQ grouping-lock spec runs after Rails has reloaded application constants. The job already raises the intended lock-acquisition error and prevents concurrent suggestion generation. This updates the assertion to compare the error class name, preserving that behavior without depending on a reload-sensitive Ruby `Class` object. The failure was reproduced in [CircleCI backend job 171299](https://app.circleci.com/pipelines/github/chatwoot/chatwoot/116560/workflows/efcee2f1-5af8-4121-82c3-920002dc420b/jobs/171299). ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? The isolated job spec passes, the exact 18-way CircleCI shard selection passes all 442 examples, and the changed spec passes Ruby lint. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes |
||
|
|
8fcd32f442 |
chore(search): support Elastic Cloud API keys (#15231)
# Pull Request Template ## Description Adds API-key authorization support for Searchkick/OpenSearch so Elastic Cloud deployments can configure advanced search with an Elastic API key instead of embedding basic auth in the URL. The initializer now accepts `OPENSEARCH_API_KEY` or `ELASTICSEARCH_API_KEY` and forwards it as an `Authorization: ApiKey ...` header. `.env.example` also documents the OpenSearch/Elasticsearch-compatible search variables. Refs https://linear.app/chatwoot/issue/CW-7511/populate-test-data-set-and-run-experiments ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [x] This change requires a documentation update ## How Has This Been Tested? - `bundle exec ruby -c config/initializers/searchkick.rb` - `bundle exec ruby -c spec/config/searchkick_spec.rb` - `bundle exec rspec spec/config/searchkick_spec.rb` - `bundle exec rubocop config/initializers/searchkick.rb spec/config/searchkick_spec.rb` - `git diff --check` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules |
||
|
|
59eac9a7c5 |
feat(whatsapp): add cloud template management token (#15218)
Chatwoot Cloud customers can now provide a dedicated WhatsApp business management token when their Embedded Signup credential cannot access message templates. Once validated, the token is stored securely and used only for template synchronization. Existing inboxes continue using their configured WhatsApp API key when no business management token is present. Sending, receiving, webhooks, phone-number health, and other WhatsApp operations remain unchanged. ### Things to know - This option is available only on Chatwoot Cloud. - Saving the token verifies that `whatsapp_business_management` is granted through Meta's permissions endpoint; template synchronization still verifies access to the configured WhatsApp Business Account. - The token is encrypted using the existing external-credentials encryption mechanism. - Self-hosted installations continue using the existing API key flow. ### How to test 1. On Chatwoot Cloud, open a WhatsApp Cloud inbox and go to **Configuration**. 2. Enter a token with `whatsapp_business_management` access and save it. 3. Confirm the token is accepted and the value is not exposed again in the UI or API. 4. Select **Sync Templates** and confirm templates are fetched with the saved business management token. 5. Remove the token and confirm template synchronization falls back to the inbox API key. 6. Confirm the business management token controls are not shown on a self-hosted installation. ### What changed - Added an encrypted `business_management_token` credential to WhatsApp channels. - Added Cloud-only endpoints and UI controls to validate the required permission, save, and remove the token. - Added template-sync credential selection with API-key fallback. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
d5f7a2be64 |
fix: don't show the self-hosted upgrade banner on chatwoot cloud (#15195)
## Description The dashboard "update to vX.Y.Z is available" banner is a self-hosted upgrade nudge, but nothing gates it to self-hosted installs. On a managed cloud instance the banner can still appear: `latest_chatwoot_version` is populated in Redis from the hub's advertised latest stable release, and the frontend compares it (`semver.lt`) against the per-request `appVersion` baked into the dashboard HTML. When a browser session is holding HTML rendered before the current deploy, `appVersion` lags the hub's advertised version and the banner fires, even though the managed instance is already on the newest build. This gates the value at the source: `api/v1/accounts#show` no longer sets `latest_chatwoot_version` when running on cloud, so `hasAnUpdateAvailable` short-circuits (`semver.valid(null)` is false) and the banner never renders there. Self-hosted behaviour is unchanged. Fixes https://linear.app/chatwoot/issue/CW-7763 |
||
|
|
1b487f49d8 |
fix: reject cross-account author on help center articles (#15191)
## Description The help center article endpoints permit `author_id` and render the author through the agent serializer (`_agent.json.jbuilder`), which exposes `email` plus name, role, and availability. `author_id` was never scoped to the current account, so a forged or stale author disclosed the profile of a user in another account. This is the article path of the same serializer disclosure class as the conversation participants fix. The fix has two parts: - **Serializer guard (the disclosure fix).** The article partials now render the author only when they are a member of the current account (`article.author&.account`). This closes the leak on every read and for every row, including articles that already carry a forged or stale out-of-account author, and mirrors how the conversation assignee is already handled. - **Create-only validation.** `author_id` is checked against the account's users on create, so a new article cannot be forged with an out-of-account author. Update needs no guard: a non-member author is simply never rendered, so editing an article whose author has left the account continues to work. Fixes https://linear.app/chatwoot/issue/CW-7665 Related: https://github.com/chatwoot/chatwoot/pull/15180 (https://linear.app/chatwoot/issue/CW-7746). |
||
|
|
d04a717701 |
fix(whatsapp): resolve replies across scoped message ids (#15107)
WhatsApp coexistence replies can carry a BSUID-scoped `context.id` even when Chatwoot stored the original message with a phone-scoped WAMID. Although both identifiers refer to the same message, their complete values differ, so incoming replies retained the external reference but did not populate the internal `in_reply_to` relationship. Agents consequently saw the reply text without the quoted-message preview. This resolves the original message within the selected conversation and stores the internal reply relationship. Exact WAMID matches remain the primary path; scoped identifiers fall back to the unique decoded message token. Fixed https://linear.app/chatwoot/issue/CW-7663/whatsapp-quoted-replies-are-not-linked-across-scoped-wamids and https://github.com/chatwoot/chatwoot/issues/14953 ## How to reproduce 1. Use a WhatsApp Cloud inbox with coexistence enabled. 2. Send a message whose source ID is stored using the phone-scoped WAMID. 3. Reply to it from WhatsApp when the webhook carries a BSUID-scoped `context.id` for the same message. 4. Before this change, the incoming message appears without its quoted-message preview. 5. After this change, the reply references and displays the original message. ## What changed - Resolve incoming reply context IDs against messages in the selected conversation. - Keep exact source-ID matching as the first lookup path. - Decode scoped WAMIDs and match only a unique 20- or 32-character message token. - Populate `content_attributes.in_reply_to` while preserving `in_reply_to_external_id`. - Leave malformed, unmatched, or ambiguous identifiers unlinked. ## How to test 1. Open a WhatsApp Cloud conversation and send a message to the contact. 2. Reply to that message from WhatsApp through a coexistence identity. 3. Confirm the incoming message displays the original message as a quoted preview. 4. Confirm ordinary exact-ID replies continue to resolve. 5. Confirm an unknown or malformed context ID does not attach to another message. ## Things to know Meta documents `context.id` as the replied-to message identifier, but does not document the internal WAMID encoding or the phone-versus-BSUID scope transformation. The fallback is therefore limited to the selected conversation and succeeds only when one stored message has the decoded token. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |