All about coding

Ruby and Rails technical content written by Lucian Ghinda

Ruby Open Source: chatwoot

#ruby #ruby-on-rails #opensource #reviews #coding

An analysis of the open-source Ruby on Rails application for customer engagement

Continuing the series about open-source Ruby with chatwoot

As a reminder, this is a 30-minute review so this is a high-level overview and there might be things that I missed or that I misunderstood.

The product

chatwoot is an “Open-source customer engagement suite, an alternative to Intercom, Zendesk, Salesforce Service Cloud etc”

What is chatwoot from their website

The interface looks like this (source their website)

an example of chatwood main interface

They were part of YCombinator batch in 2021.

Open Source

Repository and License

The repository is on Github at https://github.com/chatwoot/chatwoot and it is open sourced with a license that seems a variant of MIT license:

Ruby and Rails version

At the moment of writing this article (November 2023), it runs on Ruby 3.2.2 and Rails 7.0.8

Architecture

Backend

Frontend

Background processing queue

Database:

Stats

Running rails stats returned the following:

As this project uses Vue on the front, here is the output of running an extension in VScode called Code Counter:

Not sure how it does all these calculations but it seems the application has quite some lines of code in JS (JavaScript and Vue files together).

Style Guide

They use Rubocop with some custom settings added in their .rubocop.yml file:

require:
  - rubocop-performance
  - rubocop-rails
  - rubocop-rspec

Among the cops that have their default settings changed:

and there are more there.

Storage, Persistence and in-memory storage

As a database it uses PostgreSQL.

It defines two global variables for Redis:

# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
  redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
  Redis::Namespace.new('alfred', redis: redis, warning: true)
end

# Velma : Determined protector
# used in rack attack
$velma = ConnectionPool.new(size: 5, timeout: 1) do
  config = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
  Redis::Namespace.new('velma', redis: config, warning: true)
end

Then these global variables are used in other places in the code:

Gems used

Here is a selection from all the gems they use:

Design Patterns

The “app” directory has the following sub-folders that are not the ones included by default in Rails:

Here are some examples of some patterns from the codebase:

Actions

Inside actions, some objects define the perform method where they execute a series of steps:

class SomeAction
  pattr_initialize [:user, :base]

  def perform
    ActiveRecord::Base.transaction do
      action1
      action2
      # ...
    end
  end
end

Where pattr_initialize comes from attr_extras gem and it will create an initializer with those variables and declare them as private.

Builders

These have the same interface where they define perform and it seems to be a flavor of Builder Pattern maybe with a service object where the builder will create or find one or more records.

Here is an example of the AccountBuilder that will create an account and then create a user and link these two together.

class AccountBuilder
  include CustomExceptions::Account
  pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]

  def perform
    if @user.nil?
      validate_email
      validate_user
    end
    ActiveRecord::Base.transaction do
      @account = create_account
      @user = create_and_link_user
    end
    [@user, @account]
  rescue StandardError => e
    puts e.inspect
    raise e
  end

  # ... more methods
end

Controllers

Take a look at the app/controllers/api there is a versioning of the API using namespaces (app/controllers/api/v1 , app/controllers/api/v2 )

The controllers appears to be slim calling other objects and usually returning a JSON represented in views via .json.builder files.

Dashboards

Here they define the Administrate custom dashboards

Dispatchers

They implement the Wisper::Publisher module to dispatch events. Here is an example of an async dispatcher using a job:

# https://github.com/chatwoot/chatwoot/blob/develop/app/dispatchers/async_dispatcher.rb

class AsyncDispatcher < BaseDispatcher
  def dispatch(event_name, timestamp, data)
    EventDispatcherJob.perform_later(event_name, timestamp, data)
  end

  def publish_event(event_name, timestamp, data)
    event_object = Events::Base.new(event_name, timestamp, data)
    publish(event_object.method_name, event_object)
  end

  def listeners
    [
      CampaignListener.instance,
      CsatSurveyListener.instance,
      HookListener.instance,
      InstallationWebhookListener.instance,
      NotificationListener.instance,
      ReportingEventListener.instance,
      WebhookListener.instance,
      AutomationRuleListener.instance
    ]
  end
end

Finders

Finders are another patterns used here which takes care of querying the DB while taking care of filters or other conditions coming from params in controllers. They usually define a perform method and the initializer accepts a params to get the params from the controller.

Here is an example:

# https://github.com/chatwoot/chatwoot/blob/develop/app/finders/conversation_finder.rb#L1
class ConversationFinder
  attr_reader :current_user, :current_account, :params

  # params
  # assignee_type, inbox_id, :status

  def initialize(current_user, params)
    @current_user = current_user
    @current_account = current_user.account
    @params = params
  end

  def perform
   # calls to compose the response from other methods 
  end

  private

  # other methods ...

  def find_all_conversations
    @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
    filter_by_conversation_type if params[:conversation_type]
    @conversations
  end

  def filter_by_assignee_type
    case @assignee_type
    when 'me'
      @conversations = @conversations.assigned_to(current_user)
    when 'unassigned'
      @conversations = @conversations.unassigned
    when 'assigned'
      @conversations = @conversations.assigned
    end
    @conversations
  end

  def filter_by_conversation_type
    case @params[:conversation_type]
    when 'mention'
      conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
      @conversations = @conversations.where(id: conversation_ids)
    when 'participating'
      @conversations = current_user.participating_conversations.where(account_id: current_account.id)
    when 'unattended'
      @conversations = @conversations.unattended
    end
    @conversations
  end

  def filter_by_query
    return unless params[:q]

    allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
    @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
                                  .where(messages: { message_type: allowed_message_types }).includes(:messages)
                                  .where('messages.content ILIKE :search', search: "%#{params[:q]}%")
                                  .where(messages: { message_type: allowed_message_types })
  end

  # some other methods ... 
end

Listeners

The listeners are implemented using the Singleton interface and all inherit from the BaseListener and usually will do an action like calling a Job or Builder or some other object when the event is received.

Mailboxes

This could be a good repo if you want to see how to read and process inbound emails. Take a look at ApplicationMailbox which it matches either a reply to a conversation or a new conversation received via a channel:

class ApplicationMailbox < ActionMailbox::Base
  include MailboxHelper

  # Last part is the regex for the UUID
  # Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
  REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
  CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}

  # routes as a reply to existing conversations
  routing(
    ->(inbound_mail) { reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail) } => :reply
  )

  # routes as a new conversation in email channel
  routing(
    ->(inbound_mail) { EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
  )
# ... more methods
end

Mailers

The Mailers are implemented using liquid gem. The logic to decide how to deliver a reply based on the inbox_type can be found in the ConversationReplymailerHelper in a method that looks like this:

# https://github.com/chatwoot/chatwoot/blob/develop/app/mailers/conversation_reply_mailer_helper.rb#L2
  def prepare_mail(cc_bcc_enabled)
    @options = {
      to: to_emails,
      from: email_from,
      reply_to: email_reply_to,
      subject: mail_subject,
      message_id: custom_message_id,
      in_reply_to: in_reply_to_email
    }

    if cc_bcc_enabled
      @options[:cc] = cc_bcc_emails[0]
      @options[:bcc] = cc_bcc_emails[1]
    end
    ms_smtp_settings
    set_delivery_method

    mail(@options)
  end

Models

Models are Active Record models with just the right amount of logic. They are not very big but they are also not slim.

Policies

They use pundit gem and thus this folder contains policies. Each method is simple and easy to understand.

Services

There are 63 files inside the services having a similar interface, defining a perform or perform_reply method.

Lib

There are over 60 fiels in the /lib folder. They don’t use the same pattern and are doing various things. From defining some types or constants like Events::Types to for example a client connecting to MicrosoftGraphAuth

Testing

They use RSpec for testing with FactoryBot and some fixtures.

Looking a bit at the structure of some tests:

The tests appear to be simple with little setup in a before block and some let! and the test is self-contained.

Conclusion

In conclusion, chatwoot is an open-source customer engagement suite with a well-structured codebase and a variety of design patterns. Most of them are trying to define a common interface (like perform method). The controllers and models are pretty close to vanilla Rails: slim controllers, with some logic in models, making use of callbacks.

It employs several gems and libraries to enhance its functionality but without changing too much the flavour of Rails.

The project uses Ruby 3.2.2, Rails 7.0.8, and Vue for its front end and also has a bit of ERB for the Administrate gem.

From the Ruby on Rails perspective it could be a project easy to grasp with a bit of mental load on the testing side where there is a mix of let, subject and shared_examples.


Enjoyed this article?

👉 Join my Short Ruby News newsletter for weekly Ruby updates from the community and visit rubyandrails.info, a directory with learning content about Ruby.

👐 Subscribe to my Ruby and Ruby on rails courses over email at learn.shortruby.com - effortless learning anytime, anywhere

🤝 Let’s connect on Ruby.social or Linkedin or Twitter where I post mainly about Ruby and Rails.

🎥 Follow me on my YouTube channel for short videos about Ruby

View posts by tag

#ruby #concurrency #general-advice #life-hack #ruby-on-rails #google-cloud #kubernetes #bash #apis #rest-api #guide #docker #bugs-and-errors #howtos #newbie #programming-languages #programming-tips #til #coding #learning #postgresql #sql #design-patterns #web-api #programming-blogs #programmer #databases #programming #learn-coding #learning-journey #side-project #coding-challenge #design-principles #solid-principles #git #tips #testing #test-driven-development #test-automation #code #shortpost #refactoring #code-review #rails #developer #documentation #focus #blogging #content-creation #share #advice #newsletter #active-record #ai #ai-tools #generative-ai #web-development #errors #cursor-ide #ides #gpt-4 #news #scalability #tailwind-css #chatgpt #opensource #reviews #startups #github #technology #types #sorbet #performance #rubymine #vscode #codesnippet #features #syntax #friendlyrb #conference #directories #writing #summary #animation #articles #podcasts #publishing #patterns #pattern-matching #content #updates #seo-for-developers #marketing #properties #gems #matching #rspec #llm #tools #lsp #claudeai #opencode #mcp #rubocop #quality #planning #products #mvp #upgrade #agentic-ai #skills #superpowers