matrix-python 1.2.0a0__tar.gz → 1.3.3a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. matrix_python-1.3.3a0/.github/dependabot.yml +6 -0
  2. matrix_python-1.3.3a0/.github/workflows/CODEOWNERS +1 -0
  3. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/PKG-INFO +8 -10
  4. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/README.md +7 -9
  5. matrix_python-1.3.3a0/examples/extension.py +45 -0
  6. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/__init__.py +2 -0
  7. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/_version.py +3 -3
  8. matrix_python-1.3.3a0/matrix/bot.py +288 -0
  9. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/context.py +0 -1
  10. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/errors.py +13 -6
  11. matrix_python-1.3.3a0/matrix/extension.py +56 -0
  12. matrix_python-1.3.3a0/matrix/registry.py +333 -0
  13. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/scheduler.py +6 -1
  14. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix_python.egg-info/PKG-INFO +8 -10
  15. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix_python.egg-info/SOURCES.txt +7 -0
  16. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/pyproject.toml +7 -1
  17. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_bot.py +321 -3
  18. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_context.py +0 -1
  19. matrix_python-1.3.3a0/tests/test_extension.py +159 -0
  20. matrix_python-1.3.3a0/tests/test_registry.py +362 -0
  21. matrix_python-1.2.0a0/matrix/bot.py +0 -496
  22. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/.github/workflows/codeql.yml +0 -0
  23. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/.github/workflows/publish.yml +0 -0
  24. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/.github/workflows/scorecard.yml +0 -0
  25. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/.github/workflows/tests.yml +0 -0
  26. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/.gitignore +0 -0
  27. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/CODE_OF_CONDUCT.md +0 -0
  28. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/CONTRIBUTING.md +0 -0
  29. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/LICENSE +0 -0
  30. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/README.md +0 -0
  31. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/checks.py +0 -0
  32. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/config.yaml +0 -0
  33. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/cooldown.py +0 -0
  34. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/error_handling.py +0 -0
  35. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/ping.py +0 -0
  36. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/reaction.py +0 -0
  37. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/examples/scheduler.py +0 -0
  38. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/checks.py +0 -0
  39. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/command.py +0 -0
  40. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/config.py +0 -0
  41. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/content.py +0 -0
  42. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/group.py +0 -0
  43. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/help/__init__.py +0 -0
  44. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/help/help_command.py +0 -0
  45. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/help/pagination.py +0 -0
  46. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/message.py +0 -0
  47. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/room.py +0 -0
  48. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix/types.py +0 -0
  49. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  50. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix_python.egg-info/requires.txt +0 -0
  51. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/matrix_python.egg-info/top_level.txt +0 -0
  52. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/mypy.ini +0 -0
  53. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/setup.cfg +0 -0
  54. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/config_fixture.yaml +0 -0
  55. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/config_fixture_token.yaml +0 -0
  56. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/help/test_default_help_command.py +0 -0
  57. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/help/test_help_command.py +0 -0
  58. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/help/test_pagination.py +0 -0
  59. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_command.py +0 -0
  60. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_config.py +0 -0
  61. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_group.py +0 -0
  62. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_message.py +0 -0
  63. {matrix_python-1.2.0a0 → matrix_python-1.3.3a0}/tests/test_room.py +0 -0
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "monthly"
@@ -0,0 +1 @@
1
+ * @PenguinBoi12
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.2.0a0
3
+ Version: 1.3.3a0
4
4
  Summary: An easy-to-use Matrix bot framework designed for quick development and minimal setup
5
5
  Author: Simon Roy, Chris Dedman Rollet
6
6
  Maintainer-email: Code Society Lab <admin@codesociety.xyz>
@@ -697,8 +697,6 @@ Requires-Dist: mypy; extra == "dev"
697
697
  Requires-Dist: types-PyYAML; extra == "dev"
698
698
  Requires-Dist: types-Markdown; extra == "dev"
699
699
 
700
- <h1 align="center">Matrix.py</h1>
701
-
702
700
  <div align="center">
703
701
  <em>A simple, developer-friendly library to create powerful <a href="https://matrix.org">Matrix</a> bots.</em>
704
702
  </div>
@@ -708,13 +706,14 @@ Requires-Dist: types-Markdown; extra == "dev"
708
706
  <hr />
709
707
 
710
708
  [![Static Badge](https://img.shields.io/badge/%F0%9F%93%9A-Documentation-%235c5c5c)](https://github.com/Code-Society-Lab/matrixpy/wiki)
711
- [![Join on Discord](https://discordapp.com/api/guilds/823178343943897088/widget.png?style=shield)](https://discord.gg/code-society-823178343943897088)
709
+ [![Join Discord](https://discordapp.com/api/guilds/823178343943897088/widget.png?style=shield)](https://discord.gg/code-society-823178343943897088)
710
+ [![Join Matrix](https://img.shields.io/matrix/codesociety%3Amatrix.org?logo=matrix&label=%20&labelColor=%23202020&color=%23202020)](https://matrix.to/#/%23codesociety:matrix.org )
712
711
  [![Tests](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/tests.yml/badge.svg)](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/tests.yml)
713
712
  [![CodeQL Advanced](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/codeql.yml)
714
713
  [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Code-Society-Lab/matrixpy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Code-Society-Lab/matrixpy)
715
714
 
716
715
  Matrix.py is a lightweight and intuitive Python library to build bots on
717
- the [Matrix protocol]([Matrix](https://matrix.org)). It provides a clean,
716
+ the [Matrix protocol](https://matrix.org). It provides a clean,
718
717
  decorator-based API similar to popular event-driven frameworks, allowing
719
718
  developers to focus on behavior rather than boilerplate.
720
719
 
@@ -736,7 +735,7 @@ pip install matrix-python
736
735
 
737
736
  If you plan on contributing to matrix.py, we recommend to install the development libraries:
738
737
  ```
739
- pip install .[env]
738
+ pip install -e .[dev]
740
739
  ```
741
740
 
742
741
  *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.
@@ -745,19 +744,18 @@ pip install .[env]
745
744
  ```python
746
745
  from matrix import Bot, Context
747
746
 
748
- bot = Bot(username="@gracehopper:matrix.org", password="grace1234")
747
+ bot = Bot(config="config.yml")
749
748
 
750
749
 
751
750
  @bot.command("ping")
752
751
  async def ping(ctx: Context):
753
- print(f"{ctx.sender} invoked {ctx.body} in room {ctx.room_name}.")
754
752
  await ctx.send("Pong!")
755
753
 
756
754
 
757
755
  bot.start()
758
756
  ```
759
757
 
760
- [Documentation](https://github.com/Code-Society-Lab/matrixpy/wiki) - [Examples]([https://github.com/Code-Society-Lab/matrixpy/wiki](https://github.com/Code-Society-Lab/matrixpy/tree/main/examples))
758
+ [Documentation](https://github.com/Code-Society-Lab/matrixpy/wiki) - [Examples](https://github.com/Code-Society-Lab/matrixpy/tree/main/examples)
761
759
 
762
760
  # Contributing
763
761
  We welcome everyone to contribute!
@@ -765,7 +763,7 @@ We welcome everyone to contribute!
765
763
  Whether it's fixing bugs, suggesting features, or improving the docs - every bit helps.
766
764
  - Submit an issue
767
765
  - Open a pull request
768
- - Or just hop into our [Discord community](https://discord.gg/code-society-823178343943897088) and say hi!
766
+ - Or just hop into our [Matrix](https://matrix.to/#/%23codesociety:matrix.org) or [Discord](https://discord.gg/code-society-823178343943897088) server and say hi!
769
767
 
770
768
  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).
771
769
 
@@ -1,5 +1,3 @@
1
- <h1 align="center">Matrix.py</h1>
2
-
3
1
  <div align="center">
4
2
  <em>A simple, developer-friendly library to create powerful <a href="https://matrix.org">Matrix</a> bots.</em>
5
3
  </div>
@@ -9,13 +7,14 @@
9
7
  <hr />
10
8
 
11
9
  [![Static Badge](https://img.shields.io/badge/%F0%9F%93%9A-Documentation-%235c5c5c)](https://github.com/Code-Society-Lab/matrixpy/wiki)
12
- [![Join on Discord](https://discordapp.com/api/guilds/823178343943897088/widget.png?style=shield)](https://discord.gg/code-society-823178343943897088)
10
+ [![Join Discord](https://discordapp.com/api/guilds/823178343943897088/widget.png?style=shield)](https://discord.gg/code-society-823178343943897088)
11
+ [![Join Matrix](https://img.shields.io/matrix/codesociety%3Amatrix.org?logo=matrix&label=%20&labelColor=%23202020&color=%23202020)](https://matrix.to/#/%23codesociety:matrix.org )
13
12
  [![Tests](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/tests.yml/badge.svg)](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/tests.yml)
14
13
  [![CodeQL Advanced](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Code-Society-Lab/matrixpy/actions/workflows/codeql.yml)
15
14
  [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Code-Society-Lab/matrixpy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Code-Society-Lab/matrixpy)
16
15
 
17
16
  Matrix.py is a lightweight and intuitive Python library to build bots on
18
- the [Matrix protocol]([Matrix](https://matrix.org)). It provides a clean,
17
+ the [Matrix protocol](https://matrix.org). It provides a clean,
19
18
  decorator-based API similar to popular event-driven frameworks, allowing
20
19
  developers to focus on behavior rather than boilerplate.
21
20
 
@@ -37,7 +36,7 @@ pip install matrix-python
37
36
 
38
37
  If you plan on contributing to matrix.py, we recommend to install the development libraries:
39
38
  ```
40
- pip install .[env]
39
+ pip install -e .[dev]
41
40
  ```
42
41
 
43
42
  *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.
@@ -46,19 +45,18 @@ pip install .[env]
46
45
  ```python
47
46
  from matrix import Bot, Context
48
47
 
49
- bot = Bot(username="@gracehopper:matrix.org", password="grace1234")
48
+ bot = Bot(config="config.yml")
50
49
 
51
50
 
52
51
  @bot.command("ping")
53
52
  async def ping(ctx: Context):
54
- print(f"{ctx.sender} invoked {ctx.body} in room {ctx.room_name}.")
55
53
  await ctx.send("Pong!")
56
54
 
57
55
 
58
56
  bot.start()
59
57
  ```
60
58
 
61
- [Documentation](https://github.com/Code-Society-Lab/matrixpy/wiki) - [Examples]([https://github.com/Code-Society-Lab/matrixpy/wiki](https://github.com/Code-Society-Lab/matrixpy/tree/main/examples))
59
+ [Documentation](https://github.com/Code-Society-Lab/matrixpy/wiki) - [Examples](https://github.com/Code-Society-Lab/matrixpy/tree/main/examples)
62
60
 
63
61
  # Contributing
64
62
  We welcome everyone to contribute!
@@ -66,7 +64,7 @@ We welcome everyone to contribute!
66
64
  Whether it's fixing bugs, suggesting features, or improving the docs - every bit helps.
67
65
  - Submit an issue
68
66
  - Open a pull request
69
- - Or just hop into our [Discord community](https://discord.gg/code-society-823178343943897088) and say hi!
67
+ - Or just hop into our [Matrix](https://matrix.to/#/%23codesociety:matrix.org) or [Discord](https://discord.gg/code-society-823178343943897088) server and say hi!
70
68
 
71
69
  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).
72
70
 
@@ -0,0 +1,45 @@
1
+ from matrix import Extension, Context
2
+
3
+ extension = Extension("math")
4
+
5
+
6
+ @extension.group("math", description="Math Group")
7
+ async def math_group(ctx: Context):
8
+ pass
9
+
10
+
11
+ @math_group.command()
12
+ async def add(ctx: Context, a: int, b: int):
13
+ await ctx.reply(f"**{a} + {b} = {a + b}**")
14
+
15
+
16
+ @math_group.command()
17
+ async def subtract(ctx: Context, a: int, b: int):
18
+ await ctx.reply(f"{a} - {b} = {a - b}")
19
+
20
+
21
+ @math_group.command()
22
+ async def multiply(ctx: Context, a: int, b: int):
23
+ await ctx.reply(f"{a} x {b} = {a * b}")
24
+
25
+
26
+ @math_group.command()
27
+ async def divide(ctx: Context, a: int, b: int):
28
+ await ctx.reply(f"{a} ÷ {b} = {a / b}")
29
+
30
+
31
+ @divide.error(ZeroDivisionError)
32
+ async def divide_error(ctx: Context, error):
33
+ await ctx.reply(f"Divide error: {error}")
34
+
35
+
36
+ """
37
+ from matrix import Bot
38
+ from math_extension import extension as math_extension
39
+
40
+ bot = Bot(config="config.yaml")
41
+
42
+
43
+ bot.load_extension(math_extension)
44
+ bot.start()
45
+ """
@@ -15,6 +15,7 @@ from .command import Command
15
15
  from .help import HelpCommand
16
16
  from .checks import cooldown
17
17
  from .room import Room
18
+ from .extension import Extension
18
19
 
19
20
  __all__ = [
20
21
  "Bot",
@@ -26,4 +27,5 @@ __all__ = [
26
27
  "HelpCommand",
27
28
  "cooldown",
28
29
  "Room",
30
+ "Extension",
29
31
  ]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.2.0a0'
32
- __version_tuple__ = version_tuple = (1, 2, 0, 'a0')
31
+ __version__ = version = '1.3.3a0'
32
+ __version_tuple__ = version_tuple = (1, 3, 3, 'a0')
33
33
 
34
- __commit_id__ = commit_id = 'gdd753b685'
34
+ __commit_id__ = commit_id = 'g7d083d049'
@@ -0,0 +1,288 @@
1
+ import time
2
+ import inspect
3
+ import asyncio
4
+ import logging
5
+
6
+ from typing import Union, Optional
7
+
8
+ from nio import AsyncClient, Event, MatrixRoom
9
+
10
+ from .room import Room
11
+ from .group import Group
12
+ from .config import Config
13
+ from .context import Context
14
+ from .extension import Extension
15
+ from .registry import Registry
16
+ from .help import HelpCommand, DefaultHelpCommand
17
+ from .scheduler import Scheduler
18
+ from .errors import AlreadyRegisteredError, CommandNotFoundError, CheckError
19
+
20
+
21
+ class Bot(Registry):
22
+ """
23
+ The base class defining a Matrix bot.
24
+
25
+ This class manages the connection to a Matrix homeserver, listens
26
+ for events, and dispatches them to registered handlers. It also supports
27
+ a command system with decorators for easy registration.
28
+ """
29
+
30
+ def __init__(
31
+ self, *, config: Union[Config, str], help: Optional[HelpCommand] = None
32
+ ) -> None:
33
+ if isinstance(config, Config):
34
+ self.config = config
35
+ elif isinstance(config, str):
36
+ self.config = Config(config_path=config)
37
+ else:
38
+ raise TypeError("config must be a Config instance or a config file path")
39
+
40
+ super().__init__(self.__class__.__name__, prefix=self.config.prefix)
41
+
42
+ self.client: AsyncClient = AsyncClient(self.config.homeserver)
43
+ self.extensions: dict[str, Extension] = {}
44
+ self.scheduler: Scheduler = Scheduler()
45
+ self.log: logging.Logger = logging.getLogger(__name__)
46
+
47
+ self.start_at: float | None = None # unix timestamp
48
+
49
+ self.help: HelpCommand = help or DefaultHelpCommand(prefix=self.prefix)
50
+ self.register_command(self.help)
51
+
52
+ self.client.add_event_callback(self._on_event, Event)
53
+ self._auto_register_events()
54
+
55
+ def get_room(self, room_id: str) -> Room:
56
+ """Retrieve a Room instance based on the room_id."""
57
+ matrix_room = self.client.rooms[room_id]
58
+ return Room(matrix_room=matrix_room, client=self.client)
59
+
60
+ def load_extension(self, extension: Extension) -> None:
61
+ self.log.debug(f"Loading extension: '{extension.name}'")
62
+
63
+ if extension.name in self.extensions:
64
+ raise AlreadyRegisteredError(extension)
65
+
66
+ for cmd in extension._commands.values():
67
+ if isinstance(cmd, Group):
68
+ self.register_group(cmd)
69
+ else:
70
+ self.register_command(cmd)
71
+
72
+ for event_type, handlers in extension._event_handlers.items():
73
+ self._event_handlers[event_type].extend(handlers)
74
+
75
+ self._checks.extend(extension._checks)
76
+ self._error_handlers.update(extension._error_handlers)
77
+ self._command_error_handlers.update(extension._command_error_handlers)
78
+
79
+ for job in extension._scheduler.jobs:
80
+ self.scheduler.scheduler.add_job(
81
+ job.func,
82
+ trigger=job.trigger,
83
+ name=job.name,
84
+ )
85
+
86
+ self.extensions[extension.name] = extension
87
+ extension.load()
88
+ self.log.debug("loaded extension '%s'", extension.name)
89
+
90
+ def unload_extension(self, ext_name: str) -> None:
91
+ self.log.debug("Unloading extension: '%s'", ext_name)
92
+
93
+ extension = self.extensions.pop(ext_name, None)
94
+ if extension is None:
95
+ raise ValueError(f"No extension named '{ext_name}' is loaded")
96
+
97
+ for cmd_name in extension._commands:
98
+ self._commands.pop(cmd_name, None)
99
+
100
+ for event_type, handlers in extension._event_handlers.items():
101
+ for handler in handlers:
102
+ self._event_handlers[event_type].remove(handler)
103
+
104
+ for check in extension._checks:
105
+ self._checks.remove(check)
106
+
107
+ for exc_type in extension._error_handlers:
108
+ self._error_handlers.pop(exc_type, None)
109
+
110
+ for exc_type in extension._command_error_handlers:
111
+ self._command_error_handlers.pop(exc_type, None)
112
+
113
+ for job in extension._scheduler.jobs:
114
+ bot_job = next((j for j in self.scheduler.jobs if j.func is job.func), None)
115
+ if bot_job:
116
+ bot_job.remove()
117
+
118
+ extension.unload()
119
+ self.log.debug("unloaded extension '%s'", ext_name)
120
+
121
+ def _auto_register_events(self) -> None:
122
+ for attr in dir(self):
123
+ if not attr.startswith("on_"):
124
+ continue
125
+ coro = getattr(self, attr, None)
126
+ if inspect.iscoroutinefunction(coro):
127
+ try:
128
+ self.event(coro)
129
+ except ValueError: # ignore unknown name
130
+ continue
131
+
132
+ async def _on_event(self, room: MatrixRoom, event: Event) -> None:
133
+ # ignore bot events
134
+ if event.sender == self.client.user:
135
+ return
136
+
137
+ # ignore events that happened before the bot started
138
+ if self.start_at and self.start_at > (event.server_timestamp / 1000):
139
+ return
140
+
141
+ try:
142
+ await self._dispatch(room, event)
143
+ except Exception as error:
144
+ await self.on_error(error)
145
+
146
+ async def _dispatch(self, room: MatrixRoom, event: Event) -> None:
147
+ """Internal type-based fan-out plus optional command handling."""
148
+ for event_type, funcs in self._event_handlers.items():
149
+ if isinstance(event, event_type):
150
+ for func in funcs:
151
+ await func(room, event)
152
+
153
+ async def _process_commands(self, room: MatrixRoom, event: Event) -> None:
154
+ """Parse and execute commands"""
155
+ ctx = await self._build_context(room, event)
156
+
157
+ if ctx.command:
158
+ for check in self._checks:
159
+ if not await check(ctx):
160
+ raise CheckError(ctx.command, check)
161
+
162
+ await ctx.command(ctx)
163
+
164
+ async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
165
+ room = self.get_room(matrix_room.room_id)
166
+ ctx = Context(bot=self, room=room, event=event)
167
+ prefix: str | None = None
168
+
169
+ if self.prefix is not None and ctx.body.startswith(self.prefix):
170
+ prefix = self.prefix
171
+ else:
172
+ prefix = next(
173
+ (
174
+ cmd.prefix
175
+ for cmd in self._commands.values()
176
+ if cmd.prefix is not None and ctx.body.startswith(cmd.prefix)
177
+ ),
178
+ self.config.prefix,
179
+ )
180
+
181
+ if prefix is None or not ctx.body.startswith(prefix):
182
+ return ctx
183
+
184
+ if parts := ctx.body[len(prefix) :].split():
185
+ cmd_name = parts[0]
186
+ cmd = self._commands.get(cmd_name)
187
+
188
+ if cmd and cmd.prefix and not ctx.body.startswith(cmd.prefix):
189
+ return ctx
190
+
191
+ if not cmd:
192
+ raise CommandNotFoundError(cmd_name)
193
+
194
+ ctx.command = cmd
195
+
196
+ return ctx
197
+
198
+ async def on_message(self, room: MatrixRoom, event: Event) -> None:
199
+ """
200
+ Invoked when a message event is received.
201
+
202
+ This method is automatically called when a :class:`nio.RoomMessageText`
203
+ event is detected. It is primarily responsible for detecting and
204
+ processing commands that match the bot's defined prefix.
205
+
206
+ :param ctx: The context object containing information about the Matrix
207
+ room and the message event.
208
+ :type ctx: Context
209
+ """
210
+ await self._process_commands(room, event)
211
+
212
+ async def on_ready(self) -> None:
213
+ """Invoked after a successful login, before sync starts."""
214
+ self.log.info("bot is ready")
215
+
216
+ async def on_error(self, error: Exception) -> None:
217
+ """
218
+ Handle errors by invoking a registered error handler,
219
+ a generic error callback, or logging the exception.
220
+
221
+ :param error: The exception instance that was raised.
222
+ :type error: Exceptipon
223
+ """
224
+ if handler := self._error_handlers.get(type(error)):
225
+ await handler(error)
226
+ return
227
+
228
+ if self._on_error:
229
+ await self._on_error(error)
230
+ return
231
+ self.log.exception("Unhandled error: '%s'", error)
232
+
233
+ async def on_command_error(self, ctx: "Context", error: Exception) -> None:
234
+ """
235
+ Handles errors raised during command invocation.
236
+
237
+ This method is called automatically when a command error occurs.
238
+ If a specific error handler is registered for the type of the
239
+ exception, it will be invoked with the current context and error.
240
+
241
+ :param ctx: The context in which the command was invoked.
242
+ :type ctx: Context
243
+ :param error: The exception that was raised during command execution.
244
+ :type error: Exception
245
+ """
246
+ if handler := self._command_error_handlers.get(type(error)):
247
+ await handler(ctx, error)
248
+
249
+ async def run(self) -> None:
250
+ """
251
+ Log in to the Matrix homeserver and begin syncing events.
252
+
253
+ This method should be used within an asynchronous context,
254
+ typically via :func:`asyncio.run`. It handles authentication,
255
+ calls the :meth:`on_ready` hook, and starts the long-running
256
+ sync loop for receiving events.
257
+ """
258
+ self.client.user = self.config.user_id
259
+
260
+ self.start_at = time.time()
261
+ self.log.info("starting – timestamp=%s", self.start_at)
262
+
263
+ if self.config.token:
264
+ self.client.access_token = self.config.token
265
+ else:
266
+ login_resp = await self.client.login(self.config.password)
267
+ self.log.info("logged in: %s", login_resp)
268
+
269
+ self.scheduler.start()
270
+
271
+ await self.on_ready()
272
+ await self.client.sync_forever(timeout=30_000)
273
+
274
+ def start(self) -> None:
275
+ """
276
+ Synchronous entry point for running the bot.
277
+
278
+ This is a convenience wrapper that allows running the bot like a
279
+ script using a blocking call. It internally calls :meth:`run` within
280
+ :func:`asyncio.run`, and ensures the client is closed gracefully
281
+ on interruption.
282
+ """
283
+ try:
284
+ asyncio.run(self.run())
285
+ except KeyboardInterrupt:
286
+ self.log.info("bot interrupted by user")
287
+ finally:
288
+ asyncio.run(self.client.close())
@@ -28,7 +28,6 @@ class Context:
28
28
  self.sender: str = event.sender
29
29
 
30
30
  # Command metadata
31
- self.prefix: str = bot.prefix
32
31
  self.command: Optional[Command] = None
33
32
  self.subcommand: Optional[Command] = None
34
33
  self._args: List[str] = shlex.split(self.body)
@@ -4,6 +4,7 @@ import inspect
4
4
  if TYPE_CHECKING:
5
5
  from .command import Command # pragma: no cover
6
6
  from .group import Group # pragma: no cover
7
+ from .extension import Extension
7
8
 
8
9
  Callback = Callable[..., Coroutine[Any, Any, Any]]
9
10
 
@@ -12,6 +13,17 @@ class MatrixError(Exception):
12
13
  pass
13
14
 
14
15
 
16
+ class RegistryError(MatrixError):
17
+ pass
18
+
19
+
20
+ class AlreadyRegisteredError(RegistryError):
21
+ def __init__(self, entry: "Command | Group | Extension"):
22
+ super().__init__(
23
+ f"{entry.__class__.__name__} '{entry.name}' is already registered"
24
+ )
25
+
26
+
15
27
  class CommandError(MatrixError):
16
28
  pass
17
29
 
@@ -21,7 +33,7 @@ class CommandNotFoundError(CommandError):
21
33
  super().__init__(f"Command with name '{cmd}' not found")
22
34
 
23
35
 
24
- class AlreadyRegisteredError(CommandError):
36
+ class CommandAlreadyRegisteredError(CommandError):
25
37
  def __init__(self, cmd: "Command"):
26
38
  super().__init__(f"Command '{cmd}' is already registered")
27
39
 
@@ -40,11 +52,6 @@ class GroupError(CommandError):
40
52
  pass
41
53
 
42
54
 
43
- class GroupAlreadyRegisteredError(GroupError):
44
- def __init__(self, group: "Group"):
45
- super().__init__(f"Group '{group}' is already registered")
46
-
47
-
48
55
  class ConfigError(MatrixError):
49
56
  def __init__(self, error: str):
50
57
  super().__init__(f"Missing required configuration: '{error}'")
@@ -0,0 +1,56 @@
1
+ import logging
2
+ import inspect
3
+
4
+ from typing import Any, Callable, Coroutine, Optional
5
+ from matrix.registry import Registry
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class Extension(Registry):
11
+ def __init__(self, name: str, prefix: Optional[str] = None) -> None:
12
+ super().__init__(name, prefix=prefix)
13
+ self._on_load: Optional[Callable] = None
14
+ self._on_unload: Optional[Callable] = None
15
+
16
+ def load(self) -> None:
17
+ if self._on_load:
18
+ self._on_load()
19
+
20
+ def on_load(self, func: Callable) -> Callable:
21
+ """Decorator to register a function to be called after this extension
22
+ is loaded into the bot.
23
+
24
+ ## Example
25
+
26
+ ```python
27
+ @extension.on_load
28
+ def setup():
29
+ print("extension loaded")
30
+ ```
31
+ """
32
+ if inspect.iscoroutinefunction(func):
33
+ raise TypeError("on_load handler must not be a coroutine")
34
+ self._on_load = func
35
+ return func
36
+
37
+ def unload(self) -> None:
38
+ if self._on_unload:
39
+ self._on_unload()
40
+
41
+ def on_unload(self, func: Callable) -> Callable:
42
+ """Decorator to register a function to be called before this extension
43
+ is unloaded from the bot.
44
+
45
+ ## Example
46
+
47
+ ```python
48
+ @extension.on_unload
49
+ def teardown():
50
+ print("extension unloaded")
51
+ ```
52
+ """
53
+ if inspect.iscoroutinefunction(func):
54
+ raise TypeError("on_unload handler must not be a coroutine")
55
+ self._on_unload = func
56
+ return func