From 941c8a86b41e62b9e8ca91b75dd7a33663cee83a Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Tue, 5 May 2026 17:20:22 +0530
Subject: [PATCH 01/17] fix: use a dedicated PAT for ghsa linear sync gh action
(#14364)
The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token.
Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`.
Tested locally: the same PAT returns the full triage list
Changes
----
- Switch to custom token
- Add a discord alert for new advisories
- Switch to python
---
.github/scripts/ghsa_linear_sync.py | 195 +++++++++++++++++++++++++
.github/workflows/ghsa-linear-sync.yml | 94 ++----------
2 files changed, 208 insertions(+), 81 deletions(-)
create mode 100644 .github/scripts/ghsa_linear_sync.py
diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py
new file mode 100644
index 000000000..064361b26
--- /dev/null
+++ b/.github/scripts/ghsa_linear_sync.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Sync triage GitHub security advisories to Linear issues."""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Any
+
+import requests
+
+GITHUB_API = "https://api.github.com"
+LINEAR_API = "https://api.linear.app/graphql"
+
+SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
+SEVERITY_COLOR = {
+ "critical": 15548997,
+ "high": 15105570,
+ "medium": 15844367,
+ "low": 3066993,
+}
+DEFAULT_COLOR = 9807270
+
+
+def required_env(name: str) -> str:
+ value = os.environ.get(name)
+ if not value:
+ sys.exit(f"Missing required env var: {name}")
+ return value
+
+
+def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
+ url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
+ params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ advisories: list[dict[str, Any]] = []
+ while url:
+ r = requests.get(url, headers=headers, params=params, timeout=30)
+ r.raise_for_status()
+ advisories.extend(r.json())
+ next_link = r.links.get("next")
+ url = next_link["url"] if next_link else None
+ params = None
+ return advisories
+
+
+def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
+ r = requests.post(
+ LINEAR_API,
+ headers={"Authorization": api_key},
+ json={"query": query, "variables": variables},
+ timeout=30,
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
+ query = (
+ "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
+ "{ nodes { id } } }"
+ )
+ resp = linear_call(query, {"q": ghsa_id}, api_key)
+ return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
+
+
+def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
+ query = (
+ "mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
+ "{ success issue { identifier url } } }"
+ )
+ resp = linear_call(query, {"input": input_data}, api_key)
+ create = resp.get("data", {}).get("issueCreate") or {}
+ if not create.get("success"):
+ return None
+ return create.get("issue")
+
+
+def reporter_login(advisory: dict[str, Any]) -> str:
+ for credit in advisory.get("credits") or []:
+ user = (credit or {}).get("user") or {}
+ if user.get("login"):
+ return user["login"]
+ return "unknown"
+
+
+def cvss_score(advisory: dict[str, Any]) -> str:
+ score = (advisory.get("cvss") or {}).get("score")
+ return str(score) if score is not None else "n/a"
+
+
+def build_description(adv: dict[str, Any]) -> str:
+ return (
+ f"**GHSA:** {adv['ghsa_id']}\n"
+ f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
+ f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
+ f"**Reporter:** {reporter_login(adv)}\n"
+ f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
+ f"**Advisory:** {adv['html_url']}\n\n"
+ f"---\n\n"
+ f"{adv.get('description') or 'No description provided.'}"
+ )
+
+
+def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
+ severity = adv.get("severity") or "unknown"
+ title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
+ payload = {
+ "username": "GHSA Sync",
+ "embeds": [
+ {
+ "title": title,
+ "url": issue["url"],
+ "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
+ "fields": [
+ {"name": "Linear", "value": issue["identifier"], "inline": True},
+ {
+ "name": "Severity",
+ "value": f"{severity} (CVSS {cvss_score(adv)})",
+ "inline": True,
+ },
+ {
+ "name": "Advisory",
+ "value": f"[GitHub]({adv['html_url']})",
+ "inline": True,
+ },
+ ],
+ }
+ ],
+ }
+ try:
+ requests.post(webhook_url, json=payload, timeout=10)
+ except requests.RequestException:
+ pass
+
+
+def main() -> int:
+ repo = required_env("GITHUB_REPOSITORY")
+ gh_token = required_env("GHSA_READ_TOKEN")
+ linear_api_key = required_env("LINEAR_API_KEY")
+ team_id = required_env("LINEAR_TEAM_ID")
+ project_id = required_env("LINEAR_PROJECT_ID")
+ label_id = required_env("LINEAR_LABEL_ID")
+ discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
+
+ advisories = fetch_triage_advisories(repo, gh_token)
+ print(f"Fetched {len(advisories)} triage advisories")
+
+ created = skipped = failed = 0
+
+ for adv in advisories:
+ ghsa_id = adv.get("ghsa_id")
+ if not ghsa_id:
+ failed += 1
+ continue
+
+ try:
+ if linear_issue_exists(ghsa_id, linear_api_key):
+ skipped += 1
+ continue
+
+ severity = adv.get("severity") or "unknown"
+ issue = linear_create_issue(
+ {
+ "title": f"[{ghsa_id}] {adv.get('summary', '')}",
+ "description": build_description(adv),
+ "teamId": team_id,
+ "projectId": project_id,
+ "labelIds": [label_id],
+ "priority": SEVERITY_PRIORITY.get(severity, 3),
+ },
+ linear_api_key,
+ )
+ except requests.RequestException:
+ failed += 1
+ continue
+
+ if not issue:
+ failed += 1
+ continue
+
+ created += 1
+ if discord_webhook:
+ post_discord(adv, issue, discord_webhook)
+
+ print(f"Created {created}, skipped {skipped}, failed {failed}")
+ return 1 if failed > 0 else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml
index 961114bc9..a21fbea7f 100644
--- a/.github/workflows/ghsa-linear-sync.yml
+++ b/.github/workflows/ghsa-linear-sync.yml
@@ -5,93 +5,25 @@ on:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
+permissions:
+ contents: read
+
jobs:
sync:
runs-on: ubuntu-latest
- permissions:
- security-events: read
steps:
- - name: Fetch triage advisories
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh api --paginate \
- -H "Accept: application/vnd.github+json" \
- "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \
- | jq -cs 'add | [.[] | {
- ghsa_id, cve_id, summary, severity, state, html_url,
- description, created_at,
- cvss_score: .cvss.score,
- reporter: ([.credits[]?.user.login] | first // "unknown")
- }]' > advisories.json
- echo "Fetched $(jq 'length' advisories.json) triage advisories"
-
- - name: Create Linear issues for new advisories
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Install dependencies
+ run: pip install requests==2.32.3
+ - name: Sync advisories
env:
+ GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
- run: |
- created_count=0
- skipped_count=0
- failed_count=0
- while read -r advisory; do
- ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id')
- summary=$(printf '%s' "$advisory" | jq -r '.summary')
- severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"')
- cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"')
- cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"')
- reporter=$(printf '%s' "$advisory" | jq -r '.reporter')
- html_url=$(printf '%s' "$advisory" | jq -r '.html_url')
- created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1)
- description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."')
-
- existing=$(curl -s -X POST https://api.linear.app/graphql \
- -H "Content-Type: application/json" \
- -H "Authorization: $LINEAR_API_KEY" \
- -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \
- | jq '.data.issues.nodes | length')
-
- if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then
- skipped_count=$((skipped_count+1))
- continue
- fi
-
- priority=3
- case "$severity" in
- critical) priority=1 ;;
- high) priority=2 ;;
- medium) priority=3 ;;
- low) priority=4 ;;
- esac
-
- title="[$ghsa_id] $summary"
- body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \
- "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description")
-
- success=$(curl -s -X POST https://api.linear.app/graphql \
- -H "Content-Type: application/json" \
- -H "Authorization: $LINEAR_API_KEY" \
- -d "$(jq -n \
- --arg title "$title" \
- --arg body "$body" \
- --arg teamId "$LINEAR_TEAM_ID" \
- --arg projectId "$LINEAR_PROJECT_ID" \
- --arg labelId "$LINEAR_LABEL_ID" \
- --argjson priority "$priority" \
- '{
- query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }",
- variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}}
- }')" | jq -r '.data.issueCreate.success // false')
-
- if [ "$success" = "true" ]; then
- created_count=$((created_count+1))
- else
- failed_count=$((failed_count+1))
- fi
- done < <(jq -c '.[]' advisories.json)
- echo "Created $created_count, skipped $skipped_count, failed $failed_count"
- if [ "$failed_count" -gt 0 ]; then
- exit 1
- fi
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ run: python3 .github/scripts/ghsa_linear_sync.py
From 2192af80f405c70e3021191ff8b3f7e539c35e9d Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Tue, 5 May 2026 17:46:21 +0530
Subject: [PATCH 02/17] fix: html-escape captured values in helpcenter article
markdown embeds (#14140)
Embed templates interpolate regex captures from user-authored article
URLs into HTML attribute values. CommonMark's angle-bracket link
destination syntax allows characters that the capture regexes don't
filter, so the unescaped substitution could produce malformed attribute
output. Escaping at substitution time keeps the render deterministic
regardless of the URL.
### How was this tested?
Added specs.
Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/)
Co-authored-by: Sony Mathew
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
lib/custom_markdown_renderer.rb | 5 +++--
spec/lib/custom_markdown_renderer_spec.rb | 18 ++++++++++++++++++
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index e7eefe5c8..d5c9a3351 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -77,9 +77,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
return nil unless embed_config
template = embed_config['template']
- # Use Ruby's built-in named captures with gsub to handle CSS % values
+ # Use gsub (not format) so CSS `%` values in templates don't need escaping.
+ # Captured values are HTML-escaped since they land inside HTML attribute contexts.
match_data.named_captures.each do |var_name, value|
- template = template.gsub("%{#{var_name}}", value)
+ template = template.gsub("%{#{var_name}}", CGI.escapeHTML(value))
end
template
end
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 3415a811d..cb13f6cf9 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -238,5 +238,23 @@ describe CustomMarkdownRenderer do
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
end
end
+
+ context 'when captured values contain HTML-special characters' do
+ # CommonMark angle-bracket link destinations `[text]()` permit characters
+ # like `"` that the embed regex captures would otherwise pass through raw into
+ # attribute values. Captures are HTML-escaped before interpolation so the
+ # substituted value cannot break out of the surrounding attribute context.
+ it 'escapes double quotes in captured YouTube video_id' do
+ markdown = "\n[demo]()\n"
+ output = render_markdown(markdown)
+ expect(output).not_to include('onload="alert(1)"')
+ expect(output).to include('"')
+ end
+
+ it 'leaves legitimate alphanumeric IDs untouched' do
+ output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
+ expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"')
+ end
+ end
end
end
From cc5974da9bec82fccf7fcba1ed14b7326dc486c8 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 6 May 2026 09:54:00 +0400
Subject: [PATCH 03/17] feat(inbox): Add beta badge for TikTok and Voice
channels (#14378)
TikTok and Voice channels in the inbox creation flow now display a small
"Beta" badge next to their title, signaling that these integrations are
still being polished while keeping them available for users to try.
Fixes
https://linear.app/chatwoot/issue/CW-7026/add-beta-label-for-tiktok-and-voice-inboxes
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context)
---
.../dashboard/components/ChannelSelector.vue | 25 ++++++++++++++++---
.../components/widgets/ChannelItem.vue | 5 ++++
2 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/app/javascript/dashboard/components/ChannelSelector.vue b/app/javascript/dashboard/components/ChannelSelector.vue
index 2e3ea7d86..ef5256577 100644
--- a/app/javascript/dashboard/components/ChannelSelector.vue
+++ b/app/javascript/dashboard/components/ChannelSelector.vue
@@ -1,5 +1,7 @@
@@ -37,9 +45,18 @@ defineProps({
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
+
{{ description }}
@@ -50,7 +67,7 @@ defineProps({
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
- {{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
+ {{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 31c58da47..2cbad72c5 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value;
});
+const isBeta = computed(() => {
+ return ['tiktok', 'voice'].includes(props.channel.key);
+});
+
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
@@ -90,6 +94,7 @@ const onItemClick = () => {
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
+ :is-beta="isBeta"
:disabled="!isActive"
@click="onItemClick"
/>
From b8108b71c1095e10bed68adfa6933c577cb692a1 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 6 May 2026 11:21:15 +0400
Subject: [PATCH 04/17] fix(tiktok): Resolve media upload failures and gate
attachments by conversation capability (#13643)
This PR fixes TikTok attachment send failures and adds a
capability-based guard so attachments are only enabled for conversations
that support media sending.
- Fixed TikTok media upload request formatting so TikTok accepts image
uploads reliably.
- Added TikTok capability check (IMAGE_SEND) during conversation
creation.
- Stored capability in
conversation.additional_attributes.tiktok_capabilities.
- Updated reply composer UI to disable/hide attachment upload for TikTok
conversations where image_send is false.
Fixes
https://linear.app/chatwoot/issue/CW-6532/enable-attachments-based-on-the-conversation-capability
and
https://linear.app/chatwoot/issue/CW-6996/unable-to-send-image-attachments-to-tiktok-customer-400-parsing-error
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
.../widgets/conversation/ReplyBox.vue | 9 +-
app/services/tiktok/client.rb | 55 +++++--
app/services/tiktok/messaging_helpers.rb | 22 ++-
app/services/tiktok/send_on_tiktok_service.rb | 19 ++-
spec/services/tiktok/client_spec.rb | 136 ++++++++++++++++++
spec/services/tiktok/message_service_spec.rb | 57 ++++++--
.../tiktok/send_on_tiktok_service_spec.rb | 87 ++++++++++-
7 files changed, 352 insertions(+), 33 deletions(-)
create mode 100644 spec/services/tiktok/client_spec.rb
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index d18be0caa..872f68640 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -273,6 +273,10 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
+ const { image_send: imageSend } =
+ this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
+ const tiktokAttachmentSupported = imageSend ?? true;
+
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -283,7 +287,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
- this.isATiktokChannel
+ (this.isATiktokChannel && tiktokAttachmentSupported)
);
},
replyButtonLabel() {
@@ -706,6 +710,7 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -1025,6 +1030,8 @@ export default {
});
},
attachFile({ blob, file }) {
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
+
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
diff --git a/app/services/tiktok/client.rb b/app/services/tiktok/client.rb
index dfbe8ba1f..5b112d8d7 100644
--- a/app/services/tiktok/client.rb
+++ b/app/services/tiktok/client.rb
@@ -1,3 +1,6 @@
+require 'faraday'
+require 'faraday/multipart'
+
class Tiktok::Client
# Always use Tiktok::TokenService to get a valid access token
pattr_initialize [:business_id!, :access_token!]
@@ -31,14 +34,32 @@ class Tiktok::Client
json['data']['download_url']
end
+ def image_send_capable?(conversation_id, conversation_type: 'SINGLE')
+ endpoint = "#{api_base_url}/business/message/capabilities/get/"
+ headers = { 'Access-Token': access_token }
+ query = {
+ business_id: business_id,
+ conversation_id: conversation_id,
+ conversation_type: conversation_type,
+ capability_types: ['IMAGE_SEND'].to_json
+ }
+
+ response = HTTParty.get(endpoint, query: query, headers: headers)
+ json = process_json_response(response, 'Failed to fetch TikTok message capabilities')
+ capabilities = json.dig('data', 'capability_infos') || []
+ image_send = capabilities.find { |capability| capability['capability_type'] == 'IMAGE_SEND' }
+
+ image_send&.[]('capability_result') == true
+ end
+
def send_text_message(conversation_id, text, referenced_message_id: nil)
send_message(conversation_id, 'TEXT', text, referenced_message_id: referenced_message_id)
end
- def send_media_message(conversation_id, attachment, referenced_message_id: nil)
+ def send_media_message(conversation_id, attachment)
# As of now, only IMAGE media type is supported
- media_id = upload_media(attachment.file, 'IMAGE')
- send_message(conversation_id, 'IMAGE', media_id, referenced_message_id: referenced_message_id)
+ media_id = upload_media(attachment.file.blob, 'IMAGE')
+ send_message(conversation_id, 'IMAGE', media_id)
end
private
@@ -69,31 +90,39 @@ class Tiktok::Client
json['data']['message']['message_id']
end
- def upload_media(file, media_type = 'IMAGE')
+ def upload_media(blob, media_type = 'IMAGE')
endpoint = "#{api_base_url}/business/message/media/upload/"
- headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
- file.open do |temp_file|
- body = {
+ blob.open do |temp_file|
+ temp_file.rewind
+ payload = {
business_id: business_id,
media_type: media_type,
- file: temp_file
+ file: Faraday::Multipart::FilePart.new(temp_file, blob.content_type || 'application/octet-stream', blob.filename.to_s)
}
- response = HTTParty.post(endpoint, body: body, headers: headers)
+ response = multipart_connection.post(endpoint, payload) do |request|
+ request.headers['Access-Token'] = access_token
+ end
json = process_json_response(response, 'Failed to upload TikTok media')
json['data']['media_id']
end
end
+ def multipart_connection
+ @multipart_connection ||= Faraday.new do |faraday|
+ faraday.request :multipart
+ end
+ end
+
def api_base_url
"https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}"
end
def process_json_response(response, error_prefix)
unless response.success?
- Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
- raise "#{response.code}: #{response.body}"
+ Rails.logger.error "#{error_prefix}. Status: #{response_status(response)}, Body: #{response.body}"
+ raise "#{response_status(response)}: #{response.body}"
end
res = JSON.parse(response.body)
@@ -101,4 +130,8 @@ class Tiktok::Client
res
end
+
+ def response_status(response)
+ response.respond_to?(:code) ? response.code : response.status
+ end
end
diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb
index 0f9d9e0b3..ce2aea817 100644
--- a/app/services/tiktok/messaging_helpers.rb
+++ b/app/services/tiktok/messaging_helpers.rb
@@ -48,14 +48,26 @@ module Tiktok::MessagingHelpers
inbox_id: channel.inbox.id,
contact_id: contact_inbox.contact.id,
contact_inbox_id: contact_inbox.id,
- additional_attributes: conversation_additional_attributes(tt_conversation_id)
+ additional_attributes: conversation_additional_attributes(channel, tt_conversation_id)
}
end
- def conversation_additional_attributes(tt_conversation_id)
- {
- conversation_id: tt_conversation_id
- }
+ def conversation_additional_attributes(channel, tt_conversation_id)
+ attributes = { conversation_id: tt_conversation_id }
+ capabilities = tiktok_conversation_capabilities(channel, tt_conversation_id)
+ attributes[:tiktok_capabilities] = capabilities if capabilities.present?
+ attributes
+ end
+
+ def tiktok_conversation_capabilities(channel, tt_conversation_id)
+ image_send = tiktok_client(channel).image_send_capable?(tt_conversation_id)
+ { image_send: image_send, updated_at: Time.current.iso8601 }
+ rescue StandardError => e
+ Rails.logger.error(
+ 'Failed to fetch TikTok conversation capabilities ' \
+ "for tt_conversation_id=#{tt_conversation_id}, business_id=#{channel.business_id}: #{e.class}: #{e.message}"
+ )
+ {}
end
def find_message(tt_conversation_id, tt_message_id)
diff --git a/app/services/tiktok/send_on_tiktok_service.rb b/app/services/tiktok/send_on_tiktok_service.rb
index d7db5f326..58771cd05 100644
--- a/app/services/tiktok/send_on_tiktok_service.rb
+++ b/app/services/tiktok/send_on_tiktok_service.rb
@@ -1,4 +1,7 @@
class Tiktok::SendOnTiktokService < Base::SendOnChannelService
+ SUPPORTED_IMAGE_CONTENT_TYPES = %w[image/jpeg image/png].freeze
+ MAX_IMAGE_SIZE = 3.megabytes
+
private
def channel_class
@@ -18,8 +21,22 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
def validate_message_support!
return unless message.attachments.any?
+
raise 'Sending attachments with text is not supported on TikTok.' if message.outgoing_content.present?
raise 'Sending multiple attachments in a single TikTok message is not supported.' unless message.attachments.one?
+
+ validate_attachment_support!(message.attachments.first)
+ end
+
+ def validate_attachment_support!(attachment)
+ raise 'Sending image attachments is not supported for this TikTok conversation.' unless image_send_capable?
+ raise 'Only image attachments are supported on TikTok.' unless attachment.image?
+ raise 'TikTok supports only JPG and PNG images.' unless SUPPORTED_IMAGE_CONTENT_TYPES.include?(attachment.file.content_type)
+ raise 'TikTok image attachments must be smaller than 3 MB.' if attachment.file.byte_size > MAX_IMAGE_SIZE
+ end
+
+ def image_send_capable?
+ message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send') != false
end
def send_message
@@ -27,7 +44,7 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService
tt_referenced_message_id = message.content_attributes['in_reply_to_external_id']
if message.attachments.any?
- tiktok_client.send_media_message(tt_conversation_id, message.attachments.first, referenced_message_id: tt_referenced_message_id)
+ tiktok_client.send_media_message(tt_conversation_id, message.attachments.first)
else
tiktok_client.send_text_message(tt_conversation_id, message.outgoing_content, referenced_message_id: tt_referenced_message_id)
end
diff --git a/spec/services/tiktok/client_spec.rb b/spec/services/tiktok/client_spec.rb
new file mode 100644
index 000000000..9bf02be91
--- /dev/null
+++ b/spec/services/tiktok/client_spec.rb
@@ -0,0 +1,136 @@
+require 'rails_helper'
+
+RSpec.describe Tiktok::Client do
+ let(:client) { described_class.new(business_id: 'biz-123', access_token: 'token-123') }
+ let(:response) { instance_double(HTTParty::Response) }
+
+ describe '#image_send_capable?' do
+ before do
+ allow(HTTParty).to receive(:get).and_return(response)
+ allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
+ end
+
+ it 'returns true when IMAGE_SEND capability is enabled' do
+ allow(client).to receive(:process_json_response).with(
+ response,
+ 'Failed to fetch TikTok message capabilities'
+ ).and_return(
+ {
+ 'data' => {
+ 'capability_infos' => [
+ { 'capability_type' => 'IMAGE_SEND', 'capability_result' => true }
+ ]
+ }
+ }
+ )
+
+ result = client.image_send_capable?('tt-conv-1')
+
+ expect(result).to be(true)
+ expect(HTTParty).to have_received(:get).with(
+ 'https://business-api.tiktok.com/open_api/v1.3/business/message/capabilities/get/',
+ query: {
+ business_id: 'biz-123',
+ conversation_id: 'tt-conv-1',
+ conversation_type: 'SINGLE',
+ capability_types: '["IMAGE_SEND"]'
+ },
+ headers: { 'Access-Token': 'token-123' }
+ )
+ end
+
+ it 'returns false when IMAGE_SEND capability is not enabled' do
+ allow(client).to receive(:process_json_response).with(
+ response,
+ 'Failed to fetch TikTok message capabilities'
+ ).and_return(
+ {
+ 'data' => {
+ 'capability_infos' => [
+ { 'capability_type' => 'IMAGE_SEND', 'capability_result' => false }
+ ]
+ }
+ }
+ )
+
+ result = client.image_send_capable?('tt-conv-1')
+
+ expect(result).to be(false)
+ end
+ end
+
+ describe '#upload_media' do
+ let(:connection) { instance_double(Faraday::Connection) }
+ let(:request) { instance_double(Faraday::Request, headers: {}) }
+ let(:response) { instance_double(Faraday::Response, success?: true, body: response_body) }
+ let(:response_body) do
+ {
+ code: 0,
+ message: 'OK',
+ data: { media_id: 'media-123' }
+ }.to_json
+ end
+ let(:blob) do
+ instance_double(
+ ActiveStorage::Blob,
+ content_type: 'image/png',
+ filename: ActiveStorage::Filename.new('avatar.png')
+ )
+ end
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3')
+ allow(Faraday).to receive(:new).and_return(connection)
+ allow(blob).to receive(:open) do |&block|
+ File.open(Rails.root.join('spec/assets/avatar.png'), 'rb', &block)
+ end
+ end
+
+ it 'posts media upload with access token header' do
+ captured_endpoint = nil
+ allow(connection).to receive(:post) do |endpoint, _payload, &block|
+ captured_endpoint = endpoint
+ block.call(request)
+ response
+ end
+
+ media_id = client.send(:upload_media, blob)
+
+ expect(media_id).to eq('media-123')
+ expect(captured_endpoint).to eq('https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/')
+ expect(request.headers['Access-Token']).to eq('token-123')
+ end
+
+ it 'uploads media as a multipart file with filename and content type' do
+ captured_payload = nil
+ allow(connection).to receive(:post) do |_endpoint, payload, &block|
+ captured_payload = payload
+ block.call(request)
+ response
+ end
+
+ client.send(:upload_media, blob)
+
+ expect(captured_payload[:business_id]).to eq('biz-123')
+ expect(captured_payload[:media_type]).to eq('IMAGE')
+ expect(captured_payload[:file]).to be_a(Faraday::Multipart::FilePart)
+ expect(captured_payload[:file].content_type).to eq('image/png')
+ expect(captured_payload[:file].original_filename).to eq('avatar.png')
+ end
+ end
+
+ describe '#send_media_message' do
+ let(:file) { Struct.new(:blob).new('blob') }
+ let(:attachment) { instance_double(Attachment, file: file) }
+
+ it 'sends image messages' do
+ allow(client).to receive(:upload_media).with('blob', 'IMAGE').and_return('media-123')
+ allow(client).to receive(:send_message).and_return('tt-msg-123')
+
+ message_id = client.send_media_message('tt-conv-1', attachment)
+
+ expect(message_id).to eq('tt-msg-123')
+ expect(client).to have_received(:send_message).with('tt-conv-1', 'IMAGE', 'media-123')
+ end
+ end
+end
diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb
index ee7b113ac..75878fe9f 100644
--- a/spec/services/tiktok/message_service_spec.rb
+++ b/spec/services/tiktok/message_service_spec.rb
@@ -6,10 +6,11 @@ RSpec.describe Tiktok::MessageService do
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
+ let(:tiktok_client) { instance_double(Tiktok::Client, image_send_capable?: true) }
let(:text_content) do
{
type: 'text',
- message_id: 'tt-msg-lock',
+ message_id: 'tt-msg-1',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
text: { body: 'Hello from TikTok' },
@@ -20,6 +21,10 @@ RSpec.describe Tiktok::MessageService do
}.deep_symbolize_keys
end
+ before do
+ allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
+ end
+
describe '#perform' do
subject(:perform_text_message) do
service = described_class.new(channel: channel, content: current_content)
@@ -30,20 +35,8 @@ RSpec.describe Tiktok::MessageService do
let(:current_content) { text_content }
it 'creates an incoming text message' do
- content = {
- type: 'text',
- message_id: 'tt-msg-1',
- timestamp: 1_700_000_000_000,
- conversation_id: 'tt-conv-1',
- text: { body: 'Hello from TikTok' },
- from: 'Alice',
- from_user: { id: 'user-1' },
- to: 'Biz',
- to_user: { id: 'biz-123' }
- }.deep_symbolize_keys
-
expect do
- service = described_class.new(channel: channel, content: content)
+ service = described_class.new(channel: channel, content: text_content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
service.perform
end.to change(Message, :count).by(1)
@@ -57,6 +50,18 @@ RSpec.describe Tiktok::MessageService do
expect(message.content_attributes['is_unsupported']).to be_nil
end
+ it 'stores TikTok conversation capabilities when creating a new conversation' do
+ service = described_class.new(channel: channel, content: text_content)
+ allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
+
+ service.perform
+
+ message = Message.last
+ expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send')).to be(true)
+ expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'updated_at')).to be_present
+ expect(tiktok_client).to have_received(:image_send_capable?).with('tt-conv-1')
+ end
+
it 'creates an incoming unsupported message for non-supported types' do
content = {
type: 'sticker',
@@ -135,6 +140,30 @@ RSpec.describe Tiktok::MessageService do
tempfile.close!
end
+ it 'creates a conversation even when capability lookup fails' do
+ allow(tiktok_client).to receive(:image_send_capable?).and_raise('TikTok capability API error')
+
+ content = {
+ type: 'text',
+ message_id: 'tt-msg-5',
+ timestamp: 1_700_000_000_000,
+ conversation_id: 'tt-conv-1',
+ text: { body: 'Hello with capability failure' },
+ from: 'Alice',
+ from_user: { id: 'user-1' },
+ to: 'Biz',
+ to_user: { id: 'biz-123' }
+ }.deep_symbolize_keys
+
+ service = described_class.new(channel: channel, content: content)
+ allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
+
+ expect { service.perform }.to change(Message, :count).by(1)
+
+ message = Message.last
+ expect(message.conversation.additional_attributes['tiktok_capabilities']).to be_nil
+ end
+
context 'when lock_to_single_conversation is enabled' do
it 'reuses the last resolved conversation' do
inbox.update!(lock_to_single_conversation: true)
diff --git a/spec/services/tiktok/send_on_tiktok_service_spec.rb b/spec/services/tiktok/send_on_tiktok_service_spec.rb
index 0543fa695..2ca1ffd03 100644
--- a/spec/services/tiktok/send_on_tiktok_service_spec.rb
+++ b/spec/services/tiktok/send_on_tiktok_service_spec.rb
@@ -48,10 +48,33 @@ RSpec.describe Tiktok::SendOnTiktokService do
described_class.new(message: message).perform
- expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
+ expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
expect(message.reload.source_id).to eq('tt-msg-124')
end
+ it 'sends outgoing image message without quote metadata' do
+ allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
+ allow(tiktok_client).to receive(:send_text_message)
+
+ message = build(
+ :message,
+ message_type: :outgoing,
+ inbox: inbox,
+ conversation: conversation,
+ account: inbox.account,
+ content: nil,
+ content_attributes: { in_reply_to_external_id: 'quoted-message-id' }
+ )
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first)
+ expect(tiktok_client).not_to have_received(:send_text_message)
+ end
+
it 'marks message as failed when sending multiple attachments' do
allow(tiktok_client).to receive(:send_media_message)
@@ -67,5 +90,67 @@ RSpec.describe Tiktok::SendOnTiktokService do
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
expect(tiktok_client).not_to have_received(:send_media_message)
end
+
+ it 'marks message as failed when conversation cannot send images' do
+ allow(tiktok_client).to receive(:send_media_message)
+ conversation.update!(additional_attributes: { conversation_id: 'tt-conv-1', tiktok_capabilities: { image_send: false } })
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(
+ message,
+ 'failed',
+ 'Sending image attachments is not supported for this TikTok conversation.'
+ )
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when attachment is not an image' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
+ attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'Only image attachments are supported on TikTok.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when image format is unsupported' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv')
+ message.save!
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok supports only JPG and PNG images.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
+
+ it 'marks message as failed when image is larger than 3 MB' do
+ allow(tiktok_client).to receive(:send_media_message)
+
+ message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ message.save!
+ allow(message.attachments.first.file).to receive(:byte_size).and_return(4.megabytes)
+
+ described_class.new(message: message).perform
+
+ expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok image attachments must be smaller than 3 MB.')
+ expect(tiktok_client).not_to have_received(:send_media_message)
+ end
end
end
From 00ba46848615dba4baed0b263f7575f84dbca5b8 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 6 May 2026 15:10:11 +0530
Subject: [PATCH 05/17] fix(macros): disable public visibility for agents
(#14349)
---
.../dashboard/i18n/locale/en/macros.json | 7 +-
.../dashboard/settings/macros/Index.vue | 3 +
.../dashboard/settings/macros/MacroEditor.vue | 11 ++-
.../dashboard/settings/macros/MacroForm.vue | 28 +++++--
.../settings/macros/MacroProperties.vue | 53 ++++++++++++-
.../settings/macros/MacrosTableRow.vue | 15 +++-
.../macros/specs/MacroProperties.spec.js | 79 +++++++++++++++++++
.../macros/specs/MacrosTableRow.spec.js | 63 +++++++++++++++
app/policies/macro_policy.rb | 14 ++--
.../api/v1/accounts/macros_controller_spec.rb | 31 ++++++++
10 files changed, 281 insertions(+), 23 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacroProperties.spec.js
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
diff --git a/app/javascript/dashboard/i18n/locale/en/macros.json b/app/javascript/dashboard/i18n/locale/en/macros.json
index 67a5c5003..e51975921 100644
--- a/app/javascript/dashboard/i18n/locale/en/macros.json
+++ b/app/javascript/dashboard/i18n/locale/en/macros.json
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
+ "VIEW": {
+ "TOOLTIP": "View macro"
+ },
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
- "DESCRIPTION": "This macro is available publicly for all agents in this account."
+ "DESCRIPTION": "This macro is available publicly for all agents in this account.",
+ "CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
+ "EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
index 2ff6122b7..b79d78e30 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/Index.vue
@@ -9,10 +9,12 @@ import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
+import { useAdmin } from 'dashboard/composables/useAdmin';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
+const { isAdmin } = useAdmin();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
@@ -109,6 +111,7 @@ const tableHeaders = computed(() => {
v-for="macro in items"
:key="macro.id"
:macro="macro"
+ :can-manage-public-macros="isAdmin"
@delete="openDeletePopup(macro)"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
index 0b916a4ab..b941598fe 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
@@ -8,6 +8,7 @@ import { MACRO_ACTION_TYPES } from './constants';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import { useMacros } from 'dashboard/composables/useMacros';
+import { useAdmin } from 'dashboard/composables/useAdmin';
const store = useStore();
const getters = useStoreGetters();
@@ -18,6 +19,7 @@ const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
+const { isAdmin } = useAdmin();
const macro = ref(null);
const mode = ref('CREATE');
@@ -33,6 +35,9 @@ provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
+const isPublicMacroReadOnly = computed(
+ () => macro.value?.visibility === 'global' && !isAdmin.value
+);
const fetchDropdownData = () => {
store.dispatch('agents/get');
@@ -92,7 +97,7 @@ const initNewMacro = () => {
action_params: [],
},
],
- visibility: 'global',
+ visibility: isAdmin.value ? 'global' : 'personal',
};
};
@@ -110,6 +115,8 @@ watch(
);
const saveMacro = async macroData => {
+ if (isPublicMacroReadOnly.value) return;
+
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
@@ -136,6 +143,8 @@ const saveMacro = async macroData => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
index 432b0d46b..b8b00aac9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroForm.vue
@@ -16,6 +16,14 @@ export default {
type: Object,
default: () => ({}),
},
+ canManagePublicMacros: {
+ type: Boolean,
+ default: true,
+ },
+ readOnly: {
+ type: Boolean,
+ default: false,
+ },
},
emits: ['submit'],
setup() {
@@ -112,19 +120,23 @@ export default {
@@ -55,8 +89,13 @@ export default {
@@ -69,13 +108,18 @@ export default {
class="text-n-brand size-4"
/>
-
- {{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
+
+ {{ publicVisibilityDescription }}
@@ -111,6 +155,7 @@ export default {
solid
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
class="w-full"
+ :disabled="readOnly"
@click="$emit('submit')"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
index 934a451de..f7edcb512 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacrosTableRow.vue
@@ -11,6 +11,10 @@ const props = defineProps({
type: Object,
required: true,
},
+ canManagePublicMacros: {
+ type: Boolean,
+ default: true,
+ },
});
defineEmits(['delete']);
const { t } = useI18n();
@@ -32,6 +36,14 @@ const visibilityLabel = computed(() => {
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
return t(i18nKey);
});
+
+const canManageMacro = computed(
+ () => props.canManagePublicMacros || props.macro.visibility !== 'global'
+);
+
+const editTooltip = computed(() =>
+ canManageMacro.value ? t('MACROS.EDIT.TOOLTIP') : t('MACROS.VIEW.TOOLTIP')
+);
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
+ shallowMount(MacroProperties, {
+ props: {
+ macroName: 'Close conversation',
+ macroVisibility: 'personal',
+ ...props,
+ },
+ global: {
+ provide: {
+ v$: {
+ macro: {
+ name: {
+ $error: false,
+ },
+ },
+ },
+ },
+ stubs: {
+ WootInput: true,
+ NextButton: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('MacroProperties.vue', () => {
+ it('allows administrators to select public visibility', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: true });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeUndefined();
+ expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
+ });
+
+ it('disables public visibility for agents with helper copy', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can create public macros.'
+ );
+ });
+
+ it('keeps existing public macros visibly selected when public is disabled', () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ });
+
+ expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
+ });
+
+ it('shows existing public macros as read-only for agents', async () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ readOnly: true,
+ });
+ const [publicButton, privateButton] = wrapper.findAll('button');
+
+ await privateButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(privateButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can edit public macros.'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
new file mode 100644
index 000000000..268a54240
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
@@ -0,0 +1,63 @@
+import { shallowMount } from '@vue/test-utils';
+import MacrosTableRow from '../MacrosTableRow.vue';
+
+const macro = visibility => ({
+ id: 1,
+ name: 'Close conversation',
+ visibility,
+ created_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+ updated_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+});
+
+const mountComponent = props =>
+ shallowMount(MacrosTableRow, {
+ props: {
+ macro: macro('global'),
+ canManagePublicMacros: true,
+ ...props,
+ },
+ global: {
+ stubs: {
+ Avatar: true,
+ BaseTableRow: {
+ template: '
',
+ },
+ BaseTableCell: {
+ template: '
',
+ },
+ Button: true,
+ RouterLink: {
+ template: ' ',
+ },
+ },
+ },
+ });
+
+describe('MacrosTableRow.vue', () => {
+ it('shows actions for public macros when public macros can be managed', () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+
+ it('keeps public macros viewable without delete actions when public macros cannot be managed', () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(1);
+ });
+
+ it('keeps actions available for personal macros when public macros cannot be managed', () => {
+ const wrapper = mountComponent({
+ macro: macro('personal'),
+ canManagePublicMacros: false,
+ });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+});
diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb
index 5d7af755b..d70b688a8 100644
--- a/app/policies/macro_policy.rb
+++ b/app/policies/macro_policy.rb
@@ -12,11 +12,15 @@ class MacroPolicy < ApplicationPolicy
end
def update?
- author? || (@account_user.administrator? && @record.global?)
+ return @account_user.administrator? if @record.global?
+
+ author?
end
def destroy?
- author? || orphan_record?
+ return @account_user.administrator? if @record.global?
+
+ author?
end
def execute?
@@ -28,10 +32,4 @@ class MacroPolicy < ApplicationPolicy
def author?
@record.created_by == @account_user.user
end
-
- def orphan_record?
- return @account_user.administrator? if @record.created_by.nil? && @record.global?
-
- false
- end
end
diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
index 1af908e11..1501309a0 100644
--- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb
@@ -239,6 +239,22 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
+ # A public macro can still point to an agent when an admin who authored it
+ # is later changed to the agent role. Public macros should remain
+ # admin-managed even when the original author is no longer an admin.
+ it 'does not allow agents to update public macros they created' do
+ macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
+
+ put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
+ params: params,
+ headers: agent.create_new_auth_token
+
+ json_response = response.parsed_body
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(json_response['error']).to eq('You are not authorized to do this action')
+ end
+
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
@@ -551,6 +567,21 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
+ # A public macro can still point to an agent when an admin who authored it
+ # is later changed to the agent role. Public macros should remain
+ # admin-managed even when the original author is no longer an admin.
+ it 'does not allow agents to delete public macros they created' do
+ macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
+
+ delete "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
+ headers: agent.create_new_auth_token
+
+ json_response = response.parsed_body
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(json_response['error']).to eq('You are not authorized to do this action')
+ end
+
it 'Unauthorize to delete the macro' do
macro = create(:macro, account: account, created_by: agent, updated_by: agent)
From 8f532f45ff77382bc65672f85e10f73878f5150c Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 6 May 2026 15:13:51 +0530
Subject: [PATCH 06/17] feat: add attachments section to conversation sidebar
(#14371)
---
.../conversation/components/GalleryView.vue | 6 +
.../dashboard/composables/useUISettings.js | 1 +
.../i18n/locale/en/conversation.json | 14 +-
.../dashboard/conversation/ContactPanel.vue | 13 +
.../dashboard/conversation/SharedFiles.vue | 421 ++++++++++++++++++
.../store/modules/conversations/getters.js | 2 +
.../specs/conversations/getters.spec.js | 25 ++
.../conversations/attachments.json.jbuilder | 1 +
.../accounts/conversations_controller_spec.rb | 2 +
9 files changed, 484 insertions(+), 1 deletion(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
index aa17dabf9..6b356f4d3 100644
--- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
@@ -22,6 +22,10 @@ const props = defineProps({
type: Array,
required: true,
},
+ autoPlay: {
+ type: Boolean,
+ default: false,
+ },
});
const emit = defineEmits(['close']);
@@ -309,6 +313,7 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
+ :autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -317,6 +322,7 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
+ :autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js
index 9f1943414..4f015e1d8 100644
--- a/app/javascript/dashboard/composables/useUISettings.js
+++ b/app/javascript/dashboard/composables/useUISettings.js
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
+ { name: 'shared_files' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 88e66ebe3..2dc3ed70e 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -365,7 +365,19 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
- "SHOPIFY_ORDERS": "Shopify Orders"
+ "SHOPIFY_ORDERS": "Shopify Orders",
+ "SHARED_FILES": "Attachments"
+ },
+ "SHARED_FILES": {
+ "EMPTY": "No attachments yet",
+ "DOWNLOAD": "Download file",
+ "DOWNLOAD_ERROR": "Could not download the file. Please try again.",
+ "MEDIA_HEADING": "Media",
+ "FILES_HEADING": "Files",
+ "VIEW_ALL": "View all",
+ "SHOW_LESS": "Show less",
+ "MORE_COUNT": "+{count}",
+ "UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 653b43840..fe95f9e99 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -17,6 +17,7 @@ import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
+import SharedFiles from './SharedFiles.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
@@ -297,6 +298,18 @@ onMounted(() => {
+
+
toggleSidebarUIState('is_shared_files_open', value)
+ "
+ >
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
new file mode 100644
index 000000000..272407a53
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
+
+ {{ mediaAttachments.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayDuration(attachment) }}
+
+
+
+ {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+ {{
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
+ count: mediaOverflow,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
+
+ {{ fileAttachments.length }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index 333009707..5e85c423c 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -57,6 +57,8 @@ const getters = {
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
return attachments[selectedChatId] || [];
},
+ getSelectedChatAttachmentsLoaded: ({ selectedChatId, attachments }) =>
+ selectedChatId !== null && attachments[selectedChatId] !== undefined,
getChatListFilters: ({ conversationFilters }) => conversationFilters,
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 69bf9c2ac..a7ee3833e 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -328,6 +328,31 @@ describe('#getters', () => {
});
});
+ describe('#getSelectedChatAttachmentsLoaded', () => {
+ it('returns true when attachments have been fetched for the selected chat', () => {
+ const state = { selectedChatId: 1, attachments: { 1: [] } };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
+ });
+
+ it('returns true when the fetched attachment list is non-empty', () => {
+ const state = {
+ selectedChatId: 1,
+ attachments: { 1: [{ id: 1, file_name: 'test' }] },
+ };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
+ });
+
+ it('returns false when attachments have not been fetched yet', () => {
+ const state = { selectedChatId: 1, attachments: {} };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
+ });
+
+ it('returns false when no chat is selected', () => {
+ const state = { selectedChatId: null, attachments: {} };
+ expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
+ });
+ });
+
describe('#getContextMenuChatId', () => {
it('returns the context menu chat id', () => {
const state = { contextMenuChatId: 1 };
diff --git a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
index 167b18390..8bd647f27 100644
--- a/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
+++ b/app/views/api/v1/accounts/conversations/attachments.json.jbuilder
@@ -3,6 +3,7 @@ json.meta do
end
json.payload @attachments do |attachment|
+ json.id attachment.push_event_data[:id]
json.message_id attachment.push_event_data[:message_id]
json.thumb_url attachment.push_event_data[:thumb_url]
json.data_url attachment.push_event_data[:data_url]
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index e007f4900..19d080b47 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -1039,6 +1039,8 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
+ attachment = conversation.messages.last.attachments.first
+ expect(response_body['payload'].first['id']).to eq(attachment.id)
expect(response_body['payload'].first['file_type']).to eq('image')
expect(response_body['payload'].first['sender']['id']).to eq(conversation.messages.last.sender.id)
end
From 9c8cfc40b6380be66e3e1c426a84ff8b1b21342e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 15:14:54 +0530
Subject: [PATCH 07/17] chore(deps): bump dompurify from 3.3.2 to 3.4.0
(#14074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to
3.4.0.
Release notes
Sourced from dompurify's
releases .
DOMPurify 3.4.0
Most relevant changes:
Fixed a problem with FORBID_TAGS not winning over
ADD_TAGS, thanks @kodareef5
Fixed several minor problems and typos regarding MathML attributes,
thanks @DavidOliver
Fixed ADD_ATTR/ADD_TAGS function leaking
into subsequent array-based calls, thanks @1Jesper1
Fixed a missing SAFE_FOR_TEMPLATES scrub in
RETURN_DOM path, thanks @bencalif
Fixed a prototype pollution via
CUSTOM_ELEMENT_HANDLING, thanks @trace37labs
Fixed an issue with ADD_TAGS function form bypassing
FORBID_TAGS, thanks @eddieran
Fixed an issue with ADD_ATTR predicates skipping URI
validation, thanks @christos-eth
Fixed an issue with USE_PROFILES prototype pollution,
thanks @christos-eth
Fixed an issue leading to possible mXSS via Re-Contextualization,
thanks @researchatfluidattacks
and others
Fixed an issue with closing tags leading to possible mXSS, thanks @frevadiscor
Fixed a problem with the type dentition patcher after Node version
bump
Fixed freezing BS runs by reducing the tested browsers array
Bumped several dependencies where possible
Added needed files for OpenSSF scorecard checks
Published Advisories are here:
https://github.com/cure53/DOMPurify/security/advisories?state=published
DOMPurify 3.3.3
Fixed an engine requirement for Node 20 which caused hiccups, thanks
@Rotzbua
Commits
5b16e0b
Getting 3.x branch ready for 3.4.0 release (#1250 )
8bcbf73
chore: Preparing 3.3.3 release
5faddd6
fix: engine requirement (#1210 )
0f91e3a
Update README.md
d5ff1a8
Merge branch 'main' of github.com:cure53/DOMPurify
c3efd48
fix: moved back from jsdom 28 to jsdom 20
988b888
fix: moved back from jsdom 28 to jsdom 20
2726c74
chore: Preparing 3.3.2 release
6202c7e
build(deps): bump @tootallnate/once and jsdom (#1204 )
302b51d
fix: Expanded the regex ever so slightly to also cover script
Additional commits viewable in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
package.json | 2 +-
pnpm-lock.yaml | 13 ++++++-------
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/package.json b/package.json
index bb5b59ba3..35e09cfbc 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
- "dompurify": "3.3.2",
+ "dompurify": "3.4.0",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2a3aee897..d76d0a061 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,8 +128,8 @@ importers:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
dompurify:
- specifier: 3.3.2
- version: 3.3.2
+ specifier: 3.4.0
+ version: 3.4.0
flag-icons:
specifier: ^7.2.3
version: 7.2.3
@@ -2194,9 +2194,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
- dompurify@3.3.2:
- resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
- engines: {node: '>=20'}
+ dompurify@3.4.0:
+ resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -6861,7 +6860,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
- dompurify@3.3.2:
+ dompurify@3.4.0:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9656,7 +9655,7 @@ snapshots:
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
dependencies:
- dompurify: 3.3.2
+ dompurify: 3.4.0
vue: 3.5.12(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@8.57.0):
From deb259c8d2ee09cdbd3856d93b12bb7cd0bceec6 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 15:33:40 +0530
Subject: [PATCH 08/17] chore(deps): bump rack from 3.2.5 to 3.2.6 (#13987)
Bumps [rack](https://github.com/rack/rack) from 3.2.5 to 3.2.6.
Release notes
Sourced from rack's
releases .
v3.2.6
Full Changelog : https://github.com/rack/rack/compare/v3.2.5...v3.2.6
Changelog
Sourced from rack's
changelog .
[3.2.6] - 2026-04-01
Security
CVE-2026-34763
Root directory disclosure via unescaped regex interpolation in
Rack::Directory.
CVE-2026-34230
Avoid O(n^2) algorithm in Rack::Utils.select_best_encoding
which could lead to denial of service.
CVE-2026-32762
Forwarded header semicolon injection enables Host and Scheme
spoofing.
CVE-2026-26961
Raise error for multipart requests with multiple boundary
parameters.
CVE-2026-34786
Rack::Static header_rules bypass via
URL-encoded path mismatch.
CVE-2026-34831
Content-Length mismatch in Rack::Files error
responses.
CVE-2026-34826
Multipart byte range processing allows denial of service via excessive
overlapping ranges.
CVE-2026-34835
Rack::Request accepts invalid Host characters, enabling
host allowlist bypass.
CVE-2026-34830
Rack::Sendfile header-based X-Accel-Mapping
regex injection enables unauthorized X-Accel-Redirect.
CVE-2026-34785
Rack::Static prefix matching can expose unintended files
under the static root.
CVE-2026-34829
Multipart parsing without Content-Length header allows
unbounded chunked file uploads.
CVE-2026-34827
Multipart header parsing allows denial of service via escape-heavy
quoted parameters.
CVE-2026-26962
Improper unfolding of folded multipart headers preserves CRLF in parsed
parameter values.
Commits
e1f22fd
Bump patch version.
31989fd
Fix typo in test.
d268165
Fix test expectation.
8f425de
Add Ruby v4.0 to the test matrix.
bf83042
Drop EOL Rubies from external tests.
d50c4d3
Implement OBS unfolding for multipart requests per RFC 5322 2.2.3
bfb6914
Limit the number of quoted escapes during multipart parsing
b3e5945
Add Content-Length size check in Rack::Multipart::Parser
7a8f326
Fix root prefix bug in Rack::Static
a57bc14
Only do a simple substitution on the x-accel-mapping paths
Additional commits viewable in compare
view
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
Gemfile.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index d85999b57..f4d0277b4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -684,7 +684,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.5)
+ rack (3.2.6)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
From dd52f1d32b6cd81cd0de817e1b7c3ad30a963bcb Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 15:37:32 +0530
Subject: [PATCH 09/17] chore(deps): bump rack-session from 2.1.1 to 2.1.2
(#14017)
Bumps [rack-session](https://github.com/rack/rack-session) from 2.1.1 to
2.1.2.
Changelog
Sourced from rack-session's
changelog .
v2.1.2
CVE-2026-39324
Don't fall back to unencrypted coder if encryptors are present.
Commits
504367b
Bump patch version.
f43638c
Don't fall back to unencrypted coder if encryptors are present.
dadcfe6
Bump actions/checkout from 4 to 5 (#54 )
4eb9ea8
Add top level session spec to validate existing formats.
8f94577
Add rails to external tests.
38ea47d
Allow the v2 encryptor to serialize messages with Marshal
(#44 )
43f2e3a
Fix compatibility with older Rubies.
6a060b8
Support UTF-8 data when using the JSON serializer (#39 )
8ce0146
Fix auth_tag retrieval on JRuby (#32 )
7727185
Add AEAD encryption (#23 )
See full diff in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sony Mathew
---
Gemfile.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index f4d0277b4..bd21b7a36 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -699,7 +699,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
- rack-session (2.1.1)
+ rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
From 8d7e926e06c815ecc40b51f448a2567c22f1ba94 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 6 May 2026 16:33:16 +0530
Subject: [PATCH 10/17] fix: [Snyk] Security upgrade video.js from 7.18.1 to
7.21.1 (#13973)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

### Snyk has created this PR to fix 1 vulnerabilities in the yarn
dependencies of this project.
#### Snyk changed the following file(s):
- `package.json`
#### Note for
[zero-installs](https://yarnpkg.com/features/zero-installs) users
If you are using the Yarn feature
[zero-installs](https://yarnpkg.com/features/zero-installs) that was
introduced in Yarn V2, note that this PR does not update the
`.yarn/cache/` directory meaning this code cannot be pulled and
immediately developed on as one would expect for a zero-install project
- you will need to run `yarn` to update the contents of the
`./yarn/cache` directory.
If you are not using zero-install you can ignore this as your flow
should likely be unchanged.
⚠️ Warning
```
Failed to update the yarn.lock, please update manually before merging.
```
#### Vulnerabilities that will be fixed with an upgrade:
| | Issue |
:-------------------------:|:-------------------------
 | XML Injection
[SNYK-JS-XMLDOMXMLDOM-15869636](https://snyk.io/vuln/SNYK-JS-XMLDOMXMLDOM-15869636)
---
> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.
---
**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._
For more information:
🧐 [View latest project
report](https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source=github&utm_medium=referral&page=fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates?utm_source=github&utm_content=fix-pr-template)
🛠 [Adjust project
settings](https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source=github&utm_medium=referral&page=fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://docs.snyk.io/scan-with-snyk/snyk-open-source/manage-vulnerabilities/upgrade-package-versions-to-fix-vulnerabilities?utm_source=github&utm_content=fix-pr-template)
---
**Learn how to fix vulnerabilities with free interactive lessons:**
🦉 [XML Injection](https://learn.snyk.io/lesson/xxe/?loc=fix-pr)
[//]: #
'snyk:metadata:{"breakingChangeRiskLevel":null,"FF_showPullRequestBreakingChanges":false,"FF_showPullRequestBreakingChangesWebSearch":false,"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"video.js","from":"7.18.1","to":"7.21.1"}],"env":"prod","issuesToFix":["SNYK-JS-XMLDOMXMLDOM-15869636","SNYK-JS-XMLDOMXMLDOM-15869636"],"prId":"a31a1fb5-a9f0-4513-9316-be8798abfd9c","prPublicId":"a31a1fb5-a9f0-4513-9316-be8798abfd9c","packageManager":"yarn","priorityScoreList":[null],"projectPublicId":"3ca3819e-26b5-4e23-ac65-184abd9a6f10","projectUrl":"https://app.snyk.io/org/chatwoot/project/3ca3819e-26b5-4e23-ac65-184abd9a6f10?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["updated-fix-title","pr-warning-shown"],"type":"auto","upgrade":["SNYK-JS-XMLDOMXMLDOM-15869636"],"vulns":["SNYK-JS-XMLDOMXMLDOM-15869636"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}'
---------
Co-authored-by: snyk-bot
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
---
package.json | 2 +-
pnpm-lock.yaml | 85 ++++++++++++++++++++------------------------------
2 files changed, 35 insertions(+), 52 deletions(-)
diff --git a/package.json b/package.json
index 35e09cfbc..1fbef2b1f 100644
--- a/package.json
+++ b/package.json
@@ -94,7 +94,7 @@
"tinykeys": "^3.0.0",
"turbolinks": "^5.2.0",
"urlpattern-polyfill": "^10.0.0",
- "video.js": "7.18.1",
+ "video.js": "7.21.1",
"videojs-record": "4.5.0",
"videojs-wavesurfer": "3.8.0",
"virtua": "^0.48.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d76d0a061..b018dbdfe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -206,8 +206,8 @@ importers:
specifier: ^10.0.0
version: 10.0.0
video.js:
- specifier: 7.18.1
- version: 7.18.1
+ specifier: 7.21.1
+ version: 7.21.1
videojs-record:
specifier: 4.5.0
version: 4.5.0
@@ -441,10 +441,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- '@babel/runtime@7.25.6':
- resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==}
- engines: {node: '>=6.9.0'}
-
'@babel/runtime@7.26.7':
resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==}
engines: {node: '>=6.9.0'}
@@ -1386,17 +1382,14 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
- '@videojs/http-streaming@2.13.1':
- resolution: {integrity: sha512-1x3fkGSPyL0+iaS3/lTvfnPTtfqzfgG+ELQtPPtTvDwqGol9Mx3TNyZwtSTdIufBrqYRn7XybB/3QNMsyjq13A==}
+ '@videojs/http-streaming@2.15.1':
+ resolution: {integrity: sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==}
engines: {node: '>=8', npm: '>=5'}
peerDependencies:
video.js: ^6 || ^7
- '@videojs/vhs-utils@3.0.4':
- resolution: {integrity: sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==}
- engines: {node: '>=8', npm: '>=5'}
-
'@videojs/vhs-utils@3.0.5':
resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==}
engines: {node: '>=8', npm: '>=5'}
@@ -1570,8 +1563,8 @@ packages:
'@vueuse/shared@12.0.0':
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
- '@xmldom/xmldom@0.7.13':
- resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
+ '@xmldom/xmldom@0.8.13':
+ resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
@@ -1618,8 +1611,8 @@ packages:
activestorage@5.2.8:
resolution: {integrity: sha512-bueFOxBGIAUdrjbLyBZ8Xlkcecy8vr05sCk5VV37BbFi+RehPoEjfvKX3iYYPY7RFVhl+L43W9/ZbN3xNNLPtQ==}
- aes-decrypter@3.1.2:
- resolution: {integrity: sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A==}
+ aes-decrypter@3.1.3:
+ resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
@@ -3197,8 +3190,8 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
- m3u8-parser@4.7.0:
- resolution: {integrity: sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ==}
+ m3u8-parser@4.8.0:
+ resolution: {integrity: sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==}
magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
@@ -3309,8 +3302,8 @@ packages:
mlly@1.8.1:
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
- mpd-parser@0.21.0:
- resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
+ mpd-parser@0.22.1:
+ resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==}
hasBin: true
mri@1.2.0:
@@ -4497,8 +4490,8 @@ packages:
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
- video.js@7.18.1:
- resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
+ video.js@7.21.1:
+ resolution: {integrity: sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==}
videojs-font@3.2.0:
resolution: {integrity: sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==}
@@ -4981,10 +4974,6 @@ snapshots:
dependencies:
'@babel/types': 7.26.0
- '@babel/runtime@7.25.6':
- dependencies:
- regenerator-runtime: 0.14.1
-
'@babel/runtime@7.26.7':
dependencies:
regenerator-runtime: 0.14.1
@@ -5919,22 +5908,16 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
- '@videojs/http-streaming@2.13.1(video.js@7.18.1)':
+ '@videojs/http-streaming@2.15.1(video.js@7.21.1)':
dependencies:
'@babel/runtime': 7.26.7
- '@videojs/vhs-utils': 3.0.4
- aes-decrypter: 3.1.2
+ '@videojs/vhs-utils': 3.0.5
+ aes-decrypter: 3.1.3
global: 4.4.0
- m3u8-parser: 4.7.0
- mpd-parser: 0.21.0
+ m3u8-parser: 4.8.0
+ mpd-parser: 0.22.1
mux.js: 6.0.1
- video.js: 7.18.1
-
- '@videojs/vhs-utils@3.0.4':
- dependencies:
- '@babel/runtime': 7.26.7
- global: 4.4.0
- url-toolkit: 2.2.5
+ video.js: 7.21.1
'@videojs/vhs-utils@3.0.5':
dependencies:
@@ -6213,7 +6196,7 @@ snapshots:
transitivePeerDependencies:
- typescript
- '@xmldom/xmldom@0.7.13': {}
+ '@xmldom/xmldom@0.8.13': {}
abab@2.0.6: {}
@@ -6248,7 +6231,7 @@ snapshots:
dependencies:
spark-md5: 3.0.2
- aes-decrypter@3.1.2:
+ aes-decrypter@3.1.3:
dependencies:
'@babel/runtime': 7.26.7
'@videojs/vhs-utils': 3.0.5
@@ -8106,7 +8089,7 @@ snapshots:
dependencies:
yallist: 4.0.0
- m3u8-parser@4.7.0:
+ m3u8-parser@4.8.0:
dependencies:
'@babel/runtime': 7.26.7
'@videojs/vhs-utils': 3.0.5
@@ -8220,11 +8203,11 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
- mpd-parser@0.21.0:
+ mpd-parser@0.22.1:
dependencies:
'@babel/runtime': 7.26.7
'@videojs/vhs-utils': 3.0.5
- '@xmldom/xmldom': 0.7.13
+ '@xmldom/xmldom': 0.8.13
global: 4.4.0
mri@1.2.0: {}
@@ -9526,17 +9509,17 @@ snapshots:
dependencies:
base64-arraybuffer: 1.0.2
- video.js@7.18.1:
+ video.js@7.21.1:
dependencies:
- '@babel/runtime': 7.25.6
- '@videojs/http-streaming': 2.13.1(video.js@7.18.1)
+ '@babel/runtime': 7.26.7
+ '@videojs/http-streaming': 2.15.1(video.js@7.21.1)
'@videojs/vhs-utils': 3.0.5
'@videojs/xhr': 2.6.0
- aes-decrypter: 3.1.2
+ aes-decrypter: 3.1.3
global: 4.4.0
keycode: 2.2.1
- m3u8-parser: 4.7.0
- mpd-parser: 0.21.0
+ m3u8-parser: 4.8.0
+ mpd-parser: 0.22.1
mux.js: 6.0.1
safe-json-parse: 4.0.0
videojs-font: 3.2.0
@@ -9547,7 +9530,7 @@ snapshots:
videojs-record@4.5.0:
dependencies:
recordrtc: 5.6.2
- video.js: 7.18.1
+ video.js: 7.21.1
videojs-wavesurfer: 3.8.0
webrtc-adapter: 9.0.1
@@ -9557,7 +9540,7 @@ snapshots:
videojs-wavesurfer@3.8.0:
dependencies:
- video.js: 7.18.1
+ video.js: 7.21.1
wavesurfer.js: 7.8.6
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
From 815593eec9c25fde840333c1b3b610be566eb042 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 6 May 2026 17:33:23 +0530
Subject: [PATCH 11/17] feat: display conversation ID conversation view
(#14381)
---
.../conversation/ConversationHeader.vue | 22 ++++++++++++++++++-
.../i18n/locale/en/conversation.json | 1 +
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
index 4edfd643c..4a46afe73 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
@@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
+import { copyTextToClipboard } from 'shared/helpers/clipboard';
+import { useAlert } from 'dashboard/composables';
const props = defineProps({
chat: {
@@ -91,6 +93,15 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
+
+const copyConversationId = async () => {
+ try {
+ await copyTextToClipboard(String(props.chat.id));
+ useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
+ } catch (error) {
+ // error
+ }
+};
@@ -133,9 +144,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);