Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d23d9435 | ||
|
|
e9a92a6366 | ||
|
|
bec795f764 | ||
|
|
9f64d50011 | ||
|
|
e6dfb91fcc | ||
|
|
d9c07fe2e9 | ||
|
|
f93f2067b6 | ||
|
|
f926f5e500 | ||
|
|
c9f6fb202c | ||
|
|
3dfb5061e1 | ||
|
|
72a59e4795 | ||
|
|
cabe9bc733 | ||
|
|
33f7550525 | ||
|
|
7830fec604 | ||
|
|
59d869d1ed | ||
|
|
8a3b129292 | ||
|
|
af1dfc21f6 | ||
|
|
ba04e0b678 | ||
|
|
8625a00918 | ||
|
|
16e638aeb7 |
+31
-20
@@ -136,6 +136,8 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
activesupport (>= 5.0, < 7.7)
|
||||
auth-sanitizer (0.2.1)
|
||||
version_gem (~> 1.1, >= 1.1.10)
|
||||
aws-actionmailbox-ses (0.1.0)
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
@@ -168,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -184,6 +186,7 @@ GEM
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
byebug (11.1.3)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
@@ -312,7 +315,7 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
@@ -435,7 +438,8 @@ GEM
|
||||
hana (1.3.7)
|
||||
hash_diff (1.1.1)
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
html2text (0.4.0)
|
||||
nokogiri (>= 1.0, < 2.0)
|
||||
http (5.1.1)
|
||||
@@ -470,7 +474,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.19.8)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -568,7 +572,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
multi_xml (0.9.1)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
@@ -603,19 +607,26 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oauth-tty (1.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
base64 (~> 0.1)
|
||||
cgi
|
||||
oauth-tty (~> 1.0, >= 1.0.8)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oauth-tty (1.0.8)
|
||||
auth-sanitizer (~> 0.1, >= 0.1.3)
|
||||
cgi
|
||||
version_gem (~> 1.1, >= 1.1.9)
|
||||
oauth2 (2.0.22)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
jwt (>= 1.0, < 4.0)
|
||||
logger (~> 1.2)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -935,9 +946,9 @@ GEM
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
snaky_hash (2.0.5)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (>= 1.1.8, < 3)
|
||||
sorbet-runtime (0.5.11934)
|
||||
spring (4.1.1)
|
||||
spring-watcher-listen (2.1.0)
|
||||
@@ -995,7 +1006,7 @@ GEM
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
version_gem (1.1.11)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.1
|
||||
4.14.2
|
||||
|
||||
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
def help_center_generation
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def help_center_generation_status
|
||||
{
|
||||
generation_id: nil,
|
||||
state: nil,
|
||||
articles_count: 0,
|
||||
categories_count: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
|
||||
|
||||
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
|
||||
getHelpCenterGeneration() {
|
||||
return axios.get(`${this.url}/help_center_generation`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
|
||||
@@ -375,10 +375,10 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
|
||||
@@ -94,8 +94,18 @@ export default {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentAccount.id'(id) {
|
||||
if (id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeAccount();
|
||||
// Account already in the store (navigated in): seed immediately.
|
||||
if (this.currentAccount.id) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const getAccountId = computed(() => currentAccount.value.id.toString());
|
||||
const getAccountId = computed(() => currentAccount.value?.id?.toString());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -109,6 +109,15 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
});
|
||||
},
|
||||
|
||||
openArticle(slug) {
|
||||
if (!slug) {
|
||||
throw new Error('Article slug is required');
|
||||
}
|
||||
|
||||
IFrameHelper.events.toggleBubble('open');
|
||||
IFrameHelper.sendMessage('open-article', { slug });
|
||||
},
|
||||
|
||||
setUser(identifier, user) {
|
||||
if (typeof identifier !== 'string' && typeof identifier !== 'number') {
|
||||
throw new Error('Identifier should be a string or a number');
|
||||
|
||||
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Help center article tables persist column widths as an internal
|
||||
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
|
||||
// editor's markdown round-trip and must never surface as text — markdown-it runs
|
||||
// with `html: false`, which would otherwise escape it into a visible comment in
|
||||
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
|
||||
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||
@@ -75,7 +82,7 @@ class MessageFormatter {
|
||||
isAPrivateNote = false,
|
||||
linkify = true
|
||||
) {
|
||||
this.message = message || '';
|
||||
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
|
||||
this.isAPrivateNote = isAPrivateNote;
|
||||
this.isATweet = isATweet;
|
||||
this.linkify = linkify;
|
||||
|
||||
@@ -38,3 +38,24 @@ export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||
// Return the first match that exists in the allowed list, or null
|
||||
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the link consumed by the in-widget article viewer, appending the query
|
||||
* params it expects (plain layout, theme and locale).
|
||||
*
|
||||
* @export
|
||||
* @param {Object} options
|
||||
* @param {string} options.link Relative article/portal path (e.g. `hc/slug/articles/foo`).
|
||||
* @param {(string|null)} [options.locale] Resolved portal locale.
|
||||
* @param {boolean} [options.prefersDarkMode] Whether the widget is in dark mode.
|
||||
* @returns {string} The link with the article viewer query params appended.
|
||||
*/
|
||||
export const buildArticleViewerLink = ({ link, locale, prefersDarkMode }) => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode ? 'dark' : 'light',
|
||||
...(locale && { locale }),
|
||||
});
|
||||
|
||||
return `${link}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('help center table colwidth marker', () => {
|
||||
it('strips the internal colwidths marker from rendered output', () => {
|
||||
const message =
|
||||
'<!--cw-colwidths:120,200-->\n| A | B |\n| --- | --- |\n| 1 | 2 |';
|
||||
const formatter = new MessageFormatter(message);
|
||||
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
|
||||
expect(formatter.plainText).not.toContain('cw-colwidths');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sanitize', () => {
|
||||
it('sanitizes markup and removes all unnecessary elements', () => {
|
||||
const message =
|
||||
|
||||
@@ -22,6 +22,10 @@ import { useRouter } from 'vue-router';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import {
|
||||
getMatchingLocale,
|
||||
buildArticleViewerLink,
|
||||
} from 'shared/helpers/portalHelper';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -160,6 +164,26 @@ export default {
|
||||
this.$root.$i18n.locale = localeWithoutVariation;
|
||||
}
|
||||
},
|
||||
openArticle(slug) {
|
||||
const { portal } = window.chatwootWebChannel;
|
||||
if (!portal || !slug) return;
|
||||
|
||||
const locale = getMatchingLocale(
|
||||
this.$root.$i18n.locale,
|
||||
portal.config?.allowed_locales
|
||||
);
|
||||
const link = buildArticleViewerLink({
|
||||
link: `hc/${portal.slug}/articles/${slug}`,
|
||||
locale,
|
||||
prefersDarkMode: this.prefersDarkMode,
|
||||
});
|
||||
// Add a timestamp so the route always changes, even when the same article
|
||||
// is requested again or the iframe was browsed to another page.
|
||||
this.router.push({
|
||||
name: 'article-viewer',
|
||||
query: { link, v: Date.now() },
|
||||
});
|
||||
},
|
||||
registerUnreadEvents() {
|
||||
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
|
||||
const { name: routeName } = this.$route;
|
||||
@@ -316,6 +340,8 @@ export default {
|
||||
this.setBubbleLabel();
|
||||
} else if (message.event === 'set-color-scheme') {
|
||||
this.setColorScheme(message.darkMode);
|
||||
} else if (message.event === 'open-article') {
|
||||
this.openArticle(message.slug);
|
||||
} else if (message.event === 'toggle-open') {
|
||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
import {
|
||||
getMatchingLocale,
|
||||
buildArticleViewerLink,
|
||||
} from 'shared/helpers/portalHelper';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -38,14 +41,11 @@ const fetchArticles = () => {
|
||||
};
|
||||
|
||||
const openArticleInArticleViewer = link => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||
...(locale.value && { locale: locale.value }),
|
||||
const linkToOpen = buildArticleViewerLink({
|
||||
link,
|
||||
locale: locale.value,
|
||||
prefersDarkMode: prefersDarkMode.value,
|
||||
});
|
||||
|
||||
// Combine link with query parameters
|
||||
const linkToOpen = `${link}?${params.toString()}`;
|
||||
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
<!--
|
||||
Key by fullPath (not just the link) so the iframe remounts on every
|
||||
navigation here, including re-opening the same article via the SDK after
|
||||
the iframe was browsed to another help-center page. See App.vue#openArticle.
|
||||
-->
|
||||
<IframeLoader :key="$route.fullPath" :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -126,12 +126,17 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
|
||||
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
|
||||
return true unless channel.account.active?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def embedded_signup_channel?(channel)
|
||||
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def find_channel_by_url_param(params)
|
||||
return unless params[:phone_number]
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class WebhookListener < BaseListener
|
||||
|
||||
def inbox_created(event)
|
||||
inbox, account = extract_inbox_and_account(event)
|
||||
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
|
||||
payload = inbox_webhook_data.merge(event: __method__.to_s)
|
||||
deliver_account_webhooks(payload, account)
|
||||
end
|
||||
@@ -78,7 +78,7 @@ class WebhookListener < BaseListener
|
||||
changed_attributes = extract_changed_attributes(event)
|
||||
return if changed_attributes.blank?
|
||||
|
||||
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
|
||||
payload = inbox_webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
|
||||
deliver_account_webhooks(payload, account)
|
||||
end
|
||||
|
||||
@@ -23,7 +23,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
|
||||
# Like #push_data but with message text normalized for external integrations (webhooks).
|
||||
def webhook_data
|
||||
push_data.merge(messages: webhook_push_messages)
|
||||
push_data.merge(
|
||||
account: account.webhook_data,
|
||||
messages: webhook_push_messages
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -32,4 +32,8 @@ class Inbox::EventDataPresenter < SimpleDelegator
|
||||
channel: channel
|
||||
}
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
push_data.merge(account: account.webhook_data)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
|
||||
# Refresh the access tokens using the refresh token
|
||||
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
|
||||
def refresh_tokens
|
||||
raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
|
||||
|
||||
oauth_strategy = build_oauth_strategy
|
||||
token_service = build_token_service(oauth_strategy)
|
||||
|
||||
|
||||
@@ -37,12 +37,18 @@ class WebsiteBrandingService
|
||||
private
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
body = nil
|
||||
SafeFetch.fetch(@url, validate_content_type: false) do |result|
|
||||
body = result.tempfile.read
|
||||
end
|
||||
@http_status = 200
|
||||
return nil if body.blank?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
rescue StandardError => e
|
||||
Nokogiri::HTML(body)
|
||||
rescue SafeFetch::HttpError => e
|
||||
@http_status = e.message.to_i
|
||||
nil
|
||||
rescue SafeFetch::Error => e
|
||||
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService
|
||||
|
||||
access_token = exchange_code_for_token
|
||||
phone_info = fetch_phone_info(access_token)
|
||||
validate_token_access(access_token)
|
||||
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
|
||||
@@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService
|
||||
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
end
|
||||
|
||||
def validate_token_access(access_token)
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
end
|
||||
|
||||
def create_or_reauthorize_channel(access_token, phone_info)
|
||||
if @inbox_id.present?
|
||||
Whatsapp::ReauthorizationService.new(
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(@waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,9 @@ if resource.custom_attributes.present?
|
||||
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
|
||||
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
|
||||
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
|
||||
if resource.custom_attributes['help_center_generation_id'].present?
|
||||
json.help_center_generation_id resource.custom_attributes['help_center_generation_id']
|
||||
end
|
||||
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
|
||||
if resource.custom_attributes['marked_for_deletion_reason'].present?
|
||||
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
|
||||
|
||||
@@ -130,7 +130,11 @@ json.bot_name resource.channel.try(:bot_name) if resource.telegram?
|
||||
if resource.whatsapp?
|
||||
json.message_templates resource.channel.try(:message_templates)
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
# Only show reauthorization for embedded signup; manual flow uses API keys, not OAuth
|
||||
json.reauthorization_required(
|
||||
(resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' &&
|
||||
resource.channel.try(:reauthorization_required?)
|
||||
)
|
||||
end
|
||||
|
||||
## Voice attributes for TwilioSms
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.14.1'
|
||||
version: '4.14.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update]
|
||||
resource :onboarding, only: [:update] do
|
||||
get :help_center_generation
|
||||
end
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
|
||||
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
|
||||
def test
|
||||
tool = account_custom_tools.new(custom_tool_params)
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
body = execute_test_request(tool)
|
||||
render json: { status: 200, body: body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
module Enterprise::Api::V1::Accounts::OnboardingsController
|
||||
def help_center_generation
|
||||
@account = Current.account
|
||||
render json: help_center_generation_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def help_center_generation_status
|
||||
generation_id = help_center_generation_id
|
||||
return super if generation_id.blank?
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
|
||||
{
|
||||
generation_id: generation_id,
|
||||
state: state,
|
||||
articles_count: articles_count,
|
||||
categories_count: categories_count
|
||||
}
|
||||
end
|
||||
|
||||
def help_center_generation_id
|
||||
@account.custom_attributes['help_center_generation_id']
|
||||
end
|
||||
|
||||
def articles_count
|
||||
onboarding_portal&.articles&.count || 0
|
||||
end
|
||||
|
||||
def categories_count
|
||||
onboarding_portal&.categories&.count || 0
|
||||
end
|
||||
|
||||
def onboarding_portal
|
||||
@onboarding_portal ||= @account.portals.first
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
_account_id, _portal_id, user_id, generation_id = job.arguments
|
||||
_account_id, _portal_id, _user_id, generation_id = job.arguments
|
||||
reason = "firecrawl exhausted: #{error.message}"
|
||||
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
|
||||
job.send(:skip_generation, generation_id: generation_id, reason: reason)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id)
|
||||
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
)
|
||||
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
|
||||
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
skip_generation(generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
|
||||
articles.each do |article|
|
||||
Onboarding::HelpCenterArticleWriterJob.perform_later(
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
account_id, portal_id, user_id, generation_id, article
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_and_broadcast(user:, generation_id:, reason:)
|
||||
def skip_generation(generation_id:, reason:)
|
||||
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
|
||||
Onboarding::HelpCenterBroadcaster.completed(
|
||||
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article_payload)
|
||||
user = User.find(user_id)
|
||||
payload = article_payload.with_indifferent_access
|
||||
article = Onboarding::HelpCenterArticleBuilder.new(
|
||||
def perform(account_id, portal_id, user_id, generation_id, article)
|
||||
Onboarding::HelpCenterArticleBuilder.new(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
user: User.find(user_id),
|
||||
article: article
|
||||
).perform
|
||||
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
finalize(generation_id: generation_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
user, generation_id = failure_context
|
||||
generation_id = arguments[3]
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
finalize(generation_id: generation_id)
|
||||
end
|
||||
|
||||
def failure_context
|
||||
_account_id, _portal_id, user_id, generation_id = arguments
|
||||
[User.find_by(id: user_id), generation_id]
|
||||
end
|
||||
|
||||
def finalize(user:, generation_id:, article:)
|
||||
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
|
||||
if article
|
||||
Onboarding::HelpCenterBroadcaster.article_generated(
|
||||
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
|
||||
)
|
||||
end
|
||||
return unless result[:completed]
|
||||
|
||||
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
|
||||
def finalize(generation_id:)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
module Onboarding::HelpCenterBroadcaster
|
||||
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
|
||||
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def article_generated(user:, generation_id:, article:, articles_finished:)
|
||||
broadcast(user, ARTICLE_GENERATED, {
|
||||
generation_id: generation_id,
|
||||
article_id: article.id,
|
||||
articles_finished: articles_finished
|
||||
})
|
||||
end
|
||||
|
||||
def completed(user:, generation_id:, status:, skip_reason: nil)
|
||||
broadcast(user, GENERATION_COMPLETED, {
|
||||
generation_id: generation_id,
|
||||
status: status,
|
||||
skip_reason: skip_reason
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast(user, event, payload)
|
||||
token = user&.pubsub_token
|
||||
return if token.blank?
|
||||
|
||||
ActionCableBroadcastJob.perform_later([token], event, payload)
|
||||
end
|
||||
end
|
||||
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
|
||||
|
||||
generation_id = SecureRandom.uuid
|
||||
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
|
||||
@account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
@@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response.body)
|
||||
response_body = execute_http_request(url, body, tool_context)
|
||||
@custom_tool.format_response(response_body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
'An error occurred while executing the request'
|
||||
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
|
||||
private
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
||||
IPAddr.new('::1'), # IPv6 Loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
||||
].freeze
|
||||
|
||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
# Route through SafeFetch so custom tool requests share the app's centralized HTTP
|
||||
# fetching (resolution, timeouts, response size limits, and redirect handling).
|
||||
def execute_http_request(url, body, tool_context)
|
||||
uri = URI.parse(url)
|
||||
json_body = body if @custom_tool.http_method == 'POST'
|
||||
|
||||
# Check if resolved IP is private
|
||||
check_private_ip!(uri.host)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
http.max_retries = 0 # Disable redirects
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
apply_metadata_headers(request, tool_context)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_response!(response)
|
||||
|
||||
response
|
||||
response_body = +''
|
||||
SafeFetch.fetch(
|
||||
url,
|
||||
method: @custom_tool.http_method == 'POST' ? :post : :get,
|
||||
body: json_body,
|
||||
headers: request_headers(tool_context, json_body),
|
||||
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
|
||||
max_bytes: MAX_RESPONSE_SIZE,
|
||||
validate_content_type: false
|
||||
) { |result| response_body = result.tempfile.read }
|
||||
response_body
|
||||
end
|
||||
|
||||
def check_private_ip!(hostname)
|
||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
||||
|
||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
||||
rescue Resolv::ResolvError, SocketError => e
|
||||
raise "DNS resolution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_response!(response)
|
||||
content_length = response['content-length']&.to_i
|
||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
||||
|
||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
def build_http_request(uri, body)
|
||||
if @custom_tool.http_method == 'POST'
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
if body
|
||||
request.body = body
|
||||
request['Content-Type'] = 'application/json'
|
||||
end
|
||||
else
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
end
|
||||
request
|
||||
end
|
||||
|
||||
def apply_authentication(request)
|
||||
def request_headers(tool_context, json_body)
|
||||
headers = @custom_tool.build_auth_headers
|
||||
headers.each { |key, value| request[key] = value }
|
||||
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
|
||||
def apply_metadata_headers(request, tool_context)
|
||||
state = tool_context&.state || {}
|
||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||
metadata_headers.each { |key, value| request[key] = value }
|
||||
headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
|
||||
headers['Content-Type'] = 'application/json' if json_body.present?
|
||||
headers
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
|
||||
end
|
||||
|
||||
# Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
|
||||
# so cells without an explicit colwidth render the same minimum here as in the editor.
|
||||
TABLE_CELL_MIN_WIDTH_PX = 50
|
||||
COLWIDTHS_COMMENT = /<!--cw-colwidths:([\d,]+)-->/
|
||||
|
||||
# The article editor serializes column widths as a `<!--cw-colwidths:...-->` HTML
|
||||
# comment immediately before each resized table. Capture it (emitting nothing) so the
|
||||
# next `table` can size itself; any other raw HTML keeps its default rendering.
|
||||
def html(node)
|
||||
match = node.string_content.match(COLWIDTHS_COMMENT)
|
||||
return super unless match
|
||||
|
||||
@pending_colwidths = match[1].split(',').map(&:to_i)
|
||||
end
|
||||
|
||||
def table(node)
|
||||
out('<div class="tableWrapper">')
|
||||
super
|
||||
widths = @pending_colwidths
|
||||
@pending_colwidths = nil
|
||||
|
||||
if sized_widths?(widths)
|
||||
out(table_wrapper_open(widths))
|
||||
out(inject_table_sizing(capture_html { super(node) }, widths))
|
||||
else
|
||||
out('<div class="tableWrapper">')
|
||||
super
|
||||
end
|
||||
out('</div>')
|
||||
end
|
||||
|
||||
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
private
|
||||
|
||||
def sized_widths?(widths)
|
||||
widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
def fully_sized?(widths)
|
||||
widths.all? { |w| w.to_i.positive? }
|
||||
end
|
||||
|
||||
# Fully-sized tables hug their exact width so the card doesn't trail empty space;
|
||||
# partial tables stay a plain full-width card so flexible columns can expand.
|
||||
def table_wrapper_open(widths)
|
||||
return '<div class="tableWrapper">' unless fully_sized?(widths)
|
||||
|
||||
%(<div class="tableWrapper" style="width: #{total_width(widths)}px; max-width: 100%;">)
|
||||
end
|
||||
|
||||
# Let the gem render the whole table, then splice a <colgroup> and sizing style
|
||||
# into the opening <table> tag. Delegating the row/cell/tbody/alignment markup to
|
||||
# super keeps this working across commonmarker upgrades.
|
||||
# `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
|
||||
def inject_table_sizing(html, widths)
|
||||
opening = %(<table style="#{table_sizing_style(widths)}">\n#{colgroup_html(widths)})
|
||||
html.sub(/<table[^>]*>\n?/, opening)
|
||||
end
|
||||
|
||||
# Capture everything `super` writes by swapping the renderer's output buffer.
|
||||
def capture_html
|
||||
original = @stream
|
||||
@stream = StringIO.new(+'')
|
||||
yield
|
||||
@stream.string
|
||||
ensure
|
||||
@stream = original
|
||||
end
|
||||
|
||||
# Total table width: each column's saved width, or the cell min for unsized ones.
|
||||
def total_width(widths)
|
||||
widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
|
||||
end
|
||||
|
||||
# Fully sized → lock to the exact total (min-width too, so a narrow saved width
|
||||
# beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
|
||||
# the container (flexible columns) yet scrolls when the sized columns exceed it.
|
||||
def table_sizing_style(widths)
|
||||
total = total_width(widths)
|
||||
return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
|
||||
|
||||
"table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
|
||||
end
|
||||
|
||||
def colgroup_html(widths)
|
||||
cols = widths.map { |w| w.to_i.positive? ? %(<col style="width: #{w.to_i}px;">) : '<col>' }
|
||||
"<colgroup>#{cols.join}</colgroup>\n"
|
||||
end
|
||||
|
||||
def extract_image_width(src)
|
||||
query = URI.parse(src).query
|
||||
raw = query && CGI.parse(query)['cw_image_width']&.first
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.14.1",
|
||||
"version": "4.14.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.17",
|
||||
"@chatwoot/prosemirror-schema": "1.3.19",
|
||||
"@chatwoot/utils": "^0.0.55",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
|
||||
Generated
+5
-5
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.17
|
||||
version: 1.3.17
|
||||
specifier: 1.3.19
|
||||
version: 1.3.19
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.55
|
||||
version: 0.0.55
|
||||
@@ -458,8 +458,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.17':
|
||||
resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.19':
|
||||
resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
|
||||
|
||||
'@chatwoot/utils@0.0.55':
|
||||
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
|
||||
@@ -5128,7 +5128,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.17':
|
||||
'@chatwoot/prosemirror-schema@1.3.19':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
|
||||
@@ -100,6 +100,35 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
|
||||
end
|
||||
|
||||
it 'returns reauthorization_required for embedded signup whatsapp channel when reauth required' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['reauthorization_required']).to be(true)
|
||||
end
|
||||
|
||||
it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
whatsapp_channel.update!(provider_config: whatsapp_channel.provider_config.merge('source' => 'manual'))
|
||||
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['reauthorization_required']).to be(false)
|
||||
end
|
||||
|
||||
it 'returns the inbox if assigned inbox is assigned as agent' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
|
||||
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an agent (non-admin)' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no help center generation has started' do
|
||||
it 'returns not_started with zero counts' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include(
|
||||
'generation_id' => nil,
|
||||
'state' => nil,
|
||||
'articles_count' => 0,
|
||||
'categories_count' => 0
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
|
||||
it 'does not add the user to the target account' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
rescue StandardError => e
|
||||
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
|
||||
end.not_to change(AccountUser, :count)
|
||||
expect(existing_user.reload.accounts).not_to include(account)
|
||||
end
|
||||
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
|
||||
it 'does not convert the user provider to saml' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
rescue StandardError => e
|
||||
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
|
||||
end.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Onboarding API', type: :request do
|
||||
let(:account) { create(:account, domain: 'example.com') }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
|
||||
context 'when help center generation is in progress' do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let!(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:category) { create(:category, portal: portal, account_id: account.id) }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
|
||||
create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
|
||||
end
|
||||
|
||||
it 'returns Redis state and help center counts' do
|
||||
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include(
|
||||
'generation_id' => generation_id,
|
||||
'articles_count' => 1,
|
||||
'categories_count' => 1
|
||||
)
|
||||
expect(response.parsed_body['state']).to include(
|
||||
'status' => 'generating',
|
||||
'finished' => '1',
|
||||
'total' => '3'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
hash_including('title' => 'Valid')
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
describe 'missing state' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
it 'does not raise when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
expect { described_class.perform_now(*job_args) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,7 +58,7 @@ RSpec.describe MutexApplicationJob do
|
||||
|
||||
describe '.retry_on_lock_conflict' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
Class.new(MutexApplicationJob) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
|
||||
|
||||
attr_reader :fallback_args
|
||||
@@ -90,7 +90,7 @@ RSpec.describe MutexApplicationJob do
|
||||
|
||||
context 'without an exhaustion handler' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
Class.new(MutexApplicationJob) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1
|
||||
|
||||
def perform(lock_key)
|
||||
|
||||
@@ -62,6 +62,14 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
job.perform_now(params)
|
||||
end
|
||||
|
||||
it 'still enqueues for manual channels even when reauthorization required' do
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual'))
|
||||
channel.prompt_reauthorization!
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
|
||||
job.perform_now(params)
|
||||
end
|
||||
|
||||
it 'will not enqueue if channel is not present' do
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
|
||||
|
||||
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#table' do
|
||||
def render_table(markdown)
|
||||
doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
|
||||
described_class.new.render(doc)
|
||||
end
|
||||
|
||||
let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
|
||||
|
||||
it 'renders a table without column widths when no marker is present' do
|
||||
output = render_table(plain_table)
|
||||
expect(output).to include('<div class="tableWrapper"><table>')
|
||||
expect(output).not_to include('colgroup')
|
||||
expect(output).not_to include('cw-colwidths')
|
||||
end
|
||||
|
||||
context 'when every column has a saved width' do
|
||||
it 'lays the table out at the total width with a sized colgroup' do
|
||||
output = render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")
|
||||
# Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
|
||||
expect(output).to include('<div class="tableWrapper" style="width: 320px; max-width: 100%;">')
|
||||
expect(output).to include('<table style="table-layout: fixed; width: 320px !important; min-width: 320px !important;">')
|
||||
expect(output).to include('<colgroup><col style="width: 120px;"><col style="width: 200px;"></colgroup>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when only some columns have a saved width' do
|
||||
it 'fills the container so unsized columns stay flexible, floored at the sized total' do
|
||||
output = render_table("<!--cw-colwidths:150,0-->\n#{plain_table}")
|
||||
# max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
|
||||
expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
|
||||
expect(output).to include('<colgroup><col style="width: 150px;"><col></colgroup>')
|
||||
# No exact-width lock on the wrapper or table — the table must be free to expand.
|
||||
expect(output).to include('<div class="tableWrapper"><table')
|
||||
expect(output).not_to include('width: 200px !important')
|
||||
end
|
||||
end
|
||||
|
||||
it 'associates each marker with the table that follows it' do
|
||||
markdown = "#{plain_table}\n<!--cw-colwidths:150,250-->\n| P | Q |\n| --- | --- |\n| a | b |\n"
|
||||
output = render_table(markdown)
|
||||
|
||||
# First table has no marker and stays unsized; the marker applies to the second table.
|
||||
expect(output).to include('<div class="tableWrapper"><table>')
|
||||
expect(output).to include('width: 400px !important;')
|
||||
expect(output).to include('<col style="width: 250px;">')
|
||||
expect(output.scan('colgroup').length).to eq(2)
|
||||
end
|
||||
|
||||
it 'does not emit the marker comment into the rendered html' do
|
||||
expect(render_table("<!--cw-colwidths:120,200-->\n#{plain_table}")).not_to include('cw-colwidths')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image' do
|
||||
it 'renders width in px with responsive cap and auto height' do
|
||||
markdown = ''
|
||||
|
||||
@@ -82,7 +82,7 @@ describe AgentBotListener do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url,
|
||||
hash_including(event: 'conversation_status_changed', changed_attributes: anything),
|
||||
hash_including(event: 'conversation_status_changed', account: account.webhook_data, changed_attributes: anything),
|
||||
:agent_bot_webhook,
|
||||
hash_including(secret: agent_bot.secret)
|
||||
).once
|
||||
@@ -158,6 +158,24 @@ describe AgentBotListener do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_resolved' do
|
||||
let(:event_name) { 'conversation.resolved' }
|
||||
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
context 'when agent bot is configured' do
|
||||
it 'sends account details in the conversation payload' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url,
|
||||
hash_including(event: 'conversation_resolved', account: account.webhook_data),
|
||||
:agent_bot_webhook,
|
||||
hash_including(secret: agent_bot.secret)
|
||||
).once
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webwidget_triggered' do
|
||||
let(:event_name) { 'webwidget.triggered' }
|
||||
|
||||
|
||||
@@ -101,6 +101,17 @@ describe WebhookListener do
|
||||
).once
|
||||
listener.conversation_created(conversation_created_event)
|
||||
end
|
||||
|
||||
it 'includes account details in the conversation payload' do
|
||||
webhook = create(:webhook, inbox: inbox, account: account)
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url,
|
||||
hash_including(account: account.webhook_data),
|
||||
:account_webhook,
|
||||
hash_including(secret: webhook.secret)
|
||||
).once
|
||||
listener.conversation_created(conversation_created_event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is an API Channel' do
|
||||
@@ -250,7 +261,7 @@ describe WebhookListener do
|
||||
|
||||
context 'when webhook is configured' do
|
||||
it 'triggers webhook' do
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
|
||||
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
|
||||
@@ -258,6 +269,17 @@ describe WebhookListener do
|
||||
).once
|
||||
listener.inbox_created(inbox_created_event)
|
||||
end
|
||||
|
||||
it 'includes account details in the inbox payload' do
|
||||
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url,
|
||||
hash_including(account: account.webhook_data),
|
||||
:account_webhook,
|
||||
hash_including(secret: webhook.secret)
|
||||
).once
|
||||
listener.inbox_created(inbox_created_event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -287,7 +309,7 @@ describe WebhookListener do
|
||||
it 'triggers webhook' do
|
||||
webhook = create(:webhook, account: account, subscriptions: ['inbox_updated'])
|
||||
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
|
||||
changed_attributes_data = [{ 'name' => { 'previous_value': 'Inbox 1', 'current_value': inbox.name } }]
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
|
||||
@@ -46,6 +46,10 @@ RSpec.describe Conversations::EventDataPresenter do
|
||||
end
|
||||
|
||||
describe '#webhook_data' do
|
||||
it 'includes account details for webhook consumers' do
|
||||
expect(presenter.webhook_data[:account]).to eq(conversation.account.webhook_data)
|
||||
end
|
||||
|
||||
it 'normalizes hard-break backslashes in message content' do
|
||||
message = create(:message, conversation: conversation, account: conversation.account,
|
||||
message_type: :outgoing, content: "Hello\\\nWorld")
|
||||
|
||||
@@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
|
||||
allow(phone_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
validation_service = instance_double(Whatsapp::TokenValidationService)
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, params[:waba_id]).and_return(validation_service)
|
||||
allow(validation_service).to receive(:perform)
|
||||
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenValidationService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:service) { described_class.new(access_token, waba_id) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when token has access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => [waba_id, 'another_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'validates successfully' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'some_other_scope',
|
||||
'target_ids' => ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error('No WABA scope found in token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user