diff --git a/Dockerfile b/Dockerfile
index a027b9e..b830622 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,8 +3,8 @@ FROM python:3.11-alpine
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
-RUN apk add --no-cache bash build-base capstone-dev mandoc-doc man-db w3m youtube-dl aspell aspell-fr && \
- pip install --no-cache-dir -r requirements.txt && \
+RUN apk add --no-cache bash build-base capstone-dev mandoc-doc man-db w3m youtube-dl aspell aspell-fr py3-matrix-nio && \
+ pip install --no-cache-dir --ignore-installed -r requirements.txt && \
pip install bs4 capstone dnspython openai && \
apk del build-base capstone-dev && \
ln -s /var/lib/nemubot/home /home/nemubot
diff --git a/nemubot/message/printer/Matrix.py b/nemubot/message/printer/Matrix.py
new file mode 100644
index 0000000..ad1b99e
--- /dev/null
+++ b/nemubot/message/printer/Matrix.py
@@ -0,0 +1,69 @@
+# Nemubot is a smart and modulable IM bot.
+# Copyright (C) 2012-2026 Mercier Pierre-Olivier
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from nemubot.message.visitor import AbstractVisitor
+
+
+class Matrix(AbstractVisitor):
+
+ """Visitor that sends bot responses as Matrix room messages.
+
+ Instead of accumulating text like the IRC printer does, each visit_*
+ method calls send_func(room_id, text) directly for every destination room.
+ """
+
+ def __init__(self, send_func):
+ """
+ Argument:
+ send_func -- callable(room_id: str, text: str) that sends a plain-text
+ message to the given Matrix room
+ """
+ self._send = send_func
+
+ def visit_Text(self, msg):
+ if isinstance(msg.message, str):
+ for room in msg.to:
+ self._send(room, msg.message)
+ else:
+ # Nested message object — let it visit itself
+ msg.message.accept(self)
+
+ def visit_DirectAsk(self, msg):
+ text = msg.message if isinstance(msg.message, str) else str(msg.message)
+ # Rooms that are NOT the designated nick → prefix with "nick: "
+ others = [to for to in msg.to if to != msg.designated]
+ if len(others) == 0 or len(others) != len(msg.to):
+ # At least one room IS the designated target → send plain
+ for room in msg.to:
+ self._send(room, text)
+ if len(others):
+ # Other rooms → prefix with nick
+ for room in others:
+ self._send(room, "%s: %s" % (msg.designated, text))
+
+ def visit_Command(self, msg):
+ parts = ["!" + msg.cmd]
+ if msg.args:
+ parts.extend(msg.args)
+ for room in msg.to:
+ self._send(room, " ".join(parts))
+
+ def visit_OwnerCommand(self, msg):
+ parts = ["`" + msg.cmd]
+ if msg.args:
+ parts.extend(msg.args)
+ for room in msg.to:
+ self._send(room, " ".join(parts))
diff --git a/nemubot/server/Matrix.py b/nemubot/server/Matrix.py
new file mode 100644
index 0000000..ed4b746
--- /dev/null
+++ b/nemubot/server/Matrix.py
@@ -0,0 +1,200 @@
+# Nemubot is a smart and modulable IM bot.
+# Copyright (C) 2012-2026 Mercier Pierre-Olivier
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import asyncio
+import shlex
+import threading
+
+import nemubot.message as message
+from nemubot.server.threaded import ThreadedServer
+
+
+class Matrix(ThreadedServer):
+
+ """Matrix server implementation using matrix-nio's AsyncClient.
+
+ Runs an asyncio event loop in a daemon thread. Incoming room messages are
+ converted to nemubot bot messages and pushed through the pipe; outgoing
+ responses are sent via the async client from the same event loop.
+ """
+
+ def __init__(self, homeserver, user_id, password=None, access_token=None,
+ owner=None, nick=None, channels=None, **kwargs):
+ """Prepare a connection to a Matrix homeserver.
+
+ Keyword arguments:
+ homeserver -- base URL of the homeserver, e.g. "https://matrix.org"
+ user_id -- full MXID (@user:server) or bare localpart
+ password -- login password (required if no access_token)
+ access_token -- pre-obtained access token (alternative to password)
+ owner -- MXID of the bot owner (marks frm_owner on messages)
+ nick -- display name / prefix for DirectAsk detection
+ channels -- list of room IDs / aliases to join on connect
+ """
+
+ # Ensure fully-qualified MXID
+ if not user_id.startswith("@"):
+ host = homeserver.split("//")[-1].rstrip("/")
+ user_id = "@%s:%s" % (user_id, host)
+
+ super().__init__(name=user_id)
+
+ self.homeserver = homeserver
+ self.user_id = user_id
+ self.password = password
+ self.access_token = access_token
+ self.owner = owner
+ self.nick = nick or user_id
+
+ self._initial_rooms = channels or []
+ self._client = None
+ self._loop = None
+ self._thread = None
+
+
+ # Open/close
+
+ def _start(self):
+ self._thread = threading.Thread(
+ target=self._run_loop,
+ daemon=True,
+ name="nemubot.Matrix/" + self._name,
+ )
+ self._thread.start()
+
+ def _stop(self):
+ if self._client and self._loop and not self._loop.is_closed():
+ try:
+ asyncio.run_coroutine_threadsafe(
+ self._client.close(), self._loop
+ ).result(timeout=5)
+ except Exception:
+ self._logger.exception("Error while closing Matrix client")
+ if self._thread:
+ self._thread.join(timeout=5)
+
+
+ # Asyncio thread
+
+ def _run_loop(self):
+ self._loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self._loop)
+ try:
+ self._loop.run_until_complete(self._async_main())
+ except Exception:
+ self._logger.exception("Unhandled exception in Matrix event loop")
+ finally:
+ self._loop.close()
+
+ async def _async_main(self):
+ from nio import AsyncClient, LoginError, RoomMessageText
+
+ self._client = AsyncClient(self.homeserver, self.user_id)
+
+ if self.access_token:
+ self._client.access_token = self.access_token
+ self._logger.info("Using provided access token for %s", self.user_id)
+ elif self.password:
+ resp = await self._client.login(self.password)
+ if isinstance(resp, LoginError):
+ self._logger.error("Matrix login failed: %s", resp.message)
+ return
+ self._logger.info("Logged in to Matrix as %s", self.user_id)
+ else:
+ self._logger.error("Need either password or access_token to connect")
+ return
+
+ self._client.add_event_callback(self._on_room_message, RoomMessageText)
+
+ for room in self._initial_rooms:
+ await self._client.join(room)
+ self._logger.info("Joined room %s", room)
+
+ await self._client.sync_forever(timeout=30000, full_state=True)
+
+
+ # Incoming messages
+
+ async def _on_room_message(self, room, event):
+ """Callback invoked by matrix-nio for each m.room.message event."""
+
+ if event.sender == self.user_id:
+ return # ignore own messages
+
+ text = event.body
+ room_id = room.room_id
+ frm = event.sender
+
+ common_args = {
+ "server": self.name,
+ "to": [room_id],
+ "to_response": [room_id],
+ "frm": frm,
+ "frm_owner": frm == self.owner,
+ }
+
+ if len(text) > 1 and text[0] == '!':
+ text = text[1:].strip()
+ try:
+ args = shlex.split(text)
+ except ValueError:
+ args = text.split(' ')
+ msg = message.Command(cmd=args[0], args=args[1:], **common_args)
+
+ elif (text.lower().startswith(self.nick.lower() + ":")
+ or text.lower().startswith(self.nick.lower() + ",")):
+ text = text[len(self.nick) + 1:].strip()
+ msg = message.DirectAsk(designated=self.nick, message=text,
+ **common_args)
+
+ else:
+ msg = message.Text(message=text, **common_args)
+
+ self._push_message(msg)
+
+
+ # Outgoing messages
+
+ def send_response(self, response):
+ if response is None:
+ return
+ if isinstance(response, list):
+ for r in response:
+ self.send_response(r)
+ return
+
+ from nemubot.message.printer.Matrix import Matrix as MatrixPrinter
+ printer = MatrixPrinter(self._send_text)
+ response.accept(printer)
+
+ def _send_text(self, room_id, text):
+ """Thread-safe: schedule a Matrix room_send on the asyncio loop."""
+ if not self._client or not self._loop or self._loop.is_closed():
+ self._logger.warning("Cannot send: Matrix client not ready")
+ return
+ future = asyncio.run_coroutine_threadsafe(
+ self._client.room_send(
+ room_id=room_id,
+ message_type="m.room.message",
+ content={"msgtype": "m.text", "body": text},
+ ignore_unverified_devices=True,
+ ),
+ self._loop,
+ )
+ future.add_done_callback(
+ lambda f: self._logger.warning("Matrix send error: %s", f.exception())
+ if not f.cancelled() and f.exception() else None
+ )
diff --git a/nemubot/server/__init__.py b/nemubot/server/__init__.py
index a39c491..9e186ed 100644
--- a/nemubot/server/__init__.py
+++ b/nemubot/server/__init__.py
@@ -72,5 +72,37 @@ def factory(uri, ssl=False, **init_args):
from ssl import wrap_socket
srv._fd = context.wrap_socket(srv._fd, server_hostname=o.hostname)
+ elif o.scheme == "matrix":
+ # matrix://localpart:password@homeserver.tld/!room:homeserver.tld
+ # matrix://localpart:password@homeserver.tld/%23alias:homeserver.tld
+ # Use matrixs:// for https (default) vs http
+ args = dict(init_args)
+
+ homeserver = "https://" + o.hostname
+ if o.port is not None:
+ homeserver += ":%d" % o.port
+ args["homeserver"] = homeserver
+
+ if o.username is not None:
+ args["user_id"] = o.username
+ if o.password is not None:
+ args["password"] = unquote(o.password)
+
+ # Parse rooms from path (comma-separated, URL-encoded)
+ if o.path and o.path != "/":
+ rooms = [unquote(r) for r in o.path.lstrip("/").split(",") if r]
+ if rooms:
+ args.setdefault("channels", []).extend(rooms)
+
+ params = parse_qs(o.query)
+ if "token" in params:
+ args["access_token"] = params["token"][0]
+ if "nick" in params:
+ args["nick"] = params["nick"][0]
+ if "owner" in params:
+ args["owner"] = params["owner"][0]
+
+ from nemubot.server.Matrix import Matrix as MatrixServer
+ srv = MatrixServer(**args)
return srv
diff --git a/requirements.txt b/requirements.txt
index e69de29..45eefe2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -0,0 +1 @@
+matrix-nio