Compare commits
8 Commits
v0.4.0
...
2d47483d55
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d47483d55 | |||
| fc4f664a5c | |||
| fa97e4b098 | |||
| 1fe4434e16 | |||
| 4f0792b9aa | |||
| 31fbcb4697 | |||
| 3e15ae426c | |||
| b8e598715c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
session_storage/
|
||||||
*.vscode
|
*.vscode
|
||||||
.venv/
|
.venv/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -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
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())
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
@@ -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)):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user