Matrix bots for Rails. Bots are Ruby classes that run as Active Job jobs;
activematrixd, a daemon written in Rust and shipped inside the gem, holds
every Matrix connection.
Status: this is the 0.1 design, in development on
master. The 0.0.x releases on RubyGems are the previous, in-process design and are not compatible with it.
- Ruby 4.0 or newer
- Rails 8.1 or newer
- PostgreSQL 18 or newer
Nothing older is supported. Platform gems carry the daemon prebuilt for Linux (glibc and musl) and macOS once 0.1 is released; until then, or on another platform, build it with Cargo (see Development).
Matrix ⇄ activematrixd ─── writes ──▶ PostgreSQL ◀── reads ─── Rails (your bots)
syncs agents rooms, members, state, inbox
sends the outbox ◀── outbox ──────────────────── replies, commands
pushes "new events in room X" ─▶ POST /active_matrix/deliveries ─▶ jobs
- The daemon syncs every agent, mirrors room state into tables your app can query, and queues new events in an inbox.
- It tells Rails which rooms have new events. Rails enqueues a job per room; the job runs your bot on each event, oldest first, exactly once: the event is marked handled in the same transaction as everything the handler writes.
- What a bot sends goes to an outbox in that transaction. The daemon sends it after the commit and never if the handler fails. The outbox row's id is the Matrix transaction id, so a retried send cannot post twice.
- Access tokens are sealed with a key only the daemon holds. Rails never sees them.
# Gemfile
gem 'activematrix'$ bundle install
$ bin/rails generate active_matrix:install
$ bin/rails db:migrateThe generator copies the migrations, mounts the engine at /active_matrix,
and writes config/initializers/active_matrix.rb, app/bots/application_bot.rb
and config/activematrixd.toml.
Two secrets, from the environment:
$ export ACTIVEMATRIX_MASTER_KEY=$(bundle exec activematrix keygen) # the daemon only
$ export ACTIVEMATRIX_DELIVERY_SECRET=$(openssl rand -hex 32) # the daemon and RailsThe master key seals access tokens: keep it with your other secrets, since
losing it means logging every agent in again. The delivery secret signs the
daemon's requests to Rails; Rails reads it from
config.delivery_secret (credentials or ACTIVEMATRIX_DELIVERY_SECRET).
$ bin/rails generate active_matrix:bot captain status deploy# app/bots/captain_bot.rb
class CaptainBot < ApplicationBot
setting :greeting, :string, default: 'Welcome aboard'
# !status
command :status, 'Show the status' do
reply "All systems nominal. Deploys today: #{memory.fetch(:deploys, 0)}"
end
# !deploy api !deploy api us --force
command :deploy, 'Deploy a target', power: :moderator do |target, region = 'eu', force: false|
memory.increment(:deploys)
reply "Deploying #{target} to #{region}#{' (forced)' if force}", thread: true
end
# !say any text, exactly as typed
command :say, 'Repeat the text', raw: true do |text|
reply text
end
on 'm.room.member', if: -> { content['membership'] == 'join' && sender != me } do
reply "#{settings.greeting}, #{sender}!", mentions: [sender]
end
on_invite { join }
end- Positional block parameters take the arguments; keyword parameters take
--flags. Wrong arguments or an unknown flag answer with a usage line built from the block:Usage: !deploy TARGET [REGION] [--force]. - Quotes group words:
!say "two words".raw: truepasses the text after the command name as typed instead. !helplists the commands with their descriptions.- Commands come from
m.textmessages; notices (what bots send each other) and edits are not commands.prefix '!', '/'changes the prefixes.
dm: true(direct rooms only) ordm: false(group rooms only).power: :moderator(50),:admin(100) or a level, read from the room's power levels. Below it, the bot says which level is needed.if: -> { ... }, evaluated in the bot.before_command :method, only: :deployruns first;halt!stops the command.rescue_from SomeError, with: :method(or a block) answers instead of failing.
event, content, body, sender, room_id |
the event |
room, direct? |
the mirrored room (room.power_level(user), room.state(type, key), room.members) |
me, agent |
the bot's own user id and agent record |
settings |
the agent's settings, typed by setting declarations |
memory, conversation, global |
memory for the agent, this user in this room, and every agent |
Memory takes any JSON value: memory[:key] = value, memory.fetch(:key, default),
memory.write(:key, value, expires_in: 1.hour), memory.increment(:count)
(atomic), memory.delete(:key).
All of these write to the outbox in the handler's transaction:
reply(text, html:, notice:, thread:, quote:, mentions:) |
send a message; thread: true answers in the event's thread |
react('👍'), edit(event_id, text), redact(event_id, reason:) |
relations |
send_event(type, content), set_state(type, content, state_key:) |
anything else |
join, leave, invite(user), kick(user), ban(user), unban(user) |
membership |
typing, typing(on: false), read_receipt |
presence in the room |
deliver_to(:other_agent, 'topic', payload) |
hand work to another agent's on_agent_message('topic') |
Messages always carry m.mentions, so clients notify exactly the users in
mentions: and not whoever the text happens to name.
Handlers run in your job backend; a handler that raises rolls back what it wrote and is retried, then set aside after five failures so the room moves on.
An agent is a Matrix account running a bot:
ActiveMatrix::Agent.create!(name: 'captain', bot_class: 'CaptainBot',
homeserver_url: 'https://matrix.example.org',
settings: { 'greeting' => 'Ahoy' })Log it in once; the daemon stores the access token sealed:
$ bundle exec activematrix login captain # prompts for the password
$ bundle exec activematrix login captain --password-stdin
$ echo "$TOKEN" | bundle exec activematrix login captain --token-stdinagent.update!(enabled: false) stops it within a heartbeat. The daemon owns
state (offline, starting, online, reconnecting, failed) and
last_error; an agent whose token the homeserver rejects is failed until
it logs in again. Every room the agent knows is mirrored in agent.rooms;
membership says whether it is joined, invited or gone.
$ bundle exec activematrix check # configuration, database and migrations
$ bundle exec activematrix startRun it next to your web and job processes. It needs the same database as
Rails, the master key, and the delivery URL and secret. On SIGTERM it
drains: agents stop, their leases are released, and it exits; a second
signal exits at once.
Agents are leased: each daemon process claims up to max_agents, renews
their leases every heartbeat, and gives them back on shutdown. Run more
processes or replicas to run more agents; a daemon that dies loses its
agents to the others when the leases run out.
config/activematrixd.toml, each setting overridable by the variable next
to it. Secrets come from the environment only.
| Setting | Variable | Default |
|---|---|---|
| database | ACTIVEMATRIX_DATABASE_URL, else DATABASE_URL |
required |
| master key | ACTIVEMATRIX_MASTER_KEY (and ACTIVEMATRIX_MASTER_KEY_PREVIOUS while rotating) |
required |
delivery_url |
ACTIVEMATRIX_DELIVERY_URL |
required |
| delivery secret | ACTIVEMATRIX_DELIVERY_SECRET |
required, 32+ characters |
probe_bind |
ACTIVEMATRIX_PROBE_BIND |
127.0.0.1:3042 |
max_agents |
ACTIVEMATRIX_MAX_AGENTS |
100 |
heartbeat_secs |
ACTIVEMATRIX_HEARTBEAT_SECS |
10 |
lease_ttl_secs |
ACTIVEMATRIX_LEASE_TTL_SECS |
30 (at least 3 heartbeats) |
shutdown_timeout_secs |
ACTIVEMATRIX_SHUTDOWN_TIMEOUT_SECS |
30 |
node_id |
ACTIVEMATRIX_NODE_ID |
hostname:pid |
ACTIVEMATRIX_CONFIG (or --config) points at another file; RUST_LOG
sets the log level. To rotate the master key, move the current one to
ACTIVEMATRIX_MASTER_KEY_PREVIOUS and set a new one: tokens are resealed as
agents start.
Answered from memory, so a database outage does not restart the daemon:
GET /livez: the daemon's heartbeat is running.GET /readyz: live and not draining (503 while draining).GET /status: JSON with every agent and its state.GET /metrics: Prometheus text.
Bind probe_bind to 0.0.0.0:3042 in containers.
Schedule ActiveMatrix::MemoryReaperJob, for example hourly in
config/recurring.yml. It deletes expired memories, and handled inbox events
and sent outbox commands older than config.inbox_retention (30 days) and
config.outbox_retention (7 days).
No daemon or homeserver needed: events go into the inbox the way the daemon writes them, the job runs at once, and you assert on the outbox.
require 'test_helper'
require 'active_matrix/testing'
class CaptainBotTest < ActiveSupport::TestCase
include ActiveMatrix::Testing
setup do
@agent = ActiveMatrix::Agent.create!(name: 'captain', bot_class: 'CaptainBot',
homeserver_url: 'https://matrix.test')
end
test 'say repeats the text' do
deliver_message(@agent, '!say hello there')
assert_replied @agent, 'hello there'
end
enddeliver_message, deliver_event and deliver_invite put events in;
replies, assert_replied, assert_no_replies and assert_outbox read
what the bot queued.
The migrations upgrade the 0.0.x tables in place: agents keep their name,
bot class and settings, and agent stores, the knowledge base and chat context
move into memories. Access tokens do not carry over (0.0.x sealed them with
Active Record encryption, which the daemon cannot read), so run
activematrix login for each agent. The bot API is new: port bots to
ActiveMatrix::Bot::Base.
- End-to-end encryption. Agents cannot read or send in encrypted rooms.
- Application service (bridge) mode.
$ mise install # Ruby 4.0 and Rust 1.99, from mise.toml
$ docker compose up -d # PostgreSQL 18 on port 5440
$ bundle install
$ (cd test/dummy && RAILS_ENV=test bin/rails db:prepare)
$ bundle exec rake test # the Ruby suite, under padded_cell
$ cargo test --workspace # the daemon, against the same PostgreSQL
$ bundle exec rake test:system # everything together, against a fake homeserver
$ dictator lint . # line and file length, layout, whitespace
$ cargo fmt --all && cargo clippy --all-targets- Style is checked by dictator
(
.dictate.toml), rustfmt (rustfmt.toml) and clippy; there is no RuboCop. test/systemruns the real daemon against the dummy app, served over HTTP by shuttlebay, andactivematrix-fakehs.rake test:systembuilds both binaries first;ACTIVEMATRIX_SYSTEM_LOG=1prints the app's log, and a failing test prints the daemon's log and the agent, inbox and outbox rows.- The same tests run against Synapse:
docker compose --profile contract up -d && bundle exec rake test:contract. CI does it nightly (contract.yml); fault injection tests skip there. - The daemon's tests load
crates/activematrixd/tests/schema.sql, a snapshot of the engine's tables. After changing a migration, runbundle exec rake active_matrix:schema_snapshot; CI fails when it is stale. crates/activematrix-fakehsis a fake homeserver used by the daemon's tests; its binary has an admin API under/_fake/for tests in other languages.bundle exec rake 'package[arm64-darwin]'builds a platform gem with the daemon inside;ACTIVEMATRIX_INSTALL_DIRpoints the launcher at a local build instead.- Releases: release-please opens the release PR; merging it tags the
version, and
release.ymlbuilds the six platform gems (Linux gnu and musl, macOS, each on x86_64 and arm64) plus the platformless one, and attaches them to the GitHub Release. Runningrelease.ymlby hand builds and checks them without releasing.
MIT. ActiveMatrix began as a fork of matrix-sdk by Alexander Olofsson.