Compare commits
15 Commits
v0.3.0
...
2d47483d55
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d47483d55 | |||
| fc4f664a5c | |||
| fa97e4b098 | |||
| 1fe4434e16 | |||
| 4f0792b9aa | |||
| 31fbcb4697 | |||
| 3e15ae426c | |||
| b8e598715c | |||
| f58c8601d1 | |||
| 159a43ebe6 | |||
| 9f4cd4948a | |||
| d14e110525 | |||
| 3d74cb737b | |||
| 7d6230881b | |||
| 5fd87879ca |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
__pycache__/
|
||||
session_storage/
|
||||
*.vscode
|
||||
.venv/
|
||||
dist/
|
||||
|
||||
96
README.md
96
README.md
@@ -1,69 +1,61 @@
|
||||
# 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
|
||||
## 🚀 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
|
||||
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.4.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/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
|
||||
that starts with `!test`, `!hello` or `!hi`.
|
||||
## 🏷️ Versioning
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from mab import MatrixBot, MatrixBotConfig
|
||||
from mab import TextCommandFilter
|
||||
from mab.types import RoomEventData
|
||||
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.
|
||||
|
||||
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)
|
||||
## 🛠️ Development
|
||||
|
||||
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")
|
||||
)
|
||||
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()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
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
18
examples/_environment.py
Normal 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
51
examples/echo_bot.py
Normal 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
103
examples/image_gen_bot.py
Normal 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())
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mab"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
authors = [
|
||||
{ name = "Tyukalov Nikita", email = "nikita@tyukalov.su" }
|
||||
]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from . import bot
|
||||
from . import types
|
||||
|
||||
from .types import MatrixBotConfig
|
||||
from .types import MatrixBotConfig, RoomEventData, MessageType
|
||||
|
||||
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 +16,29 @@ __all__ = [
|
||||
|
||||
# .types
|
||||
"MatrixBotConfig",
|
||||
"RoomEventData",
|
||||
"MessageType",
|
||||
|
||||
# .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",
|
||||
]
|
||||
@@ -51,7 +51,13 @@ class Callbacks:
|
||||
for callback_info in self._filters:
|
||||
if not isinstance(callback_info, self._FilterBasedCallback):
|
||||
continue
|
||||
if not callback_info.filter(room, event):
|
||||
try:
|
||||
if not await callback_info.filter(room, event, self._client):
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
continue
|
||||
event_data = RoomEventData(
|
||||
room=room,
|
||||
|
||||
@@ -17,11 +17,14 @@ class ClientAuth:
|
||||
#
|
||||
# PRIVATE
|
||||
#
|
||||
@staticmethod
|
||||
async def _default_password_callback() -> str:
|
||||
async def _default_password_callback(self) -> str:
|
||||
if self._config is None:
|
||||
raise RuntimeError("No config")
|
||||
if "MATRIX_PASSWORD" in os.environ:
|
||||
return os.environ["MATRIX_PASSWORD"]
|
||||
return await aioconsole.ainput("Matrix password: ")
|
||||
if self._config.allow_ainput_password:
|
||||
return await aioconsole.ainput("Matrix password: ")
|
||||
raise RuntimeError("Can't get password")
|
||||
|
||||
async def _login_using_session_data(self, client: AsyncClient) -> None:
|
||||
"""
|
||||
@@ -98,11 +101,13 @@ class ClientAuth:
|
||||
self._logger = logging.getLogger("ClientAuth")
|
||||
self._storage = storage
|
||||
self._full_matrix_username: str | None = None
|
||||
self._config: MatrixBotConfig | None = None
|
||||
|
||||
async def setup(self, config: MatrixBotConfig) -> None:
|
||||
"""
|
||||
Setup `ClientAuth` object using `config`.
|
||||
"""
|
||||
self._config = config
|
||||
self._full_matrix_username = Utils.build_full_matrix_username(config)
|
||||
|
||||
async def login(self, client: AsyncClient) -> None:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from typing import Callable, Coroutine, Any
|
||||
@@ -97,6 +98,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 +177,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,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import Any, Type
|
||||
|
||||
from nio import AsyncClient
|
||||
from nio import MatrixRoom, Event
|
||||
|
||||
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):
|
||||
@@ -29,7 +35,7 @@ class BaseEventFilter(ABC):
|
||||
)
|
||||
|
||||
def __ror__(self, other):
|
||||
return self.__ror__(other)
|
||||
return self.__or__(other)
|
||||
|
||||
# XOR
|
||||
def __xor__(self, other):
|
||||
@@ -51,28 +57,39 @@ 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
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> 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:
|
||||
event - the event to check againts this filter
|
||||
|
||||
Returns:
|
||||
True if the event satisfies this filter
|
||||
False if the event does not satisfy this filter
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
"""
|
||||
pass
|
||||
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
|
||||
|
||||
Returns:
|
||||
- True if the event satisfies this filter
|
||||
- False if the event does not satisfy this filter
|
||||
"""
|
||||
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, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
return isinstance(event, self._type)
|
||||
|
||||
class CompoundEventFilter(BaseEventFilter):
|
||||
"""Event filter that consists of multiple filters"""
|
||||
@@ -101,8 +118,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)):
|
||||
@@ -123,8 +140,8 @@ class CompoundEventFilter(BaseEventFilter):
|
||||
expression = f"~{reprs[0]}"
|
||||
return f"({expression})"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
evaluated = [arg(room, event) for arg in self._arguments]
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
evaluated = [await arg(room, event, client) for arg in self._arguments]
|
||||
if self._operator == self.OPERATOR_AND:
|
||||
return all(evaluated)
|
||||
elif self._operator == self.OPERATOR_OR:
|
||||
|
||||
196
src/mab/filters/body.py
Normal file
196
src/mab/filters/body.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import re
|
||||
import traceback
|
||||
from .message import NewMessageFilter
|
||||
|
||||
from nio import AsyncClient
|
||||
from nio import MatrixRoom, Event
|
||||
|
||||
class BodyExistsFilter(NewMessageFilter):
|
||||
"""
|
||||
This filter returns True if all conditions are met:
|
||||
1. `event` has attribute `body`
|
||||
2. `event.body` is instance of `str`
|
||||
3. `event.body.strip()` evaluates to True
|
||||
|
||||
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.
|
||||
"""
|
||||
def __init__(self, *, ignore_filename_in_body: bool = True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ignore_filename_in_body = ignore_filename_in_body
|
||||
|
||||
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"):
|
||||
return False
|
||||
if not isinstance(event.body, str): # type: ignore
|
||||
return False
|
||||
if not event.body.strip(): # type: ignore
|
||||
return False
|
||||
if self._ignore_filename_in_body:
|
||||
content = event.source["content"]
|
||||
if "filename" in content and content["filename"] == event.body: # type: ignore
|
||||
return False
|
||||
return True
|
||||
|
||||
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
|
||||
`any_case` is True.
|
||||
|
||||
`needle` list is stored by reference so you can dynamically edit behavior of
|
||||
this filter.
|
||||
|
||||
Please note that only `event.body` is converted to lower case if `any_case`
|
||||
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, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if type(needle) is str:
|
||||
needle = [needle]
|
||||
self._any_case = any_case
|
||||
self._needle = needle
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for n in self._needle:
|
||||
if n in body:
|
||||
return True
|
||||
return False
|
||||
|
||||
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`
|
||||
is True.
|
||||
|
||||
`substring` list is stored by reference so you can dynamically edit behavior
|
||||
of this filter.
|
||||
|
||||
Please note that only `event.body` is converted to lower case if `any_case`
|
||||
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, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if type(substring) is str:
|
||||
substring = [substring]
|
||||
self._any_case = any_case
|
||||
self._substring = substring
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for s in self._substring:
|
||||
if body.startswith(s):
|
||||
return True
|
||||
return False
|
||||
|
||||
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`
|
||||
is True.
|
||||
|
||||
`substring` list is stored by reference so you can dynamically edit behavior
|
||||
of this filter.
|
||||
|
||||
Please note that only `event.body` is converted to lower case if `any_case`
|
||||
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, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if type(substring) is str:
|
||||
substring = [substring]
|
||||
self._any_case = any_case
|
||||
self._substring = substring
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for s in self._substring:
|
||||
if body.endswith(s):
|
||||
return True
|
||||
return False
|
||||
|
||||
class BodyCommandFilter(BodyExistsFilter):
|
||||
"""
|
||||
This filter returns True if all conditions are met:
|
||||
1. `event.body` contains at least `min_args + 1` words after split()
|
||||
2. `event.body` contains at most `max_args + 1` words after split()
|
||||
3. First element of splitted `event.body` starts with `prefix`
|
||||
4. First element of splitted `event.body` (after lstripping `prefix`) starts
|
||||
with any of strings in `verbs` list
|
||||
|
||||
`verbs` list is stored by reference so you can dynamically edit behavior of
|
||||
this filter.
|
||||
|
||||
Please note that prefix is checked case sensetively. However, event.body is
|
||||
converted to lower case when `verbs` matching is performed. So you must
|
||||
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.
|
||||
"""
|
||||
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
|
||||
self._min_args = min_args
|
||||
self._max_args = max_args
|
||||
self._prefix = prefix
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
parts = [p.strip() for p in event.body.split() if p.strip()] # type: ignore
|
||||
args_count = len(parts) - 1
|
||||
if args_count < self._min_args:
|
||||
return False
|
||||
if self._max_args is not None and args_count > self._max_args:
|
||||
return False
|
||||
if not parts[0].startswith(self._prefix):
|
||||
return False
|
||||
cmd = parts[0][len(self._prefix):].lower()
|
||||
for verb in self._verbs:
|
||||
if cmd == verb:
|
||||
setattr(event, "command_args", parts[1:])
|
||||
return True
|
||||
return False
|
||||
|
||||
class BodyRegexFilter(BodyExistsFilter):
|
||||
"""
|
||||
This filter returns True if the `event.body` passes the regex.
|
||||
"""
|
||||
def __init__(self, regex: re.Pattern | str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if isinstance(regex, str):
|
||||
regex = re.compile(regex)
|
||||
self._regex = regex
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
try:
|
||||
return self._regex.match(event.body) is not None # type: ignore
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
return False
|
||||
112
src/mab/filters/message.py
Normal file
112
src/mab/filters/message.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import traceback
|
||||
from .base import BaseEventFilter, EventTypeFilter
|
||||
from ..types import MessageType
|
||||
|
||||
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.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
return "m.new_content" not in 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, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
return "m.new_content" in event.source["content"]
|
||||
|
||||
class RedactedMessageFilter(EventTypeFilter):
|
||||
"""
|
||||
This filter returns True if the event is a RedactionEvent.
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(RedactionEvent, **kwargs)
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
return await super().__call__(room, event, client)
|
||||
|
||||
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.
|
||||
"""
|
||||
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, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
sender = event.sender.lower() if self._any_case else event.sender
|
||||
for s in self._sender:
|
||||
if sender == s:
|
||||
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, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
if not await super().__call__(room, event, client):
|
||||
return False
|
||||
return client.user_id == event.sender
|
||||
17
src/mab/filters/room.py
Normal file
17
src/mab/filters/room.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from .base import BaseEventFilter
|
||||
|
||||
from nio import AsyncClient
|
||||
from nio import MatrixRoom, Event
|
||||
|
||||
class RoomEncryptedFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the room is encrypted.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
async def __call__(self, room: MatrixRoom, event: Event, client: AsyncClient) -> bool:
|
||||
try:
|
||||
return room.encrypted
|
||||
except:
|
||||
return False
|
||||
@@ -1,164 +0,0 @@
|
||||
from .base import BaseEventFilter
|
||||
|
||||
from nio import MatrixRoom, Event
|
||||
|
||||
class TextFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the event contains `body` attribute.
|
||||
`body` attribute contains unformatted text, string.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def __repr__(self):
|
||||
return "TextFilter()"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
return hasattr(event, "body") and type(event.body) is str # type: ignore
|
||||
|
||||
class FormattedTextFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the event contains valid `formatted_body`
|
||||
attribute. `formatted_body` attribute contains formatted text, string.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def __repr__(self):
|
||||
return "FormattedTextFilter()"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
return hasattr(event, "formatted_body") and type(event.formatted_body) is str # type: ignore
|
||||
|
||||
class TextContainsFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the `event.body` contains `needle`
|
||||
substring (or any of neddle 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"TextContainsFilter({repr(self._needle)}, any_case={repr(self._any_case)})"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
try:
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for n in self._needle:
|
||||
if n in body:
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
class TextStartsWithFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the `event.body` 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"TextStartsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
try:
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for s in self._substring:
|
||||
if body.startswith(s):
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
class TextEndsWithFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if the `event.body` 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"TextEndsWithFilter({repr(self._substring)}, any_case={repr(self._any_case)})"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
try:
|
||||
body = event.body.lower() if self._any_case else event.body # type: ignore
|
||||
for s in self._substring:
|
||||
if body.endswith(s):
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
class TextCommandFilter(BaseEventFilter):
|
||||
"""
|
||||
This filter returns True if all conditions are True:
|
||||
1. `event.body` contains at least `min_args + 1` words after split()
|
||||
2. `event.body` conrains at most `max_args + 1` words after split()
|
||||
3. First element of splitted `event.body` starts with `prefix`
|
||||
4. First element of splitted `event.body` (after stripping `prefix`)
|
||||
starts with any of strings in `verbs` list (case-insensitive)
|
||||
|
||||
Remarks:
|
||||
- If this filter is satified, 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.
|
||||
"""
|
||||
def __init__(self, verbs: str | list[str], min_args: int = 0, max_args: int | None = None, prefix: str = "!"):
|
||||
super().__init__()
|
||||
if type(verbs) is str:
|
||||
verbs = [verbs]
|
||||
verbs = [v.lower() for v in verbs]
|
||||
self._verbs = verbs
|
||||
self._min_args = min_args
|
||||
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)})"
|
||||
|
||||
def __call__(self, room: MatrixRoom, event: Event) -> bool:
|
||||
try:
|
||||
parts = [p.strip() for p in event.body.split() if p.strip()] # type: ignore
|
||||
args_count = len(parts) - 1
|
||||
if args_count < self._min_args:
|
||||
return False
|
||||
if self._max_args is not None and args_count > self._max_args:
|
||||
return False
|
||||
if not parts[0].startswith(self._prefix):
|
||||
return False
|
||||
cmd = parts[0][len(self._prefix):].lower()
|
||||
for verb in self._verbs:
|
||||
if cmd == verb:
|
||||
setattr(event, "command_args", parts[1:])
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from nio import MatrixRoom, Event
|
||||
from nio import UploadResponse
|
||||
@@ -97,4 +98,14 @@ class UploadResult:
|
||||
"""Name of the uploaded file"""
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user