8 Commits

Author SHA1 Message Date
2d47483d55 Fixed BodyRegexFilter returning re.Match 2026-09-12 20:22:02 +03:00
fc4f664a5c Fixed BaseEventFilter.__ror__ recursion 2026-09-12 20:20:41 +03:00
fa97e4b098 Repo improvements
- Improved README.md
- Added examples
- Added `session_storage/` to .gitignore so that examples do not introduce leftover files after execution
2026-09-12 20:16:39 +03:00
1fe4434e16 Filters refactoring and small improvement
- Removed `filters/__init__.py` (filters are to be imported from `mab` directly)
- Renamed `Text` filters to `Body` filters
- Fixed `base.py` filters not propagating **kwargs to base classes
- Added `__init__` for BaseEventFilter so that keyword argument that remain unused by its children get logged using `logging.critical`
2026-09-12 20:14:28 +03:00
4f0792b9aa Added filter for msgtype 2026-09-12 20:08:27 +03:00
31fbcb4697 Added send_image_bytes(...) to avoid temp files 2026-09-12 20:04:55 +03:00
3e15ae426c Added MatrixBot.run() for dev simplification 2026-09-12 18:06:55 +03:00
b8e598715c Little README update 2026-09-09 19:46:05 +03:00
13 changed files with 407 additions and 98 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/ __pycache__/
session_storage/
*.vscode *.vscode
.venv/ .venv/
dist/ dist/

View File

@@ -1,67 +1,61 @@
# mab # 🤖 mab
**mab** *(MAtrix Bot)* is a **very** simple Python package that can be used to **mab** *(MAtrix Bot)* is a **very** simple Python package that can be used to
develop **very** simple Matrix bots. I have decided to make something like this develop **very** simple Matrix bots. It does not aim to be the best library out
because I wasn't satisfied by simplicity and usage of other libraries. So there, but it aims to be convenient and usable for relatively serious projects.
this library does not aim to be "the best matrix bot library", it only aims to
be good enough for me.
## Features ## ✨ Features
The package supports the following features: The library supports the following features:
- **Completely `asyncio` based**
- **Filter-based callback system** - **Filter-based callback system**
- **Images sending** - **Sending images**
- **Videos sending with automatic thumbnail generation (requires `ffmpeg`)** - **Sending videos with automatic thumbnail generation (requires `ffmpeg`)**
## Installation ## 🚀 Usage
Use `pip` to install this package: Use `apt` to install required system packages and `pip` to install the package.
You may need to use `root` privileges to use `apt`. It's highly recommended you
use `venv` or another Python virtual environment. Here are the commands to
install the latest version of the library:
```bash ```bash
apt install libmagic1-dev libolm-dev
python -m pip install git+https://git.tyukalov.su/nikita/mab@v0.4.0 python -m pip install git+https://git.tyukalov.su/nikita/mab@v0.4.0
``` ```
You should specify package version you want to use, because `main` without tags `libmagic1-dev` is needed for automatic file MIME type detection, `libolm-dev`
contain unstable code. is needed for E2EE to work.
## Basic usage Please inspect [`examples/shell_bot.py`](examples/shell_bot.py),
[`examples/echo_bot.py`](examples/echo_bot.py) or open [`examples/`](examples/)
directory to find usage examples. Examples require that you set
`MATRIX_HOMESERVER` and `MATRIX_USERNAME` environment variables. Examples create
`session_storage` directory in working directory.
This is the most simple bot you can create. It would respond to any message ## 🏷️ Versioning
that contains `hello` and `hi` words.
```python Releases are tagged in this repository using the `vX.Y.Z` format. If the commit
import asyncio is not tagged, it must be treated as versionless and should not be used for your
from pathlib import Path application.
from mab import MatrixBot, MatrixBotConfig - `X` **(Major)**: Breaking architectiral changes or complete rewrites. Existing
from mab import TextContainsFilter, SenderIsBotFilter code will break. Note that `0.Y.Z` versions are considered **very unstable**,
from mab.types import RoomEventData the API may change at any time and some features do not work as expected.
- `Y` **(Minor)**: Breaking API changes, feature removals, or behavioral
modifications. Existing code will likely break.
- `Z` **(Patch)**: Backward-compatible feature additions, bug fixes, or internal
changes. Existing code will not break.
async def on_message(data: RoomEventData) -> None: ## 🛠️ Development
text = f"Your message contains {len(data.event.body)} symbols"
await data.bot.send_text_to_room(data.room, text)
async def main() -> None: Here's the list of commands you should execute to get started with development
# create and start the bot (including cloning the repository and installing required packages). Please note
cfg = MatrixBotConfig( that your workflow may use something other than `venv`.
matrix_homeserver_url="matrix.domain.su", ```bash
matrix_username_localpart="nagibator666", apt install libmagic1-dev libolm-dev
storage_directory=Path("storage_nagibator666") git clone https://git.tyukalov.su/nikita/mab
) cd mab
bot = MatrixBot(matrix_bot_config) python3 -m venv .venv
bot.add_callback( . .venv/bin/activate
~SenderIsBotFilter() & TextContainsFilter(["test", "hello", "hi"]), pip install -e .
on_message
)
await bot.start()
# wait for Ctrl+C
try:
while True:
await asyncio.sleep(1)
except:
pass
# stop the bot
await bot.stop()
if __name__ == "__main__":
asyncio.run(main())
``` ```

18
examples/_environment.py Normal file
View File

@@ -0,0 +1,18 @@
import os
import sys
def check_environment() -> None:
"""
This function checks if required environment variables are set. It prints
problem resolution guide and exits using `sys.exit(1)` on problem.
"""
if "MATRIX_HOMESERVER" not in os.environ:
print("Please set `MATRIX_HOMESERVER` environment variable!")
print("P.S. use something like this in your shell:")
print(" export MATRIX_HOMESERVER=\"https://matrix.server.net\"")
sys.exit(1)
if "MATRIX_USERNAME" not in os.environ:
print("Please set `MATRIX_USERNAME` environment variable!")
print("P.S. use something like this in your shell:")
print(" export MATRIX_USERNAME=\"megakiller228\"")
sys.exit(1)

51
examples/echo_bot.py Normal file
View File

@@ -0,0 +1,51 @@
"""
This example implements Matrix bot that echoes all text messages it receives.
It uses environment variables to specify authorization data. Use Ctrl+C to stop
the bot.
"""
import asyncio
import os
import logging
from mab import (
MatrixBot,
MatrixBotConfig,
RoomEventData,
BodyExistsFilter,
MessageTypeFilter,
SenderIsBotFilter,
MessageType
)
from _environment import check_environment
async def on_text_message(data: RoomEventData) -> None:
"""This callback is called when a text message arrives."""
await data.bot.send_text(data.room, data.event.body) # type: ignore
async def main() -> None:
"""Application entry point"""
logging.basicConfig(level=logging.INFO)
logging.getLogger("nio").setLevel(logging.CRITICAL+1)
check_environment()
config = MatrixBotConfig(
matrix_homeserver_url=os.environ["MATRIX_HOMESERVER"],
matrix_username_localpart=os.environ["MATRIX_USERNAME"],
storage_directory="session_storage"
)
bot = MatrixBot(config)
bot.add_callback(
~SenderIsBotFilter() & BodyExistsFilter() & MessageTypeFilter(MessageType.TEXT),
on_text_message)
# run until Ctrl+C
try:
await bot.run()
except asyncio.CancelledError:
pass
if __name__ == "__main__":
asyncio.run(main())

103
examples/image_gen_bot.py Normal file
View File

@@ -0,0 +1,103 @@
"""
This example implements Matrix bot that generates a pixelized noise image with
specified maximum R, G and B values.
It uses environment variables to specify authorization data. Use Ctrl+C to stop
the bot.
"""
import asyncio
from io import BytesIO
import os
import logging
import random
from PIL import Image
from mab import (
MatrixBot,
MatrixBotConfig,
RoomEventData,
MessageTypeFilter,
BodyCommandFilter,
SenderIsBotFilter,
MessageType
)
from _environment import check_environment
async def on_gen_command(data: RoomEventData) -> None:
"""This callback is called when `!gen R G B` command is received."""
# convert R, G and B to floats
try:
r, g, b = [float(v) for v in data.event.command_args] # type: ignore
except:
await data.bot.send_text(data.room, "Invalid arguments")
return
await data.bot.send_text(data.room, "Generating the noise...")
# create the basic noise
img = Image.new("RGB", (16, 16))
for x in range(img.width):
for y in range(img.height):
col = (random.random() * r, random.random() * g, random.random() * b)
img.putpixel(
(x, y),
tuple(int(c * 255) for c in col)
)
# pixelized upscale
img = img.resize((2048, 2048), resample=Image.Resampling.NEAREST)
# save to buffer
buf = BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
buf = buf.read()
# send
await data.bot.send_image_bytes(data.room, buf, "noise.png")
async def on_wrong_message(data: RoomEventData) -> None:
"""This callback is called when a wrong message is received."""
await data.bot.send_text(
data.room,
"Text me something like <code>!gen 0.1 0.7 1.0</code>"
)
async def main() -> None:
"""Application entry point"""
logging.basicConfig(level=logging.INFO)
logging.getLogger("nio").setLevel(logging.CRITICAL+1)
check_environment()
config = MatrixBotConfig(
matrix_homeserver_url=os.environ["MATRIX_HOMESERVER"],
matrix_username_localpart=os.environ["MATRIX_USERNAME"],
storage_directory="session_storage"
)
bot = MatrixBot(config)
command_filter = MessageTypeFilter(MessageType.TEXT) & BodyCommandFilter(
verbs=["gen"],
min_args=3,
max_args=3
)
# callback for message that
# 1. are sent not by this bot
# 2. do match the command filter
bot.add_callback(
~SenderIsBotFilter() & command_filter,
on_gen_command)
# callback for message that
# 1. are sent not by this bot
# 2. do NOT match the command filter
bot.add_callback(
~SenderIsBotFilter() & ~command_filter,
on_wrong_message
)
# run until Ctrl+C
try:
await bot.run()
except asyncio.CancelledError:
pass
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,12 +1,13 @@
from . import bot from . import bot
from . import types from . import types
from .types import MatrixBotConfig from .types import MatrixBotConfig, RoomEventData, MessageType
from .bot import MatrixBot from .bot import MatrixBot
from .filters.base import * from .filters.base import *
from .filters.text import * from .filters.message import *
from .filters.body import *
__all__ = [ __all__ = [
# module names # module names
@@ -15,18 +16,29 @@ __all__ = [
# .types # .types
"MatrixBotConfig", "MatrixBotConfig",
"RoomEventData",
"MessageType",
# .bot # .bot
"MatrixBot", "MatrixBot",
# .filters.base # .filters.base
"BaseEventFilter", "BaseEventFilter",
"EventTypeFilter",
# .filters.text # .filters.body
"TextFilter", "BodyExistsFilter",
"FormattedTextFilter", "BodyContainsFilter",
"TextContainsFilter", "BodyStartsWithFilter",
"TextStartsWithFilter", "BodyEndsWithFilter",
"TextEndsWithFilter", "BodyCommandFilter",
"TextCommandFilter", "BodyRegexFilter",
# .filters.message
"MessageTypeFilter",
"NewMessageFilter",
"EditedMessageFilter",
"RedactedMessageFilter",
"SenderIsFilter",
"SenderIsBotFilter",
] ]

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
from io import BytesIO
import logging import logging
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
@@ -214,6 +215,69 @@ class ClientSender:
} }
return (await self.send_content(room, content)).event_id return (await self.send_content(room, content)).event_id
async def send_image_bytes(self,
room: MatrixRoom | str,
data: bytes,
filename: str,
*,
text: str | None = None,
is_html: bool | None = None,
timeout: float | None = 60 * 60) -> str:
"""
Send the image to `room`. Please note that formatted text is displayed
incorrectly in some clients as of September 8th, 2026
Args:
- room - the room to send the text to
- bytes - the image to send
- filename - filename to use for the file
- text - image caption to use (`None` to disable)
- is_html - whether the text is HTML-formatted (`None` for auto)
- timeout - upload timeout in seconds (`None` to disable)
Returns:
- `event_id` of sent message on success
- Raises an exception on error
"""
# caption must not actually be empty
if text is None or not text.strip():
text = filename
is_html = False
# check if the file is image
mime_type: str = magic.from_buffer(data, mime=True)
if not mime_type.startswith("image/"):
raise RuntimeError(f"Data has non-image mime-type")
# get image size
buffer = BytesIO(data)
with Image.open(buffer) as image:
width, height = image.size
buffer.seek(0)
# upload
async with asyncio.timeout(timeout):
upload_result = await self._uploader.upload_using_provider(
provider=buffer,
mime_type=mime_type,
filename=filename,
filesize=len(data))
# prepare the content and send
content = {
"msgtype": "m.image",
"filename": filename,
**self._process_html_text(text, is_html),
"file": {
"url": upload_result.response.content_uri,
"mimetype": mime_type,
**upload_result.keys
},
"info": {
"mimetype": mime_type,
"size": upload_result.filesize,
"w": width,
"h": height
}
}
return (await self.send_content(room, content)).event_id
async def send_video(self, async def send_video(self,
room: MatrixRoom | str, room: MatrixRoom | str,
path: Path | str, path: Path | str,

View File

@@ -1,3 +1,4 @@
import asyncio
import logging import logging
from typing import Callable, Coroutine, Any from typing import Callable, Coroutine, Any
@@ -97,6 +98,20 @@ class MatrixBot:
""" """
await self._client_manager.stop() await self._client_manager.stop()
async def run(self) -> None:
"""
Start bot operation in foreground. You may cancel task running this
method to stop the bot.
Warning: calling `stop()` is not a supported way to stop the bot. You
should cancel this task instead.
"""
await self.start()
try:
await asyncio.Event().wait()
finally:
await self.stop()
def get_client(self) -> AsyncClient: def get_client(self) -> AsyncClient:
""" """
Get AsyncClient. Get AsyncClient.
@@ -162,6 +177,39 @@ class MatrixBot:
timeout=timeout timeout=timeout
) )
async def send_image_bytes(self,
room: MatrixRoom | str,
data: bytes,
filename: str,
*,
text: str | None = None,
is_html: bool | None = None,
timeout: float | None = 60 * 60) -> str:
"""
Send the image to `room`. Please note that formatted text is displayed
incorrectly in some clients as of September 8th, 2026
Args:
- room - the room to send the text to
- bytes - the image to send
- filename - filename to use for the file
- text - image caption to use (`None` to disable)
- is_html - whether the text is HTML-formatted (`None` for auto)
- timeout - upload timeout in seconds (`None` to disable)
Returns:
- `event_id` of sent message on success
- Raises an exception on error
"""
return await self._client_sender.send_image_bytes(
room=room,
data=data,
filename=filename,
text=text,
is_html=is_html,
timeout=timeout
)
async def send_video(self, async def send_video(self,
room: MatrixRoom | str, room: MatrixRoom | str,
path: Path | str, path: Path | str,

View File

@@ -1,28 +0,0 @@
from .base import *
from .message import *
from .room import *
from .text import *
__all__ = [
# base.py
"BaseEventFilter",
"EventTypeFilter",
# message.py
"NewMessageFilter",
"EditedMessageFilter",
"RedactedMessageFilter",
"SenderIsFilter",
"SenderIsBotFilter",
# room.py
"RoomEncryptedFilter",
# text.py
"TextFilter",
"TextContainsFilter",
"TextStartsWithFilter",
"TextEndsWithFilter",
"TextCommandFilter",
"TextRegexFilter"
]

View File

@@ -9,6 +9,10 @@ class BaseEventFilter(ABC):
"""Base class for all message filters""" """Base class for all message filters"""
_logger = logging.Logger("EventFilter") _logger = logging.Logger("EventFilter")
def __init__(self, **kwargs):
for a in kwargs:
self._logger.critical(f"Unknown keyword argument for some filter is used: '{a}'={repr(kwargs[a])}")
# AND # AND
def __and__(self, other): def __and__(self, other):
if not isinstance(other, BaseEventFilter): if not isinstance(other, BaseEventFilter):
@@ -31,7 +35,7 @@ class BaseEventFilter(ABC):
) )
def __ror__(self, other): def __ror__(self, other):
return self.__ror__(other) return self.__or__(other)
# XOR # XOR
def __xor__(self, other): def __xor__(self, other):
@@ -80,7 +84,8 @@ class BaseEventFilter(ABC):
class EventTypeFilter(BaseEventFilter): class EventTypeFilter(BaseEventFilter):
"""Event filter that checks if the event is an instance of some class""" """Event filter that checks if the event is an instance of some class"""
def __init__(self, type: Type): def __init__(self, type: Type, **kwargs):
super().__init__(**kwargs)
self._type = type self._type = type
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool: async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
@@ -113,8 +118,8 @@ class CompoundEventFilter(BaseEventFilter):
CompoundEventFilter.OPERATOR_INVERT: [1], CompoundEventFilter.OPERATOR_INVERT: [1],
}[op] }[op]
def __init__(self, operator: str, arguments: list[BaseEventFilter]): def __init__(self, operator: str, arguments: list[BaseEventFilter], **kwargs):
super().__init__() super().__init__(**kwargs)
if not self._is_operator_valid(operator): if not self._is_operator_valid(operator):
raise RuntimeError(f"Invalid operator `{operator}`") raise RuntimeError(f"Invalid operator `{operator}`")
if not self._is_elements_count_valid(operator, len(arguments)): if not self._is_elements_count_valid(operator, len(arguments)):

View File

@@ -5,7 +5,7 @@ from .message import NewMessageFilter
from nio import AsyncClient from nio import AsyncClient
from nio import MatrixRoom, Event from nio import MatrixRoom, Event
class TextFilter(NewMessageFilter): class BodyExistsFilter(NewMessageFilter):
""" """
This filter returns True if all conditions are met: This filter returns True if all conditions are met:
1. `event` has attribute `body` 1. `event` has attribute `body`
@@ -18,12 +18,17 @@ class TextFilter(NewMessageFilter):
If `event.body` value equals to `event.source["content"]["filename"]` (if it If `event.body` value equals to `event.source["content"]["filename"]` (if it
is present, of course) then this filter will not match it by default. You is present, of course) then this filter will not match it by default. You
may disable `ignore_filename_in_body` to disable this feature. may disable `ignore_filename_in_body` to disable this feature.
This filter will match any message that has `body` in it, including images,
videos, files, etc.
""" """
def __init__(self, *, ignore_filename_in_body: bool = True, **kwargs): def __init__(self, *, ignore_filename_in_body: bool = True, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._ignore_filename_in_body = ignore_filename_in_body self._ignore_filename_in_body = ignore_filename_in_body
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool: async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
return False
if not hasattr(event, "body"): if not hasattr(event, "body"):
return False return False
if not isinstance(event.body, str): # type: ignore if not isinstance(event.body, str): # type: ignore
@@ -36,7 +41,7 @@ class TextFilter(NewMessageFilter):
return False return False
return True return True
class TextContainsFilter(TextFilter): class BodyContainsFilter(BodyExistsFilter):
""" """
This filter returns True if `event.body` contains `needle` substring (or any This filter returns True if `event.body` contains `needle` substring (or any
of neddle from the list). `event.body` will be converted to lower case if of neddle from the list). `event.body` will be converted to lower case if
@@ -65,7 +70,7 @@ class TextContainsFilter(TextFilter):
return True return True
return False return False
class TextStartsWithFilter(TextFilter): class BodyStartsWithFilter(BodyExistsFilter):
""" """
This filter returns True if `event.body` starts with `substring` (or any of This filter returns True if `event.body` starts with `substring` (or any of
substrings from the list). The check will be case insensetive if `any_case` substrings from the list). The check will be case insensetive if `any_case`
@@ -94,7 +99,7 @@ class TextStartsWithFilter(TextFilter):
return True return True
return False return False
class TextEndsWithFilter(TextFilter): class BodyEndsWithFilter(BodyExistsFilter):
""" """
This filter returns True if `event.body` ends with `substring` (or any of This filter returns True if `event.body` ends with `substring` (or any of
substrings from the list). The check will be case insensetive if `any_case` substrings from the list). The check will be case insensetive if `any_case`
@@ -123,7 +128,7 @@ class TextEndsWithFilter(TextFilter):
return True return True
return False return False
class TextCommandFilter(TextFilter): class BodyCommandFilter(BodyExistsFilter):
""" """
This filter returns True if all conditions are met: This filter returns True if all conditions are met:
1. `event.body` contains at least `min_args + 1` words after split() 1. `event.body` contains at least `min_args + 1` words after split()
@@ -171,7 +176,7 @@ class TextCommandFilter(TextFilter):
return True return True
return False return False
class TextRegexFilter(TextFilter): class BodyRegexFilter(BodyExistsFilter):
""" """
This filter returns True if the `event.body` passes the regex. This filter returns True if the `event.body` passes the regex.
""" """
@@ -185,7 +190,7 @@ class TextRegexFilter(TextFilter):
if not await super().__call__(room, event, client): if not await super().__call__(room, event, client):
return False return False
try: try:
return self._regex.match(event.body) # type: ignore return self._regex.match(event.body) is not None # type: ignore
except: except:
self._logger.error(traceback.format_exc()) self._logger.error(traceback.format_exc())
return False return False

View File

@@ -1,11 +1,36 @@
import traceback import traceback
from .base import BaseEventFilter, EventTypeFilter from .base import BaseEventFilter, EventTypeFilter
from ..types import MessageType
from nio import AsyncClient from nio import AsyncClient
from nio import MatrixRoom, Event from nio import MatrixRoom, Event
from nio import RedactionEvent from nio import RedactionEvent
class MessageTypeFilter(BaseEventFilter):
"""
This filter should be used to match specific message types (text-only,
images, videos, files, etc) based on `event.source["content"]["msgtype"]`
value.
`types` list is stored by reference so you may modify the behavior of this
filter dynamically.
"""
def __init__(self, types: list[MessageType] | MessageType, **kwargs):
super().__init__(**kwargs)
if isinstance(types, MessageType):
types = [types]
self._types = types
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
return False
if "msgtype" not in event.source["content"]:
return False
return (
event.source["content"]["msgtype"] in [t.value for t in self._types]
)
class NewMessageFilter(BaseEventFilter): class NewMessageFilter(BaseEventFilter):
""" """
This filter returns True if the event is a new message. Most filters are This filter returns True if the event is a new message. Most filters are

View File

@@ -2,6 +2,7 @@
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from nio import MatrixRoom, Event from nio import MatrixRoom, Event
from nio import UploadResponse from nio import UploadResponse
@@ -98,3 +99,13 @@ class UploadResult:
filesize: int filesize: int
"""Size of uploaded file""" """Size of uploaded file"""
class MessageType(Enum):
TEXT = "m.text"
EMOTE = "m.emote"
NOTICE = "m.notice"
IMAGE = "m.image"
FILE = "m.file"
AUDIO = "m.audio"
LOCATION = "m.location"
VIDEO = "m.video"