Project

turbo_chat

0.0
The project is in a healthy, maintained state
A mountable Rails engine with chats, messages, memberships, and Turbo Stream updates.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.0, < 8.0
>= 1.4, < 3.0
 Project Readme

TurboChat

TurboChat is a mountable Rails chat engine for server-rendered applications.

Status: actively maintained.

What You Get

  • Turbo Stream chat UI with two layout styles (bounded card or full-viewport).
  • Role-based permissions with a swappable adapter.
  • Mentions, invitations, inline message editing, and typing signals.
  • Moderation: mute, timeout, ban, and message deletion.
  • System messages for membership lifecycle events.
  • Browser and server-side event hooks.

Basic Setup

Add the gem:

# Gemfile
gem "turbo_chat"

Install and migrate:

bundle install
bin/rails generate turbo_chat:install
bin/rails db:migrate

Mount the engine:

# config/routes.rb
mount TurboChat::Engine => "/", as: "turbo_chat"

This mounts TurboChat at your app root.

Because the engine defines root as chats#index, both of these resolve to chat index:

  • /
  • /chats

Route helpers:

  • turbo_chat.root_path -> /
  • turbo_chat.chats_path -> /chats
  • turbo_chat.chat_path(chat) -> /chats/:id
  • turbo_chat_path -> the mount point (/)

Host App Requirements

1. Mark your participant model

class User < ApplicationRecord
  acts_as_chat_participant
end

This adds has_many :chat_memberships and the associations TurboChat needs to resolve participants in chats.

2. Resolve the current participant

If your app exposes current_user and it returns a model using acts_as_chat_participant, TurboChat works out of the box.

Resolution order:

  1. Host ApplicationController#current_chat_participant (if defined)
  2. config.chat.current_participant_resolver (if configured)
  3. current_user (if available)
  4. Raise NotImplementedError

For non-current_user auth:

TurboChat.configure do |config|
  config.chat.current_participant_resolver = ->(controller) { controller.send(:current_member) }
end

First Working Example

chat = TurboChat::Chat.create!(title: "Support")
TurboChat::ChatMembership.create!(chat: chat, participant: current_user, role: :admin)
<%= link_to "Open chat", turbo_chat.chat_path(chat) %>

Configuration

The installer generates a full initializer at config/initializers/turbo_chat.rb with every option documented. Start with the defaults and change only what you need.

Config is organized into scoped namespaces:

TurboChat.configure do |config|
  # Limits
  config.chat.max_chat_participants = 10
  config.chat_message.max_message_length = 1000

  # Features
  config.chat_message.enable_mentions = true
  config.chat_message.blocked_words = []
  config.chat_message.blocked_words_action = :reject  # or :scramble

  # Layout
  config.style.chat_style = "chat_style_bounded"       # or "chat_style_unbounded"
  config.chat_message.message_insert_position = "append_end" # or "append_start"

  # Events (all opt-in, all false by default)
  config.events.emit_typing_events = true
  config.events.emit_message_events = true
end

Available scopes: config.chat, config.chat_message, config.style, config.events, config.moderation, config.signals. Flat aliases (e.g. config.max_message_length) still work for backward compatibility.

Chat Modes

Chats default to chat_mode: :standard.

chat = TurboChat::Chat.create!(title: "Assistant", chat_mode: :assistant)

Built-in assistant-mode defaults make the chat simpler for 1:1 assistant use cases:

  • max_chat_participants = 2
  • members panel and invite UI hidden
  • mentions disabled
  • system messages disabled
  • chat close action hidden
  • invitation, mention, lifecycle, and moderation events disabled

You can override assistant-mode defaults without changing standard chats:

TurboChat.configure do |config|
  config.mode(:assistant).show_members = true
  config.mode(:assistant).enable_mentions = true
end

Rate Limiting

TurboChat does not include built-in rate limiting. For production, add request throttling using middleware such as rack-attack.

Roles and Permissions

Built-in roles

Role Rank Key capabilities
member 0 View, post, mention members, edit own messages
moderator 1 + invite, @all/@ROLE mentions, mute/timeout/ban, delete messages
admin 2 + close/reopen chats, change member roles

Higher-rank roles can act on lower-rank members but not on peers or above.

Custom roles

TurboChat.configure do |config|
  config.add_role(
    :support_agent,
    name: "Support Agent",
    rank: 1,
    permissions: %i[view_chat post_message delete_message]
  )
end

Permission adapter

All access checks go through config.chat.permission_adapter (default: TurboChat::Permission). To customize, create a class that implements the same public interface:

class CustomPermission < TurboChat::Permission
  def can_post_message?
    super && !participant.suspended?
  end
end

TurboChat.configure do |config|
  config.chat.permission_adapter = CustomPermission
end

The adapter must respond to: can_view_chat?, can_create_chat?, can_post_message?, can_invite_member?, can_grant_member_permissions?, can_mute_member?, can_timeout_member?, can_ban_member?, can_delete_message?, can_edit_message?, can_close_chat?, can_reopen_chat?.

Invitations

Admins and moderators can invite participants through the members panel. The invited participant sees pending invitations on the chat index with Accept/Decline buttons.

Programmatic invitation:

TurboChat::ChatMembership.create!(
  chat: chat,
  participant: invitee,
  role: :member,
  invitation_accepted: false
)

Accept/decline routes are built in: PATCH /chats/:id/accept and PATCH /chats/:id/decline.

Message Ingest API

Post messages as a specific participant:

TurboChat::Messages.send_message_as(
  current_user,
  chat,
  body: "Internal note",
  source: :app
)

External ingest with idempotency (chat_id + source + external_id):

TurboChat::Messages.ingest_external!(
  chat: chat,
  participant: current_user,
  body: "Hello from WhatsApp",
  source: :whatsapp,
  external_id: webhook_payload.fetch("message_id"),
  sent_at: webhook_payload["sent_at"]
)

Duplicate webhook deliveries with the same external_id resolve to the existing message.

Source labels shown in message badges are configurable:

config.chat_message.message_source_labels = {
  "app" => "In App",
  "whatsapp" => "WhatsApp",
  "sms_gateway" => "SMS"
}

Moderation API

TurboChat::Moderation.mute_member!(actor: moderator, membership: membership)
TurboChat::Moderation.unmute_member!(actor: moderator, membership: membership)
TurboChat::Moderation.timeout_member!(actor: moderator, membership: membership, until_time: 30.minutes.from_now)
TurboChat::Moderation.ban_member!(actor: moderator, membership: membership)
TurboChat::Moderation.delete_message!(actor: moderator, message: message)
TurboChat::Moderation.close_chat!(actor: admin, chat: chat)
TurboChat::Moderation.reopen_chat!(actor: admin, chat: chat)

All actions are permission-checked and generate system messages in the chat timeline. Mute, timeout, ban, and role changes are visible to all participants as system messages (disable with config.chat.system_messages = false).

Raises TurboChat::Moderation::AuthorizationError or TurboChat::Moderation::InvalidActionError.

Signals API

TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :typing)
TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :thinking)
TurboChat::Signals.start!(chat: chat, participant: user, signal_type: :planning)
TurboChat::Signals.custom!(chat: chat, participant: user, signal_text: "Reviewing your request")
TurboChat::Signals.clear!(chat: chat, participant: user)

Block form that auto-clears when the work finishes:

TurboChat::Signals.with(chat: chat, participant: user, signal_type: :thinking) do
  # long-running operation
end

Signal lifetime is configurable via config.signals.signal_ttl_seconds (default: 60).

Events

All event emissions are opt-in.

Browser events (CustomEvent on document)

config.events.emit_typing_events = true         # turbo-chat:typing-started, turbo-chat:typing-ended
config.events.emit_message_events = true         # turbo-chat:message-sent
config.events.emit_mention_events = true         # turbo-chat:mention
config.events.emit_invitation_events = true      # turbo-chat:invitation-accepted
config.events.emit_chat_lifecycle_events = true  # turbo-chat:chat-invited, chat-joined, chat-declined,
                                                 #   chat-left, chat-closed, chat-reopened
document.addEventListener("turbo-chat:message-sent", function (event) {
  console.log(event.detail); // { chatId: "123" }
});

Server-side events (ActiveSupport::Notifications)

config.moderation.emit_moderation_events = true    # turbo_chat.moderation.member_muted, member_banned, ...
config.moderation.emit_blocked_words_events = true  # turbo_chat.blocked_words.detected, rejected, scrambled
ActiveSupport::Notifications.subscribe(/turbo_chat\.moderation\./) do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  Rails.logger.info("[TurboChat] #{event.name} #{event.payload.inspect}")
end

Theming

TurboChat ships a default theme built on CSS custom properties. Override them in your app stylesheet:

:root {
  --chat-text: #12263f;
  --chat-muted: #5f738a;
  --chat-border: #c9d5e3;
  --chat-surface: #ffffff;
  --chat-surface-soft: #f6f9fd;
  --chat-primary: #1f6edc;
  --chat-primary-dark: #1452ac;
  --chat-primary-soft: #e7f1ff;
  --chat-danger: #b42318;
  --chat-danger-soft: #fff1f3;
  --chat-success: #1f7a40;
  --chat-success-soft: #ecfdf3;
  --chat-mention-highlight-color: #b42318;
  --chat-shadow: 0 20px 48px rgba(16, 36, 58, 0.12);
}

Additional style config:

config.style.own_message_hex_color = "#e7f1ff"
config.style.other_message_hex_color = "#ffffff"
config.style.mention_mark_hex_color = "#b42318"
config.style.composer_placeholder_text = "Type a message..."

Upgrade

bin/rails turbo_chat:install:migrations
bin/rails db:migrate

Known Limitations

  • Stream subscriptions after member removal. Turbo Stream subscriptions persist until page reload. A removed member may see new messages until they navigate away. Stream names are cryptographically signed so this is not a security issue.
  • Blocked word filtering. Word-boundary matching can be bypassed with Unicode homoglyphs, zero-width characters, or leetspeak. Treat it as a first line of defense.
  • Invitable participants cap. The invite picker returns at most 100 non-member participants. Larger user bases should provide a custom search endpoint.

Dependencies

  • Ruby >= 3.1
  • Rails >= 7.0, < 8.0
  • turbo-rails >= 1.4, < 3.0