diff --git a/README.md b/README.md index 7a086a5..db51eed 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ decorator-based API similar to popular event-driven frameworks, allowing developers to focus on behavior rather than boilerplate. #### Key Features + - Minimal setup, easy to extend - Event-driven API using async/await - Clean command registration @@ -28,6 +29,7 @@ developers to focus on behavior rather than boilerplate. # Quickstart **Requirements** + - Python 3.10+ ``` @@ -35,38 +37,45 @@ pip install matrix-python ``` If you plan on contributing to matrix.py, we recommend to install the development libraries: + ``` pip install -e .[dev] ``` -*Note*: It is recommended to use a [virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/) when installing python packages. - +*Note*: It is recommended to use +a [virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/) +when installing python packages. ```python from matrix import Bot, Context -bot = Bot(config="config.yml") +bot = Bot() @bot.command("ping") async def ping(ctx: Context): - await ctx.send("Pong!") + await ctx.reply("Pong!") -bot.start() +bot.start(config="config.yml") ``` [Documentation](https://github.com/Code-Society-Lab/matrixpy/wiki) - [Examples](https://github.com/Code-Society-Lab/matrixpy/tree/main/examples) # Contributing -We welcome everyone to contribute! + +We welcome everyone to contribute! Whether it's fixing bugs, suggesting features, or improving the docs - every bit helps. + - Submit an issue - Open a pull request -- Or just hop into our [Matrix](https://matrix.to/#/%23codesociety:matrix.org) or [Discord](https://discord.gg/code-society-823178343943897088) server and say hi! +- Or just hop into our [Matrix](https://matrix.to/#/%23codesociety:matrix.org) + or [Discord](https://discord.gg/code-society-823178343943897088) server and say hi! -If you intend to contribute, please read the [CONTRIBUTING.md](./CONTRIBUTING.md) first. Additionally, **every contributor** is expected to follow the [code of conduct](./CODE_OF_CONDUCT.md). +If you intend to contribute, please read the [CONTRIBUTING.md](./CONTRIBUTING.md) first. Additionally, **every +contributor** is expected to follow the [code of conduct](./CODE_OF_CONDUCT.md). # License + matrix.py is released under [GPL-3.0](https://opensource.org/license/gpl-3-0) diff --git a/examples/checks.py b/examples/checks.py index 58bf128..16b042c 100644 --- a/examples/checks.py +++ b/examples/checks.py @@ -1,7 +1,7 @@ from matrix import Bot, Context from matrix.errors import CheckError -bot = Bot(config="config.yaml") +bot = Bot() allowed_users = {"@alice:matrix.org", "@bob:matrix.org"} @@ -23,4 +23,4 @@ async def permission_error_handler(ctx: Context, error: CheckError) -> None: await ctx.reply(f"Access denied: {error}") -bot.start() +bot.start(config="config.yaml") diff --git a/examples/cooldown.py b/examples/cooldown.py index 8450840..de8f95c 100644 --- a/examples/cooldown.py +++ b/examples/cooldown.py @@ -1,7 +1,7 @@ from matrix import Bot, Context, cooldown from matrix.errors import CooldownError -bot = Bot(config="config.yaml") +bot = Bot() # Invoke by using !hello @@ -27,4 +27,4 @@ async def cooldown_function(ctx: Context, error: CooldownError) -> None: await ctx.reply(f"⏳ Try again in {error.retry:.1f}s") -bot.start() +bot.start(config="config.yaml") diff --git a/examples/error_handling.py b/examples/error_handling.py index 88b7c25..3f6ee3b 100644 --- a/examples/error_handling.py +++ b/examples/error_handling.py @@ -1,7 +1,7 @@ from matrix import Bot, Context from matrix.errors import CommandNotFoundError, MissingArgumentError -bot = Bot(config="config.yaml") +bot = Bot() @bot.error(CommandNotFoundError) @@ -30,4 +30,4 @@ async def command_error(ctx: Context, error: MissingArgumentError) -> None: await ctx.reply(f"{error}") -bot.start() +bot.start(config="config.yaml") diff --git a/examples/extension.py b/examples/extension.py index 6addf63..9a95811 100644 --- a/examples/extension.py +++ b/examples/extension.py @@ -34,12 +34,14 @@ async def divide_error(ctx: Context, error): """ +# bot.py + from matrix import Bot from math_extension import extension as math_extension -bot = Bot(config="config.yaml") +bot = Bot() bot.load_extension(math_extension) -bot.start() +bot.start(config="config.yaml") """ diff --git a/examples/ping.py b/examples/ping.py index 58d6f39..7c82be8 100644 --- a/examples/ping.py +++ b/examples/ping.py @@ -1,6 +1,6 @@ from matrix import Bot, Context -bot = Bot(config="config.yaml") +bot = Bot() @bot.command("ping") @@ -8,4 +8,4 @@ async def ping(ctx: Context) -> None: await ctx.reply("Pong!") -bot.start() +bot.start(config="config.yaml") diff --git a/examples/reaction.py b/examples/reaction.py index 204bcd1..5f5c1ac 100644 --- a/examples/reaction.py +++ b/examples/reaction.py @@ -1,43 +1,52 @@ -from asyncio import Event -from matrix import Bot, Room +from nio import MatrixRoom, RoomMessageText, ReactionEvent +from matrix import Bot +from matrix.message import Message -bot = Bot(config="config.yaml") +bot = Bot() @bot.event -async def on_message(room: Room, event: Event) -> None: +async def on_message(room: MatrixRoom, event: RoomMessageText) -> None: """ This function listens for new messages in a room and reacts based on the message content. """ room = bot.get_room(room.room_id) + message = Message( + room=room, + event_id=event.event_id, + body=event.body, + client=bot.client, + ) + if event.body.lower().startswith("thanks"): - await room.send(event=event, key="🙏") + await message.react("🙏") if event.body.lower().startswith("hello"): # Can also react with a text message instead of emoji - await room.send(event=event, key="hi") + await message.react("hi") if event.body.lower().startswith("❤️"): - await room.send(event=event, message="❤️") + await message.react("❤️") @bot.event -async def on_react(room: Room, event: Event) -> None: +async def on_react(room: MatrixRoom, event: ReactionEvent) -> None: """ This function listens for new member reaction to messages in a room, and reacts based on the reaction emoji. """ room = bot.get_room(room.room_id) - + message = Message( + room=room, + event_id=event.event_id, + body=None, + client=bot.client, + ) emoji = event.key - event_id = event.source["content"]["m.relates_to"]["event_id"] if emoji == "🙏": - await room.send(event=event_id, key="❤️") - - if emoji == "❤️": - await room.send(message="❤️") + await message.react("❤️") -bot.start() +bot.start(config="config.yaml") diff --git a/examples/scheduler.py b/examples/scheduler.py index 955535a..3426efe 100644 --- a/examples/scheduler.py +++ b/examples/scheduler.py @@ -1,6 +1,6 @@ from matrix import Bot, Context -bot = Bot(config="config.yaml") +bot = Bot() room_id = "!your_room_id:matrix.org" # Replace with your room ID @@ -15,19 +15,19 @@ async def ping(ctx: Context) -> None: @bot.schedule("* * * * *") async def scheduled_task() -> None: # This task runs every minute. - await room.send(message="Scheduled ping!") + await room.send("Scheduled ping!") @bot.schedule("0 * * * *") async def hourly_task() -> None: # This task runs every hour. - await room.send(message="This is your hourly update!") + await room.send("This is your hourly update!") @bot.schedule("0 9 * * 1-5") async def weekday_morning_task() -> None: # This task runs every weekday at 9 AM. - await room.send(message="Good morning! Here's your weekday update!") + await room.send("Good morning! Here's your weekday update!") -bot.start() +bot.start(config="config.yaml") diff --git a/matrix/bot.py b/matrix/bot.py index 163a847..ed09437 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -28,29 +28,34 @@ class Bot(Registry): """ def __init__( - self, *, config: Union[Config, str], help: Optional[HelpCommand] = None + self, + *, + help_: Optional[HelpCommand] = None, ) -> None: - if isinstance(config, Config): - self.config = config - elif isinstance(config, str): - self.config = Config(config_path=config) - else: - raise TypeError("config must be a Config instance or a config file path") + super().__init__(self.__class__.__name__) - super().__init__(self.__class__.__name__, prefix=self.config.prefix) - - self.client: AsyncClient = AsyncClient(self.config.homeserver) + self._config: Config | None = None + self._client: AsyncClient | None = None + self._help: HelpCommand | None = help_ self.extensions: dict[str, Extension] = {} self.scheduler: Scheduler = Scheduler() self.log: logging.Logger = logging.getLogger(__name__) + self.start_at: float | None = None - self.start_at: float | None = None # unix timestamp + @property + def client(self) -> AsyncClient: + assert self._client is not None, "Bot has not been started." + return self._client - self.help: HelpCommand = help or DefaultHelpCommand(prefix=self.prefix) - self.register_command(self.help) + @property + def config(self) -> Config: + assert self._config is not None, "Bot has not been started." + return self._config - self.client.add_event_callback(self._on_matrix_event, Event) - self._auto_register_events() + @property + def help(self) -> HelpCommand: + assert self._help is not None, "Bot has not been started." + return self._help def _auto_register_events(self) -> None: for attr in dir(self): @@ -192,7 +197,26 @@ async def _on_command_error(self, ctx: Context, error: Exception) -> None: # ENTRYPOINT - def start(self) -> None: + def _load_config(self, config: Config | str) -> None: + if self._config is not None: + raise RuntimeError("Config is already loaded.") + + if isinstance(config, str): + config = Config(config_path=config) + elif not isinstance(config, Config): + raise TypeError("config must be a Config instance or a config file path") + + self._config = config + self._client = AsyncClient(config.homeserver) + self._help = self._help or DefaultHelpCommand() + + self.prefix = config.prefix + self.register_command(self.help) + + self.client.add_event_callback(self._on_matrix_event, Event) + self._auto_register_events() + + def start(self, *, config: Config | str) -> None: """ Synchronous entry point for running the bot. @@ -201,6 +225,9 @@ def start(self) -> None: :func:`asyncio.run`, and ensures the client is closed gracefully on interruption. """ + if config is not None: + self._load_config(config) + try: asyncio.run(self.run()) except KeyboardInterrupt: diff --git a/tests/test_bot.py b/tests/test_bot.py index 95e3d01..00bf5ef 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -15,14 +15,15 @@ @pytest.fixture def bot(): - bot = Bot(config="tests/config_fixture.yaml") + b = Bot() + b._load_config("tests/config_fixture.yaml") - bot.client = MagicMock() - bot.client.room_send = AsyncMock() - bot.log = MagicMock() - bot.log.getChild.return_value = MagicMock() + b._client = MagicMock() + b._client.room_send = AsyncMock() + b.log = MagicMock() + b.log.getChild.return_value = MagicMock() - return bot + return b @pytest.fixture @@ -46,12 +47,8 @@ def event(): def test_bot_init_with_config(): - bot = Bot( - config=Config( - username="grace", - password="grace1234", - ) - ) + bot = Bot() + bot._load_config(Config(username="grace", password="grace1234")) assert bot.config.user_id == "grace" assert bot.config.password == "grace1234" @@ -59,8 +56,9 @@ def test_bot_init_with_config(): def test_bot_init_with_invalid_config_file(): + bot = Bot() with pytest.raises(FileNotFoundError): - Bot(config="not-a-dict") + bot._load_config("not-a-dict") def test_auto_register_events_registers_known_events(bot): @@ -107,7 +105,7 @@ async def handler2(room, event): @pytest.mark.asyncio async def test_on_event_ignores_self_events(bot): bot.start_at = None - bot.client.user = "@grace:matrix.org" + bot._client.user = "@grace:matrix.org" event = MagicMock(spec=RoomMessageText) event.sender = "@grace:matrix.org" @@ -122,7 +120,7 @@ async def test_on_event_ignores_self_events(bot): @pytest.mark.asyncio async def test_on_event_ignores_old_events(bot, room, event): - bot.client.user = "@somebot:matrix.org" + bot._client.user = "@somebot:matrix.org" bot.start_at = event.server_timestamp / 1000 + 10 bot._dispatch_matrix_event = AsyncMock() @@ -142,7 +140,7 @@ async def test_on_event_calls_error_handler(bot): event.sender = "@someone:matrix.org" event.server_timestamp = 999999999 bot.start_at = 0 - bot.client.user = "@grace:matrix.org" + bot._client.user = "@grace:matrix.org" await bot._on_matrix_event(MatrixRoom("!roomid", "alias"), event) custom_error_handler.assert_awaited_once() @@ -380,42 +378,44 @@ async def cmd2(ctx): @pytest.mark.asyncio async def test_run_uses_token(): - bot = Bot(config="tests/config_fixture_token.yaml") + bot = Bot() + bot._load_config("tests/config_fixture_token.yaml") - bot.client.sync_forever = AsyncMock() + bot._client.sync_forever = AsyncMock() bot.on_ready = AsyncMock() await bot.run() - assert bot.client.access_token == "abc123" + assert bot._client.access_token == "abc123" bot.on_ready.assert_awaited_once() - bot.client.sync_forever.assert_awaited_once() + bot._client.sync_forever.assert_awaited_once() @pytest.mark.asyncio async def test_run_with_username_and_password(bot): - bot.client.login = AsyncMock(return_value="login_resp") - bot.client.sync_forever = AsyncMock() + bot._client.login = AsyncMock(return_value="login_resp") + bot._client.sync_forever = AsyncMock() bot._on_ready = AsyncMock() await bot.run() - bot.client.login.assert_awaited_once_with("grace1234") + bot._client.login.assert_awaited_once_with("grace1234") bot._on_ready.assert_awaited_once() - bot.client.sync_forever.assert_awaited_once() + bot._client.sync_forever.assert_awaited_once() def test_start_handles_keyboard_interrupt(caplog): - bot = Bot(config="tests/config_fixture.yaml") - + bot = Bot() + bot._client = MagicMock() + bot._client.close = AsyncMock() bot.run = AsyncMock(side_effect=KeyboardInterrupt) - bot.client.close = AsyncMock() - with caplog.at_level("INFO"): - bot.start() + with patch.object(bot, "_load_config"): + with caplog.at_level("INFO"): + bot.start(config="tests/config_fixture.yaml") assert "bot interrupted by user" in caplog.text - bot.client.close.assert_awaited_once() + bot._client.close.assert_awaited_once() @pytest.mark.asyncio