Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-07-04 14:38:18 +05:30
committed by GitHub
18 changed files with 252 additions and 21 deletions
@@ -17,7 +17,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
limit_results
end
def show; end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@@ -8,7 +8,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
@categories = @portal.categories.order(position: :asc)
end
def show; end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
private
@@ -4,7 +4,9 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show; end
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
+7 -3
View File
@@ -1,9 +1,13 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
# attachment message without content, message_content is nil
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
return '' unless message_content.presence
# Use CommonMarker to convert markdown to plain text for notifications
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
# To: @👍 customer support
CommonMarker.render_doc(message_content).to_plaintext.strip
end
def render_message_content(message_content)
+17
View File
@@ -1,4 +1,21 @@
module PortalHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
uri = URI.parse(cdn_url)
uri.path = '/og'
uri.query = URI.encode_www_form(
clientRef: client_ref,
title: title,
portalName: portal_name
)
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
@@ -39,15 +39,21 @@ const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
params: { inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
accountId,
accountId: accountId.value,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
conversationType: conversationTypeMap[name],
customViewId,
});
});
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
import MentionBox from '../mentions/MentionBox.vue';
export default {
@@ -16,6 +17,9 @@ export default {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
sanitizedSearchKey() {
return sanitizeVariableSearchKey(this.searchKey);
},
items() {
return [
...this.standardAttributeVariables,
@@ -25,8 +29,8 @@ export default {
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
variable.key.includes(this.searchKey)
variable.label.includes(this.sanitizedSearchKey) ||
variable.key.includes(this.sanitizedSearchKey)
);
}).map(variable => ({
label: variable.key,
@@ -83,3 +83,16 @@ export const convertToPortalSlug = text => {
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
/**
* Strip curly braces, commas and leading/trailing whitespace from a search key.
* Eg. "{{contact.name}}," => "contact.name"
* @param {string} searchKey
* @returns {string}
*/
export const sanitizeVariableSearchKey = (searchKey = '') => {
return searchKey
.replace(/[{}]/g, '') // remove all curly braces
.replace(/,/g, '') // remove commas
.trim();
};
@@ -4,6 +4,7 @@ import {
convertToAttributeSlug,
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
} from '../commons';
describe('#getTypingUsersText', () => {
@@ -107,3 +108,37 @@ describe('convertToPortalSlug', () => {
expect(convertToPortalSlug('Room rental')).toBe('room-rental');
});
});
describe('sanitizeVariableSearchKey', () => {
it('removes braces', () => {
expect(sanitizeVariableSearchKey('{{contact.name}}')).toBe('contact.name');
});
it('removes right braces', () => {
expect(sanitizeVariableSearchKey('contact.name}}')).toBe('contact.name');
});
it('removes braces, comma and whitespace', () => {
expect(sanitizeVariableSearchKey(' {{contact.name }},')).toBe(
'contact.name'
);
});
it('trims whitespace', () => {
expect(sanitizeVariableSearchKey(' contact.name ')).toBe('contact.name');
});
it('handles multiple commas', () => {
expect(sanitizeVariableSearchKey('{{contact.name}},,')).toBe(
'contact.name'
);
});
it('returns empty string when only braces/commas/whitespace', () => {
expect(sanitizeVariableSearchKey(' { }, , ')).toBe('');
});
it('returns empty string for undefined input', () => {
expect(sanitizeVariableSearchKey()).toBe('');
});
});
@@ -1,4 +1,15 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
<title><%= @portal.name %></title>
<meta name="title" content="<%= @portal.name %>">
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
@@ -2,13 +2,23 @@
<title><%= @article.title %> | <%= @portal.name %></title>
<% if @article.meta["title"].present? %>
<meta name="title" content="<%= @article.meta["title"] %>">
<meta property="og:title" content="<%= @article.meta["title"] %>">
<meta name="twitter:title" content="<%= @article.meta["title"] %>">
<% end %>
<% if @article.meta["description"].present? %>
<meta name="description" content="<%= @article.meta["description"] %>">
<meta property="og:description" content="<%= @article.meta["description"] %>">
<meta name="twitter:description" content="<%= @article.meta["description"] %>">
<% end %>
<% if @article.meta["tags"].present? %>
<meta name="tags" content="<%= @article.meta["tags"].join(',') %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
<% if !@is_plain_layout_enabled %>
@@ -1,7 +1,16 @@
<% content_for :head do %>
<title><%= @category.name %> | <%= @portal.name %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.name %>">
<% if @category.description.present? %>
<meta name="description" content="<%= @category.description %>">
<meta property="og:description" content="<%= @category.description %>">
<meta name="twitter:description" content="<%= @category.description %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
@@ -35,7 +44,7 @@
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</a>
</div>
</div>
<% end %>
<% end %>
</div>
+15
View File
@@ -361,6 +361,20 @@
locked: true
# ------- End of Instagram Channel Related Config ------- #
# ------- OG Image Related Config ------- #
- name: OG_IMAGE_CDN_URL
display_title: 'OG Image CDN URL'
description: 'The CDN URL for serving OG images'
value: ''
locked: false
- name: OG_IMAGE_CLIENT_REF
display_title: 'OG Image Client Reference'
description: 'Token used to block unauthorized access to OG images'
value: ''
locked: false
type: secret
# ------- End of OG Image Related Config ------- #
# ------- Github Integration Config ------- #
- name: GITHUB_CLIENT_ID
display_title: 'Github Client ID'
@@ -374,3 +388,4 @@
description: 'Github client secret'
type: secret
## ------ End of Github Integration Config ------- #
@@ -33,6 +33,7 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF]
end
end
+7 -1
View File
@@ -5,7 +5,13 @@ module RegexHelper
# valid unicode letter, unicode number, underscore, hyphen
# shouldn't start with a underscore or hyphen
UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE = Regexp.new('\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z')
MENTION_REGEX = Regexp.new('\[(@[\w_. ]+)\]\(mention://(?:user|team)/\d+/(.*?)+\)')
# Regex to match mention markdown links and extract display names
# Matches: [@display name](mention://user|team/id/url_encoded_name)
# Captures: 1) @display name (including emojis), 2) url_encoded_name
# Uses [^]]+ to match any characters except ] in display name to support emojis
# NOTE: Still used by Slack integration (lib/integrations/slack/send_on_slack_service.rb)
# while notifications use CommonMarker for better markdown processing
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
+29 -1
View File
@@ -3,9 +3,37 @@ require 'rails_helper'
describe MessageFormatHelper do
describe '#transform_user_mention_content' do
context 'when transform_user_mention_content called' do
it 'return transormed text correctly' do
it 'return transformed text correctly' do
expect(helper.transform_user_mention_content('[@john](mention://user/1/John%20K), check this ticket')).to eq '@john, check this ticket'
end
it 'handles emoji in display names correctly' do
content = '[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support), please help'
expected = '@👍 customer support, please help'
expect(helper.transform_user_mention_content(content)).to eq expected
end
it 'handles multiple mentions with emojis and spaces' do
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and [@🚀 Dev Team](mention://team/2/%F0%9F%9A%80%20Dev%20Team)'
expected = 'Hey @John Doe and @🚀 Dev Team'
expect(helper.transform_user_mention_content(content)).to eq expected
end
it 'handles emoji-only team names' do
expect(helper.transform_user_mention_content('[@🔥](mention://team/3/%F0%9F%94%A5) urgent')).to eq '@🔥 urgent'
end
it 'handles special characters in names' do
expect(helper.transform_user_mention_content('[@user@domain.com](mention://user/4/user%40domain.com) check')).to eq '@user@domain.com check'
end
it 'returns empty string for nil content' do
expect(helper.transform_user_mention_content(nil)).to eq ''
end
it 'returns empty string for empty content' do
expect(helper.transform_user_mention_content('')).to eq ''
end
end
end
+39 -6
View File
@@ -250,12 +250,45 @@ describe PortalHelper do
describe '#thumbnail_bg_color' do
it 'returns the correct color based on username length' do
expect(helper.thumbnail_bg_color('')).to be_in(['#6D95BA', '#A4C3C3', '#E19191'])
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA') # Length 3, so index is 0
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3') # Length 4, so index is 1
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3') # Length 10, so index is 1
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191') # Length 8, so index is 2
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191') # Length 17, so index is 2
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA') # Length 18, so index is 0
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA')
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3')
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3')
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191')
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191')
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA')
end
end
describe '#set_og_image_url' do
let(:portal_name) { 'Chatwoot Portal' }
let(:title) { 'Welcome to Chatwoot' }
context 'when CDN URL is present' do
before do
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: 'https://cdn.example.com')
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
end
it 'returns the composed OG image URL with correct params' do
result = helper.set_og_image_url(portal_name, title)
uri = URI.parse(result)
expect(uri.path).to eq('/og')
params = Rack::Utils.parse_query(uri.query)
expect(params['clientRef']).to eq('client-123')
expect(params['title']).to eq(title)
expect(params['portalName']).to eq(portal_name)
end
end
context 'when CDN URL is blank' do
before do
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: '')
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
end
it 'returns nil' do
expect(helper.set_og_image_url(portal_name, title)).to be_nil
end
end
end
end
+33
View File
@@ -128,6 +128,39 @@ has been assigned to you"
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Peter please check this?"
end
it 'returns appropriate body suited for the notification type conversation_mention if username contains emoji' do
conversation = create(:conversation)
content = 'Hey [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please check this?'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @👍 customer support please check this?"
end
it 'returns appropriate body suited for the notification type conversation_mention if team name contains emoji and spaces' do
conversation = create(:conversation)
content = 'Please check [@🚀 Development Team](mention://team/2/%F0%9F%9A%80%20Development%20Team)'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Please check @🚀 Development Team"
end
it 'returns appropriate body suited for the notification type conversation_mention with mixed emoji and regular mentions' do
conversation = create(:conversation)
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and ' \
'[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please review'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Doe and @👍 customer support please review"
end
it 'returns appropriate body suited for the notification type conversation_mention with special characters in names' do
conversation = create(:conversation)
content = 'Please review [@user@domain.com](mention://user/4/user%40domain.com)'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Please review @user@domain.com"
end
it 'calls remove duplicate notification job' do
allow(Notification::RemoveDuplicateNotificationJob).to receive(:perform_later)
notification = create(:notification, notification_type: 'conversation_mention')