Ruby Open Source: chatwoot

Ruby Open Source: chatwoot

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

ยท

10 min read

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

  • It uses Rails to create a REST API with jbuilder as the serializer

Frontend

  • It uses Vue for the front end with Tailwind CSS. You can explore the package.json here

  • It also uses Administrate gem as an admin dashboard

Background processing queue

  • Sidekiq with Redis

Database:

  • PostgreSQL

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:

  • RSpec/ExampleLength with max=25

  • Style/FrozenStringLiteralComment with enabled=false

  • Style/OpenStructUse with enabled=false

  • Style/GlobalVars are allowed in redis and rack_attack initializers and in lib/global_config.rb

  • Style/ClassVars are allowed in one file app/services/email_templates/db_resolver_service.rb

  • Style/HashSyntax has the shorthand syntax disabled

  • Naming/VariableNumber is disabled

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:

  • act_as_taggable_on - "A tagging plugin for Rails applications that allows for custom tagging along dynamic contexts"

  • attr_extras - "Takes some boilerplate out of Ruby with methods like attr_initialize"

  • hashie - "Hashie is a growing collection of tools that extend Hashes and make them more useful"

  • responders - "A set of Rails responders to dry up your application"

  • telephone_number - "TelephoneNumber is global phone number validation gem based on Google's libphonenumber library"

  • valid_email2 - "Validate emails with the help of the mail gem instead of some clunky regexp. Aditionally validate that the domain has a MX record. Optionally validate against a static list of disposable email services. Optionally validate that the email is not subaddressed (RFC5233)"

  • flag_shih_tzu - "This gem lets you use a single integer column in an ActiveRecord model to store a collection of boolean attributes (flags). Each flag can be used almost in the same way you would use any boolean attribute on an ActiveRecord object"

  • haikunator - "Generate Heroku-like memorable random names to use in your apps or anywhere else."

  • commonmarker - "Ruby wrapper for Rust's comrak crate. It passes all of the CommonMark test suite, and is therefore spec-complete. It also includes extensions to the CommonMark spec as documented in the GitHub Flavored Markdown spec, such as support for tables, strikethroughs, and autolinking"

  • down -"Down is a utility tool for streaming, flexible and safe downloading of remote files. It can use open-uri + Net::HTTP, http.rb, HTTPX, or wget as the backend HTTP library"

  • gmail_xoauth - "Get access to Gmail IMAP and SMTP via OAuth2 and OAuth 1.0a, using the standard Ruby Net libraries. The gem supports 3-legged OAuth, and 2-legged OAuth for Google Apps Business or Education account owners"

  • csv-safe - "This gem decorates the built in CSV library to prevent CSV injection attacks. Wherever you would use CSV in your code, use CSVSafe. The gem will encode your fields in UTF-8"

  • hairtrigger - "lets you create and manage database triggers in a concise, db-agnostic, Rails-y way. You declare triggers right in your models in Ruby, and a simple rake task does all the dirty work for you"

  • wisper - "A micro library providing Ruby objects with Publish-Subscribe capabilities"

  • groupdate - "The simplest way to group by: day, week, hour of the day, and more"

  • html2text - "a very simple gem that uses DOM methods to convert HTML into a format similar to what would be rendered by a browser - perfect for places where you need a quick text representation"

  • informers - "State-of-the-art natural language processing for Ruby: Sentiment analysis, Question answering, Named-entity recognition, Text generation"

  • climate_control - "Climate Control modifies environment variables only within the context of the block, ensuring values are managed properly and consistently"

Design Patterns

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

  • actions

  • builders

  • dashboards

  • dispatchers

  • drops

  • fields

  • finders

  • listeners

  • mailboxes

  • policies

  • presenters

  • services

  • workers

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:

  • They use nested describe or describe with nested context

  • let and let!

  • subject

  • A limited number of shared_examples inside models

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

Did you find this article valuable?

Support Lucian Ghinda by becoming a sponsor. Any amount is appreciated!