Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 7f9e94af2e Merge branch 'develop' into feat/github-oauth 2025-06-12 12:00:51 +05:30
Muhsin Keloth cf73ba195e feat: add github auth 2025-06-12 11:55:57 +05:30
201 changed files with 676 additions and 3011 deletions
+4 -8
View File
@@ -172,8 +172,6 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
@@ -435,12 +433,10 @@ GEM
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.1.1) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) letter_opener (1.8.1)
logger (~> 1.6) launchy (>= 2.2, < 3)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0) lint_roller (1.1.0)
liquid (5.4.0) liquid (5.4.0)
@@ -567,7 +563,7 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (6.0.2) public_suffix (6.0.0)
puma (6.4.3) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return; if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') { if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10; textElement.textContent = '•'.repeat(10);
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true'; textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText); navigator.clipboard.writeText(secretField.dataset.secretText);
} }
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field { .cell-data__secret-field {
align-items: center; align-items: center;
color: $hint-grey;
display: flex; display: flex;
span { span {
flex: 0 0 auto; flex: 1;
} }
[data-secret-toggler], button {
[data-secret-copier] { margin-left: 5px;
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg { svg {
fill: currentColor; fill: currentColor;
height: 1.25rem;
width: 1.25rem;
} }
} }
} }
@@ -31,7 +31,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
resolved_counts: fetch_resolved_counts(conversation_filter), resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
} }
end end
@@ -63,9 +63,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
end end
def fetch_resolved_counts(conversation_filter) def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn, fetch_counts(conversation_filter.merge(status: :resolved))
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end end
def fetch_counts(conversation_filter) def fetch_counts(conversation_filter)
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: scope, scope: 'email profile https://mail.google.com/',
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -81,15 +81,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def create_channel def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -0,0 +1,13 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :check_authorization
def show
@github_integration = Current.account.hooks.find_by(app_id: 'github')
end
private
def check_authorization
authorize ::Webhook, :show?
end
end
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
def destroy def destroy
@@ -31,12 +31,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -48,30 +42,17 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
def unlink_issue def unlink_issue
link_id = permitted_params[:link_id] link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id) issue = linear_processor_service.unlink_issue(link_id)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def scope def base_url
'email profile https://mail.google.com/' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def scope def base_url
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
@@ -0,0 +1,70 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
before_action :authenticate_user!
before_action :set_account
def show
if params[:error].present?
redirect_to app_account_integrations_path(account_id: @account.id), alert: params[:error_description]
return
end
if params[:code].present?
response = exchange_code_for_token(params[:code])
if response[:access_token].present?
hook = @account.hooks.find_or_initialize_by(app_id: 'github')
hook.access_token = response[:access_token]
hook.status = 'enabled'
if hook.save
redirect_to app_account_integrations_path(account_id: @account.id), notice: 'GitHub integration connected successfully!'
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to save GitHub integration'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to get access token from GitHub'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'GitHub authorization failed'
end
end
private
def set_account
@account = Current.user.accounts.find(params[:account_id]) if params[:account_id].present?
@account ||= Current.user.accounts.first
return if @account
redirect_to root_path, alert: 'Account not found'
end
def exchange_code_for_token(code)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
client_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
return {} unless client_id && client_secret
response = HTTParty.post('https://github.com/login/oauth/access_token', {
body: {
client_id: client_id,
client_secret: client_secret,
code: code
},
headers: {
'Accept' => 'application/json'
}
})
if response.success?
JSON.parse(response.body).with_indifferent_access
else
{}
end
rescue StandardError => e
Rails.logger.error "GitHub OAuth error: #{e.message}"
{}
end
end
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -8
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
) )
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/' redirect_to '/'
@@ -63,6 +64,10 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
@@ -81,17 +86,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_from_signed_id def account_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? ::Redis::Alfred.get(cache_key)
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
end end
def account def account
@account ||= account_from_signed_id @account ||= Account.find(account_id)
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -7,11 +7,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index def index
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count @articles_count = @articles.count
search_articles search_articles
order_by_sort_param order_by_sort_param
limit_results limit_results
@@ -39,8 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] 'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
} }
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
+71
View File
@@ -0,0 +1,71 @@
module Github::IntegrationHelper
def github_integration_url(account_id)
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback?account_id=#{account_id}"
end
def github_oauth_url(account_id)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
return nil unless client_id
params = {
client_id: client_id,
redirect_uri: github_integration_url(account_id),
scope: 'repo',
state: generate_state_token(account_id)
}
"https://github.com/login/oauth/authorize?#{params.to_query}"
end
def github_configured?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? &&
GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def github_integration_enabled?(account)
return false unless github_configured?
account.hooks.exists?(app_id: 'github', status: 'enabled')
end
def github_repositories(access_token)
return [] unless access_token
response = HTTParty.get('https://api.github.com/user/repos', {
headers: {
'Authorization' => "token #{access_token}",
'Accept' => 'application/vnd.github.v3+json'
},
query: {
per_page: 100,
sort: 'updated'
}
})
if response.success?
JSON.parse(response.body)
else
[]
end
rescue StandardError => e
Rails.logger.error "GitHub API error: #{e.message}"
[]
end
private
def generate_state_token(account_id)
payload = {
account_id: account_id,
timestamp: Time.current.to_i
}
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
end
def verify_state_token(state)
JWT.decode(state, Rails.application.secret_key_base, true, { algorithm: 'HS256' })
rescue JWT::DecodeError
nil
end
end
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
); );
} }
unlinkIssue(linkId, issueIdentifier, conversationId) { unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, { return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId, link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
}); });
} }
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData issueData
); );
}); });
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
}); });
describe('link_issue', () => { describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
} }
); );
}); });
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
}); });
describe('getLinkedIssue', () => { describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios; window.axios = originalAxios;
}); });
it('creates a valid request with link_id only', () => { it('creates a valid request', () => {
LinearAPIClient.unlinkIssue('link123'); LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue', '/api/v1/integrations/linear/unlink_issue',
{ {
link_id: 'link123', link_id: 1,
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
} }
); );
}); });
@@ -47,7 +47,7 @@
@apply max-w-full; @apply max-w-full;
.multiselect__option { .multiselect__option {
@apply text-sm font-normal flex justify-between items-center; @apply text-sm font-normal;
span { span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -58,7 +58,7 @@
} }
&::after { &::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; @apply bottom-0 flex items-center justify-center text-center;
} }
&.multiselect__option--highlight { &.multiselect__option--highlight {
@@ -74,7 +74,7 @@
} }
&.multiselect__option--highlight::after { &.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12; @apply bg-transparent;
} }
&.multiselect__option--selected { &.multiselect__option--selected {
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)" @mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)" @mouseleave="emit('hover', false)"
> >
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3"> <div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" /> <Checkbox v-model="modelValue" />
</div> </div>
<div class="flex relative justify-between w-full gap-1"> <div class="flex relative justify-between w-full gap-1">
@@ -19,7 +19,6 @@ const isConversationRoute = computed(() => {
'conversation_through_mentions', 'conversation_through_mentions',
'conversation_through_unattended', 'conversation_through_unattended',
'conversation_through_participating', 'conversation_through_participating',
'inbox_view_conversation',
]; ];
return CONVERSATION_ROUTES.includes(route.name); return CONVERSATION_ROUTES.includes(route.name);
}); });
@@ -13,7 +13,6 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill', 'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill', 'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
}; };
const providerIconMap = { const providerIconMap = {
@@ -19,12 +19,6 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill'); expect(icon).toBe('i-ri-whatsapp-fill');
}); });
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
});
describe('Email channel', () => { describe('Email channel', () => {
it('returns mail icon for generic email channel', () => { it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' }; const inbox = { channel_type: 'Channel::Email' };
@@ -1,21 +1,51 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue'; import { computed, ref, onMounted, nextTick } from 'vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: [String, Number], default: '' }, modelValue: {
type: { type: String, default: 'text' }, type: [String, Number],
customInputClass: { type: [String, Object, Array], default: '' }, default: '',
placeholder: { type: String, default: '' }, },
label: { type: String, default: '' }, type: {
id: { type: String, default: '' }, type: String,
message: { type: String, default: '' }, default: 'text',
disabled: { type: Boolean, default: false }, },
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
validator: value => ['info', 'error', 'success'].includes(value), validator: value => ['info', 'error', 'success'].includes(value),
}, },
min: { type: String, default: '' }, min: {
autofocus: { type: Boolean, default: false }, type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -26,10 +56,6 @@ const emit = defineEmits([
'enter', 'enter',
]); ]);
// Generate a unique ID per component instance when `id` prop is not provided.
const { uid } = getCurrentInstance();
const uniqueId = computed(() => props.id || `input-${uid}`);
const isFocused = ref(false); const isFocused = ref(false);
const inputRef = ref(null); const inputRef = ref(null);
@@ -85,7 +111,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1"> <div class="relative flex flex-col min-w-0 gap-1">
<label <label
v-if="label" v-if="label"
:for="uniqueId" :for="id"
class="mb-0.5 text-sm font-medium text-n-slate-12" class="mb-0.5 text-sm font-medium text-n-slate-12"
> >
{{ label }} {{ label }}
@@ -93,7 +119,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input --> <!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" /> <slot name="prefix" />
<input <input
:id="uniqueId" :id="id"
ref="inputRef" ref="inputRef"
:value="modelValue" :value="modelValue"
:class="[ :class="[
@@ -379,7 +379,7 @@ const shouldRenderMessage = computed(() => {
function openContextMenu(e) { function openContextMenu(e) {
const shouldSkipContextMenu = const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') || e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase()); e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) { if (shouldSkipContextMenu || getSelection().toString()) {
return; return;
} }
@@ -14,8 +14,7 @@ const fromEmail = computed(() => {
}); });
const toEmail = computed(() => { const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value; return contentAttributes.value?.email?.to ?? [];
return email?.to ?? toEmails ?? [];
}); });
const ccEmail = computed(() => { const ccEmail = computed(() => {
@@ -17,7 +17,7 @@ export default {
<button <button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60" class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
> >
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" /> <img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3 <h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize" class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
> >
@@ -756,7 +756,6 @@ function toggleSelectAll(check) {
} }
useEmitter('fetch_conversation_stats', () => { useEmitter('fetch_conversation_stats', () => {
if (hasAppliedFiltersOrActiveFolders.value) return;
store.dispatch('conversationStats/get', conversationFilters.value); store.dispatch('conversationStats/get', conversationFilters.value);
}); });
@@ -855,7 +854,7 @@ watch(conversationFilters, (newVal, oldVal) => {
:active-status="activeStatus" :active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats" :conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length" :is-list-loading="chatListLoading"
@add-folders="onClickOpenAddFoldersModal" @add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal" @delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal" @filters-modal="onToggleAdvanceFiltersModal"
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed'); const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() => const headerClass = computed(() =>
isRelaxed.value isRelaxed.value
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg' ? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
: '' : ''
); );
</script> </script>
@@ -41,10 +41,6 @@ export default {
); );
} }
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [ return [
'website', 'website',
'twilio', 'twilio',
@@ -54,7 +50,6 @@ export default {
'telegram', 'telegram',
'line', 'line',
'instagram', 'instagram',
'voice',
].includes(key); ].includes(key);
}, },
}, },
@@ -183,18 +183,13 @@ const createIssue = async () => {
state_id: formState.stateId || undefined, state_id: formState.stateId || undefined,
priority: formState.priority || undefined, priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined, label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
}; };
try { try {
isCreating.value = true; isCreating.value = true;
const response = await LinearAPI.createIssue(payload); const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data; const { id: issueId } = response.data;
await LinearAPI.link_issue( await LinearAPI.link_issue(props.conversationId, issueId, props.title);
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')); useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE); useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose(); onClose();
@@ -46,9 +46,9 @@ const loadLinkedIssues = async () => {
} }
}; };
const unlinkIssue = async (linkId, issueIdentifier) => { const unlinkIssue = async linkId => {
try { try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId); await LinearAPI.unlinkIssue(linkId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE); useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter( linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId issue => issue.id !== linkId
@@ -110,7 +110,7 @@ onMounted(() => {
<LinearIssueItem <LinearIssueItem
v-for="linkedIssue in linkedIssues" v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id" :key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0" class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue" :linked-issue="linkedIssue"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
@@ -14,8 +14,6 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']); const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = { const priorityMap = {
1: 'Urgent', 1: 'Urgent',
2: 'High', 2: 'High',
@@ -23,7 +21,7 @@ const priorityMap = {
4: 'Low', 4: 'Low',
}; };
const issue = computed(() => linkedIssue.issue); const issue = computed(() => props.linkedIssue.issue);
const assignee = computed(() => { const assignee = computed(() => {
const assigneeDetails = issue.value.assignee; const assigneeDetails = issue.value.assignee;
@@ -39,7 +37,7 @@ const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]); const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => { const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier); emit('unlinkIssue', props.linkedIssue.id);
}; };
</script> </script>
@@ -63,7 +63,7 @@ const onSearch = async value => {
isFetching.value = true; isFetching.value = true;
const response = await LinearAPI.searchIssues(value); const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({ issues.value = response.data.map(issue => ({
id: issue.identifier, id: issue.id,
name: `${issue.identifier} ${issue.title}`, name: `${issue.identifier} ${issue.title}`,
icon: 'status', icon: 'status',
iconColor: issue.state.color, iconColor: issue.state.color,
@@ -125,10 +125,6 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM; return channelType.value === INBOX_TYPES.INSTAGRAM;
}); });
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return { return {
inbox, inbox,
isAFacebookInbox, isAFacebookInbox,
@@ -146,6 +142,5 @@ export const useInbox = () => {
is360DialogWhatsAppChannel, is360DialogWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel, isAnInstagramChannel,
isAVoiceChannel,
}; };
}; };
-10
View File
@@ -10,7 +10,6 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line', LINE: 'Channel::Line',
SMS: 'Channel::Sms', SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram', INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
}; };
const INBOX_ICON_MAP_FILL = { const INBOX_ICON_MAP_FILL = {
@@ -23,7 +22,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
}; };
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -38,7 +36,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line', [INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
}; };
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -50,7 +47,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO: case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP: case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || ''; return phoneNumber || '';
case INBOX_TYPES.EMAIL: case INBOX_TYPES.EMAIL:
@@ -89,9 +85,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE: case INBOX_TYPES.LINE:
return 'line'; return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default: default:
return 'chat'; return 'chat';
} }
@@ -131,9 +124,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM: case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram'; return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default: default:
return 'chat'; return 'chat';
} }
@@ -268,46 +268,6 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel" "ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
} }
}, },
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": { "API_CHANNEL": {
"TITLE": "API Channel", "TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.", "DESC": "Integrate with API channel and start supporting your customers.",
@@ -829,8 +789,7 @@
"TELEGRAM": "Telegram", "TELEGRAM": "Telegram",
"LINE": "Line", "LINE": "Line",
"API": "API Channel", "API": "API Channel",
"INSTAGRAM": "Instagram", "INSTAGRAM": "Instagram"
"VOICE": "Voice"
} }
} }
} }
@@ -329,12 +329,26 @@
"BUTTON_TEXT": "Connect Linear workspace" "BUTTON_TEXT": "Connect Linear workspace"
} }
}, },
"NOTION": { "GITHUB": {
"DELETE": { "TITLE": "GitHub Integration",
"TITLE": "Are you sure you want to delete the Notion integration?", "DESCRIPTION": "Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.", "NOT_CONFIGURED": {
"CONFIRM": "Yes, delete", "TITLE": "GitHub Integration Not Configured",
"CANCEL": "Cancel" "DESCRIPTION": "Contact your administrator to configure GitHub OAuth settings."
},
"CONNECT": {
"TITLE": "Connect GitHub",
"DESCRIPTION": "Connect your GitHub account to start creating issues from conversations.",
"BUTTON": "Connect GitHub"
},
"CONNECTED": {
"TITLE": "GitHub Connected",
"DESCRIPTION": "Your GitHub account is successfully connected. You can now create issues from conversations."
},
"DISCONNECT": {
"BUTTON": "Disconnect",
"SUCCESS": "GitHub integration disconnected successfully",
"ERROR": "Failed to disconnect GitHub integration"
} }
} }
}, },
@@ -545,8 +559,6 @@
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected", "SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve", "BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete", "BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": { "BULK_APPROVE": {
@@ -152,7 +152,7 @@
"ATTRIBUTES": { "ATTRIBUTES": {
"MESSAGE_TYPE": "Tipo da Mensagem", "MESSAGE_TYPE": "Tipo da Mensagem",
"MESSAGE_CONTAINS": "A mensagem contém", "MESSAGE_CONTAINS": "A mensagem contém",
"EMAIL": "E-mail", "EMAIL": "e-mail",
"INBOX": "Caixa de Entrada", "INBOX": "Caixa de Entrada",
"CONVERSATION_LANGUAGE": "Idioma da conversa", "CONVERSATION_LANGUAGE": "Idioma da conversa",
"PHONE_NUMBER": "Número de Telefone", "PHONE_NUMBER": "Número de Telefone",
@@ -130,15 +130,15 @@ export default {
</div> </div>
<div class="flex multiselect-wrap--medium"> <div class="flex multiselect-wrap--medium">
<div <div
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600" class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 after:left-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 before:left-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
> >
<fluent-icon <fluent-icon
icon="arrow-up" icon="arrow-up"
class="absolute -top-1 ltr:left-2 rtl:right-2" class="absolute -top-1 left-2"
size="17" size="17"
/> />
</div> </div>
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8"> <div class="flex flex-col w-full">
<label class="multiselect__label"> <label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }} {{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label <woot-label
@@ -157,13 +157,6 @@ const bulkCheckbox = computed({
}, },
}); });
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => { const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null; hoveredCard.value = isHovered ? id : null;
}; };
@@ -277,11 +270,7 @@ onMounted(() => {
<template #controls> <template #controls>
<div <div
v-if="shouldShowDropdown" v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1" class="mb-4 -mt-3 flex justify-between items-center"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
bulkSelectionState.hasSelected,
}"
> >
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3"> <div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false"> <OnClickOutside @trigger="isStatusFilterOpen = false">
@@ -317,18 +306,13 @@ onMounted(() => {
> >
<div <div
v-if="bulkSelectionState.hasSelected" v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3" class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5"> <Checkbox
<Checkbox v-model="bulkCheckbox"
v-model="bulkCheckbox" :indeterminate="bulkSelectionState.isIndeterminate"
:indeterminate="bulkSelectionState.isIndeterminate" />
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums"> <span class="text-sm text-n-slate-10 tabular-nums">
{{ {{
$t('CAPTAIN.RESPONSES.SELECTED', { $t('CAPTAIN.RESPONSES.SELECTED', {
@@ -338,23 +322,17 @@ onMounted(() => {
</span> </span>
</div> </div>
<div class="h-4 w-px bg-n-strong" /> <div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center"> <div class="flex gap-2">
<Button <Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')" :label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm sm
ghost slate
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove" @click="handleBulkApprove"
/> />
<div class="h-4 w-px bg-n-strong" />
<Button <Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')" :label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm sm
ruby slate
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()" @click="bulkDeleteDialog.dialogRef.open()"
/> />
</div> </div>
@@ -1,7 +1,6 @@
<script setup> <script setup>
import IframeLoader from 'shared/components/IframeLoader.vue'; import IframeLoader from 'shared/components/IframeLoader.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({ defineProps({
url: { url: {
@@ -12,8 +11,6 @@ defineProps({
const emit = defineEmits(['back', 'insert']); const emit = defineEmits(['back', 'insert']);
const isRTL = useMapGetter('accounts/isRTL');
const onBack = e => { const onBack = e => {
e.stopPropagation(); e.stopPropagation();
emit('back'); emit('back');
@@ -38,7 +35,7 @@ const onInsert = e => {
</div> </div>
<div class="-ml-4 h-full overflow-y-auto"> <div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0"> <div class="w-full h-full min-h-0">
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied /> <IframeLoader :url="url" />
</div> </div>
</div> </div>
@@ -71,12 +71,6 @@ export default {
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
); );
}, },
showAudioTranscriptionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CAPTAIN
);
},
languagesSortedByCode() { languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages]; const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) => return enabledLanguages.sort((l1, l2) =>
@@ -243,7 +237,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" /> <woot-loading-state v-if="uiFlags.isFetchingItem" />
</div> </div>
<AutoResolve v-if="showAutoResolutionConfig" /> <AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" /> <AudioTranscription v-if="isOnChatwootCloud" />
<AccountId /> <AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud"> <div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete /> <AccountDelete />
@@ -10,7 +10,6 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue'; import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue'; import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue'; import Instagram from './channels/Instagram.vue';
import Voice from './channels/Voice.vue';
const channelViewList = { const channelViewList = {
facebook: Facebook, facebook: Facebook,
@@ -23,7 +22,6 @@ const channelViewList = {
line: Line, line: Line,
telegram: Telegram, telegram: Telegram,
instagram: Instagram, instagram: Instagram,
voice: Voice,
}; };
export default defineComponent({ export default defineComponent({
@@ -36,7 +36,6 @@ export default {
{ key: 'telegram', name: 'Telegram' }, { key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' }, { key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' }, { key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
]; ];
}, },
...mapGetters({ ...mapGetters({
@@ -1,184 +0,0 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { isPhoneE164 } from 'shared/helpers/Validators';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import PageHeader from '../../SettingsSubPageHeader.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const state = reactive({
phoneNumber: '',
accountSid: '',
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
const validationRules = {
phoneNumber: { required, isPhoneE164 },
accountSid: { required },
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formErrors = computed(() => ({
phoneNumber: v$.value.phoneNumber?.$error
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
: '',
accountSid: v$.value.accountSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
: '',
authToken: v$.value.authToken?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
: '',
apiKeySid: v$.value.apiKeySid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
: '',
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
const config = {
account_sid: state.accountSid,
auth_token: state.authToken,
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
async function createChannel() {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
try {
const channel = await store.dispatch('inboxes/createVoiceChannel', {
name: `Voice (${state.phoneNumber})`,
voice: {
phone_number: state.phoneNumber,
provider: 'twilio',
provider_config: getProviderConfig(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: channel.id },
});
} catch (error) {
useAlert(
error.response?.data?.message ||
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
}
}
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form
class="flex flex-col gap-4 flex-wrap mx-0"
@submit.prevent="createChannel"
>
<Input
v-model="state.phoneNumber"
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
:message="formErrors.phoneNumber"
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
@blur="v$.phoneNumber?.$touch"
/>
<Input
v-model="state.accountSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
:message="formErrors.accountSid"
:message-type="formErrors.accountSid ? 'error' : 'info'"
@blur="v$.accountSid?.$touch"
/>
<Input
v-model="state.authToken"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
:message="formErrors.authToken"
:message-type="formErrors.authToken ? 'error' : 'info'"
@blur="v$.authToken?.$touch"
/>
<Input
v-model="state.apiKeySid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
:message="formErrors.apiKeySid"
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
@blur="v$.apiKeySid?.$touch"
/>
<Input
v-model="state.apiKeySecret"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
:message="formErrors.apiKeySecret"
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
:disabled="isSubmitDisabled"
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
/>
</div>
</form>
</div>
</template>
@@ -12,6 +12,7 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,6 +12,7 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,9 +30,14 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -45,7 +50,9 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization(); const response = await client.value.generateAuthorization({
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -68,6 +75,11 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
@@ -29,7 +29,6 @@ const i18nMap = {
'Channel::Line': 'LINE', 'Channel::Line': 'LINE',
'Channel::Api': 'API', 'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM', 'Channel::Instagram': 'INSTAGRAM',
'Channel::Voice': 'VOICE',
}; };
const twilioChannelName = () => { const twilioChannelName = () => {
@@ -5,19 +5,15 @@ import {
useMapGetter, useMapGetter,
useStore, useStore,
} from 'dashboard/composables/store'; } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue'; import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
const { t } = useI18n();
const store = useStore(); const store = useStore();
const integrationLoaded = ref(false); const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'notion'); const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags'); const uiFlags = useMapGetter('integrations/getUIFlags');
@@ -25,32 +21,22 @@ const integrationAction = computed(() => {
if (integration.value.enabled) { if (integration.value.enabled) {
return 'disconnect'; return 'disconnect';
} }
return integration.value.action;
return '';
}); });
const authorize = async () => { const initializeGithubIntegration = async () => {
const response = await notionClient.generateAuthorization(); await store.dispatch('integrations/get', 'github');
const {
data: { url },
} = response;
window.location.href = url;
};
const initializeNotionIntegration = async () => {
await store.dispatch('integrations/get', 'notion');
integrationLoaded.value = true; integrationLoaded.value = true;
}; };
onMounted(() => { onMounted(() => {
initializeNotionIntegration(); initializeGithubIntegration();
}); });
</script> </script>
<template> <template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto"> <div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion"> <div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration <Integration
:integration-id="integration.id" :integration-id="integration.id"
:integration-logo="integration.logo" :integration-logo="integration.logo"
@@ -59,19 +45,10 @@ onMounted(() => {
:integration-enabled="integration.enabled" :integration-enabled="integration.enabled"
:integration-action="integrationAction" :integration-action="integrationAction"
:delete-confirmation-text="{ :delete-confirmation-text="{
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'), title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'), message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}" }"
> />
<template #action>
<ButtonNext
faded
blue
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="authorize"
/>
</template>
</Integration>
</div> </div>
<div v-else class="flex items-center justify-center flex-1"> <div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" /> <Spinner size="" color-scheme="primary" />
@@ -8,8 +8,8 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue'; import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue'; import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue'; import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue'; import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default { export default {
routes: [ routes: [
@@ -91,15 +91,6 @@ export default {
}, },
props: route => ({ code: route.query.code }), props: route => ({ code: route.query.code }),
}, },
{
path: 'notion',
name: 'settings_integrations_notion',
component: Notion,
meta: {
permissions: ['administrator'],
},
props: route => ({ code: route.query.code }),
},
{ {
path: 'shopify', path: 'shopify',
name: 'settings_integrations_shopify', name: 'settings_integrations_shopify',
@@ -110,6 +101,14 @@ export default {
}, },
props: route => ({ error: route.query.error }), props: route => ({ error: route.query.error }),
}, },
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
permissions: ['administrator'],
},
},
{ {
path: ':integration_id', path: ':integration_id',
name: 'settings_applications_integration', name: 'settings_applications_integration',
@@ -75,34 +75,10 @@ export default {
onRegistrationSuccess() { onRegistrationSuccess() {
this.hasEnabledPushPermissions = true; this.hasEnabledPushPermissions = true;
}, },
onRequestPermissions(value) { onRequestPermissions() {
if (value) { requestPushPermissions({
// Enable / re-enable push notifications onSuccess: this.onRegistrationSuccess,
requestPushPermissions({ });
onSuccess: this.onRegistrationSuccess,
});
} else {
// Disable push notifications
this.disablePushPermissions();
}
},
disablePushPermissions() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (subscription) {
return subscription.unsubscribe();
}
return null;
})
.finally(() => {
this.hasEnabledPushPermissions = false;
})
.catch(() => {
// error
})
);
}, },
getPushSubscription() { getPushSubscription() {
verifyServiceWorkerExistence(registration => verifyServiceWorkerExistence(registration =>
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
cellProps.getValue() cellProps.getValue()
); );
const columns = computed(() => [ const columns = [
columnHelper.accessor('name', { columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`), header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300, width: 300,
@@ -90,7 +90,7 @@ const columns = computed(() => [
width: 200, width: 200,
cell: defaulSpanRender, cell: defaulSpanRender,
}), }),
]); ];
const renderAvgTime = value => (value ? formatTime(value) : '--'); const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -142,9 +142,7 @@ const table = useVueTable({
get data() { get data() {
return tableData.value; return tableData.value;
}, },
get columns() { columns,
return columns.value;
},
enableSorting: false, enableSorting: false,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@@ -9,7 +9,29 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events'; import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
import { channelActions, buildInboxData } from './inboxes/channelActions';
const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
export const state = { export const state = {
records: [], records: [],
@@ -198,12 +220,6 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
// - createWebsiteChannel
// - createTwilioChannel
// - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => { updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true }); commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try { try {
@@ -1,52 +0,0 @@
import * as types from '../../mutation-types';
import InboxesAPI from '../../../api/inboxes';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const channelActions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await InboxesAPI.create({
name: params.name,
channel: { ...params.voice, type: 'voice' },
});
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
};
@@ -62,28 +62,6 @@ describe('#actions', () => {
}); });
}); });
describe('#createVoiceChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
await actions.createVoiceChannel({ commit }, inboxList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.ADD_INBOXES, inboxList[0]],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#createFBChannel', () => { describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] }); axios.post.mockResolvedValue({ data: inboxList[0] });
+4 -7
View File
@@ -112,14 +112,11 @@ export const InitializationHelpers = {
}, },
setDirectionAttribute: () => { setDirectionAttribute: () => {
const htmlElement = document.querySelector('html'); const portalElement = document.getElementById('portal');
// If direction is already applied through props, do not apply again (iframe case) if (!portalElement) return;
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
if (!htmlElement || hasDirApplied) return;
const localeFromHtml = htmlElement.lang; const locale = document.querySelector('.locale-switcher')?.value;
htmlElement.dir = portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
}, },
initializeThemesInPortal: initializeTheme, initializeThemesInPortal: initializeTheme,
@@ -1,56 +1,64 @@
<script setup> <script>
import { ref, useTemplateRef, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue'; import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
const props = defineProps({ export default {
url: { name: 'IframeLoader',
type: String, components: {
default: '', ArticleSkeletonLoader,
}, },
isRtl: { props: {
type: Boolean, url: {
default: false, type: String,
default: '',
},
isRtl: {
type: Boolean,
default: false,
},
}, },
isDirApplied: { data() {
type: Boolean, return {
default: false, isLoading: true,
showEmptyState: !this.url,
};
}, },
}); watch: {
isRtl: {
immediate: true,
handler(value) {
this.$nextTick(() => {
const iframeElement = this.$el.querySelector('iframe');
if (iframeElement) {
iframeElement.onload = () => {
try {
const iframeDocument =
iframeElement.contentDocument ||
(iframeElement.contentWindow &&
iframeElement.contentWindow.document);
const { t } = useI18n(); if (iframeDocument) {
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
const iframe = useTemplateRef('iframe'); }
const isLoading = ref(true); } catch (e) {
const showEmptyState = ref(!props.url); // error
}
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr')); };
}
const applyDirection = () => { });
if (!iframe.value) return; },
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case) },
try { },
const doc = methods: {
iframe.value.contentDocument || iframe.value.contentWindow?.document; handleIframeLoad() {
if (doc?.documentElement) { // Once loaded, the loading state is hidden
doc.documentElement.dir = direction.value; this.isLoading = false;
doc.documentElement.setAttribute('data-dir-applied', 'true'); },
} handleIframeError() {
} catch (e) { // Hide the loading state and show the empty state when an error occurs
// error this.isLoading = false;
} this.showEmptyState = true;
}; },
},
watch(() => props.isRtl, applyDirection);
const handleIframeLoad = () => {
isLoading.value = false;
applyDirection();
};
const handleIframeError = () => {
isLoading.value = false;
showEmptyState.value = true;
}; };
</script> </script>
@@ -58,7 +66,6 @@ const handleIframeError = () => {
<div class="relative overflow-hidden pb-1/2 h-full"> <div class="relative overflow-hidden pb-1/2 h-full">
<iframe <iframe
v-if="url" v-if="url"
ref="iframe"
:src="url" :src="url"
class="absolute w-full h-full top-0 left-0" class="absolute w-full h-full top-0 left-0"
@load="handleIframeLoad" @load="handleIframeLoad"
@@ -72,7 +79,7 @@ const handleIframeError = () => {
v-if="showEmptyState" v-if="showEmptyState"
class="absolute w-full h-full top-0 left-0 flex justify-center items-center" class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
> >
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p> <p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
</div> </div>
</div> </div>
</template> </template>
@@ -1,40 +0,0 @@
/**
* Determine the best-matching locale from the list of locales allowed by the portal.
*
* The matching happens in the following order:
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
* (e.g., `fr` ➜ `fr`).
* 2. Base language match – the base part of a compound locale (before the underscore)
* matches (e.g., `fr_CA` ➜ `fr`).
* 3. Variant match – when the base language is selected but a regional variant exists
* in the portal list (e.g., `fr` ➜ `fr_BE`).
*
* If none of these rules find a match, the function returns `null`,
* Don't show popular articles if locale doesn't match with allowed locales
*
* @export
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
* @param {string[]} allowedLocales Array of locales enabled for the portal.
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
*/
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Ensure inputs are valid
if (
!selectedLocale ||
!Array.isArray(allowedLocales) ||
!allowedLocales.length
) {
return null;
}
const [lang] = selectedLocale.split('_');
const priorityMatches = [
selectedLocale, // exact match
lang, // base language match
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
];
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};
@@ -1,28 +0,0 @@
import { getMatchingLocale } from 'shared/helpers/portalHelper';
describe('portalHelper - getMatchingLocale', () => {
it('returns exact match when present', () => {
const result = getMatchingLocale('fr', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns base language match when exact variant not present', () => {
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns variant match when base language not present', () => {
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
expect(result).toBe('fr_BE');
});
it('returns null when no match found', () => {
const result = getMatchingLocale('de', ['en', 'fr']);
expect(result).toBeNull();
});
it('returns null for invalid inputs', () => {
expect(getMatchingLocale('', [])).toBeNull();
expect(getMatchingLocale(null, null)).toBeNull();
});
});
@@ -63,9 +63,6 @@ export default {
isATelegramChannel() { isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM; return this.channelType === INBOX_TYPES.TELEGRAM;
}, },
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() { isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox; const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms'; return this.isATwilioChannel && medium === 'sms';
@@ -7,7 +7,6 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js'; import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode'; import { useDarkMode } from 'widget/composables/useDarkMode';
import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore(); const store = useStore();
const router = useRouter(); const router = useRouter();
@@ -21,8 +20,17 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => { const locale = computed(() => {
const { locale: selectedLocale } = i18n; const { locale: selectedLocale } = i18n;
const { allowed_locales: allowedLocales } = portal.value.config; const {
return getMatchingLocale(selectedLocale.value, allowedLocales); allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
}); });
const fetchArticles = () => { const fetchArticles = () => {
@@ -38,7 +46,6 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({ const params = new URLSearchParams({
show_plain_layout: 'true', show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light', theme: prefersDarkMode.value ? 'dark' : 'light',
...(locale.value && { locale: locale.value }),
}); });
// Combine link with query parameters // Combine link with query parameters
@@ -57,8 +64,7 @@ const hasArticles = computed(
() => () =>
!articleUiFlags.value.isFetching && !articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError && !articleUiFlags.value.isError &&
!!popularArticles.value.length && !!popularArticles.value.length
!!locale.value
); );
onMounted(() => fetchArticles()); onMounted(() => fetchArticles());
</script> </script>
@@ -23,7 +23,6 @@ export const actions = {
commit('setError', false); commit('setError', false);
try { try {
if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`); const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) { if (cachedData) {
commit('setArticles', cachedData); commit('setArticles', cachedData);
@@ -1,16 +1,24 @@
<script> <script>
import IframeLoader from 'shared/components/IframeLoader.vue'; import IframeLoader from 'shared/components/IframeLoader.vue';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default { export default {
name: 'ArticleViewer', name: 'ArticleViewer',
components: { components: {
IframeLoader, IframeLoader,
}, },
computed: {
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
},
}; };
</script> </script>
<template> <template>
<div class="bg-white h-full"> <div class="bg-white h-full">
<IframeLoader :url="$route.query.link" /> <IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
</div> </div>
</template> </template>
+1 -1
View File
@@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
def process_event_params(channel, params) def process_event_params(channel, params)
return unless params[:telegram] return unless params[:telegram]
if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present? if params.dig(:telegram, :edited_message).present?
Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
else else
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
@@ -47,10 +47,6 @@ class ReportingEventListener < BaseListener
message = extract_message_and_account(event)[0] message = extract_message_and_account(event)[0]
conversation = message.conversation conversation = message.conversation
waiting_since = event.data[:waiting_since] waiting_since = event.data[:waiting_since]
return if waiting_since.blank?
# When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new( reporting_event = ReportingEvent.new(
+3 -16
View File
@@ -69,10 +69,6 @@ class Channel::Telegram < ApplicationRecord
message.conversation[:additional_attributes]['chat_id'] message.conversation[:additional_attributes]['chat_id']
end end
def business_connection_id(message)
message.conversation[:additional_attributes]['business_connection_id']
end
def reply_to_message_id(message) def reply_to_message_id(message)
message.content_attributes['in_reply_to_external_id'] message.content_attributes['in_reply_to_external_id']
end end
@@ -99,13 +95,7 @@ class Channel::Telegram < ApplicationRecord
end end
def send_message(message) def send_message(message)
response = message_request( response = message_request(chat_id(message), message.outgoing_content, reply_markup(message), reply_to_message_id(message))
chat_id(message),
message.outgoing_content,
reply_markup(message),
reply_to_message_id(message),
business_connection_id: business_connection_id(message)
)
process_error(message, response) process_error(message, response)
response.parsed_response['result']['message_id'] if response.success? response.parsed_response['result']['message_id'] if response.success?
end end
@@ -141,12 +131,9 @@ class Channel::Telegram < ApplicationRecord
stripped_html.gsub('&lt;br&gt;', "\n") stripped_html.gsub('&lt;br&gt;', "\n")
end end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil) def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
text_payload = convert_markdown_to_telegram_html(text) text_payload = convert_markdown_to_telegram_html(text)
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage", HTTParty.post("#{telegram_api_url}/sendMessage",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
@@ -154,6 +141,6 @@ class Channel::Telegram < ApplicationRecord
reply_markup: reply_markup, reply_markup: reply_markup,
parse_mode: 'HTML', parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}.merge(business_body)) })
end end
end end
-10
View File
@@ -198,21 +198,11 @@ class Conversation < ApplicationRecord
private private
def execute_after_update_commit_callbacks def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change notify_status_change
create_activity create_activity
notify_conversation_updation notify_conversation_updation
end end
def handle_resolved_status_change
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
return unless saved_change_to_status? && status == 'resolved'
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
def ensure_snooze_until_reset def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed? self.snoozed_until = nil unless snoozed?
end end
+19 -13
View File
@@ -43,6 +43,8 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}" "#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear' when 'linear'
build_linear_action build_linear_action
when 'github'
build_github_action
else else
params[:action] params[:action]
end end
@@ -55,11 +57,11 @@ class Integrations::App
when 'linear' when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify' when 'shopify'
shopify_enabled?(account) account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
when 'leadsquared' when 'leadsquared'
account.feature_enabled?('crm_integration') account.feature_enabled?('crm_integration')
when 'notion' when 'github'
notion_enabled?(account) GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present?
else else
true true
end end
@@ -77,6 +79,16 @@ class Integrations::App
].join('&') ].join('&')
end end
def build_github_action
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
[
'https://github.com/login/oauth/authorize?response_type=code',
"client_id=#{client_id}",
"redirect_uri=#{self.class.github_integration_url}",
'scope=repo'
].join('&')
end
def enabled?(account) def enabled?(account)
case params[:id] case params[:id]
when 'webhook' when 'webhook'
@@ -100,6 +112,10 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback" "#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self class << self
def apps def apps
Hashie::Mash.new(APPS_CONFIG) Hashie::Mash.new(APPS_CONFIG)
@@ -115,14 +131,4 @@ class Integrations::App
all.detect { |app| app.id == params[:id] } all.detect { |app| app.id == params[:id] }
end end
end end
private
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
def notion_enabled?(account)
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
end
end end
-4
View File
@@ -53,10 +53,6 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow' app_id == 'dialogflow'
end end
def notion?
app_id == 'notion'
end
def disable def disable
update(status: 'disabled') update(status: 'disabled')
end end
@@ -24,15 +24,6 @@ class Instagram::Messenger::MessageText < Instagram::BaseMessageText
end end
def handle_client_error(error) def handle_client_error(error)
# Handle error code 230: User consent is required to access user profile
# This typically occurs when the connected Instagram account attempts to send a message to a user
# who has never messaged this Instagram account before.
# We can safely ignore this error as per Facebook documentation.
if error.message.include?('230')
Rails.logger.warn error
return
end
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}") Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}") Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
@@ -1,41 +0,0 @@
class Linear::ActivityMessageService
attr_reader :conversation, :action_type, :issue_data, :user
def initialize(conversation:, action_type:, user:, issue_data: {})
@conversation = conversation
@action_type = action_type
@issue_data = issue_data
@user = user
end
def perform
return unless conversation && issue_data[:id] && user
content = generate_activity_content
return unless content
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content))
end
private
def generate_activity_content
case action_type.to_sym
when :issue_created
I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id])
when :issue_linked
I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id])
when :issue_unlinked
I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id])
end
end
def activity_message_params(content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
end
@@ -5,34 +5,22 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
sections << "Channel: #{@record.inbox.channel.name}" sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:' sections << 'Message History:'
sections << if @record.messages.any? sections << if @record.messages.any?
build_messages(config) build_messages
else else
'No messages in this conversation' 'No messages in this conversation'
end end
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details] sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
attributes = build_attributes
if attributes.present?
sections << 'Conversation Attributes:'
sections << attributes
end
sections.join("\n") sections.join("\n")
end end
private private
def build_messages(config = {}) def build_messages
return "No messages in this conversation\n" if @record.messages.empty? return "No messages in this conversation\n" if @record.messages.empty?
message_text = '' message_text = ''
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc) @record.messages.chat.order(created_at: :asc).each do |message|
messages.each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
message_text << format_message(message) message_text << format_message(message)
end end
message_text message_text
@@ -40,14 +28,6 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format_message(message) def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent' sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n" "#{sender}: #{message.content}\n"
end end
def build_attributes
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end
attributes.join("\n")
end
end end
@@ -4,10 +4,8 @@ class MessageTemplates::Template::AutoResolve
def perform def perform
return if conversation.account.auto_resolve_message.blank? return if conversation.account.auto_resolve_message.blank?
if within_messaging_window? ActiveRecord::Base.transaction do
conversation.messages.create!(auto_resolve_message_params) conversation.messages.create!(auto_resolve_message_params)
else
create_auto_resolve_not_sent_activity_message
end end
end end
@@ -16,21 +14,6 @@ class MessageTemplates::Template::AutoResolve
delegate :contact, :account, to: :conversation delegate :contact, :account, to: :conversation
delegate :inbox, to: :message delegate :inbox, to: :message
def within_messaging_window?
conversation.can_reply?
end
def create_auto_resolve_not_sent_activity_message
content = I18n.t('conversations.activity.auto_resolve.not_sent_due_to_messaging_window')
activity_message_params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end
def auto_resolve_message_params def auto_resolve_message_params
{ {
account_id: @conversation.account_id, account_id: @conversation.account_id,
@@ -8,25 +8,17 @@ class Telegram::IncomingMessageService
def perform def perform
# chatwoot doesn't support group conversations at the moment # chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message? return unless private_message?
set_contact set_contact
update_contact_avatar update_contact_avatar
set_conversation set_conversation
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
# Otherwise, the client will see their messages as unread.
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
# We have two options:
# 1. Send the read request to Telegram here, immediately when the message is created.
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
# See: https://core.telegram.org/bots/api#readbusinessmessage
@message = @conversation.messages.build( @message = @conversation.messages.build(
content: telegram_params_message_content, content: telegram_params_message_content,
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
message_type: message_type, message_type: :incoming,
sender: message_sender, sender: @contact,
content_attributes: telegram_params_content_attributes, content_attributes: telegram_params_content_attributes,
source_id: telegram_params_message_id.to_s source_id: telegram_params_message_id.to_s
) )
@@ -44,11 +36,6 @@ class Telegram::IncomingMessageService
contact_attributes: contact_attributes contact_attributes: contact_attributes
).perform ).perform
# TODO: Should we update contact_attributes when the user changes their first or last name?
# In business chats, when our Telegram bot initiates the conversation,
# the message does not include a language code.
# This is critical for AI assistants and translation plugins.
@contact_inbox = contact_inbox @contact_inbox = contact_inbox
@contact = contact_inbox.contact @contact = contact_inbox.contact
end end
@@ -102,19 +89,10 @@ class Telegram::IncomingMessageService
def conversation_additional_attributes def conversation_additional_attributes
{ {
chat_id: telegram_params_chat_id, chat_id: telegram_params_chat_id
business_connection_id: telegram_params_business_connection_id
} }
end end
def message_type
business_message_outgoing? ? :outgoing : :incoming
end
def message_sender
business_message_outgoing? ? nil : @contact
end
def file_content_type def file_content_type
return :image if image_message? return :image if image_message?
return :audio if audio_message? return :audio if audio_message?
@@ -213,8 +191,4 @@ class Telegram::IncomingMessageService
params[:message][:video].presence || params[:message][:video].presence ||
params[:message][:video_note].presence params[:message][:video_note].presence
end end
def transform_business_message!
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
end
end end
+4 -33
View File
@@ -13,17 +13,6 @@ module Telegram::ParamHelpers
{} {}
end end
def business_message?
telegram_params_business_connection_id.present?
end
# In business bot mode we will receive messages from our telegram.
# This is our messages posted via telegram client.
# Such messages should be outgoing (from us to client)
def business_message_outgoing?
business_message? && telegram_params_base_object[:chat][:id] != telegram_params_base_object[:from][:id]
end
def message_params? def message_params?
params[:message].present? params[:message].present?
end end
@@ -40,34 +29,24 @@ module Telegram::ParamHelpers
end end
end end
def contact_params
if business_message_outgoing?
telegram_params_base_object[:chat]
else
telegram_params_base_object[:from]
end
end
def telegram_params_from_id def telegram_params_from_id
return telegram_params_base_object[:chat][:id] if business_message?
telegram_params_base_object[:from][:id] telegram_params_base_object[:from][:id]
end end
def telegram_params_first_name def telegram_params_first_name
contact_params[:first_name] telegram_params_base_object[:from][:first_name]
end end
def telegram_params_last_name def telegram_params_last_name
contact_params[:last_name] telegram_params_base_object[:from][:last_name]
end end
def telegram_params_username def telegram_params_username
contact_params[:username] telegram_params_base_object[:from][:username]
end end
def telegram_params_language_code def telegram_params_language_code
contact_params[:language_code] telegram_params_base_object[:from][:language_code]
end end
def telegram_params_chat_id def telegram_params_chat_id
@@ -78,14 +57,6 @@ module Telegram::ParamHelpers
end end
end end
def telegram_params_business_connection_id
if callback_query_params?
params[:callback_query][:message][:business_connection_id]
else
telegram_params_base_object[:business_connection_id]
end
end
def telegram_params_message_content def telegram_params_message_content
if callback_query_params? if callback_query_params?
params[:callback_query][:data] params[:callback_query][:data]
@@ -71,7 +71,6 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup", HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
**business_connection_body,
media: attachments.map { |hash| hash.except(:attachment) }.to_json, media: attachments.map { |hash| hash.except(:attachment) }.to_json,
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}) })
@@ -109,7 +108,6 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendDocument", HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: { body: {
chat_id: chat_id, chat_id: chat_id,
**business_connection_body,
document: file, document: file,
reply_to_message_id: reply_to_message_id reply_to_message_id: reply_to_message_id
}, },
@@ -137,14 +135,4 @@ class Telegram::SendAttachmentsService
def channel def channel
@channel ||= message.inbox.channel @channel ||= message.inbox.channel
end end
def business_connection_id
@business_connection_id ||= channel.business_connection_id(message)
end
def business_connection_body
body = {}
body[:business_connection_id] = business_connection_id if business_connection_id
body
end
end end
@@ -5,7 +5,6 @@ class Telegram::UpdateMessageService
pattr_initialize [:inbox!, :params!] pattr_initialize [:inbox!, :params!]
def perform def perform
transform_business_message!
find_contact_inbox find_contact_inbox
find_conversation find_conversation
find_message find_message
@@ -37,8 +36,4 @@ class Telegram::UpdateMessageService
@message.update!(content: edited_message[:caption]) @message.update!(content: edited_message[:caption])
end end
end end
def transform_business_message!
params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present?
end
end end
@@ -4,9 +4,7 @@
I18n.t('reports.label_csv.label_title'), I18n.t('reports.label_csv.label_title'),
I18n.t('reports.label_csv.conversations_count'), I18n.t('reports.label_csv.conversations_count'),
I18n.t('reports.label_csv.avg_first_response_time'), I18n.t('reports.label_csv.avg_first_response_time'),
I18n.t('reports.label_csv.avg_resolution_time'), I18n.t('reports.label_csv.avg_resolution_time')
I18n.t('reports.label_csv.avg_reply_time'),
I18n.t('reports.label_csv.resolution_count'),
] ]
%> %>
<%= CSVSafe.generate_line headers -%> <%= CSVSafe.generate_line headers -%>
@@ -3,7 +3,7 @@
%> %>
<%= javascript_include_tag "secretField" %> <%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field"> <div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true"></span> <span data-secret-masked="true">••••••••••</span>
<button onclick="toggleSecretField(event)" data-secret-toggler> <button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20"> <svg width="20" height="20">
<use xlink:href="#eye-show" /> <use xlink:href="#eye-show" />
+1 -1
View File
@@ -4,7 +4,7 @@
<%= javascript_include_tag "secretField" %> <%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field"> <div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true"></span> <span data-secret-masked="true">••••••••••</span>
<button onclick="toggleSecretField(event)" data-secret-toggler> <button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20"> <svg width="20" height="20">
<use xlink:href="#eye-show" /> <use xlink:href="#eye-show" />
@@ -6,8 +6,8 @@
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br> <strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br> <strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br> <strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deletion due at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br> <strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }} <strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p> </p>
@@ -1,5 +1,5 @@
<% if @message.content %> <% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %> <%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %> <% end %>
<% if @large_attachments.present? %> <% if @large_attachments.present? %>
<p>Attachments:</p> <p>Attachments:</p>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900"> <header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="hidden sm:flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top"> <nav class="hidden sm:flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="flex items-center w-full py-5 overflow-hidden"> <div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10"> <a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10">
<% if @portal.logo.present? %> <% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" /> <img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<% end %> <% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span> <span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'hidden sm:block' %>"><%= @portal.name %></span>
</a> </a>
</div> </div>
<%# Go to homepage link section %> <%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5"> <div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %> <% if @portal.homepage_link %>
<div class="px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer block"> <div class="hidden px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer md:block">
<div class="flex-grow flex-shrink-0"> <div class="flex-grow flex-shrink-0">
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200"> <a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %> <%= render partial: 'icons/redirect' %>
@@ -99,11 +99,11 @@
<nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top"> <nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top">
<div class="flex items-center justify-between w-full py-5"> <div class="flex items-center justify-between w-full py-5">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white"> <a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %> <% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" /> <img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<% end %> <% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span> <span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
</a> </a>
<!-- Mobile Menu Component --> <!-- Mobile Menu Component -->
@@ -2,7 +2,7 @@
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner"> <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 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"> <div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span> <span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block' : 'hidden' %>"><%= @portal.name %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal"> <h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %> <%= portal.header_text %>
</h1> </h1>
@@ -1,4 +1,3 @@
<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %>
<input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" /> <input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" />
@@ -47,12 +46,10 @@
</div> </div>
</div> </div>
<% if has_multiple_locales %> <span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<% end %>
<!-- Locale Switcher --> <!-- Locale Switcher -->
<% if has_multiple_locales %> <% if @portal.config["allowed_locales"].length > 1 %>
<div id="header-action-button" class="flex flex-col gap-2"> <div id="header-action-button" class="flex flex-col gap-2">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2"> <h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2">
<%= I18n.t('public_portal.header.language', default: 'Language') %> <%= I18n.t('public_portal.header.language', default: 'Language') %>
@@ -156,14 +156,12 @@
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/> <path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol> </symbol>
<symbol id="icon-notion" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32"> <symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/> <path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol> </symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24"> <symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/> <path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol> </symbol>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+20 -29
View File
@@ -22,36 +22,27 @@
<% end %> <% end %>
<div class="bg-white py-2 px-4 xl:px-0"> <div class="bg-white py-2 px-4 xl:px-0">
<%= javascript_include_tag "secretField" %> <div class="mb-4">
<div class="flex items-center mb-6"> <div class="flex items-center gap-2">
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4> <h2 class="h-5 leading-5 text-n-slate-12 font-medium">Current plan</h2>
<div data-secret-text="<%= ChatwootHub.installation_identifier %>" class="cell-data__secret-field text-sm leading-5 h-5 text-n-slate-11"> <a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<span data-secret-masked="true" class="select-none"></span> <svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<button type="button" onclick="toggleSecretField(event)" data-secret-toggler class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none"> <span>Refresh</span>
<svg width="20" height="20" class="w-5 h-5 fill-current"> </a>
<use xlink:href="#eye-show" /> </div>
</svg> <p class="text-n-slate-11 mt-1"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</button> <div class="flex items-center mt-6">
<button type="button" onclick="copySecretField(event)" data-secret-copier class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none"> <h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
<svg width="20" height="20" class="w-5 h-5 fill-current"> <span class="text-sm leading-5 h-5 text-n-slate-11"><%= ChatwootHub.installation_identifier %></span>
<use xlink:href="#icon-copy" />
</svg>
</button>
</div> </div>
</div> </div>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row"> <div class="flex p-4 outline outline-1 outline-n-container rounded-lg mt-8 items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1"> <div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2"> <h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p> <p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div> </div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer"> <a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none"> <button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg> <svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span> <span class="px-1">Manage</span>
</button> </button>
@@ -73,14 +64,14 @@
<p class="text-n-slate-11 m-0 text-sm">Do you face any issues? We are here to help.</p> <p class="text-n-slate-11 m-0 text-sm">Do you face any issues? We are here to help.</p>
</div> </div>
<a href="https://discord.gg/cJXdrwS" target="_blank"> <a href="https://discord.gg/cJXdrwS" target="_blank">
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2 focus:outline-none"> <button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg> <svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<span class="px-1">Community Support</span> <span class="px-1">Community Support</span>
</button> </button>
</a> </a>
<% if ChatwootHub.pricing_plan !='community' %> <% if ChatwootHub.pricing_plan !='community' %>
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none" onclick="window.$chatwoot.toggle('open')"> <button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2" onclick="window.$chatwoot.toggle('open')">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg> <svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<span class="px-1">Chat Support</span> <span class="px-1">Chat Support</span>
</button> </button>
<% end %> <% end %>
@@ -99,7 +90,7 @@
</div> </div>
<% if !attrs[:enabled] %> <% if !attrs[:enabled] %>
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible"> <div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none"> <a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg> <svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span> <span class="px-1">Upgrade now</span>
</a> </a>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '4.3.0' version: '4.2.0'
development: development:
<<: *shared <<: *shared
+3 -7
View File
@@ -169,10 +169,6 @@
- name: crm_integration - name: crm_integration
display_name: CRM Integration display_name: CRM Integration
enabled: false enabled: false
- name: channel_voice - name: github_integration
display_name: Voice Channel display_name: Github Integration
enabled: false enabled: false
chatwoot_internal: true
- name: notion_integration
display_name: Notion Integration
enabled: false
+14 -19
View File
@@ -288,25 +288,6 @@
type: secret type: secret
## ------ End of Configs added for Linear ------ ## ## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Notion ------ ##
- name: NOTION_CLIENT_ID
display_title: 'Notion Client ID'
value:
locked: false
description: 'Notion client ID'
- name: NOTION_CLIENT_SECRET
display_title: 'Notion Client Secret'
value:
locked: false
description: 'Notion client secret'
type: secret
- name: NOTION_VERSION
display_title: 'Notion Version'
value: '2022-06-28'
locked: false
description: 'Notion version'
## ------ End of Configs added for Notion ------ ##
## ------ Configs added for Slack ------ ## ## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID - name: SLACK_CLIENT_ID
display_title: 'Slack Client ID' display_title: 'Slack Client ID'
@@ -360,3 +341,17 @@
value: 'v22.0' value: 'v22.0'
locked: true locked: true
# ------- End of Instagram Channel Related Config ------- # # ------- End of Instagram Channel Related Config ------- #
## ------ Configs added for GitHub ------ ##
- name: GITHUB_CLIENT_ID
display_title: 'GitHub Client ID'
value:
locked: false
description: 'GitHub OAuth app client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'GitHub Client Secret'
value:
locked: false
description: 'GitHub OAuth app client secret'
type: secret
## ------ End of Configs added for GitHub ------ ##
+7 -6
View File
@@ -63,12 +63,6 @@ linear:
action: https://linear.app/oauth/authorize action: https://linear.app/oauth/authorize
hook_type: account hook_type: account
allow_multiple_hooks: false allow_multiple_hooks: false
notion:
id: notion
logo: notion.png
i18n_key: notion
hook_type: account
allow_multiple_hooks: false
slack: slack:
id: slack id: slack
logo: slack.png logo: slack.png
@@ -284,3 +278,10 @@ leadsquared:
'conversation_activity_score', 'conversation_activity_score',
'transcript_activity_score', 'transcript_activity_score',
] ]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
allow_multiple_hooks: false

Some files were not shown because too many files have changed in this diff Show More