Skip to main content

Building Matrix Bots

Learn to create custom Matrix bots for your specific needs - from simple command bots to full bridge appservices.

Choosing Your Approach​

ApproachDifficultyBest ForE2EE
Maubot PluginEasyQuick bots, no server needed✅
SimpleMatrixBotLibEasy10-line bots, beginners✅
NioBotEasyDiscord.py-like syntax✅
matrix-nioMediumFull control, async Python✅
matrix-bot-sdkMediumTypeScript, Node.js✅
AppserviceHardBridges, multi-user bots✅

Quick Start: Which to Choose?​

Want a bot running in minutes?
└─► Maubot plugin (if you have Maubot)
└─► SimpleMatrixBotLib (if coding from scratch)

Building something like a Discord bot?
└─► NioBot (Python, discord.py-like)
└─► matrix-bot-sdk (TypeScript)

Need full control or building a bridge?
└─► matrix-nio (Python)
└─► Appservice (multi-user, impersonation)

Maubot Plugins​

The easiest way to create a bot if you have Maubot running.

Minimal Plugin​

helloworld.py
from maubot import Plugin, MessageEvent
from maubot.handlers import command

class HelloWorldBot(Plugin):
@command.new()
async def hello(self, evt: MessageEvent) -> None:
await evt.reply("Hello, World!")
maubot.yaml
maubot: 0.1.0
id: com.example.helloworld
version: 1.0.0
license: MIT
modules:
- helloworld
main_class: HelloWorldBot

Build and Deploy​

# Build plugin
zip -9r helloworld.mbp maubot.yaml helloworld.py

# Or with maubot CLI
mbc build --upload

Plugin with Config​

echobot.py
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper

class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("prefix")
helper.copy("response_format")

class EchoBot(Plugin):
@classmethod
def get_config_class(cls):
return Config

@command.new(name="echo")
@command.argument("message", pass_raw=True)
async def echo(self, evt: MessageEvent, message: str) -> None:
prefix = self.config["prefix"]
await evt.reply(f"{prefix} {message}")

Learn more: Maubot Dev Docs


SimpleMatrixBotLib​

Get a bot running in 10 lines of code. Great for beginners.

Installation​

pip install simplematrixbotlib

# With E2EE support
pip install simplematrixbotlib[e2ee]

Basic Bot​

bot.py
import simplematrixbotlib as botlib

creds = botlib.Creds("https://matrix.org", "@bot:matrix.org", "password")
bot = botlib.Bot(creds)

@bot.listener.on_message_event
async def echo(room, message):
match = botlib.MessageMatch(room, message, bot, "!echo")
if match.is_not_from_this_bot() and match.prefix():
await bot.api.send_text_message(room.room_id, match.args())

bot.run()

Command Matching​

@bot.listener.on_message_event
async def commands(room, message):
match = botlib.MessageMatch(room, message, bot)

if match.is_not_from_this_bot():
if match.command("!ping"):
await bot.api.send_text_message(room.room_id, "Pong!")

elif match.command("!hello"):
name = match.args() or "World"
await bot.api.send_text_message(room.room_id, f"Hello, {name}!")

Config File​

config.toml
[simplematrixbotlib.config]
join_on_invite = true
allowlist = ["@admin:matrix.org"]
config = botlib.Config()
config.load_toml("config.toml")
bot = botlib.Bot(creds, config)

Docs: simple-matrix-bot-lib.readthedocs.io


NioBot Framework​

Discord.py-like syntax for Matrix bots. Full attachment and encryption support.

Installation​

pip install nio-bot

# With E2EE
pip install nio-bot[e2ee]

Basic Bot​

bot.py
from niobot import NioBot, Context

bot = NioBot(
homeserver="https://matrix.org",
user_id="@bot:matrix.org",
command_prefix="!",
case_insensitive=True
)

@bot.command()
async def ping(ctx: Context):
"""Responds with Pong!"""
await ctx.respond("Pong!")

@bot.command()
async def echo(ctx: Context, *, message: str):
"""Echoes your message back"""
await ctx.respond(message)

bot.run(access_token="your-token")

Attachments​

from niobot import NioBot, Context, ImageAttachment

@bot.command()
async def avatar(ctx: Context):
"""Sends an image"""
attachment = await ImageAttachment.from_file("avatar.png")
await ctx.respond("Here's an image:", file=attachment)

Events​

@bot.on_event("member")
async def on_member_join(room, event):
if event.membership == "join":
await bot.send_message(room.room_id, f"Welcome, {event.display_name}!")

GitHub: nexy7574/nio-bot


Python Bot with matrix-nio​

Setup​

pip install matrix-nio

Basic Bot​

bot.py
import asyncio
from nio import AsyncClient, MatrixRoom, RoomMessageText

async def message_callback(room: MatrixRoom, event: RoomMessageText):
if event.sender == client.user_id:
return # Ignore our own messages

if event.body.startswith("!echo "):
response = event.body[6:]
await client.room_send(
room_id=room.room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": response}
)

async def main():
global client
client = AsyncClient("https://matrix.example.com", "@bot:example.com")

await client.login("your-password")
client.add_event_callback(message_callback, RoomMessageText)

await client.sync_forever(timeout=30000)

asyncio.run(main())

E2EE Support​

from nio import AsyncClient, MatrixRoom, RoomMessageText
from nio.store import SqliteStore

async def main():
config = AsyncClientConfig(store_sync_tokens=True)
client = AsyncClient(
"https://matrix.example.com",
"@bot:example.com",
store_path="./store",
config=config
)

# Import E2EE keys if needed
client.load_store()

await client.login("password")

# Trust all devices (for simplicity)
client.trust_devices()

await client.sync_forever()

TypeScript Bot with matrix-bot-sdk​

Setup​

npm init -y
npm install matrix-bot-sdk

Basic Bot​

bot.ts
import {
MatrixClient,
SimpleFsStorageProvider,
AutojoinRoomsMixin
} from "matrix-bot-sdk";

const homeserverUrl = "https://matrix.example.com";
const accessToken = "your-access-token";

const storage = new SimpleFsStorageProvider("bot.json");
const client = new MatrixClient(homeserverUrl, accessToken, storage);

AutojoinRoomsMixin.setupOnClient(client);

client.on("room.message", async (roomId, event) => {
if (event.sender === await client.getUserId()) return;
if (event.content?.msgtype !== "m.text") return;

const body = event.content.body;
if (body.startsWith("!hello")) {
await client.sendMessage(roomId, {
msgtype: "m.text",
body: "Hello, World!"
});
}
});

client.start().then(() => console.log("Bot started!"));

Command Framework​

Python Example​

from nio import AsyncClient, RoomMessageText

COMMANDS = {}

def command(name):
def decorator(func):
COMMANDS[name] = func
return func
return decorator

@command("ping")
async def cmd_ping(client, room, event, args):
await client.room_send(
room.room_id,
"m.room.message",
{"msgtype": "m.text", "body": "Pong!"}
)

@command("help")
async def cmd_help(client, room, event, args):
cmds = ", ".join(COMMANDS.keys())
await client.room_send(
room.room_id,
"m.room.message",
{"msgtype": "m.text", "body": f"Commands: {cmds}"}
)

async def handle_message(room, event):
if not event.body.startswith("!"):
return

parts = event.body[1:].split()
cmd_name = parts[0].lower()
args = parts[1:]

if cmd_name in COMMANDS:
await COMMANDS[cmd_name](client, room, event, args)

Handling Different Event Types​

Reactions​

from nio import ReactionEvent

async def reaction_callback(room, event: ReactionEvent):
print(f"{event.sender} reacted with {event.key}")
# event.reacts_to contains the event_id being reacted to

client.add_event_callback(reaction_callback, ReactionEvent)

Room Events​

from nio import RoomMemberEvent

async def member_callback(room, event: RoomMemberEvent):
if event.membership == "join":
await client.room_send(
room.room_id,
"m.room.message",
{"msgtype": "m.text", "body": f"Welcome, {event.display_name}!"}
)

client.add_event_callback(member_callback, RoomMemberEvent)

Best Practices​

1. Handle Errors Gracefully​

async def safe_send(client, room_id, content):
try:
await client.room_send(room_id, "m.room.message", content)
except Exception as e:
print(f"Failed to send message: {e}")

2. Rate Limiting​

from asyncio import Semaphore

rate_limit = Semaphore(5) # 5 concurrent requests

async def send_with_rate_limit(client, room_id, content):
async with rate_limit:
await client.room_send(room_id, "m.room.message", content)

3. Persistent Storage​

import json

def save_data(data, filename="data.json"):
with open(filename, "w") as f:
json.dump(data, f)

def load_data(filename="data.json"):
try:
with open(filename) as f:
return json.load(f)
except FileNotFoundError:
return {}

4. Configuration​

import os
from dataclasses import dataclass

@dataclass
class Config:
homeserver: str = os.getenv("MATRIX_HOMESERVER", "https://matrix.org")
user_id: str = os.getenv("MATRIX_USER_ID", "@bot:matrix.org")
password: str = os.getenv("MATRIX_PASSWORD", "")

config = Config()

Deployment​

Docker​

Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "bot.py"]

systemd​

/etc/systemd/system/matrixbot.service
[Unit]
Description=Matrix Bot
After=network.target

[Service]
User=matrixbot
WorkingDirectory=/opt/matrixbot
ExecStart=/opt/matrixbot/venv/bin/python bot.py
Restart=always

[Install]
WantedBy=multi-user.target

Building an Appservice​

Appservices (Application Services) are powerful - they can impersonate users, receive all room events, and handle namespaces of users/rooms. Used for bridges and multi-user bots.

When to Use Appservices​

Use CaseRegular BotAppservice
Simple commands✅Overkill
Welcome messages✅✅
Bridge to other platform❌✅
Impersonate users❌✅
Receive all events❌✅
Handle thousands of rooms❌✅

Registration File​

Appservices need to be registered with the homeserver:

registration.yaml
id: mybridge
url: http://localhost:9000
as_token: "generate-random-string-1"
hs_token: "generate-random-string-2"
sender_localpart: _mybridge_bot

namespaces:
users:
- exclusive: true
regex: "@_mybridge_.*:example\\.com"
aliases:
- exclusive: true
regex: "#_mybridge_.*:example\\.com"
rooms: []

rate_limited: false

Add to Synapse:

homeserver.yaml
app_service_config_files:
- /path/to/registration.yaml

TypeScript Appservice (matrix-bot-sdk)​

appservice.ts
import {
Appservice,
SimpleFsStorageProvider,
IAppserviceRegistration
} from "matrix-bot-sdk";

const registration: IAppserviceRegistration = {
id: "mybridge",
hs_token: "hs-token",
as_token: "as-token",
sender_localpart: "_mybridge_bot",
namespaces: {
users: [{ exclusive: true, regex: "@_mybridge_.*:example.com" }],
aliases: [],
rooms: []
},
url: "http://localhost:9000",
rate_limited: false
};

const storage = new SimpleFsStorageProvider("appservice.json");
const appservice = new Appservice({
port: 9000,
bindAddress: "0.0.0.0",
homeserverName: "example.com",
homeserverUrl: "https://matrix.example.com",
registration,
storage
});

// Handle messages
appservice.on("room.message", async (roomId, event) => {
console.log(`Message in ${roomId}: ${event.content?.body}`);
});

// Create a ghost user
appservice.on("query.user", async (userId, createUser) => {
// Called when someone tries to interact with a ghost user
await createUser({ displayname: "Ghost User" });
});

// Impersonate a user
const intent = appservice.getIntentForUserId("@_mybridge_alice:example.com");
await intent.sendText(roomId, "Hello from Alice!");

appservice.begin().then(() => console.log("Appservice started"));

Python Appservice (mautrix)​

appservice.py
from mautrix.appservice import AppService
from mautrix.types import RoomID, UserID

class MyBridge(AppService):
async def handle_matrix_message(self, room_id: RoomID, sender: UserID, message: str):
# Handle incoming Matrix messages
print(f"{sender} in {room_id}: {message}")

async def send_as_user(self, user_id: str, room_id: str, message: str):
# Send message as a ghost user
intent = self.az.intent.user(user_id)
await intent.send_text(room_id, message)

Appservice Architecture​

┌─────────────────────────────────────────────────────────┐
│ Homeserver │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Events for @_mybridge_*:server rooms/users │ │
│ └─────────────────────────────────────────────────┘ │
│ │ HTTP POST │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Your Appservice │ │
│ │ - Receives all relevant events │ │
│ │ - Can impersonate users in namespace │ │
│ │ - Can create rooms with aliases in namespace │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Library: matrix-appservice-bridge


Getting a Bot Account​

Option 1: Register Normally​

Create account like a regular user, then get access token:

Element: Settings → Help & About → Access Token

Option 2: Admin API (Synapse)​

# Create bot user
curl -X PUT "https://matrix.example.com/_synapse/admin/v2/users/@bot:example.com" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"password": "botpassword", "displayname": "My Bot"}'

Option 3: Shared Registration Secret​

# If registration is disabled but you have the secret
register_new_matrix_user -c /path/to/homeserver.yaml \
-u bot -p password --no-admin https://matrix.example.com

Bot Security Best Practices​

1. Use Access Tokens, Not Passwords​

# Good - use access token
client = AsyncClient(homeserver, user_id)
client.access_token = os.getenv("MATRIX_TOKEN")

# Avoid - storing password
await client.login("password") # Creates new device each time

2. Restrict Permissions​

# Only respond to allowed users
ALLOWED_USERS = ["@admin:example.com"]

async def message_callback(room, event):
if event.sender not in ALLOWED_USERS:
return

3. Validate Input​

import re

async def handle_command(room, event):
# Sanitize user input
args = event.body.split()[1:]
if args and not re.match(r'^[\w\-]+$', args[0]):
await client.room_send(room.room_id, "m.room.message",
{"msgtype": "m.text", "body": "Invalid input"})
return

4. Handle E2EE Properly​

# Verify the bot's session
# Users may need to verify the bot in their client

# For automated verification, consider:
from nio import ToDeviceEvent

async def handle_verification(event: ToDeviceEvent):
# Auto-accept verification requests (use with caution)
pass

Testing Your Bot​

Local Testing​

# Use a test homeserver
docker run -p 8008:8008 matrixdotorg/synapse:latest generate
docker run -p 8008:8008 -v $(pwd)/data:/data matrixdotorg/synapse:latest

Maubot Testing Framework​

# maubot now includes a testing framework
from maubot.testing import TestBot

async def test_hello_command():
bot = TestBot(HelloWorldBot)
response = await bot.send("!hello")
assert response == "Hello, World!"

Resources​

Libraries​

Templates​

Community​


Next: SDKs Overview →