19 Commits

Author SHA1 Message Date
f3f1e24c0b Updated to v0.5.0 2026-09-12 23:34:57 +03:00
2e4228b22a Added example for commands 2026-09-12 23:34:06 +03:00
0b79d78715 Fixed RoomEncryptedFilter 2026-09-12 23:01:48 +03:00
a90cd66d3e Fixed CTX_MESSAGE_TYPE typing 2026-09-12 22:56:45 +03:00
cb520814d8 Added description of filter system to README.md 2026-09-12 22:49:55 +03:00
4287a0d20c Improved filters system
- `RoomEventData` is renamed to `EventContext` and moved to context.py
- `EventContext.filter` is removed
- Added context variables system which improves type hints and simplifies callbacks code
- `BodyCommandFilter` sets context variables from now on
- Added `super().__call__` invocation to filters implements in base.py
- Examples are updated to include required changes
2026-09-12 22:19:28 +03:00
e10c920a56 Fixed README.md 2026-09-12 20:24:18 +03:00
156afd6b61 Fixed README references example that don't exist 2026-09-12 20:23:44 +03:00
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
f58c8601d1 Filter update, fixed bot stop, v0.4.0
- Fixed `asyncio.shield` not being awaited in `ClientManager.stop()` (that led to `next_batch` value not being saved and session not being closed properly)
- `BaseEventFilter` does not have any abstract methods anymore and can be used to use callback for any event
- `BaseEventFilter.__repr__` prints class name now
- `BaseEventFilter.__call__` returns True now
- Added `EventTypeFilter` which can be used to check if the event is an instance of some class
- Removed most room events
- Added `NewMessageFilter`, `EditedMessageFilter`, `RedactedMessageFilter`, `SenderIsFilter` and `SenderIsBotFilter`
- Removed `FormattedTextFilter`
- Text filters are derived from `NewMessageFilter` so they won't match edited messages anymore
2026-09-09 19:43:29 +03:00
159a43ebe6 Fixed _callbacks.py did not reraise CancelledError 2026-09-09 18:19:52 +03:00
9f4cd4948a Text filters update and stability
- **kwargs are propagated to base classes in text filters from now on
- `_callbacks.py` prints filter exceptions from now on
2026-09-09 18:18:07 +03:00
18 changed files with 902 additions and 299 deletions

1
.gitignore vendored
View File

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

152
README.md
View File

@@ -1,69 +1,121 @@
# mab
# 🤖 mab
**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
because I wasn't satisfied by simplicity and usage of other libraries. So
this library does not aim to be "the best matrix bot library", it only aims to
be good enough for me.
develop **very** simple Matrix bots. It does not aim to be the best library out
there, but it aims to be convenient and usable for relatively serious projects.
## Features
## ✨ Features
The package supports the following features:
The library supports the following features:
- **Completely `asyncio` based**
- **Filter-based callback system**
- **Images sending**
- **Videos sending with automatic thumbnail generation (requires `ffmpeg`)**
- **Sending images**
- **Sending videos with automatic thumbnail generation (requires `ffmpeg`)**
## Installation
## 📦 Installation
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
python -m pip install git+https://git.tyukalov.su/nikita/mab@v0.3.0
apt install libmagic1-dev libolm-dev
python -m pip install git+https://git.tyukalov.su/nikita/mab@v0.5.0
```
You should specify package version you want to use, because `main` without tags
contain unstable code.
`libmagic1-dev` is needed for automatic file MIME type detection, `libolm-dev`
is needed for E2EE to work.
## Basic usage
Please inspect [`examples/image_gen_bot.py`](examples/image_gen_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
that starts with `!test`, `!hello` or `!hi`.
## 🚀 Usage
```python
import asyncio
from mab import MatrixBot, MatrixBotConfig
from mab import TextCommandFilter
from mab.types import RoomEventData
If you use `mab`, your application will *most likely* be using **callbacks** to
react to user actions. `mab` uses filter-based callback system to avoid exposing
raw `nio-matrix` event objects.
async def on_valid_command(data: RoomEventData) -> None:
# do not respond to ourselves
if event.sender == data.bot.get_client().user_id:
return
text = f"Your message contains {len(data.event.body)} symbols"
await data.bot.send_text_to_room(data.room, text)
This is the workflow you will most likely follow:
1. **Define the callback as `async` function that take 1 argument of type
`EventContext`.** For example, this callback would print the caption of the
message:
```python
from mab import *
async def main() -> None:
# create and start the bot
cfg = MatrixBotConfig(
matrix_homeserver_url="matrix.domain.su",
matrix_username_localpart="nagibator666",
storage_directory=Path("storage_nagibator666")
async def on_media_with_body(ctx: EventContext):
"""To be called when a message with image/video and caption is received."""
print(ctx[CTX_BODY])
```
2. **Define the conditions your callback must be called on.** For example, you
may want your callback to be called when `the sender is not the bot` and
`the message contains textual body` and (`the message is an image` or
`the message is a video`).
3. **Define the conditions as `filters`.** Most of them are pretty
straightforward. For example, if you want to use the conditions from above:
```python
from mab import *
filters = (
~SenderIsBotFilter()
& MessageTypeFilter([MessageType.IMAGE, MessageType.VIDEO])
& BodyExistsFilter()
)
bot = MatrixBot(matrix_bot_config)
bot.add_callback(
TextCommandFilter(["test", "hello", "hi"]),
on_valid_command
)
await bot.start()
# wait for Ctrl+C
try:
while True:
await asyncio.sleep(1)
except:
pass
# stop the bot
await bot.stop()
```
4. **Add the callback to your `MatrixBot` instance.** For example, if you would
have used everything from above, then your code would look something like
this:
```python
from mab import *
if __name__ == "__main__":
asyncio.run(main())
# let's assume you create your MatrixBot as `bot` variable here
async def on_media_with_body(ctx: EventContext):
"""To be called when a message with image/video and caption is received."""
print(ctx[CTX_BODY])
filters = (
~SenderIsBotFilter()
& MessageTypeFilter([MessageType.IMAGE, MessageType.VIDEO])
& BodyExistsFilter()
)
bot.add_callback(filters, on_media_with_body)
...
```
Filters support bitwise operators to implement complex matching logic. Some
filters set context variables which can be accessed by
`context[CTX_KEY_NAME]`-like syntax. Possible variables are defined in
[this file](src/mab/context.py). Filters are implemented in
[files of this directory](src/mab/filters/).
## 🏷️ Versioning
Releases are tagged in this repository using the `vX.Y.Z` format. If the commit
is not tagged, it must be treated as versionless and should not be used for your
application.
- `X` **(Major)**: Breaking architectiral changes or complete rewrites. Existing
code will break. Note that `0.Y.Z` versions are considered **very unstable**,
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.
## 🛠️ Development
Here's the list of commands you should execute to get started with development
(including cloning the repository and installing required packages). Please note
that your workflow may use something other than `venv`.
```bash
apt install libmagic1-dev libolm-dev
git clone https://git.tyukalov.su/nikita/mab
cd mab
python3 -m venv .venv
. .venv/bin/activate
pip install -e .
```

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)

130
examples/command_bot.py Normal file
View File

@@ -0,0 +1,130 @@
"""
This example implements Matrix bot that can execute some commands.
It uses environment variables to specify authorization data. Use Ctrl+C to stop
the bot.
"""
import asyncio
import time
import html
import os
import traceback
import logging
from mab import *
from _environment import check_environment
async def on_help_command(ctx: EventContext) -> None:
"""!help"""
HELP_MESSAGE = (
"<strong>Here is the list of the commands:</strong><br>"
"<ul>"
"<li><code>!help</code> - this help message</li>"
"<li><code>!time</code> - get UNIX timestamp</li>"
"<li><code>!raise</code> - raise <code>RuntimeError()</code></li>"
"<li><code>!assert</code> - perform <code>assert</code> that will fail</li>"
"<li><code>!mul A B [C] [D]...</code> - multiply A, B... and so on</li>"
"<li><code>!args arg1 [arg2] ... [arg5]</code> - command that takes 1..5 arguments</li>"
"</ul>"
)
await ctx.bot.send_text(ctx.room, HELP_MESSAGE)
async def on_time_command(ctx: EventContext) -> None:
"""!time"""
await ctx.bot.send_text(
ctx.room,
f"Current UNIX timestamp is <strong>{int(time.time())}</strong>"
)
async def on_raise_command(ctx: EventContext) -> None:
"""!raise"""
await ctx.bot.send_text(
ctx.room,
"<strong>Executing <code>raise RuntimeError()</code>...</strong>"
)
raise RuntimeError()
async def on_assert_command(ctx: EventContext) -> None:
"""!assert"""
await ctx.bot.send_text(
ctx.room,
"<strong>Executing <code>assert False</code>...</strong>"
)
assert False
async def on_mul_command(ctx: EventContext) -> None:
"""!mul"""
try:
numbers = [float(v) for v in ctx[CTX_CMD_ARGS]]
v = numbers[0]
for n in numbers[1:]:
v *= n
response = " * ".join(html.escape("%.2f" % n) for n in numbers)
response += f" = <strong>{html.escape(str(v))}<strong>"
await ctx.bot.send_text(ctx.room, response)
except Exception as e:
await ctx.bot.send_text(ctx.room, f"Could not process the command: {e}")
async def on_args_command(ctx: EventContext) -> None:
"""!args"""
try:
response = (
f"Prefix: <code>{ctx[CTX_CMD_PREFIX]}</code><br>"
f"Verb: <code>{ctx[CTX_CMD_VERB]}</code><br>"
f"Arguments: <code>{len(ctx[CTX_CMD_ARGS])}</code><br>"
f"Arguments are:<br><ol>"
)
for arg in ctx[CTX_CMD_ARGS]:
response += f"<li><code>{html.escape(arg)}</code></li>"
response += "</ol>"
await ctx.bot.send_text(
ctx.room,
response
)
except:
await ctx.bot.send_text(
ctx.room,
f"Could not process the command: {traceback.format_exc()}"
)
async def invalid_usage(ctx: EventContext) -> None:
"""This callback is called when the bot used incorrectly."""
await ctx.bot.send_text(ctx.room, "Use <code>!help</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)
COMMANDS = {
on_help_command: BodyCommandFilter(["help", "?"]),
on_time_command: BodyCommandFilter("time"),
on_raise_command: BodyCommandFilter("raise"),
on_assert_command: BodyCommandFilter("assert"),
on_mul_command: BodyCommandFilter("mul", min_args=2),
on_args_command: BodyCommandFilter("args", min_args=1, max_args=5),
}
for callback, filter in COMMANDS.items():
f = ~SenderIsBotFilter() & filter
bot.add_callback(f, callback)
bot.add_callback(~SenderIsBotFilter() & NewMessageFilter(), invalid_usage)
# run until Ctrl+C
try:
await bot.run()
except asyncio.CancelledError:
pass
if __name__ == "__main__":
asyncio.run(main())

43
examples/echo_bot.py Normal file
View File

@@ -0,0 +1,43 @@
"""
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 *
from _environment import check_environment
async def on_text_message(ctx: EventContext) -> None:
"""This callback is called when a text message arrives."""
await ctx.bot.send_text(ctx.room, ctx[CTX_BODY])
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())

95
examples/image_gen_bot.py Normal file
View File

@@ -0,0 +1,95 @@
"""
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 *
from _environment import check_environment
async def on_gen_command(data: EventContext) -> 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[CTX_CMD_ARGS]]
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: EventContext) -> 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

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mab"
version = "0.3.0"
version = "0.5.0"
authors = [
{ name = "Tyukalov Nikita", email = "nikita@tyukalov.su" }
]

View File

@@ -1,12 +1,14 @@
from . import bot
from . import types
from .types import MatrixBotConfig
from .types import MatrixBotConfig, MessageType
from .context import *
from .bot import MatrixBot
from .filters.base import *
from .filters.text import *
from .filters.message import *
from .filters.body import *
__all__ = [
# module names
@@ -15,18 +17,37 @@ __all__ = [
# .types
"MatrixBotConfig",
"MessageType",
# .context
"EventContext",
"CTX_BODY",
"CTX_MESSAGE_TYPE",
"CTX_SENDER",
"CTX_CMD_PREFIX",
"CTX_CMD_VERB",
"CTX_CMD_ARGS",
# .bot
"MatrixBot",
# .filters.base
"BaseEventFilter",
"EventTypeFilter",
# .filters.text
"TextFilter",
"FormattedTextFilter",
"TextContainsFilter",
"TextStartsWithFilter",
"TextEndsWithFilter",
"TextCommandFilter",
# .filters.body
"BodyExistsFilter",
"BodyContainsFilter",
"BodyStartsWithFilter",
"BodyEndsWithFilter",
"BodyCommandFilter",
"BodyRegexFilter",
# .filters.message
"MessageTypeFilter",
"NewMessageFilter",
"EditedMessageFilter",
"RedactedMessageFilter",
"SenderIsFilter",
"SenderIsBotFilter",
]

View File

@@ -10,7 +10,8 @@ from nio import MatrixInvitedRoom, InviteMemberEvent, JoinResponse
from nio.events.room_events import Event as RoomEvent
from ._storage import Storage
from ..types import MatrixBotConfig, RoomEventData
from ..types import MatrixBotConfig
from ..context import EventContext
from ..filters.base import BaseEventFilter
if TYPE_CHECKING:
@@ -31,7 +32,7 @@ class Callbacks:
filter: BaseEventFilter
"""Filter to use for matching"""
callback: Callable[[RoomEventData], Coroutine[Any, Any, None]] | None
callback: Callable[[EventContext], Coroutine[Any, Any, None]] | None
"""Callback that will be called if the filter matches"""
stop_matching: bool
@@ -51,14 +52,19 @@ class Callbacks:
for callback_info in self._filters:
if not isinstance(callback_info, self._FilterBasedCallback):
continue
if not await callback_info.filter(room, event, self._client):
continue
event_data = RoomEventData(
event_data = EventContext(
room=room,
event=event,
filter=callback_info.filter,
bot=self._matrix_bot
)
try:
if not await callback_info.filter(event_data):
continue
except asyncio.CancelledError:
raise
except:
self._logger.error(traceback.format_exc())
continue
try:
# dump argument types
if callback_info.callback is None:
@@ -138,7 +144,7 @@ class Callbacks:
def add_room_event_callback(
self,
filter: BaseEventFilter,
callback: Callable[[RoomEventData], Coroutine[Any, Any, None]] | None,
callback: Callable[[EventContext], Coroutine[Any, Any, None]] | None,
*,
stop_matching: bool = True) -> None:
"""

View File

@@ -191,11 +191,13 @@ class ClientManager:
raise RuntimeError("The bot was never started")
self._background_task.cancel()
try:
asyncio.shield(self._background_task)
await asyncio.shield(self._background_task)
except asyncio.CancelledError:
pass
except:
self._logger.error(traceback.format_exc())
try:
asyncio.shield(self._close_client())
await asyncio.shield(self._close_client())
except:
self._logger.error(traceback.format_exc())
self._background_task = None

View File

@@ -1,4 +1,5 @@
import asyncio
from io import BytesIO
import logging
from html.parser import HTMLParser
from pathlib import Path
@@ -214,6 +215,69 @@ class ClientSender:
}
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,
room: MatrixRoom | str,
path: Path | str,

View File

@@ -1,11 +1,13 @@
import asyncio
import logging
from typing import Callable, Coroutine, Any
from nio import AsyncClient
from nio import AsyncClient, MatrixRoom
from ..filters.base import BaseEventFilter
from ..types import *
from ..context import EventContext
from ._validation import Validator
from ._storage import Storage
@@ -43,7 +45,7 @@ class MatrixBot:
def add_callback(self,
filter: BaseEventFilter,
callback: Callable[[RoomEventData], Coroutine[Any, Any, None]] | None,
callback: Callable[[EventContext], Coroutine[Any, Any, None]] | None,
*,
stop_matching: bool = True) -> None:
"""
@@ -97,6 +99,20 @@ class MatrixBot:
"""
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:
"""
Get AsyncClient.
@@ -162,6 +178,39 @@ class MatrixBot:
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,
room: MatrixRoom | str,
path: Path | str,

79
src/mab/context.py Normal file
View File

@@ -0,0 +1,79 @@
"""This module implements logic for event context"""
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
from .types import ContextDataKey, MessageType
from nio import MatrixRoom, Event
if TYPE_CHECKING:
from .bot import MatrixBot
#
# Possible context variables
#
CTX_BODY = ContextDataKey[str]("CTX_BODY")
"""Value of `event.body`"""
CTX_MESSAGE_TYPE = ContextDataKey[MessageType]("CTX_MESSAGE_TYPE")
"""Value of `msgtype` for the event"""
CTX_SENDER = ContextDataKey[str]("CTX_SENDER")
"""Value of `event.sender`"""
CTX_CMD_PREFIX = ContextDataKey[str]("CTX_CMD_PREFIX")
"""Command prefix that was used when matching"""
CTX_CMD_VERB = ContextDataKey[str]("CTX_CMD_VERB")
"""The verb that was used to execute the command"""
CTX_CMD_ARGS = ContextDataKey[list[str]]("CTX_CMD_ARGS")
"""Arguments that were passed with the command"""
CTX_ROOM_ENCRYPTED = ContextDataKey[bool]("CTX_ROOM_ENCRYPTED")
"""True if the room is encrypted"""
#
# EventContext implementation
#
@dataclass
class EventContext:
"""The class holding information about an event that happened in the room"""
room: MatrixRoom
"""The room the event has happened in"""
event: Event
"""The event that has happened in the room"""
bot: "MatrixBot"
"""The bot that is the source of the event"""
def __setitem__[T](self, key: ContextDataKey[T], value: T | None) -> None:
"""Set a value inside the context data storage. `None` removes it"""
if not hasattr(self, "_datastore"):
self._datastore: dict[ContextDataKey, Any] = {}
if value is None:
del self._datastore[key]
else:
self._datastore[key] = value
def __getitem__[T](self, key: ContextDataKey[T]) -> T:
"""
Get a value inside the context data storage.
Raises RuntimeError if the value is not present.
"""
if not hasattr(self, "_datastore") or key not in self._datastore:
raise RuntimeError(f"Context does not contain {repr(key)}")
return self._datastore[key]
def __contains__[T](self, key: ContextDataKey[T]) -> bool:
"""Check if context data storage contains the value"""
if not hasattr(self, "_datastore"):
return False
if key not in self._datastore:
return False
return True

View File

@@ -1,13 +1,20 @@
from abc import ABC, abstractmethod
import logging
from typing import Any, Type
from nio import AsyncClient
from nio import MatrixRoom, Event
from ..context import EventContext
class BaseEventFilter(ABC):
"""Base class for all message filters"""
_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
def __and__(self, other):
if not isinstance(other, BaseEventFilter):
@@ -30,7 +37,7 @@ class BaseEventFilter(ABC):
)
def __ror__(self, other):
return self.__ror__(other)
return self.__or__(other)
# XOR
def __xor__(self, other):
@@ -52,30 +59,41 @@ class BaseEventFilter(ABC):
)
# PAYLOAD
@abstractmethod
def __repr__(self) -> str:
"""This method must be redefined in derived classes to improve
debugging experience.
"""
pass
This method may be redefined in derived classes to improve debugging
experience.
"""
return str(self.__class__.__name__)
@abstractmethod
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
"""This abstract method must be redefined in derived classes so that
the filter operates according to its description. This method must
not raise exceptions. In case of exception it should log it using
`self._logger` and return False
async def __call__(self, context: EventContext) -> bool:
"""
This abstract method must be redefined in derived classes so that the
filter operates according to its description. This method must not raise
exceptions. In case of exception it should log it using `self._logger`
and return False
Args:
room - room the event has happened in
event - the event to check againts this filter
client - the client
- context - event context; your derived classes may add variables
to it (see `message.MessageTypeFilter` implementation
for reference)
Returns:
True if the event satisfies this filter
False if the event does not satisfy this filter
- True if the event satisfies this filter
- False if the event does not satisfy this filter
"""
pass
return True
class EventTypeFilter(BaseEventFilter):
"""Event filter that checks if the event is an instance of some class"""
def __init__(self, type: Type, **kwargs):
super().__init__(**kwargs)
self._type = type
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
return isinstance(context.event, self._type)
class CompoundEventFilter(BaseEventFilter):
"""Event filter that consists of multiple filters"""
@@ -104,8 +122,8 @@ class CompoundEventFilter(BaseEventFilter):
CompoundEventFilter.OPERATOR_INVERT: [1],
}[op]
def __init__(self, operator: str, arguments: list[BaseEventFilter]):
super().__init__()
def __init__(self, operator: str, arguments: list[BaseEventFilter], **kwargs):
super().__init__(**kwargs)
if not self._is_operator_valid(operator):
raise RuntimeError(f"Invalid operator `{operator}`")
if not self._is_elements_count_valid(operator, len(arguments)):
@@ -126,8 +144,10 @@ class CompoundEventFilter(BaseEventFilter):
expression = f"~{reprs[0]}"
return f"({expression})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
evaluated = [await arg(room, event, client) for arg in self._arguments]
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
evaluated = [await arg(context) for arg in self._arguments]
if self._operator == self.OPERATOR_AND:
return all(evaluated)
elif self._operator == self.OPERATOR_OR:

View File

@@ -1,11 +1,18 @@
import re
import traceback
from .base import BaseEventFilter
from .message import NewMessageFilter
from ..context import EventContext
from ..context import (
CTX_BODY,
CTX_CMD_PREFIX,
CTX_CMD_VERB,
CTX_CMD_ARGS
)
from nio import AsyncClient
from nio import MatrixRoom, Event
class TextFilter(BaseEventFilter):
class BodyExistsFilter(NewMessageFilter):
"""
This filter returns True if all conditions are met:
1. `event` has attribute `body`
@@ -14,48 +21,37 @@ class TextFilter(BaseEventFilter):
If this filter matches, you can access `event.body` and it stores
unformatted text of the message.
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
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.
This filter sets `CTX_BODY` context variable.
"""
def __init__(self):
super().__init__()
def __init__(self, *, ignore_filename_in_body: bool = True, **kwargs):
super().__init__(**kwargs)
self._ignore_filename_in_body = ignore_filename_in_body
def __repr__(self) -> str:
return "TextFilter()"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not hasattr(event, "body"):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
if not isinstance(event.body, str): # type: ignore
if not hasattr(context.event, "body"):
return False
if not event.body.strip(): # type: ignore
if not isinstance(context.event.body, str): # type: ignore
return False
if not context.event.body.strip(): # type: ignore
return False
if self._ignore_filename_in_body:
content = context.event.source["content"]
if "filename" in content and content["filename"] == context.event.body: # type: ignore
return False
context[CTX_BODY] = context.event.body # type: ignore
return True
class FormattedTextFilter(BaseEventFilter):
"""
This filter returns True if all conditions are met:
1. `event` has attribute `formatted_body`
2. `event.formatted_body` is instance of `str`
3. `event.formatted_body.strip()` evaluates to True
If this filter matches, you can access `event.formatted_body` and it stores
formatted text of the message.
"""
def __init__(self):
super().__init__()
def __repr__(self):
return "FormattedTextFilter()"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not hasattr(event, "formatted_body"):
return False
if not isinstance(event.formatted_body, str): # type: ignore
return False
if not event.formatted_body.strip(): # type: ignore
return False
return True
class TextContainsFilter(TextFilter):
class BodyContainsFilter(BodyExistsFilter):
"""
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
@@ -68,26 +64,23 @@ class TextContainsFilter(TextFilter):
is True. That means you must ensure that `needle` is lower case. The filter
will never match otherwise.
"""
def __init__(self, needle: str | list[str], *, any_case: bool = True):
super().__init__()
def __init__(self, needle: str | list[str], *, any_case: bool = True, **kwargs):
super().__init__(**kwargs)
if type(needle) is str:
needle = [needle]
self._any_case = any_case
self._needle = needle
def __repr__(self) -> str:
return f"TextContainsFilter({repr(self._needle)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
body = event.body.lower() if self._any_case else event.body # type: ignore
body = context.event.body.lower() if self._any_case else context.event.body # type: ignore
for n in self._needle:
if n in body:
return True
return False
class TextStartsWithFilter(TextFilter):
class BodyStartsWithFilter(BodyExistsFilter):
"""
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`
@@ -100,26 +93,23 @@ class TextStartsWithFilter(TextFilter):
is True. That means you must ensure that `needle` is lower case. The filter
will never match otherwise.
"""
def __init__(self, substring: str | list[str], *, any_case: bool = True):
super().__init__()
def __init__(self, substring: str | list[str], *, any_case: bool = True, **kwargs):
super().__init__(**kwargs)
if type(substring) is str:
substring = [substring]
self._any_case = any_case
self._substring = substring
def __repr__(self):
return f"TextStartsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
body = event.body.lower() if self._any_case else event.body # type: ignore
body = context.event.body.lower() if self._any_case else context.event.body # type: ignore
for s in self._substring:
if body.startswith(s):
return True
return False
class TextEndsWithFilter(TextFilter):
class BodyEndsWithFilter(BodyExistsFilter):
"""
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`
@@ -132,26 +122,23 @@ class TextEndsWithFilter(TextFilter):
is True. That means you must ensure that `needle` is lower case. The filter
will never match otherwise.
"""
def __init__(self, substring: str | list[str], *, any_case: bool = True):
super().__init__()
def __init__(self, substring: str | list[str], *, any_case: bool = True, **kwargs):
super().__init__(**kwargs)
if type(substring) is str:
substring = [substring]
self._any_case = any_case
self._substring = substring
def __repr__(self):
return f"TextEndsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
body = event.body.lower() if self._any_case else event.body # type: ignore
body = context.event.body.lower() if self._any_case else context.event.body # type: ignore
for s in self._substring:
if body.endswith(s):
return True
return False
class TextCommandFilter(TextFilter):
class BodyCommandFilter(BodyExistsFilter):
"""
This filter returns True if all conditions are met:
1. `event.body` contains at least `min_args + 1` words after split()
@@ -168,12 +155,13 @@ class TextCommandFilter(TextFilter):
store all verbs in lower case. This filter will not match any verbs that
use mixed case of upper case.
If this filter is matched, then it will set a new attribute for the event:
`event.command_args: list[str]`. You may use this attribute in your callback
for this event.
This filter sets the following context variables:
- `CTX_CMD_PREFIX` - prefix that was used
- `CTX_CMD_VERB` - verb that was used
- `CTX_CMD_ARGS` - arguments that were passed
"""
def __init__(self, verbs: str | list[str], min_args: int = 0, max_args: int | None = None, prefix: str = "!"):
super().__init__()
def __init__(self, verbs: str | list[str], min_args: int = 0, max_args: int | None = None, prefix: str = "!", **kwargs):
super().__init__(**kwargs)
if type(verbs) is str:
verbs = [verbs]
self._verbs = verbs
@@ -181,13 +169,10 @@ class TextCommandFilter(TextFilter):
self._max_args = max_args
self._prefix = prefix
def __repr__(self):
return f"TextCommandFilter({repr(self._verbs)}, {repr(self._min_args)}, {repr(self._max_args)}, {repr(self._prefix)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
parts = [p.strip() for p in event.body.split() if p.strip()] # type: ignore
parts = [p.strip() for p in context.event.body.split() if p.strip()] # type: ignore
args_count = len(parts) - 1
if args_count < self._min_args:
return False
@@ -198,27 +183,27 @@ class TextCommandFilter(TextFilter):
cmd = parts[0][len(self._prefix):].lower()
for verb in self._verbs:
if cmd == verb:
setattr(event, "command_args", parts[1:])
context[CTX_CMD_PREFIX] = self._prefix
context[CTX_CMD_VERB] = verb
context[CTX_CMD_ARGS] = parts[1:]
return True
return False
class TextRegexFilter(TextFilter):
class BodyRegexFilter(BodyExistsFilter):
"""
This filter returns True if the `event.body` passes the regex.
"""
def __init__(self, regex: re.Pattern | str):
def __init__(self, regex: re.Pattern | str, **kwargs):
super().__init__(**kwargs)
if isinstance(regex, str):
regex = re.compile(regex)
self._regex = regex
def __repr__(self):
return f"TextRegexFilter({repr(self._regex)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
if not await super().__call__(room, event, client):
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
try:
return self._regex.match(event.body) # type: ignore
return self._regex.match(context.event.body) is not None # type: ignore
except:
self._logger.error(traceback.format_exc())
return False

121
src/mab/filters/message.py Normal file
View File

@@ -0,0 +1,121 @@
import traceback
from .base import BaseEventFilter, EventTypeFilter
from ..types import MessageType
from ..context import EventContext, CTX_MESSAGE_TYPE, CTX_SENDER
from nio import AsyncClient
from nio import MatrixRoom, Event
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.
This filter sets `CTX_MESSAGE_TYPE` variable in the context.
"""
def __init__(self, types: list[MessageType] | MessageType, **kwargs):
super().__init__(**kwargs)
if isinstance(types, MessageType):
types = [types]
self._types = types
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
if "msgtype" not in context.event.source["content"]:
return False
msgtype = context.event.source["content"]["msgtype"]
if not msgtype in [t.value for t in self._types]:
return False
context[CTX_MESSAGE_TYPE] = MessageType(msgtype)
return True
class NewMessageFilter(BaseEventFilter):
"""
This filter returns True if the event is a new message. Most filters are
derived from this base class because it ignores events about edited
messages.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
return "m.new_content" not in context.event.source["content"]
class EditedMessageFilter(BaseEventFilter):
"""
This filter returns True if the event is an edited message. You may use this
filter to create callbacks that are called if the message gets edited.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
return "m.new_content" in context.event.source["content"]
class RedactedMessageFilter(EventTypeFilter):
"""
This filter returns True if the event is a RedactionEvent.
"""
def __init__(self, **kwargs):
super().__init__(RedactionEvent, **kwargs)
raise NotImplementedError()
async def __call__(self, context: EventContext) -> bool:
raise NotImplementedError()
class SenderIsFilter(BaseEventFilter):
"""
This filter returns True if `event.sender` is any of specified senders.
`event.sender` is converted to lower case if `any_case` is True (default).
Supplied sender list is NEVER converted to lower case, so it is your duty to
use lower case if `any_case` is True.
`senders` list is stored by reference so you can modify behavior of this
filter dynamically.
This filter sets `CTX_SENDER` variable in the context.
"""
def __init__(self, sender: list[str] | str, *, any_case: bool = True, **kwargs):
super().__init__(**kwargs)
if isinstance(sender, str):
sender = [sender]
self._sender = sender
self._any_case = any_case
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
sender = context.event.sender.lower() if self._any_case else context.event.sender
for s in self._sender:
if sender == s:
context[CTX_SENDER] = sender
return True
return False
class SenderIsBotFilter(BaseEventFilter):
"""
This filter returns True if `event.sender` is the client that has received
the event. You may use this filter to set callbacks for messages sent by
other users by using the following syntax:
```py
~SenderIsBotFilter()
```
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
return context.bot.get_client().user_id == context.event.sender

View File

@@ -1,94 +1,6 @@
from .base import BaseEventFilter
from nio import AsyncClient
from nio import MatrixRoom, Event
class RoomIdContainsFilter(BaseEventFilter):
"""
This filter returns True if the `room.room_id` contains `needle` (or
any of needles from the list). The check will be case insensetive if
`any_case` is True.
"""
def __init__(self, needle: str | list[str], *, any_case: bool = True):
super().__init__()
if type(needle) is str:
needle = [needle]
self._any_case = any_case
if self._any_case:
self._needle = [s.lower() for s in needle]
else:
self._needle = list(needle)
def __repr__(self):
return f"RoomIdContainsFilter({repr(self._needle)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
try:
room_id = room.room_id.lower() if self._any_case else room.room_id
for s in self._needle:
if s in room_id:
return True
return False
except:
return False
class RoomIdStartsWithFilter(BaseEventFilter):
"""
This filter returns True if the `room.room_id` starts with `substring`
(or any of substrings from the list). The check will be case insensetive
if `any_case` is True.
"""
def __init__(self, substring: str | list[str], *, any_case: bool = True):
super().__init__()
if type(substring) is str:
substring = [substring]
self._any_case = any_case
if self._any_case:
self._substring = [s.lower() for s in substring]
else:
self._substring = list(substring)
def __repr__(self):
return f"RoomIdStartsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
try:
room_id = room.room_id.lower() if self._any_case else room.room_id
for s in self._substring:
if room_id.startswith(s):
return True
return False
except:
return False
class RoomIdEndsWithFilter(BaseEventFilter):
"""
This filter returns True if the `room.room_id` ends with `substring` (or
any of substrings from the list). The check will be case insensetive
if `any_case` is True.
"""
def __init__(self, substring: str | list[str], *, any_case: bool = True):
super().__init__()
if type(substring) is str:
substring = [substring]
self._any_case = any_case
if self._any_case:
self._substring = [s.lower() for s in substring]
else:
self._substring = list(substring)
def __repr__(self):
return f"RoomIdEndsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
try:
room_id = room.room_id.lower() if self._any_case else room.room_id
for s in self._substring:
if room_id.endswith(s):
return True
return False
except:
return False
from ..context import EventContext, CTX_ROOM_ENCRYPTED
class RoomEncryptedFilter(BaseEventFilter):
"""
@@ -97,11 +9,11 @@ class RoomEncryptedFilter(BaseEventFilter):
def __init__(self):
super().__init__()
def __repr__(self):
return f"RoomEncryptedFilter()"
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
async def __call__(self, context: EventContext) -> bool:
if not await super().__call__(context):
return False
try:
return room.encrypted
context[CTX_ROOM_ENCRYPTED] = context.room.encrypted
return context.room.encrypted
except:
return False

View File

@@ -2,16 +2,10 @@
from pathlib import Path
from dataclasses import dataclass
from enum import Enum
from nio import MatrixRoom, Event
from nio import UploadResponse
from .filters.base import BaseEventFilter
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .bot import MatrixBot
@dataclass
class MatrixBotConfig:
"""Configuration for MatrixBot"""
@@ -67,22 +61,6 @@ class VideoFileProperties:
thumbnail: Path | str | bytes | None = None
"""Path to the thumbnail or the raw JPEG thumbnail data"""
@dataclass
class RoomEventData:
"""Dataclass that hold information about event that happened in the room"""
room: MatrixRoom
"""The room the event has happened in"""
event: Event
"""The event that has happened in the room"""
filter: BaseEventFilter
"""The filter that invoked this event"""
bot: "MatrixBot"
"""The bot that is the source of the event"""
@dataclass
class UploadResult:
"""Result of data upload"""
@@ -98,3 +76,30 @@ class UploadResult:
filesize: int
"""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"
class ContextDataKey[T]:
"""
Instances of this class represent a single possible data key that can be
stored inside EventContext.
"""
def __init__(self, name: str) -> None:
"""
Initialize a ContextDataKey
Args:
- name - name that will be used internally
"""
self._name = name
def __repr__(self) -> str:
return f"ContextDataKey[{type(T)}]({repr(self._name)})"