matrix-python 1.3.0a0__tar.gz → 1.4.0a0__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.4.0a0/.github/dependabot.yml +6 -0
  2. matrix_python-1.4.0a0/.github/workflows/CODEOWNERS +1 -0
  3. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/PKG-INFO +8 -10
  4. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/README.md +7 -9
  5. matrix_python-1.4.0a0/matrix/_version.py +24 -0
  6. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/bot.py +130 -122
  7. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/registry.py +77 -13
  8. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix_python.egg-info/PKG-INFO +8 -10
  9. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix_python.egg-info/SOURCES.txt +2 -0
  10. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_bot.py +38 -25
  11. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_registry.py +4 -2
  12. matrix_python-1.3.0a0/matrix/_version.py +0 -34
  13. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/.github/workflows/codeql.yml +0 -0
  14. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/.github/workflows/publish.yml +0 -0
  15. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/.github/workflows/scorecard.yml +0 -0
  16. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/.github/workflows/tests.yml +0 -0
  17. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/.gitignore +0 -0
  18. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/CODE_OF_CONDUCT.md +0 -0
  19. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/CONTRIBUTING.md +0 -0
  20. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/LICENSE +0 -0
  21. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/README.md +0 -0
  22. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/checks.py +0 -0
  23. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/config.yaml +0 -0
  24. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/cooldown.py +0 -0
  25. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/error_handling.py +0 -0
  26. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/extension.py +0 -0
  27. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/ping.py +0 -0
  28. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/reaction.py +0 -0
  29. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/examples/scheduler.py +0 -0
  30. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/__init__.py +0 -0
  31. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/checks.py +0 -0
  32. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/command.py +0 -0
  33. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/config.py +0 -0
  34. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/content.py +0 -0
  35. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/context.py +0 -0
  36. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/errors.py +0 -0
  37. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/extension.py +0 -0
  38. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/group.py +0 -0
  39. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/help/__init__.py +0 -0
  40. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/help/help_command.py +0 -0
  41. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/help/pagination.py +0 -0
  42. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/message.py +0 -0
  43. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/room.py +0 -0
  44. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/scheduler.py +0 -0
  45. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix/types.py +0 -0
  46. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  47. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix_python.egg-info/requires.txt +0 -0
  48. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/matrix_python.egg-info/top_level.txt +0 -0
  49. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/mypy.ini +0 -0
  50. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/pyproject.toml +0 -0
  51. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/setup.cfg +0 -0
  52. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/config_fixture.yaml +0 -0
  53. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/config_fixture_token.yaml +0 -0
  54. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/help/test_default_help_command.py +0 -0
  55. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/help/test_help_command.py +0 -0
  56. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/help/test_pagination.py +0 -0
  57. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_command.py +0 -0
  58. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_config.py +0 -0
  59. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_context.py +0 -0
  60. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_extension.py +0 -0
  61. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_group.py +0 -0
  62. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/tests/test_message.py +0 -0
  63. {matrix_python-1.3.0a0 → matrix_python-1.4.0a0}/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.3.0a0
3
+ Version: 1.4.0a0
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,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.4.0a0'
22
+ __version_tuple__ = version_tuple = (1, 4, 0, 'a0')
23
+
24
+ __commit_id__ = commit_id = 'g9a7d62be9'
@@ -3,7 +3,7 @@ import inspect
3
3
  import asyncio
4
4
  import logging
5
5
 
6
- from typing import Union, Optional
6
+ from typing import Union, Optional, Any
7
7
 
8
8
  from nio import AsyncClient, Event, MatrixRoom
9
9
 
@@ -49,9 +49,27 @@ class Bot(Registry):
49
49
  self.help: HelpCommand = help or DefaultHelpCommand(prefix=self.prefix)
50
50
  self.register_command(self.help)
51
51
 
52
- self.client.add_event_callback(self._on_event, Event)
52
+ self.client.add_event_callback(self._on_matrix_event, Event)
53
53
  self._auto_register_events()
54
54
 
55
+ def _auto_register_events(self) -> None:
56
+ for attr in dir(self):
57
+ if not attr.startswith("on_"):
58
+ continue
59
+
60
+ coro = getattr(self, attr, None)
61
+ if not inspect.iscoroutinefunction(coro):
62
+ continue
63
+
64
+ try:
65
+ if attr in self.LIFECYCLE_EVENTS:
66
+ self.hook(coro)
67
+
68
+ if attr in self.EVENT_MAP:
69
+ self.event(coro)
70
+ except ValueError:
71
+ continue
72
+
55
73
  def get_room(self, room_id: str) -> Room:
56
74
  """Retrieve a Room instance based on the room_id."""
57
75
  matrix_room = self.client.rooms[room_id]
@@ -72,6 +90,9 @@ class Bot(Registry):
72
90
  for event_type, handlers in extension._event_handlers.items():
73
91
  self._event_handlers[event_type].extend(handlers)
74
92
 
93
+ for hook_name, handlers in extension._hook_handlers.items():
94
+ self._hook_handlers[hook_name].extend(handlers)
95
+
75
96
  self._checks.extend(extension._checks)
76
97
  self._error_handlers.update(extension._error_handlers)
77
98
  self._command_error_handlers.update(extension._command_error_handlers)
@@ -118,133 +139,74 @@ class Bot(Registry):
118
139
  extension.unload()
119
140
  self.log.debug("unloaded extension '%s'", ext_name)
120
141
 
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)
142
+ # LIFECYCLE
211
143
 
212
144
  async def on_ready(self) -> None:
213
- """Invoked after a successful login, before sync starts."""
214
- self.log.info("bot is ready")
145
+ """Override this in a subclass."""
146
+ pass
147
+
148
+ async def _on_ready(self) -> None:
149
+ """Internal hook — always fires, calls public override then extension handlers."""
150
+ await self.on_ready()
151
+ await self._dispatch("on_ready")
215
152
 
216
153
  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.
154
+ """Override this in a subclass."""
155
+ self.log.exception("Unhandled error: '%s'", error)
220
156
 
221
- :param error: The exception instance that was raised.
222
- :type error: Exceptipon
223
- """
157
+ async def _on_error(self, error: Exception) -> None:
224
158
  if handler := self._error_handlers.get(type(error)):
225
159
  await handler(error)
226
160
  return
227
161
 
228
- if self._on_error:
229
- await self._on_error(error)
162
+ if self._fallback_error_handler:
163
+ await self._fallback_error_handler(error)
230
164
  return
165
+
166
+ await self._dispatch("on_error", error)
167
+
168
+ async def on_command(self, _ctx: Context) -> None:
169
+ """Override this in a subclass."""
170
+ pass
171
+
172
+ async def _on_command(self, ctx: Context) -> None:
173
+ await self._dispatch("on_command", ctx)
174
+
175
+ async def on_command_error(self, _ctx: Context, error: Exception) -> None:
176
+ """Override this in a subclass."""
231
177
  self.log.exception("Unhandled error: '%s'", error)
232
178
 
233
- async def on_command_error(self, ctx: "Context", error: Exception) -> None:
179
+ async def _on_command_error(self, ctx: Context, error: Exception) -> None:
234
180
  """
235
181
  Handles errors raised during command invocation.
236
182
 
237
183
  This method is called automatically when a command error occurs.
238
184
  If a specific error handler is registered for the type of the
239
185
  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
186
  """
246
187
  if handler := self._command_error_handlers.get(type(error)):
247
188
  await handler(ctx, error)
189
+ return
190
+
191
+ await self._dispatch("on_command_error", ctx, error)
192
+
193
+ # ENTRYPOINT
194
+
195
+ def start(self) -> None:
196
+ """
197
+ Synchronous entry point for running the bot.
198
+
199
+ This is a convenience wrapper that allows running the bot like a
200
+ script using a blocking call. It internally calls :meth:`run` within
201
+ :func:`asyncio.run`, and ensures the client is closed gracefully
202
+ on interruption.
203
+ """
204
+ try:
205
+ asyncio.run(self.run())
206
+ except KeyboardInterrupt:
207
+ self.log.info("bot interrupted by user")
208
+ finally:
209
+ asyncio.run(self.client.close())
248
210
 
249
211
  async def run(self) -> None:
250
212
  """
@@ -268,21 +230,67 @@ class Bot(Registry):
268
230
 
269
231
  self.scheduler.start()
270
232
 
271
- await self.on_ready()
233
+ await self._on_ready()
272
234
  await self.client.sync_forever(timeout=30_000)
273
235
 
274
- def start(self) -> None:
275
- """
276
- Synchronous entry point for running the bot.
236
+ # MATRIX EVENTS
237
+
238
+ async def on_message(self, room: MatrixRoom, event: Event) -> None:
239
+ await self._process_commands(room, event)
240
+
241
+ async def _on_matrix_event(self, room: MatrixRoom, event: Event) -> None:
242
+ # ignore bot events
243
+ if event.sender == self.client.user:
244
+ return
245
+
246
+ # ignore events that happened before the bot started
247
+ if self.start_at and self.start_at > (event.server_timestamp / 1000):
248
+ return
277
249
 
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
250
  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())
251
+ await self._dispatch_matrix_event(room, event)
252
+ except Exception as error:
253
+ await self._on_error(error)
254
+
255
+ async def _dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None:
256
+ """Fire all listeners registered for a named lifecycle event."""
257
+ for handler in self._hook_handlers.get(event_name, []):
258
+ await handler(*args, **kwargs)
259
+
260
+ async def _dispatch_matrix_event(self, room: MatrixRoom, event: Event) -> None:
261
+ """Fire all listeners registered for a named matrix event."""
262
+ for event_type, funcs in self._event_handlers.items():
263
+ if isinstance(event, event_type):
264
+ for func in funcs:
265
+ await func(room, event)
266
+
267
+ async def _process_commands(self, room: MatrixRoom, event: Event) -> None:
268
+ """Parse and execute commands"""
269
+ ctx = await self._build_context(room, event)
270
+
271
+ if ctx.command:
272
+ for check in self._checks:
273
+ if not await check(ctx):
274
+ raise CheckError(ctx.command, check)
275
+
276
+ await self._on_command(ctx)
277
+ await ctx.command(ctx)
278
+
279
+ async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
280
+ room = self.get_room(matrix_room.room_id)
281
+ ctx = Context(bot=self, room=room, event=event)
282
+ prefix = self.prefix or self.config.prefix
283
+
284
+ if not ctx.body.startswith(prefix):
285
+ return ctx
286
+
287
+ if parts := ctx.body[len(prefix) :].split():
288
+ cmd_name = parts[0]
289
+ cmd = self._commands.get(cmd_name)
290
+
291
+ if not cmd:
292
+ raise CommandNotFoundError(cmd_name)
293
+
294
+ ctx.command = cmd
295
+
296
+ return ctx
@@ -47,6 +47,13 @@ class Registry:
47
47
  "on_member_change": RoomMemberEvent,
48
48
  }
49
49
 
50
+ LIFECYCLE_EVENTS: set[str] = {
51
+ "on_ready",
52
+ "on_error",
53
+ "on_command",
54
+ "on_command_error",
55
+ }
56
+
50
57
  def __init__(self, name: str, prefix: Optional[str] = None):
51
58
  self.name = name
52
59
  self.prefix = prefix
@@ -57,7 +64,8 @@ class Registry:
57
64
  self._scheduler: Scheduler = Scheduler()
58
65
 
59
66
  self._event_handlers: Dict[Type[Event], List[Callback]] = defaultdict(list)
60
- self._on_error: Optional[ErrorCallback] = None
67
+ self._hook_handlers: Dict[str, List[Callback]] = defaultdict(list)
68
+ self._fallback_error_handler: Optional[ErrorCallback] = None
61
69
  self._error_handlers: Dict[type[Exception], ErrorCallback] = {}
62
70
  self._command_error_handlers: Dict[type[Exception], CommandErrorCallback] = {}
63
71
 
@@ -208,17 +216,15 @@ class Registry:
208
216
  if not inspect.iscoroutinefunction(f):
209
217
  raise TypeError("Event handlers must be coroutines")
210
218
 
211
- if event_spec:
212
- if isinstance(event_spec, str):
213
- event_type = self.EVENT_MAP.get(event_spec)
214
- if event_type is None:
215
- raise ValueError(f"Unknown event string: {event_spec}")
216
- else:
217
- event_type = event_spec
218
- else:
219
- event_type = self.EVENT_MAP.get(f.__name__)
220
- if event_type is None:
221
- raise ValueError(f"Unknown event name: {f.__name__}")
219
+ key = event_spec if isinstance(event_spec, str) else f.__name__
220
+ event_type: type[Event] | None = (
221
+ event_spec
222
+ if event_spec and not isinstance(event_spec, str)
223
+ else self.EVENT_MAP.get(key)
224
+ )
225
+
226
+ if event_type is None:
227
+ raise ValueError(f"Unknown event: {key!r}")
222
228
 
223
229
  return self.register_event(event_type, f)
224
230
 
@@ -238,6 +244,64 @@ class Registry:
238
244
  )
239
245
  return callback
240
246
 
247
+ def hook(
248
+ self, func: Optional[Callback] = None, *, event_name: Optional[str] = None
249
+ ) -> Union[Callback, Callable[[Callback], Callback]]:
250
+ """Decorator to register a coroutine as a lifecycle event hook.
251
+
252
+ Lifecycle events include things like ``on_ready``, ``on_command``,
253
+ and ``on_error``. If the event name is not provided, it is inferred
254
+ from the function name. Multiple handlers for the same lifecycle
255
+ event are supported and called in registration order.
256
+
257
+ ## Example
258
+
259
+ ```python
260
+ @bot.hook
261
+ async def on_ready():
262
+ print("Bot is ready!")
263
+
264
+ @bot.hook(event_name="on_command")
265
+ async def log_command(ctx):
266
+ print(f"Command invoked: {ctx.command}")
267
+ ```
268
+ """
269
+
270
+ def wrapper(f: Callback) -> Callback:
271
+ if not inspect.iscoroutinefunction(f):
272
+ raise TypeError("Lifecycle hooks must be coroutines")
273
+
274
+ name = event_name or f.__name__
275
+ if name not in self.LIFECYCLE_EVENTS:
276
+ raise ValueError(f"Unknown lifecycle event: {name}")
277
+
278
+ return self.register_hook(name, f)
279
+
280
+ if func is None:
281
+ return wrapper
282
+ return wrapper(func)
283
+
284
+ def register_hook(self, event_name: str, callback: Callback) -> Callback:
285
+ """Register a lifecycle event hook directly for a given event name.
286
+
287
+ Prefer the :meth:`hook` decorator for typical use. This method
288
+ is useful when loading lifecycle hooks from an extension.
289
+ """
290
+ if not inspect.iscoroutinefunction(callback):
291
+ raise TypeError("Lifecycle hooks must be coroutines")
292
+
293
+ if event_name not in self.LIFECYCLE_EVENTS:
294
+ raise ValueError(f"Unknown lifecycle event: {event_name}")
295
+
296
+ self._hook_handlers[event_name].append(callback)
297
+ logger.debug(
298
+ "registered lifecycle hook '%s' for event '%s' on %s",
299
+ callback.__name__,
300
+ event_name,
301
+ type(self).__name__,
302
+ )
303
+ return callback
304
+
241
305
  def check(self, func: Callback) -> Callback:
242
306
  """Register a global check that must pass before any command is invoked.
243
307
 
@@ -321,7 +385,7 @@ class Registry:
321
385
  if exception:
322
386
  self._error_handlers[exception] = func
323
387
  else:
324
- self._on_error = func
388
+ self._fallback_error_handler = func
325
389
  logger.debug(
326
390
  "registered error handler '%s' on %s",
327
391
  func.__name__,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.3.0a0
3
+ Version: 1.4.0a0
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
 
@@ -5,6 +5,8 @@ LICENSE
5
5
  README.md
6
6
  mypy.ini
7
7
  pyproject.toml
8
+ .github/dependabot.yml
9
+ .github/workflows/CODEOWNERS
8
10
  .github/workflows/codeql.yml
9
11
  .github/workflows/publish.yml
10
12
  .github/workflows/scorecard.yml
@@ -64,16 +64,15 @@ def test_bot_init_with_invalid_config_file():
64
64
 
65
65
 
66
66
  def test_auto_register_events_registers_known_events(bot):
67
- # Add a dummy coroutine named on_message_known to bot instance
68
- async def on_message_known(room, event):
67
+ async def on_message(room, event):
69
68
  pass
70
69
 
71
- setattr(bot, "on_message_known", on_message_known)
70
+ setattr(bot, "on_message", on_message)
72
71
 
73
- with patch.object(bot, "event", wraps=bot.event) as event:
72
+ with patch.object(bot, "event", wraps=bot.event) as mock_event:
74
73
  bot._auto_register_events()
75
74
 
76
- event.assert_any_call(on_message_known)
75
+ mock_event.assert_any_call(on_message)
77
76
 
78
77
 
79
78
  @pytest.mark.asyncio
@@ -100,7 +99,7 @@ async def test_dispatch_calls_all_handlers(bot):
100
99
  )
101
100
  room = MatrixRoom("!roomid:matrix.org", "room_alias")
102
101
 
103
- await bot._dispatch(room, event)
102
+ await bot._dispatch_matrix_event(room, event)
104
103
  assert "h1" in called
105
104
  assert "h2" in called
106
105
 
@@ -114,26 +113,27 @@ async def test_on_event_ignores_self_events(bot):
114
113
  event.sender = "@grace:matrix.org"
115
114
  event.server_timestamp = 123456789
116
115
 
117
- with patch.object(bot, "_dispatch", new_callable=AsyncMock) as dispatch:
118
- await bot._on_event(MatrixRoom("!room:matrix.org", "alias"), event)
116
+ with patch.object(
117
+ bot, "_dispatch_matrix_event", new_callable=AsyncMock
118
+ ) as dispatch:
119
+ await bot._on_matrix_event(MatrixRoom("!room:matrix.org", "alias"), event)
119
120
  dispatch.assert_not_called()
120
121
 
121
122
 
122
123
  @pytest.mark.asyncio
123
124
  async def test_on_event_ignores_old_events(bot, room, event):
124
- # Set start_at after event time
125
125
  bot.client.user = "@somebot:matrix.org"
126
126
  bot.start_at = event.server_timestamp / 1000 + 10
127
127
 
128
- bot._dispatch = AsyncMock()
129
- await bot._on_event(room, event)
128
+ bot._dispatch_matrix_event = AsyncMock()
129
+ await bot._on_matrix_event(room, event)
130
130
 
131
- bot._dispatch.assert_not_called()
131
+ bot._dispatch_matrix_event.assert_not_called()
132
132
 
133
133
 
134
134
  @pytest.mark.asyncio
135
135
  async def test_on_event_calls_error_handler(bot):
136
- bot._dispatch = AsyncMock(side_effect=Exception("boom"))
136
+ bot._dispatch_matrix_event = AsyncMock(side_effect=Exception("boom"))
137
137
 
138
138
  custom_error_handler = AsyncMock()
139
139
  bot.error()(custom_error_handler)
@@ -144,7 +144,7 @@ async def test_on_event_calls_error_handler(bot):
144
144
  bot.start_at = 0
145
145
  bot.client.user = "@grace:matrix.org"
146
146
 
147
- await bot._on_event(MatrixRoom("!roomid", "alias"), event)
147
+ await bot._on_matrix_event(MatrixRoom("!roomid", "alias"), event)
148
148
  custom_error_handler.assert_awaited_once()
149
149
 
150
150
 
@@ -156,13 +156,28 @@ async def test_on_message_calls_process_commands(bot, room, event):
156
156
 
157
157
 
158
158
  @pytest.mark.asyncio
159
- async def test_on_ready(bot):
160
- await bot.on_ready()
161
- bot.log.info.assert_called_once_with("bot is ready")
159
+ async def test_on_ready_dispatches(bot):
160
+ with patch.object(bot, "_dispatch", new_callable=AsyncMock) as mock_dispatch:
161
+ await bot._on_ready()
162
+ mock_dispatch.assert_awaited_once_with("on_ready")
162
163
 
163
164
 
164
165
  @pytest.mark.asyncio
165
- async def test_on_error_calls_custom_handler(bot):
166
+ async def test_on_error_calls_specific_handler(bot):
167
+ called = False
168
+
169
+ @bot.error(ValueError)
170
+ async def custom_error_handler(e):
171
+ nonlocal called
172
+ called = True
173
+
174
+ await bot._on_error(ValueError("test error"))
175
+
176
+ assert called, "Specific error handler was not called"
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_on_error_calls_fallback_handler(bot):
166
181
  called = False
167
182
 
168
183
  @bot.error()
@@ -170,15 +185,14 @@ async def test_on_error_calls_custom_handler(bot):
170
185
  nonlocal called
171
186
  called = True
172
187
 
173
- error = Exception("test error")
174
- await bot.on_error(error)
188
+ await bot._fallback_error_handler(Exception("test error"))
189
+ await bot.on_error(Exception("test error"))
175
190
 
176
- assert called, "Custom error handler was not called"
191
+ assert called, "Fallback error handler was not called"
177
192
 
178
193
 
179
194
  @pytest.mark.asyncio
180
195
  async def test_on_error_logs_when_no_handler(bot):
181
- bot._on_error = None
182
196
  error = Exception("test")
183
197
 
184
198
  await bot.on_error(error)
@@ -197,7 +211,6 @@ async def test_process_commands_executes_command(bot, event):
197
211
  event.body = "!greet"
198
212
  room = MatrixRoom("!roomid:matrix.org", "alias")
199
213
 
200
- # Patch _build_context to return context with command assigned
201
214
  with patch.object(
202
215
  bot, "_build_context", new_callable=AsyncMock
203
216
  ) as mock_build_context:
@@ -383,12 +396,12 @@ async def test_run_uses_token():
383
396
  async def test_run_with_username_and_password(bot):
384
397
  bot.client.login = AsyncMock(return_value="login_resp")
385
398
  bot.client.sync_forever = AsyncMock()
386
- bot.on_ready = AsyncMock()
399
+ bot._on_ready = AsyncMock()
387
400
 
388
401
  await bot.run()
389
402
 
390
403
  bot.client.login.assert_awaited_once_with("grace1234")
391
- bot.on_ready.assert_awaited_once()
404
+ bot._on_ready.assert_awaited_once()
392
405
  bot.client.sync_forever.assert_awaited_once()
393
406
 
394
407
 
@@ -297,12 +297,14 @@ def test_register_error_handler_with_exception_type__expect_handler_in_dict(
297
297
  assert registry._error_handlers[ValueError] is on_value_error
298
298
 
299
299
 
300
- def test_register_generic_error_handler__expect_on_error_set(registry: Registry):
300
+ def test_register_generic_error_handler__expect_fallback_error_handler_set(
301
+ registry: Registry,
302
+ ):
301
303
  @registry.error()
302
304
  async def on_any_error(error):
303
305
  pass
304
306
 
305
- assert registry._on_error is on_any_error
307
+ assert registry._fallback_error_handler is on_any_error
306
308
 
307
309
 
308
310
  def test_register_error_handler_with_non_coroutine__expect_type_error(
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '1.3.0a0'
32
- __version_tuple__ = version_tuple = (1, 3, 0, 'a0')
33
-
34
- __commit_id__ = commit_id = 'gfee3d765e'
File without changes