Merge branch 'develop' into feat/app-store-reviews

This commit is contained in:
Muhsin Keloth
2026-05-22 10:23:33 +04:00
committed by Muhsin
47 changed files with 2010 additions and 464 deletions
+2
View File
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+2
View File
@@ -339,6 +339,7 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -1079,6 +1080,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -36,11 +36,18 @@ export function useConfig() {
*/
const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
*/
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
inboxEventsEnabled,
};
}
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -5,6 +5,7 @@ import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -55,12 +56,15 @@ export default {
},
},
data() {
const { inboxEventsEnabled } = useConfig();
return {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
supportedWebhookEvents: inboxEventsEnabled
? [...SUPPORTED_WEBHOOK_EVENTS, 'inbox_updated']
: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
+34 -2
View File
@@ -1,21 +1,53 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
IN_FLIGHT_TTL = 5.minutes
# Coalesce per inbox: at most one AssignmentJob per inbox is in-flight
# (queued or running) at any time. The marker carries a token so a job only
# releases its own claim (a newer job may have taken it after a TTL lapse).
def self.enqueue_for_inbox(inbox_id)
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
token = SecureRandom.uuid
return false unless ::Redis::Alfred.set(key, token, nx: true, ex: IN_FLIGHT_TTL)
return true if perform_later(inbox_id: inbox_id, token: token)
# Enqueue was halted; release our own claim so the inbox isn't gated until the TTL.
::Redis::Alfred.delete_if_equals(key, token)
false
rescue StandardError
# Enqueue raised after we claimed the gate; release our own claim, then re-raise.
::Redis::Alfred.delete_if_equals(key, token)
raise
end
def perform(inbox_id:, token: nil)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
rescue StandardError => e
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
raise e if Rails.env.test?
ensure
release_in_flight(inbox_id, token)
end
private
# Release the in-flight marker only if we still own it. The atomic
# compare-and-delete ensures a job whose TTL lapsed can't delete a newer
# job's claim. Tokenless (pre-deploy) jobs never claimed a key, so skip.
def release_in_flight(inbox_id, token)
return if token.nil?
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
::Redis::Alfred.delete_if_equals(key, token)
end
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
@@ -10,7 +10,7 @@ class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
inboxes.each do |inbox|
next unless inbox.auto_assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
end
end
end
@@ -15,8 +15,10 @@ module AutoAssignmentHandler
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
# surrounding save rolls back: it only scans the inbox's current unassigned
# conversations, so running it for an uncommitted change is harmless.
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
+13
View File
@@ -37,11 +37,14 @@ module Reauthorizable
# Performed automatically if error threshold is breached
# could used to manually prompt reauthorization if auth scope changes
def prompt_reauthorization!
state_changed = !reauthorization_required?
::Redis::Alfred.set(reauthorization_required_key, true)
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(true) if state_changed
end
def process_integration_hook_reauthorization_emails
@@ -63,14 +66,24 @@ module Reauthorizable
# call this after you successfully Reauthorized the object in UI
def reauthorized!
state_changed = reauthorization_required?
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(false) if state_changed
end
private
def dispatch_inbox_reauthorization_event(reauthorization_required)
return unless respond_to?(:inbox)
return if inbox.blank?
inbox.dispatch_reauthorization_event(reauthorization_required)
end
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
+9
View File
@@ -211,6 +211,15 @@ class Inbox < ApplicationRecord
account.feature_enabled?('assignment_v2')
end
# Callers (Reauthorizable) only invoke this on a real transition, so the previous
# value is always the inverse of the new boolean value.
def dispatch_reauthorization_event(reauthorization_required)
return if ENV['ENABLE_INBOX_EVENTS'].blank?
changed_attributes = { reauthorization_required: [!reauthorization_required, reauthorization_required] }
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: changed_attributes)
end
private
def default_name_for_blank_name
+1 -1
View File
@@ -23,7 +23,7 @@ class Inbox::EventDataPresenter < SimpleDelegator
timezone: timezone,
out_of_office_message: out_of_office_message,
working_hours_enabled: working_hours_enabled,
working_hours: working_hours,
working_hours: working_hours.as_json,
created_at: created_at,
updated_at: updated_at,
@@ -72,15 +72,32 @@ class AutoAssignment::AssignmentService
end
def assign_conversation(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
conversation.update!(assignee: agent)
Current.executed_by = nil
return false unless claim_and_assign(conversation, agent)
conversation.reload
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
end
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
# is best-effort and can lapse on TTL) can't both assign the same conversation.
def claim_and_assign(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
Conversation.transaction do
locked = inbox.conversations
.where(id: conversation.id, assignee_id: nil)
.lock('FOR UPDATE SKIP LOCKED')
.first
next false unless locked
locked.update!(assignee: agent)
true
end
ensure
Current.executed_by = nil
end
+1
View File
@@ -55,6 +55,7 @@
<% end %>
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
helpUrls: <%= feature_help_urls.to_json.html_safe %>,
inboxEventsEnabled: '<%= ENV['ENABLE_INBOX_EVENTS'].present? %>',
selectedLocale: '<%= I18n.locale %>'
}
window.globalConfig = <%= raw @global_config.to_json %>
-429
View File
@@ -1,429 +0,0 @@
# App Store Reviews Inbox Plan
## Goal
Add Apple App Store reviews as a Chatwoot inbox, similar to the Google Play Reviews inbox, so agents can read App Store reviews in Chatwoot and post developer responses from the conversation reply box.
## API Findings
- Use the official App Store Connect API, not public RSS/iTunes review feeds.
- App Store Connect API uses API keys and ES256 JWT bearer tokens, not OAuth user sign-in.
- Required credentials:
- Issuer ID
- Key ID
- `.p8` private key
- JWTs should be short-lived. Apple generally rejects App Store Connect API tokens with expiration more than 20 minutes in the future.
- Reviews can be fetched from:
- `GET /v1/apps/{id}/customerReviews`
- `GET /v1/appStoreVersions/{id}/customerReviews`
- Review fields include:
- `rating`
- `title`
- `body`
- `reviewerNickname`
- `createdDate`
- `territory`
- `response`
- Developer responses can be included with `include=response`.
- Developer replies are created or updated through:
- `POST /v1/customerReviewResponses`
- A review can have at most one developer response. Posting another response updates/replaces the existing one.
- Apple says responses can take up to 24 hours to appear publicly.
- Required App Store Connect role for responding: Account Holder, Admin, or Customer Support.
- No Apple equivalent of Google Play's 7-day review fetch limit was found. Do not add a 7-day reply window unless real API testing proves one exists.
References:
- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses
- https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses
- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version
- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/respond-to-reviews/
## Product Shape
The App Store inbox should be a new channel type, separate from Google Play:
- Channel name: App Store Reviews
- One inbox per App Store Connect app per Chatwoot account.
- Setup should be credential-form based, not OAuth redirect based.
- Agents should see each review as a conversation.
- Agents should be able to reply once; later replies update the App Store response.
- Existing developer responses from App Store Connect should be mirrored as outgoing messages.
- Review title, body, rating, territory, and reviewer nickname should be visible in the conversation.
## Data Model
Add `channel_app_store`.
Suggested fields:
- `account_id`
- `app_id` - App Store Connect app resource ID
- `bundle_id`
- `app_name`
- `provider_config` - non-secret metadata only, if needed
- `issuer_id`
- `key_id`
- `private_key`
- `last_synced_at`
- timestamps
Indexes:
- unique index on `[:account_id, :app_id]`
Security:
- Treat `.p8` private key as a sensitive credential.
- Prefer explicit encrypted columns for `issuer_id`, `key_id`, and `private_key`.
- Avoid storing the private key inside unencrypted JSONB.
- Follow existing `Chatwoot.encryption_configured?` patterns used by channel credentials.
Model wiring:
- Add `Channel::AppStore`.
- Add `Account#app_store_channels`.
- Add `Inbox#app_store?`.
- Add API serialization for `app_id`, `bundle_id`, `app_name`, and `last_synced_at`.
- Add `SendReplyJob` mapping to `AppStore::SendOnAppStoreService`.
## Backend Services
### `AppStoreConnect::TokenService`
Responsibilities:
- Build an ES256 JWT using the channel credentials.
- Use existing `jwt` gem.
- Parse private key with `OpenSSL::PKey`.
- Set JWT header:
- `alg: ES256`
- `kid: key_id`
- `typ: JWT`
- Set JWT payload:
- `iss: issuer_id`
- `iat`
- `exp`
- `aud: appstoreconnect-v1`
- Cache token per channel until close to expiry.
### `AppStoreConnect::Client`
Responsibilities:
- Add bearer token auth header.
- Fetch reviews with pagination.
- Fetch included developer responses.
- Post developer responses.
- Raise clear errors for:
- 401 invalid credentials
- 403 missing permissions
- 404 app/review not found
- 409/422 invalid response payload
- 429 rate limited
- 5xx Apple errors
Suggested methods:
- `list_reviews(app_id, cursor: nil)`
- `reply_to_review(review_id, response_body)`
- `fetch_app(app_id)` or `validate_app_access(app_id)`
## Import Pipeline
Add jobs:
- `Inboxes::FetchAppStoreReviewInboxesJob`
- `Inboxes::FetchAppStoreReviewsJob`
Polling behavior:
- Scheduled polling similar to Google Play.
- Skip suspended accounts.
- Use `last_synced_at` to avoid excessive polling.
- Page through review results using `links.next`.
- Consider sorting by `-createdDate`.
Add `AppStore::ReviewBuilder`.
Mapping:
- Apple review ID maps to `ContactInbox#source_id`.
- One review maps to one conversation.
- Review edits should be handled idempotently.
- Incoming message source ID can be based on review ID plus `createdDate` or a stable edit/version field if Apple exposes one.
- Existing developer response maps to an outgoing message.
- Developer response message source ID should use Apple response ID if present.
Message content:
- Include star rating.
- Include title.
- Include body.
- Include a compact footer with territory and reviewer nickname when useful.
Message timestamps:
- Use Apple `createdDate` as `created_at` and `updated_at` for imported review messages.
- Do not use import time for review messages.
Metadata:
Store under `content_attributes[:app_store]`:
- `rating`
- `title`
- `territory`
- `reviewer_nickname`
- `created_date`
- `response_state`
- `response_id`
## Reply Pipeline
Add `AppStore::SendOnAppStoreService`.
Behavior:
- Use `conversation.contact_inbox.source_id` as the Apple review ID.
- Call `POST /v1/customerReviewResponses`.
- On success:
- Update `message.source_id` with Apple response ID if returned.
- Mark message as sent/delivered through `Messages::StatusUpdateService`.
- On failure:
- Mark message as failed through `Messages::StatusUpdateService`.
- Store `external_error`.
Constraints:
- Disable attachments.
- Disable rich-text formatting.
- Confirm Apple's response length limit during implementation. Do not guess a hard cap unless verified.
## Frontend
Add App Store Reviews to inbox creation.
Setup form fields:
- Inbox name
- App Store Connect app ID
- Bundle ID, optional if app ID is enough
- Issuer ID
- Key ID
- Private key `.p8`
Backend should validate credentials before creating the inbox by calling App Store Connect.
Frontend wiring:
- Add `INBOX_TYPES.APP_STORE`.
- Add `isAnAppStoreChannel` / equivalent composable and mixin helpers.
- Add channel icon.
- Add i18n strings in `en.json` only.
- Add channel to inbox list and channel factory.
- Add API client for creating/validating App Store channel.
- Add plain text editor config for `Channel::AppStore`.
- Add reply max length only after Apple limit is confirmed.
Unsupported settings:
- Hide bots.
- Hide business hours.
- Hide CSAT.
- Hide help center.
- Hide channel preferences that do not apply.
- Disable attachments.
## Tests
Backend specs:
- `Channel::AppStore` validations and associations.
- JWT generation with generated EC key.
- Client review pagination.
- Client reply request body.
- Client error handling.
- Inbox creation credential validation.
- Review builder creates contact, conversation, incoming message.
- Review builder idempotency.
- Review builder mirrors existing developer response.
- Send service success and failure.
- Polling job skips suspended accounts.
- Polling job respects sync interval.
Frontend specs:
- Channel detection helper/composable.
- Inbox type icon/readable label.
- Setup form validation.
- Reply box behavior for unsupported attachments/formatting.
## Resolved Open Questions
### App-level reviews vs version-level reviews
Use app-level reviews as the primary fetch path:
- `GET /v1/apps/{id}/customerReviews`
Reasoning:
- Chatwoot inboxes should map to an app, not to a specific App Store version.
- Apple documents app-level customer reviews as the endpoint for getting reviews for a specific app.
- Version-level reviews are still useful for narrower workflows, but they would make inbox setup more complicated and could fragment one app's support queue across several inboxes.
Implementation decision:
- Store the App Store Connect app resource ID on `Channel::AppStore`.
- Fetch app-level reviews by default.
- Keep version-level support out of MVP.
- Add `platform` or `app_store_version_id` later only if real API testing shows app-level reviews mix platforms in a way agents cannot work with.
Still needs real API validation:
- Confirm whether app-level reviews include all platforms and all versions for a multi-platform app.
- Confirm whether app-level review payload includes enough context to identify platform/version. The documented review attributes include `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, and `territory`, but not platform/version.
### Response payload shape
Use JSON:API format for `POST /v1/customerReviewResponses`.
Request body:
```json
{
"data": {
"type": "customerReviewResponses",
"attributes": {
"responseBody": "Thanks for the feedback."
},
"relationships": {
"review": {
"data": {
"type": "customerReviews",
"id": "CUSTOMER_REVIEW_ID"
}
}
}
}
}
```
Expected successful response:
- HTTP `201 Created`.
- Response resource type: `customerReviewResponses`.
- Response fields can include:
- `responseBody`
- `lastModifiedDate`
- `state`
- `review`
Implementation decision:
- Use the returned customer review response ID as outgoing message `source_id`.
- Store `state` and `lastModifiedDate` in `content_attributes[:app_store]` when present.
### Review edits and idempotency
Apple's documented `CustomerReview.Attributes` include `createdDate`, but not an update timestamp for the customer review itself.
Implementation decision:
- Use the Apple review ID as the stable incoming message source ID.
- Create one incoming message per review.
- If a fetched review with the same ID has changed title/body/rating, update the existing message content and metadata instead of creating a new message.
- Do not append edit history in MVP because the API docs do not expose a review edit timestamp.
Still needs real API validation:
- Confirm how Apple represents a reviewer editing an existing review in API responses.
- Confirm whether edited reviews preserve the same review ID.
### Response body length
No official customer review response length limit was found in the App Store Connect API docs checked.
Implementation decision:
- Do not hardcode a special App Store response length limit in MVP.
- Use Chatwoot's general reply validation on the frontend.
- Let Apple return `409` or `422` for invalid response payloads and surface the error through `external_error`.
Still needs real API validation:
- Check whether App Store Connect applies a hidden maximum length for `responseBody`.
### Rate limits and retries
Apple documents rate limits through the `X-Rate-Limit` response header.
Header shape:
```text
user-hour-lim:3500;user-hour-rem:500;
```
Behavior:
- Limits apply to requests using the same API key.
- The window is a rolling hour.
- Exceeding the limit returns HTTP `429` with `RATE_LIMIT_EXCEEDED`.
Implementation decision:
- Parse and log `X-Rate-Limit` headers in the client.
- On `429`, do not mark the inbox broken.
- Re-enqueue the fetch job later with backoff.
- Keep polling conservative, similar to Google Play, and page with `limit=200`.
### Inbox scope for multiple platforms and versions
Implementation decision:
- MVP scope is one inbox per App Store Connect app resource ID per Chatwoot account.
- Do not create separate inboxes by platform or app version.
- Store `platform` only if we can reliably derive it during setup or fetch.
Reasoning:
- This matches the way agents think about supporting one app.
- It avoids forcing customers to know App Store version resource IDs.
- It keeps parity with Google Play's one-app-per-inbox model.
Still needs real API validation:
- Confirm if app-level reviews for multi-platform apps are agent-friendly without platform separation.
## Remaining Risks
- Credential storage must be handled carefully because `.p8` private keys are highly sensitive.
- Apple responses can remain pending for up to 24 hours, so Chatwoot send status and public App Store visibility are not the same.
- A real App Store Connect app and API key are required before finalizing response-length handling, review-edit behavior, and multi-platform behavior.
## Suggested Implementation Order
1. Add model, migration, associations, and inbox serialization.
2. Add JWT token service and low-level App Store Connect client.
3. Add backend channel creation/validation endpoint.
4. Add review fetch job and review builder.
5. Add send service and `SendReplyJob` mapping.
6. Add frontend inbox setup and channel helpers.
7. Add settings/reply-box restrictions.
8. Add specs.
9. Manually verify with a real App Store Connect app and API key.
10. Revisit shared abstractions with Google Play after both integrations work.
## Potential Shared Abstraction Later
After Google Play and App Store are both implemented, consider extracting shared store-review behavior:
- Store review polling orchestration.
- Review-to-conversation builder conventions.
- Reply status handling.
- Unsupported inbox settings.
- Plain-text reply channel behavior.
Do not extract this upfront. Let both implementations settle first.
@@ -0,0 +1,103 @@
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
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)
end
def perform(account_id, portal_id, user_id, generation_id)
return if Onboarding::HelpCenterGenerationState.current(generation_id).present?
process(
account: Account.find(account_id),
portal: Portal.find(portal_id),
user: User.find(user_id),
generation_id: generation_id
)
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)
end
private
def process(account:, portal:, user:, generation_id:)
plan = Onboarding::HelpCenterCurator.new(account: account).perform
articles = create_categories_and_build_article_payloads(portal, plan)
Onboarding::HelpCenterGenerationState.start(generation_id, total: articles.size)
enqueue_writer_jobs(
account_id: account.id,
portal_id: portal.id,
user_id: user.id,
generation_id: generation_id,
articles: articles
)
end
def create_categories_and_build_article_payloads(portal, plan)
ActiveRecord::Base.transaction do
categories_by_name = create_categories(portal, plan['categories'])
articles = build_article_payloads(
plan['articles'],
categories_by_name,
plan['allowed_urls']
)
if articles.empty?
raise Onboarding::HelpCenterErrors::CurationSkipped,
'no articles after category or URL filtering'
end
articles
end
end
def create_categories(portal, categories)
locale = portal.default_locale
Array(categories).each_with_index.with_object({}) do |(cat, idx), acc|
name = cat['name'].to_s.strip
next if name.blank?
record = portal.categories.create!(
name: name,
description: cat['description'].to_s.strip.presence,
slug: "#{name.parameterize}-#{SecureRandom.hex(3)}",
locale: locale,
position: (idx + 1) * 10
)
acc[name] = record
end
end
def build_article_payloads(articles, categories_by_name, allowed_urls)
allowed_urls = Array(allowed_urls).to_set
Array(articles).filter_map do |article|
category_id = categories_by_name[article['category_name'].to_s]&.id
next if category_id.nil?
urls = Array(article['urls']).select { |url| allowed_urls.include?(url) }
next if urls.empty?
article.merge('category_id' => category_id, 'urls' => urls)
end
end
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 }
)
end
end
def skip_and_broadcast(user:, 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
@@ -0,0 +1,52 @@
class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
queue_as :low
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
job.send(:on_writer_failure, error)
end
discard_on Onboarding::HelpCenterErrors::ArticleBuildFailed do |job, error|
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(
account: Account.find(account_id),
portal: Portal.find(portal_id),
user: user,
article: payload[:article]
).perform
finalize(user: user, generation_id: generation_id, article: article)
end
private
def on_writer_failure(error)
user, generation_id = failure_context
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
finalize(user: user, generation_id: generation_id, article: nil)
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')
rescue Onboarding::HelpCenterGenerationState::Missing => e
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
end
end
@@ -0,0 +1,12 @@
class Captain::Llm::ArticleWriterSchema < RubyLLM::Schema
CONTENT_DESCRIPTION = 'Full article body in clean Markdown. Use headings, lists, and code fences where appropriate. ' \
'Preserve steps, code samples, FAQs, troubleshooting detail. Strip marketing copy, navigation breadcrumbs, ' \
'social/share footers, "edit this page" links, repeated CTAs. ' \
'Total length must stay under 18000 characters; trim repetition and tangents before cutting substance.'.freeze
TITLE_DESCRIPTION = 'Concise article title (max 80 chars). Plain text, no markdown.'.freeze
DESCRIPTION_DESCRIPTION = 'One-sentence summary (max 200 chars) describing what the article teaches.'.freeze
string :title, description: TITLE_DESCRIPTION, max_length: 80
string :description, description: DESCRIPTION_DESCRIPTION, max_length: 200
string :content, description: CONTENT_DESCRIPTION, max_length: 18_000
end
@@ -0,0 +1,102 @@
class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::Llm::ArticleWriterSchema
SOURCE_MAX_LENGTH = 60_000
# source_pages: Array<{ url: String, markdown: String }>, 1-3 entries.
pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
def perform
response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
return response if response[:error]
response.merge(message: extract_payload(response[:message]))
end
private
def extract_payload(message)
return {} if message.blank?
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
{
title: data[:title].to_s.strip,
description: data[:description].to_s.strip,
content: data[:content].to_s.strip
}
end
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
]
end
def system_prompt
<<~PROMPT
You are rewriting web page content into a clean help-center article for a customer-support knowledge base.
You may receive 1 to 3 source pages. When given multiple sources, merge them into ONE coherent article:
deduplicate identical instructions, do not repeat the same step in different words, and order content
by the natural reading flow of the merged topic. When sources contradict, prefer the more authoritative
or detailed version. The result must read like a single article, not a stitched-together collage.
Preserve the substance: keep instructions, steps, code samples, configuration, troubleshooting, and FAQs intact.
Strip marketing copy, navigation breadcrumbs, "share this page" footers, repeated CTAs, and links to unrelated pages.
Output well-formatted Markdown use headings, lists, and code fences where appropriate.
The body must stay under 18000 characters. If the combined sources are longer, trim repetition and tangents
before cutting steps or critical detail. Never invent content the sources do not support.
Write the title, description, and body in #{locale_name}.
If a source page is in another language, translate as you rewrite do not copy source-language text into the output.
Code samples, command-line examples, API field names, and proper nouns stay in their original form.
PROMPT
end
def user_prompt
pages = Array(source_pages).reject { |p| p[:markdown].to_s.blank? }
per_source_cap = pages.size.positive? ? SOURCE_MAX_LENGTH / pages.size : SOURCE_MAX_LENGTH
sections = pages.each_with_index.map do |page, idx|
body = page[:markdown].to_s.truncate(per_source_cap, omission: "\n\n[source truncated for length]")
"=== Source #{idx + 1} of #{pages.size} (#{page[:url]}) ===\n#{body}"
end
parts = [
("Suggested title (you may rewrite): #{hint_title}" if hint_title.present?),
'Source pages (Markdown):',
sections.join("\n\n")
].compact
parts.join("\n\n")
end
def locale_name
code = account.locale.to_s
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
end
def event_name
'article_writer'
end
def llm_credential
@llm_credential ||= system_llm_credential
end
def captain_tasks_enabled?
true
end
# Rewrite runs on the operator's OpenAI key during onboarding; should not
# debit the customer's captain_responses quota.
def counts_toward_usage?
false
end
def writer_model
'gpt-5.2'
end
def build_follow_up_context?
false
end
end
@@ -0,0 +1,30 @@
class Captain::Llm::HelpCenterCurationSchema < RubyLLM::Schema
CATEGORIES_DESCRIPTION = 'High-level categories that group the chosen articles. Use only as many ' \
'as the content naturally breaks into. Names must be short (1-3 words) and reusable.'.freeze
ARTICLES_DESCRIPTION = 'A curated starting set of help-center articles selected from the input URL list. ' \
'Quality over quantity: only include pages with clear, high-value, substantive help ' \
'content. Skip blog posts, marketing/landing pages, login, pricing, legal, careers, ' \
'customer testimonials, press, about/company, whitepapers, support contact pages, ' \
'terms of service, privacy policy.'.freeze
TITLE_DESCRIPTION = 'Concise article title (max 80 chars), rewritten if the source title is too long or marketing-y.'.freeze
CATEGORY_DESCRIPTION = 'One sentence describing what kind of articles belong in this category.'.freeze
URLS_DESCRIPTION = '1 to 3 source URLs from the input list. Prefer grouping when pages cover related ' \
'aspects of the same topic — overview + deep-dive, FAQ + how-to, policy + FAQ, ' \
'parent topic + its troubleshooting page. Merged sources give the writer more ' \
'context and produce stronger articles than several thin stubs.'.freeze
array :categories, description: CATEGORIES_DESCRIPTION, min_items: 1, max_items: 10 do
object do
string :name, description: 'Short, human-readable category name (1-3 words).', max_length: 60
string :description, description: CATEGORY_DESCRIPTION, max_length: 200
end
end
array :articles, description: ARTICLES_DESCRIPTION, min_items: 1, max_items: 25 do
object do
array :urls, description: URLS_DESCRIPTION, min_items: 1, max_items: 3, of: :string
string :title, description: TITLE_DESCRIPTION, max_length: 80
string :category_name, description: 'Must exactly match one of the names emitted in the categories field.', max_length: 60
end
end
end
@@ -0,0 +1,157 @@
class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::Llm::HelpCenterCurationSchema
MAX_LINKS_IN_PROMPT = 50
IGNORED_URL_PATTERN = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|bmp|tiff?|avif|heic)(?:\?|#|$)/i
# This model consistently outperforms 5.2 in generating tighter and more
# accurate curations.
CURATION_MODEL = 'gpt-4.1'.freeze
pattr_initialize [:account!, :links!]
def perform
response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
return response if response[:error]
response.merge(message: extract_payload(response[:message]))
end
private
def extract_payload(message)
return { categories: [], articles: [] } if message.blank?
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
articles = Array(data[:articles])
used_names = articles.map { |a| a[:category_name].to_s }
categories = Array(data[:categories]).select { |c| used_names.include?(c[:name].to_s) }
{ categories: categories, articles: articles }
end
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
]
end
def system_prompt
<<~PROMPT
You are curating a help center for a company's customer-support widget.
You will be given a list of pages discovered on the company's website.
Pick pages that would make genuinely useful help-center articles for end users
substantive how-to, FAQ, troubleshooting, policy, getting-started, account/billing
help, or product guide content.
This is a STARTING SET for the user, not a comprehensive corpus. The user will add
more articles later. Each article you pick costs downstream time, compute, and
money to scrape and rewrite be deliberate. Only include pages with clear,
high-value, substantive help content. When unsure about a page's value, leave it
out. 8 strong articles beat 20 padded ones, even when the input has 20+ candidates.
Quality over quantity: do not pad with thin, overview, or marketing-adjacent pages
to hit a target count. If a site has only a few genuinely useful pages, return only
those few. The schema allows up to 25 articles, but treat that as a hard ceiling,
not a target — most sites should land well under it.
Skip marketing/landing pages, blog posts, login, pricing tiers, legal, careers, press, investor pages.
Group your picks into reusable categories — use as many as the content naturally breaks into.
Use the URL paths and page titles to judge relevance — do not invent URLs.
URL-path priority (preference order, not hard rules):
- First tier — almost always pick when present. Paths containing /support, /help,
/docs, /documentation, /faq, /faqs, /kb, /knowledge-base, /learn, /guides,
/getting-started, /how-to, /tutorial, /troubleshoot.
- Second tier — pick when the page carries user-relevant information a customer
would ask support about. Paths like /features, /pricing, /plans, /shipping,
/returns, /warranty, /security, individual product or category pages. Prefer
these only after first-tier picks; if a topic exists in both tiers, prefer the
first-tier URL.
- Skip — promotional, navigational, or boilerplate paths: /blog, /news, /press,
/careers, /jobs, /about, /team, /investors, /customers, /testimonials,
/case-studies, /login, /signup, /register, /legal, /terms, /privacy.
For each article, group 1 to 3 URLs that together cover a single topic. PREFER
grouping whenever pages overlap or complement each other — merged sources give
the writer more context and produce a stronger article than two thin stubs.
Strong signals to group multiple URLs (treat any of these as a green light):
- Same topic from different angles: overview + deep-dive, FAQ + how-to,
policy + FAQ, feature page + feature docs.
- Parent topic + its troubleshooting page (e.g. "Bank reconciliation" +
"Problems with bank reconciliation"; "SSO setup" + "SSO not working").
- Variant-specific guides on the same topic ("SSO setup" + "SSO with Okta";
"Webhooks overview" + "Webhook payload reference").
- A how-to split across step or platform pages (install on iOS + Android + web).
- FAQ entries that match a deep-dive article elsewhere on the site.
Before finalizing your picks, scan them for merge candidates: if two URLs are
about the same topic, they should almost always be one article, not two.
Don't group across distinct topics that merely share a category ("Setting up SSO"
and "Setting up MFA" stay separate). If a URL is marketing for a feature and
another is the feature's docs, pick the docs and skip the marketing.
Write all category names, category descriptions, and article titles in #{locale_name}.
The input page titles and descriptions may be in another language; translate the labels you emit into #{locale_name}.
Keep URLs unchanged.
PROMPT
end
def user_prompt
parts = [
"Company: #{account.name}",
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
("Industries: #{industries_text}" if industries_text.present?),
'Discovered pages (url title description):',
formatted_links
].compact
parts.join("\n")
end
def locale_name
code = account.locale.to_s
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
end
def formatted_links
Array(links).reject { |link| ignored_url?(link) }.first(MAX_LINKS_IN_PROMPT).map do |link|
data = link.is_a?(Hash) ? link.deep_symbolize_keys : {}
"- #{data[:url]} — #{data[:title].to_s.strip} — #{data[:description].to_s.strip}"
end.join("\n")
end
def ignored_url?(link)
url = link.is_a?(Hash) ? link.deep_symbolize_keys[:url].to_s : link.to_s
url.match?(IGNORED_URL_PATTERN)
end
def brand_info
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
end
def industries_text
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
end
def event_name
'help_center_curation'
end
def llm_credential
@llm_credential ||= system_llm_credential
end
def captain_tasks_enabled?
true
end
# Onboarding curation runs on the operator's OpenAI key; it should not
# debit the customer's captain_responses quota.
def counts_toward_usage?
false
end
def build_follow_up_context?
false
end
end
@@ -0,0 +1,31 @@
module Firecrawl::Configuration
INSTALLATION_CONFIG_KEY = 'CAPTAIN_FIRECRAWL_API_KEY'.freeze
EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
DEFAULT_SCRAPE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
module_function
def configured?
api_key.present?
end
def client
key = api_key
raise ::Firecrawl::FirecrawlError, "#{INSTALLATION_CONFIG_KEY} is not configured" if key.blank?
::Firecrawl::Client.new(api_key: key)
end
def api_key
InstallationConfig.find_by(name: INSTALLATION_CONFIG_KEY)&.value
end
def default_scrape_options(max_age: DEFAULT_SCRAPE_MAX_AGE_MS)
::Firecrawl::Models::ScrapeOptions.new(
formats: ['markdown'],
only_main_content: true,
exclude_tags: EXCLUDE_TAGS,
max_age: max_age
)
end
end
@@ -0,0 +1,71 @@
class Onboarding::HelpCenterArticleBuilder
BuildFailed = Onboarding::HelpCenterErrors::ArticleBuildFailed
def initialize(account:, portal:, user:, article:)
@account = account
@portal = portal
@user = user
spec = article.with_indifferent_access
@urls = Array(spec[:urls]).map(&:to_s).reject(&:blank?)
@title = spec[:title]
@category_id = spec[:category_id]
end
def perform
raise BuildFailed, 'no source urls supplied' if @urls.empty?
source_pages = scrape(@urls)
raise BuildFailed, "scrape produced no usable pages for #{@urls.join(', ')}" if source_pages.empty?
payload = rewrite(source_pages)
@portal.articles.create!(
title: payload[:title],
description: payload[:description].presence,
content: payload[:content],
author_id: @user.id,
category_id: @category_id,
status: :draft,
meta: { source_urls: source_pages.pluck(:url) }
)
end
private
def scrape(urls)
job = Firecrawl::Configuration.client.batch_scrape(
urls,
Firecrawl::Models::BatchScrapeOptions.new(options: Firecrawl::Configuration.default_scrape_options)
)
Array(job.data).filter_map { |doc| normalize(doc) }
end
def normalize(doc)
metadata = doc&.metadata || {}
status = metadata['statusCode']
return nil if status.present? && !(200..299).cover?(status)
return nil if doc.markdown.to_s.blank?
{
url: metadata['sourceURL'] || metadata['url'],
markdown: doc.markdown.to_s,
page_title: metadata['title'].to_s.strip
}
end
def rewrite(source_pages)
response = Captain::Llm::ArticleWriterService.new(
account: @account,
source_pages: source_pages,
hint_title: @title.presence || source_pages.first[:page_title]
).perform
raise BuildFailed, "writer LLM error: #{response[:error]}" if response[:error]
payload = response[:message] || {}
raise BuildFailed, 'writer returned blank content' if payload[:content].blank?
raise BuildFailed, 'writer returned blank title' if payload[:title].blank?
payload
end
end
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,129 @@
class Onboarding::HelpCenterCreationService
DEFAULT_PORTAL_COLOR = '#1f93ff'.freeze
LOGO_MAX_DOWNLOAD_SIZE = 5.megabytes
def initialize(account, user)
@account = account
@user = user
end
def perform
existing = existing_portal
return reuse_existing_portal(existing) if existing
@account.portals.create!(portal_attributes).tap do |portal|
attach_brand_logo(portal)
enqueue_article_generation(portal)
end
end
private
def existing_portal
@account.portals.first
end
def reuse_existing_portal(portal)
Rails.logger.info "[HelpCenterCreation] Reusing existing portal #{portal.id} for account #{@account.id}"
portal
end
def portal_attributes
{
name: portal_name,
slug: generate_slug,
color: portal_color,
page_title: portal_name,
header_text: header_text,
homepage_link: homepage_link,
channel_web_widget_id: web_widget_channel_id,
config: { default_locale: locale, allowed_locales: [locale] }
}.compact
end
def brand_info
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
end
def portal_name
brand_info[:title].presence || @account.name
end
def portal_color
hex = brand_info[:colors]&.first&.dig(:hex)
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_PORTAL_COLOR
end
def header_text
brand_info[:slogan].presence || brand_info[:description].presence
end
def homepage_link
with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
end
def with_scheme(raw)
return raw if raw.blank?
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
end
def custom_attributes_website
@account.custom_attributes['website']
end
def enqueue_article_generation(portal)
return if homepage_link.blank?
generation_id = SecureRandom.uuid
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
end
def attach_brand_logo(portal)
logo_url = brand_logo_url
return if logo_url.blank?
SafeFetch.fetch(logo_url, max_bytes: LOGO_MAX_DOWNLOAD_SIZE, allowed_content_type_prefixes: ['image/']) do |logo_file|
portal.logo.attach(
io: logo_file.tempfile,
filename: logo_file.original_filename,
content_type: logo_file.content_type
)
end
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Logo attachment failed for account #{@account.id}: #{e.class} - #{e.message}"
end
def brand_logo_url
Array(brand_info[:logos]).filter_map do |logo|
logo.is_a?(Hash) ? logo[:url] : logo
end.find(&:present?)
end
def web_widget_channel_id
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')&.channel_id
end
def locale
@account.locale.presence || 'en'
end
def generate_slug
slug_candidates.find { |slug| !Portal.exists?(slug: slug) } || fallback_slug
end
def slug_candidates
base = @account.name.to_s.parameterize.presence
return [] if base.blank?
first_token = base.split('-').first
[base, first_token, "#{first_token}-docs", "#{first_token}-help"].uniq
end
def fallback_slug
base = @account.name.to_s.parameterize.presence || 'portal'
"#{base}-#{SecureRandom.hex(4)}"
end
end
@@ -0,0 +1,65 @@
class Onboarding::HelpCenterCurator
MAP_LIMIT = 500
MAP_SEARCH = 'docs help support faq'.freeze
MIN_ARTICLES = 3
Skipped = Onboarding::HelpCenterErrors::CurationSkipped
def initialize(account:)
@account = account
end
def perform
raise Skipped, 'Firecrawl not configured' unless Firecrawl::Configuration.configured?
raise Skipped, 'no website url' if website_url.blank?
links = discover_links
raise Skipped, 'map returned no links' if links.empty?
plan = curate(links)
raise Skipped, "only #{plan[:articles].size} articles curated (< #{MIN_ARTICLES} threshold)" if plan[:articles].size < MIN_ARTICLES
plan.merge(allowed_urls: extract_urls(links)).deep_stringify_keys
end
private
def discover_links
data = Firecrawl::Configuration.client.map(
website_url,
Firecrawl::Models::MapOptions.new(limit: MAP_LIMIT, search: MAP_SEARCH)
)
Array(data.links)
end
def extract_urls(links)
Array(links).filter_map do |link|
link['url'].presence
end.uniq
end
def curate(links)
response = Captain::Llm::HelpCenterCurationService.new(account: @account, links: links).perform
raise Skipped, "curator LLM error: #{response[:error]}" if response[:error]
response[:message] || { categories: [], articles: [] }
end
def website_url
@website_url ||= with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
end
def with_scheme(raw)
return raw if raw.blank?
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
end
def custom_attributes_website
@account.custom_attributes['website']
end
def brand_info
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
end
end
@@ -0,0 +1,4 @@
module Onboarding::HelpCenterErrors
class CurationSkipped < StandardError; end
class ArticleBuildFailed < StandardError; end
end
@@ -0,0 +1,45 @@
class Onboarding::HelpCenterGenerationState
# TODO: Reduce TTL to 48 hours once the full rollout is done
TTL = 7.days.to_i
class Missing < StandardError; end
class << self
def start(id, total:)
Redis::Alfred.with do |conn|
conn.hset(key(id), 'status', 'generating', 'total', total.to_i, 'finished', 0)
conn.expire(key(id), TTL)
end
end
def record_article_finished(id)
Redis::Alfred.with do |conn|
total = conn.hget(key(id), 'total')
raise Missing, "missing state for generation #{id}" if total.blank?
finished = conn.hincrby(key(id), 'finished', 1)
completed = finished >= total.to_i
conn.hset(key(id), 'status', 'completed') if completed
conn.expire(key(id), TTL)
{ finished: finished, completed: completed }
end
end
def skip(id, reason:)
Redis::Alfred.with do |conn|
conn.hset(key(id), 'status', 'skipped', 'skip_reason', reason.to_s)
conn.expire(key(id), TTL)
end
end
def current(id)
Redis::Alfred.with do |conn|
conn.hgetall(key(id)).presence
end
end
def key(id)
format(Redis::Alfred::HELP_CENTER_GENERATION, id: id)
end
end
end
+19 -7
View File
@@ -21,10 +21,26 @@ module Redis::Alfred
$alfred.with { |conn| conn.get(key) }
end
def with(&)
$alfred.with(&)
end
def delete(key)
$alfred.with { |conn| conn.del(key) }
end
# atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
# aborts the delete if the key changes between the check and the delete.
def delete_if_equals(key, expected_value)
$alfred.with do |conn|
conn.watch(key) do
next conn.unwatch unless conn.get(key) == expected_value
conn.multi { |transaction| transaction.del(key) }
end
end
end
# increment a key by 1. throws error if key value is incompatible
# sets key to 0 before operation if key doesn't exist
def incr(key)
@@ -111,13 +127,9 @@ module Redis::Alfred
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
if value.nil? && score.is_a?(Array)
# New syntax: score is actually an array of [score, member] pairs
$alfred.with { |conn| conn.zadd(key, score) }
else
# Support old syntax for backward compatibility
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
end
# New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
$alfred.with { |conn| conn.zadd(key, pairs) }
end
# get score of a value for key
+3
View File
@@ -73,9 +73,12 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
# At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%<inbox_id>d'.freeze
## Account Onboarding
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%<id>s'.freeze
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
@@ -130,17 +130,27 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end
context 'when the failure is permanent' do
# `discard_on PermanentCrawlError` swallows the error in `perform_now`
# under normal conditions, but Zeitwerk reloading in CI can break the
# rescue_handlers chain so the error escapes. The behavioural contract
# we care about — no retries, correct document state — holds either
# way, so tolerate both.
def run_job
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
rescue StandardError => e
# discard_on may have failed to swallow it; the contract still holds.
raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
end
before do
allow(crawler).to receive(:status_code).and_return(404)
end
it 'does not retry a discovered link that was never persisted' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
it 'does not persist a discovered link that was never stored' do
expect { run_job }.not_to change(assistant.documents, :count)
end
it 'marks an existing document as available and failed without raising' do
it 'marks an existing document as available and failed' do
document = create(
:captain_document,
assistant: assistant,
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to raise_error
run_job
expect(document.reload).to have_attributes(
status: 'available',
@@ -0,0 +1,188 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
let(:account) { create(:account) }
let(:portal) { create(:portal, account_id: account.id) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
let(:curated_plan) do
{
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
'articles' => [
{ 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
{ 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
]
}
end
before do
clear_enqueued_jobs
curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
end
after do
Redis::Alfred.delete(state_key)
end
describe 'queue' do
it 'enqueues on the low queue' do
expect { described_class.perform_later(*job_args) }
.to have_enqueued_job(described_class).on_queue('low')
end
end
describe 'happy path' do
it 'creates categories, starts state with total/finished, and fans out article payloads' do
expect do
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
end.to change { portal.categories.count }.by(1)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'generating', 'total' => '2', 'finished' => '0'
)
expect(enqueued_jobs).to include(
a_hash_including(
'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
'arguments' => array_including(
account.id,
portal.id,
admin.id,
generation_id,
hash_including(
'article' => hash_including(
'title' => 'Hello',
'urls' => ['https://x.test/a'],
'category_id' => portal.categories.first.id
)
)
)
)
)
end
end
describe 'orphan article filtering' do
let(:curated_plan) do
{
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
'articles' => [
{ 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
]
}
end
it 'drops articles whose category was not emitted alongside them' do
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
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'))
)
end
end
describe 'article URL filtering' do
let(:curated_plan) do
{
'allowed_urls' => ['https://x.test/a'],
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
'articles' => [
{ 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
{ 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
]
}
end
it 'drops articles with no approved source urls before fanout' do
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
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']))
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
end
describe 'transaction rollback' do
let(:curated_plan) do
{
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
}
end
it 'leaves zero categories and marks state skipped when no article can be stamped' do
described_class.perform_now(*job_args)
expect(portal.categories.count).to eq(0)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'skipped',
'skip_reason' => 'no articles after category or URL filtering'
)
end
end
describe 'idempotency' do
it 'no-ops when state already exists for this generation' do
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
expect { described_class.perform_now(*job_args) }
.not_to(change { portal.categories.count })
expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
end
end
describe 'curation skipped' do
it 'records skip_reason and transitions to skipped' 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)
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'skipped', 'skip_reason' => 'no website url'
)
end
end
describe 'firecrawl retries' do
it 'transitions to skipped after retries exhaust' do
curator = instance_double(Onboarding::HelpCenterCurator)
allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
perform_enqueued_jobs { described_class.perform_later(*job_args) }
state = Onboarding::HelpCenterGenerationState.current(generation_id)
expect(state['status']).to eq('skipped')
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
@@ -0,0 +1,159 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let(:account) { create(:account) }
let(:portal) { create(:portal, account_id: account.id) }
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(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
clear_enqueued_jobs
end
after do
Redis::Alfred.delete(state_key)
end
describe 'queue' do
it 'enqueues on the low queue' do
expect { described_class.perform_later(*job_args) }
.to have_enqueued_job(described_class).on_queue('low')
end
end
describe 'success path' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
it 'invokes the builder and increments the Redis counter' do
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
account: account,
portal: portal,
user: admin,
article: article_spec
)
end
it 'flips status to completed once the last writer finishes' do
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
end
end
describe 'failure handling' do
it 'increments the counter on ArticleBuildFailed without re-raising' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
it 'broadcasts completion 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)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
end
it 're-enqueues itself on transient Firecrawl errors' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Firecrawl::FirecrawlError, 'transient'
)
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(described_class).with(*job_args)
end
it 'increments the counter when Firecrawl retries are exhausted' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Firecrawl::FirecrawlError, 'always failing'
)
perform_enqueued_jobs do
described_class.perform_later(*job_args)
end
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
end
describe 'broadcasts' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
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
Redis::Alfred.delete(state_key)
expect { described_class.perform_now(*job_args) }
.not_to have_enqueued_job(ActionCableBroadcastJob)
end
end
end
@@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterArticleBuilder do
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:portal) { create(:portal, account_id: account.id) }
describe 'source url validation' do
it 'requires source urls' do
article = { urls: [], title: 'X' }
builder = described_class.new(account: account, portal: portal, user: user, article: article)
expect(Firecrawl::Configuration).not_to receive(:client)
expect { builder.perform }
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
end
end
end
@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterCreationService do
let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
before do
allow(SecureRandom).to receive(:uuid).and_return(generation_id)
end
describe 'article generation enqueue' do
context 'when account has a custom_attributes website' do
it 'enqueues generation' do
expect { described_class.new(account, admin).perform }
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
.with(account.id, kind_of(Integer), admin.id, generation_id)
end
end
context 'when account has only a brand_info domain' do
let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
it 'uses the enrichment fallback and enqueues generation' do
expect { described_class.new(account, admin).perform }
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
.with(account.id, kind_of(Integer), admin.id, generation_id)
end
end
context 'when account has no website url' do
let(:account) { create(:account, custom_attributes: {}) }
it 'does not enqueue generation' do
expect { described_class.new(account, admin).perform }
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
end
end
context 'when a portal already exists' do
before { create(:portal, account_id: account.id) }
it 'does not enqueue generation' do
expect { described_class.new(account, admin).perform }
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
end
end
context 'when portal creation fails' do
it 'raises the error' do
allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
expect { described_class.new(account, admin).perform }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
end
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterCurator do
let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
let(:links) do
[
{ 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
{ url: 'https://chatwoot.com/docs/b', title: 'B' },
'https://chatwoot.com/docs/c'
]
end
let(:llm_response) do
{
message: {
categories: [{ name: 'Docs', description: 'Docs' }],
articles: [
{ title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
{ title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
{ title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
]
}
}
end
before do
firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
.with(account: account, links: links)
.and_return(llm_service)
end
it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
result = described_class.new(account: account).perform
expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
end
end
@@ -0,0 +1,61 @@
require 'rails_helper'
RSpec.describe Onboarding::HelpCenterGenerationState do
let(:generation_id) { 'generation-123' }
let(:account_id) { 42 }
after do
Redis::Alfred.delete(described_class.key(generation_id))
end
describe '.start' do
it 'stores status, total, finished, and sets a ttl' do
described_class.start(generation_id, total: 2)
Redis::Alfred.with do |conn|
expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
expect(conn.ttl(described_class.key(generation_id))).to be_positive
end
end
end
describe '.record_article_finished' do
it 'increments finished and keeps completed true past the final count' do
described_class.start(generation_id, total: 2)
expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
expect(described_class.current(generation_id)).to include('status' => 'generating')
expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
end
it 'raises Missing when no state exists for the generation' do
expect { described_class.record_article_finished(generation_id) }
.to raise_error(described_class::Missing)
end
end
describe '.skip' do
it 'stores status and reason' do
described_class.start(generation_id, total: 2)
described_class.skip(generation_id, reason: 'no website url')
expect(described_class.current(generation_id)).to include(
'status' => 'skipped',
'skip_reason' => 'no website url'
)
end
end
describe '.current' do
it 'returns nil when no state exists' do
expect(described_class.current(generation_id)).to be_nil
end
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_app_store, class: 'Channel::AppStore' do
account
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
bundle_id { 'com.example.app' }
app_name { 'Example App' }
issuer_id { SecureRandom.uuid }
key_id { SecureRandom.alphanumeric(10).upcase }
private_key do
key = OpenSSL::PKey::EC.generate('prime256v1')
key.to_pem
end
to_create { |instance| instance.save!(validate: false) }
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
end
end
@@ -24,10 +24,11 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(3)
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
allow(Rails.logger).to receive(:info)
described_class.new.perform(inbox_id: inbox.id)
expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
end
it 'uses custom bulk limit from environment' do
@@ -67,16 +68,40 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
allow(Rails.logger).to receive(:error)
expect do
described_class.new.perform(inbox_id: inbox.id)
end.to raise_error(StandardError, 'Something went wrong')
expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
end
end
end
describe '.enqueue_for_inbox' do
after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
it 'enqueues one run per inbox and coalesces concurrent triggers' do
allow(described_class).to receive(:perform_later).and_return(true)
expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
expect(described_class).to have_received(:perform_later).once
end
it 'does not release a newer run marker when its own token is stale' do
key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
Redis::Alfred.set(key, 'newer-token', ex: 300)
allow(AutoAssignment::AssignmentService).to receive(:new)
.and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
expect(Redis::Alfred.get(key)).to eq('newer-token')
end
end
describe 'job configuration' do
it 'is queued in the default queue' do
expect(described_class.queue_name).to eq('default')
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
it 'queues assignment job for eligible inboxes' do
inbox_assignment_policy # ensure it exists
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
described_class.new.perform
end
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
described_class.new.perform
end
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not queue assignment job' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not process the account' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'enqueues fetch jobs only for due channels on active accounts' do
due_channel
fresh_channel
suspended_channel
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
described_class.perform_now
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
let(:review_payload) { { 'review' => { 'id' => 'review-1' }, 'response' => nil } }
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
it 'enqueues the job' do
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
.with(channel)
.on_queue('scheduled_jobs')
end
it 'fetches reviews, builds messages, and updates the sync timestamp' do
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
described_class.perform_now(channel)
expect(review_builder).to have_received(:perform)
expect(channel.reload.last_synced_at).to be_present
end
it 'captures per-review errors and continues syncing' do
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
described_class.perform_now(channel)
expect(exception_tracker).to have_received(:capture_exception)
expect(channel.reload.last_synced_at).to be_present
end
end
+6
View File
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
end
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
app_store_channel = create(:channel_app_store)
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
end
end
end
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::AppStore do
describe 'validations' do
it 'normalizes auth fields and stores app metadata from App Store Connect' do
app_store_client = instance_double(AppStoreConnect::Client)
channel = build(
:channel_app_store,
account: create(:account),
app_id: ' 123456789 ',
issuer_id: ' issuer-id ',
key_id: ' key-id ',
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
app_name: nil,
bundle_id: nil
)
allow(channel).to receive(:app_store_client).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_return(
{
'attributes' => {
'name' => 'Chatwoot iOS',
'bundleId' => 'com.chatwoot.app'
}
}
)
expect(channel).to be_valid
expect(channel.app_id).to eq('123456789')
expect(channel.issuer_id).to eq('issuer-id')
expect(channel.key_id).to eq('key-id')
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
expect(channel.app_name).to eq('Chatwoot iOS')
expect(channel.bundle_id).to eq('com.chatwoot.app')
end
it 'adds an error when App Store Connect validation fails' do
app_store_client = instance_double(AppStoreConnect::Client)
channel = build(:channel_app_store)
allow(channel).to receive(:app_store_client).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
expect(channel).not_to be_valid
expect(channel.errors[:base]).to include('invalid credentials')
end
end
describe '#sync_due?' do
it 'returns true when the channel has never synced' do
channel = build(:channel_app_store, last_synced_at: nil)
expect(channel.sync_due?).to be true
end
it 'returns false when the last sync is within the sync interval' do
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
expect(channel.sync_due?).to be false
end
end
end
@@ -0,0 +1,73 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStore::ReviewBuilder do
let(:channel) { create(:channel_app_store) }
let(:inbox) { channel.inbox }
let(:review_payload) do
{
'review' => {
'id' => 'review-1',
'attributes' => {
'rating' => 4,
'title' => 'Helpful app',
'body' => 'Works well for support.',
'territory' => 'US',
'reviewerNickname' => 'Reviewer',
'createdDate' => '2026-05-20T10:00:00-00:00'
},
'relationships' => {
'response' => {
'data' => {
'id' => 'response-1',
'type' => 'customerReviewResponses'
}
}
}
},
'response' => {
'id' => 'response-1',
'attributes' => {
'responseBody' => 'Thanks for the feedback.',
'state' => 'PUBLISHED',
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
}
}
}
end
describe '#perform' do
it 'creates a conversation with an incoming review and outgoing developer response' do
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
.to change(inbox.conversations, :count).by(1)
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
conversation = inbox.conversations.last
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
expect(conversation.contact_inbox.source_id).to eq('review-1')
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
expect(review_message.content_attributes['app_store']).to include(
'rating' => 4,
'title' => 'Helpful app',
'territory' => 'US',
'reviewer_nickname' => 'Reviewer'
)
expect(response_message.content).to eq('Thanks for the feedback.')
expect(response_message.status).to eq('delivered')
end
it 'updates an existing review message when Apple returns the same review again' do
described_class.new(review_payload: review_payload, channel: channel).perform
updated_payload = review_payload.deep_dup
updated_payload['review']['attributes']['body'] = 'Updated review body.'
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
.not_to change(Message.where(inbox_id: inbox.id), :count)
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
end
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStore::SendOnAppStoreService do
let(:channel) { create(:channel_app_store) }
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: inbox.account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
before do
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
describe '#perform' do
it 'creates an App Store response for a new reply' do
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
allow(channel).to receive(:reply_to_review).and_return('response-1')
described_class.new(message: message).perform
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks', response_id: nil)
expect(message.reload.source_id).to eq('response-1')
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
end
it 'updates the existing App Store response when the conversation already has one' do
create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Old reply',
source_id: 'response-1')
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
allow(channel).to receive(:reply_to_review).and_return('response-1')
described_class.new(message: message).perform
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply', response_id: 'response-1')
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
end
it 'marks the message as failed when attachments are present' do
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
described_class.new(message: message).perform
expect(Messages::StatusUpdateService).to have_received(:new).with(
message,
'failed',
'Sending attachments is not supported for App Store reviews.'
)
expect(exception_tracker).to have_received(:capture_exception)
end
end
end
@@ -0,0 +1,152 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStoreConnect::Client do
let(:channel) { create(:channel_app_store, app_id: '123456789') }
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
before do
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
end
describe '#fetch_app' do
it 'fetches the configured app' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
.to_return(
status: 200,
body: {
data: {
id: '123456789',
attributes: {
name: 'Chatwoot',
bundleId: 'com.chatwoot.app'
}
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
end
end
describe '#fetch_reviews' do
it 'fetches reviews and attaches the included developer response' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
.to_return(
status: 200,
body: {
data: [
{
id: 'review-1',
type: 'customerReviews',
relationships: {
response: {
data: {
id: 'response-1',
type: 'customerReviewResponses'
}
}
}
}
],
included: [
{
id: 'response-1',
type: 'customerReviewResponses',
attributes: {
responseBody: 'Thanks for the review'
}
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
review_payload = described_class.new(channel: channel).fetch_reviews.first
expect(review_payload['review']['id']).to eq('review-1')
expect(review_payload['response']['id']).to eq('response-1')
end
end
describe '#create_review_response' do
it 'creates a response for a review' do
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
.with(
body: {
data: {
type: 'customerReviewResponses',
attributes: {
responseBody: 'Thanks'
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: 'review-1'
}
}
}
}
}.to_json
)
.to_return(
status: 201,
body: { data: { id: 'response-1' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
expect(response['id']).to eq('response-1')
end
end
describe '#update_review_response' do
it 'updates an existing response' do
stub_request(:patch, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses/response-1')
.with(
body: {
data: {
type: 'customerReviewResponses',
id: 'response-1',
attributes: {
responseBody: 'Updated response'
}
}
}.to_json
)
.to_return(
status: 200,
body: { data: { id: 'response-1' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
response = described_class.new(channel: channel).update_review_response('response-1', 'Updated response')
expect(response['id']).to eq('response-1')
end
end
it 'raises a useful error when Apple returns an error response' do
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
.to_return(
status: 401,
body: {
errors: [
{
detail: 'Provide a properly configured and signed bearer token.'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect { described_class.new(channel: channel).fetch_app }
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AppStoreConnect::TokenService do
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
let(:channel) do
instance_double(
Channel::AppStore,
id: 1,
updated_at: Time.zone.at(1_700_000_000),
issuer_id: 'issuer-id',
key_id: 'key-id',
private_key: private_key
)
end
describe '#token' do
it 'generates an App Store Connect JWT with the expected claims and headers' do
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
token = described_class.new(channel: channel).token
payload, header = JWT.decode(token, nil, false)
expect(payload).to include(
'iss' => 'issuer-id',
'iat' => Time.current.to_i,
'exp' => 19.minutes.from_now.to_i,
'aud' => 'appstoreconnect-v1'
)
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
end
end
it 'returns cached tokens for the channel' do
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
expect(described_class.new(channel: channel).token).to eq('cached-token')
end
end
end