matrix-python 1.4.5a0__tar.gz → 1.4.7a0__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.
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/PKG-INFO +2 -1
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/_version.py +3 -3
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/bot.py +13 -14
- matrix_python-1.4.7a0/matrix/config.py +125 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/registry.py +5 -5
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/PKG-INFO +2 -1
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/SOURCES.txt +0 -2
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/requires.txt +1 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/pyproject.toml +1 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_bot.py +68 -43
- matrix_python-1.4.7a0/tests/test_config.py +139 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_registry.py +0 -10
- matrix_python-1.4.5a0/matrix/config.py +0 -61
- matrix_python-1.4.5a0/tests/config_fixture.yaml +0 -4
- matrix_python-1.4.5a0/tests/config_fixture_token.yaml +0 -1
- matrix_python-1.4.5a0/tests/test_config.py +0 -81
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/dependabot.yml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/CODEOWNERS +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/codeql.yml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/publish.yml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/scorecard.yml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/tests.yml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.gitignore +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/CODE_OF_CONDUCT.md +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/CONTRIBUTING.md +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/LICENSE +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/README.md +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/README.md +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/checks.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/config.yaml +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/cooldown.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/error_handling.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/extension.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/ping.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/reaction.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/scheduler.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/__init__.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/checks.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/command.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/content.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/context.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/errors.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/extension.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/group.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/__init__.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/help_command.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/pagination.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/message.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/protocols.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/room.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/scheduler.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/types.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/dependency_links.txt +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/top_level.txt +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/mypy.ini +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/setup.cfg +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_default_help_command.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_help_command.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_pagination.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_command.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_context.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_extension.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_group.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_message.py +0 -0
- {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_room.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matrix-python
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.7a0
|
|
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>
|
|
@@ -689,6 +689,7 @@ Requires-Dist: logger
|
|
|
689
689
|
Requires-Dist: PyYAML==6.0.3
|
|
690
690
|
Requires-Dist: markdown==3.10.2
|
|
691
691
|
Requires-Dist: APScheduler==3.11.2
|
|
692
|
+
Requires-Dist: envyaml==1.10.211231
|
|
692
693
|
Provides-Extra: dev
|
|
693
694
|
Requires-Dist: pytest==9.0.3; extra == "dev"
|
|
694
695
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.7a0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 7, 'a0')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gdeb79d4c2'
|
|
@@ -184,10 +184,6 @@ class Bot(Registry):
|
|
|
184
184
|
await handler(error)
|
|
185
185
|
return
|
|
186
186
|
|
|
187
|
-
if self._fallback_error_handler:
|
|
188
|
-
await self._fallback_error_handler(error)
|
|
189
|
-
return
|
|
190
|
-
|
|
191
187
|
await self._dispatch("on_error", error)
|
|
192
188
|
|
|
193
189
|
async def on_command(self, _ctx: Context) -> None:
|
|
@@ -245,8 +241,7 @@ class Bot(Registry):
|
|
|
245
241
|
:func:`asyncio.run`, and ensures the client is closed gracefully
|
|
246
242
|
on interruption.
|
|
247
243
|
"""
|
|
248
|
-
|
|
249
|
-
self._load_config(config)
|
|
244
|
+
self._load_config(config)
|
|
250
245
|
|
|
251
246
|
try:
|
|
252
247
|
asyncio.run(self.run())
|
|
@@ -264,7 +259,7 @@ class Bot(Registry):
|
|
|
264
259
|
calls the :meth:`on_ready` hook, and starts the long-running
|
|
265
260
|
sync loop for receiving events.
|
|
266
261
|
"""
|
|
267
|
-
self.client.user = self.config.
|
|
262
|
+
self.client.user = self.config.username
|
|
268
263
|
|
|
269
264
|
self.start_at = time.time()
|
|
270
265
|
self.log.info("starting – timestamp=%s", self.start_at)
|
|
@@ -327,15 +322,19 @@ class Bot(Registry):
|
|
|
327
322
|
|
|
328
323
|
async def _process_commands(self, room: Room, event: Event) -> None:
|
|
329
324
|
"""Parse and execute commands"""
|
|
330
|
-
|
|
325
|
+
try:
|
|
326
|
+
ctx = await self._build_context(room, event)
|
|
331
327
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
328
|
+
if ctx.command:
|
|
329
|
+
for check in self._checks:
|
|
330
|
+
if not await check(ctx):
|
|
331
|
+
raise CheckError(ctx.command, check)
|
|
336
332
|
|
|
337
|
-
|
|
338
|
-
|
|
333
|
+
await self._on_command(ctx)
|
|
334
|
+
await ctx.command(ctx)
|
|
335
|
+
except Exception as error:
|
|
336
|
+
ctx = Context(bot=self, room=room, event=event)
|
|
337
|
+
await self._on_command_error(ctx, error)
|
|
339
338
|
|
|
340
339
|
async def _build_context(self, matrix_room: Room, event: Event) -> Context:
|
|
341
340
|
room = self.get_room(matrix_room.room_id)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from envyaml import EnvYAML
|
|
4
|
+
|
|
5
|
+
from .errors import ConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Config:
|
|
9
|
+
"""Configuration handler for Matrix client settings.
|
|
10
|
+
|
|
11
|
+
Manages all settings required to connect and authenticate with a Matrix
|
|
12
|
+
homeserver. Configuration can be loaded from a YAML file or provided
|
|
13
|
+
directly via constructor parameters. At least one authentication method
|
|
14
|
+
must be provided.
|
|
15
|
+
|
|
16
|
+
# Example
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# Load from file
|
|
20
|
+
config = Config(config_path="path/to/config..yaml")
|
|
21
|
+
|
|
22
|
+
# Manual configuration
|
|
23
|
+
config = Config(username="@bot:matrix.org", password="secret")
|
|
24
|
+
```
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
config_path: str | None = None,
|
|
30
|
+
*,
|
|
31
|
+
homeserver: str | None = None,
|
|
32
|
+
username: str | None = None,
|
|
33
|
+
password: str | None = None,
|
|
34
|
+
token: str | None = None,
|
|
35
|
+
prefix: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize the bot configuration.
|
|
38
|
+
|
|
39
|
+
Loads configuration from a YAML file if provided, otherwise uses
|
|
40
|
+
the provided parameters directly. At least one of password or token
|
|
41
|
+
must be supplied.
|
|
42
|
+
|
|
43
|
+
# Example
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
config = Config(
|
|
47
|
+
username="@bot:matrix.org",
|
|
48
|
+
password="secret",
|
|
49
|
+
prefix="!",
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
"""
|
|
53
|
+
self._data: dict[str, Any] = {}
|
|
54
|
+
|
|
55
|
+
self.homeserver: str = homeserver or "https://matrix.org"
|
|
56
|
+
self.username: str | None = username
|
|
57
|
+
self.password: str | None = password
|
|
58
|
+
self.token: str | None = token
|
|
59
|
+
self.prefix: str = prefix or "!"
|
|
60
|
+
|
|
61
|
+
if config_path:
|
|
62
|
+
self.load_from_file(config_path)
|
|
63
|
+
else:
|
|
64
|
+
if not self.password and not self.token:
|
|
65
|
+
raise ConfigError("username and password or token")
|
|
66
|
+
|
|
67
|
+
self._data = {
|
|
68
|
+
"HOMESERVER": self.homeserver,
|
|
69
|
+
"USERNAME": self.username,
|
|
70
|
+
"PASSWORD": self.password,
|
|
71
|
+
"TOKEN": self.token,
|
|
72
|
+
"PREFIX": self.prefix,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def load_from_file(self, config_path: str) -> None:
|
|
76
|
+
"""Load Matrix client settings from a YAML config file.
|
|
77
|
+
|
|
78
|
+
Supports environment variable substitution via EnvYAML. Values in
|
|
79
|
+
the YAML file can reference environment variables using ${VAR} syntax.
|
|
80
|
+
|
|
81
|
+
# Example
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
config = Config()
|
|
85
|
+
config.load_from_file("path/to/config.yaml")
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
self._data = dict(EnvYAML(config_path))
|
|
89
|
+
|
|
90
|
+
password = self._data.get("PASSWORD", None)
|
|
91
|
+
token = self._data.get("TOKEN", None)
|
|
92
|
+
|
|
93
|
+
if not password and not token:
|
|
94
|
+
raise ConfigError("USERNAME and PASSWORD or TOKEN")
|
|
95
|
+
|
|
96
|
+
self.homeserver = self._data.get("HOMESERVER", "https://matrix.org")
|
|
97
|
+
self.username = self._data.get("USERNAME")
|
|
98
|
+
self.password = password
|
|
99
|
+
self.token = token
|
|
100
|
+
self.prefix = self._data.get("PREFIX", "!")
|
|
101
|
+
|
|
102
|
+
def get(self, key: str, *, section: str | None = None, default: Any = None) -> Any:
|
|
103
|
+
"""Access a config value by key, optionally scoped to a section.
|
|
104
|
+
|
|
105
|
+
# Example
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
config.get(key="main_channel", section="bot")
|
|
109
|
+
config.get(key="log_level", default="INFO")
|
|
110
|
+
```
|
|
111
|
+
"""
|
|
112
|
+
if section in self._data:
|
|
113
|
+
return self._data.get(section, {}).get(key, default)
|
|
114
|
+
return self._data.get(key, default)
|
|
115
|
+
|
|
116
|
+
def __getitem__(self, key: str) -> Any:
|
|
117
|
+
"""Access a config value by key, raising KeyError if not found.
|
|
118
|
+
|
|
119
|
+
# Example
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
config["bot"]["main_channel"]
|
|
123
|
+
```
|
|
124
|
+
"""
|
|
125
|
+
return self._data[key]
|
|
@@ -65,7 +65,6 @@ class Registry:
|
|
|
65
65
|
|
|
66
66
|
self._event_handlers: Dict[Type[Event], List[Callback]] = defaultdict(list)
|
|
67
67
|
self._hook_handlers: Dict[str, List[Callback]] = defaultdict(list)
|
|
68
|
-
self._fallback_error_handler: Optional[ErrorCallback] = None
|
|
69
68
|
self._error_handlers: Dict[type[Exception], ErrorCallback] = {}
|
|
70
69
|
self._command_error_handlers: Dict[type[Exception], CommandErrorCallback] = {}
|
|
71
70
|
|
|
@@ -378,14 +377,15 @@ class Registry:
|
|
|
378
377
|
```
|
|
379
378
|
"""
|
|
380
379
|
|
|
380
|
+
if not exception:
|
|
381
|
+
exception = Exception
|
|
382
|
+
|
|
381
383
|
def wrapper(func: ErrorCallback) -> ErrorCallback:
|
|
382
384
|
if not inspect.iscoroutinefunction(func):
|
|
383
385
|
raise TypeError("Error handlers must be coroutines")
|
|
384
386
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
else:
|
|
388
|
-
self._fallback_error_handler = func
|
|
387
|
+
self._error_handlers[exception] = func
|
|
388
|
+
|
|
389
389
|
logger.debug(
|
|
390
390
|
"registered error handler '%s' on %s",
|
|
391
391
|
func.__name__,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matrix-python
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.7a0
|
|
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>
|
|
@@ -689,6 +689,7 @@ Requires-Dist: logger
|
|
|
689
689
|
Requires-Dist: PyYAML==6.0.3
|
|
690
690
|
Requires-Dist: markdown==3.10.2
|
|
691
691
|
Requires-Dist: APScheduler==3.11.2
|
|
692
|
+
Requires-Dist: envyaml==1.10.211231
|
|
692
693
|
Provides-Extra: dev
|
|
693
694
|
Requires-Dist: pytest==9.0.3; extra == "dev"
|
|
694
695
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
|
|
@@ -45,8 +45,6 @@ matrix_python.egg-info/SOURCES.txt
|
|
|
45
45
|
matrix_python.egg-info/dependency_links.txt
|
|
46
46
|
matrix_python.egg-info/requires.txt
|
|
47
47
|
matrix_python.egg-info/top_level.txt
|
|
48
|
-
tests/config_fixture.yaml
|
|
49
|
-
tests/config_fixture_token.yaml
|
|
50
48
|
tests/test_bot.py
|
|
51
49
|
tests/test_command.py
|
|
52
50
|
tests/test_config.py
|
|
@@ -3,9 +3,7 @@ import pytest
|
|
|
3
3
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
4
4
|
from nio import MatrixRoom, RoomMessageText
|
|
5
5
|
|
|
6
|
-
from matrix.bot import Bot
|
|
7
|
-
from matrix.config import Config
|
|
8
|
-
from matrix.extension import Extension
|
|
6
|
+
from matrix.bot import Bot, Config, Extension, Room
|
|
9
7
|
from matrix.errors import (
|
|
10
8
|
CheckError,
|
|
11
9
|
CommandNotFoundError,
|
|
@@ -16,7 +14,30 @@ from matrix.errors import (
|
|
|
16
14
|
@pytest.fixture
|
|
17
15
|
def bot():
|
|
18
16
|
b = Bot()
|
|
19
|
-
b._load_config(
|
|
17
|
+
b._load_config(
|
|
18
|
+
Config(
|
|
19
|
+
username="grace",
|
|
20
|
+
password="grace1234",
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
b._client = MagicMock()
|
|
25
|
+
b._client.room_send = AsyncMock()
|
|
26
|
+
b.log = MagicMock()
|
|
27
|
+
b.log.getChild.return_value = MagicMock()
|
|
28
|
+
|
|
29
|
+
return b
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def bot_with_token():
|
|
34
|
+
b = Bot()
|
|
35
|
+
b._load_config(
|
|
36
|
+
Config(
|
|
37
|
+
username="grace",
|
|
38
|
+
token="abc123",
|
|
39
|
+
)
|
|
40
|
+
)
|
|
20
41
|
|
|
21
42
|
b._client = MagicMock()
|
|
22
43
|
b._client.room_send = AsyncMock()
|
|
@@ -50,7 +71,7 @@ def test_bot_init_with_config():
|
|
|
50
71
|
bot = Bot()
|
|
51
72
|
bot._load_config(Config(username="grace", password="grace1234"))
|
|
52
73
|
|
|
53
|
-
assert bot.config.
|
|
74
|
+
assert bot.config.username == "grace"
|
|
54
75
|
assert bot.config.password == "grace1234"
|
|
55
76
|
assert bot.config.homeserver == "https://matrix.org"
|
|
56
77
|
|
|
@@ -95,7 +116,8 @@ async def test_dispatch_calls_all_handlers(bot):
|
|
|
95
116
|
"type": "m.room.message",
|
|
96
117
|
}
|
|
97
118
|
)
|
|
98
|
-
|
|
119
|
+
matrix_room = MatrixRoom("!roomid:matrix.org", "room_alias")
|
|
120
|
+
room = Room(matrix_room, bot.client)
|
|
99
121
|
|
|
100
122
|
await bot._dispatch_matrix_event(room, event)
|
|
101
123
|
assert "h1" in called
|
|
@@ -183,9 +205,7 @@ async def test_on_error_calls_fallback_handler(bot):
|
|
|
183
205
|
nonlocal called
|
|
184
206
|
called = True
|
|
185
207
|
|
|
186
|
-
await bot.
|
|
187
|
-
await bot.on_error(Exception("test error"))
|
|
188
|
-
|
|
208
|
+
await bot._on_error(Exception("test error"))
|
|
189
209
|
assert called, "Fallback error handler was not called"
|
|
190
210
|
|
|
191
211
|
|
|
@@ -222,7 +242,9 @@ async def test_process_commands_executes_command(bot, event):
|
|
|
222
242
|
|
|
223
243
|
|
|
224
244
|
@pytest.mark.asyncio
|
|
225
|
-
async def
|
|
245
|
+
async def test_command_not_found_calls_command_error_handler(bot):
|
|
246
|
+
bot._on_command_error = AsyncMock()
|
|
247
|
+
|
|
226
248
|
event = RoomMessageText.from_dict(
|
|
227
249
|
{
|
|
228
250
|
"content": {"body": "!nonexistent", "msgtype": "m.text"},
|
|
@@ -235,17 +257,14 @@ async def test_command_not_found_raises(bot):
|
|
|
235
257
|
|
|
236
258
|
room = MatrixRoom("!roomid", "alias")
|
|
237
259
|
|
|
238
|
-
|
|
239
|
-
mock_ctx = MagicMock()
|
|
240
|
-
mock_ctx.body = "!nonexistent"
|
|
241
|
-
MockContext.return_value = mock_ctx
|
|
260
|
+
await bot._process_commands(room, event)
|
|
242
261
|
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
bot._on_command_error.assert_awaited_once()
|
|
263
|
+
assert isinstance(bot._on_command_error.call_args[0][1], CommandNotFoundError)
|
|
245
264
|
|
|
246
265
|
|
|
247
266
|
@pytest.mark.asyncio
|
|
248
|
-
async def
|
|
267
|
+
async def test_bot_does_not_execute_command_when_global_check_fails(bot):
|
|
249
268
|
called = False
|
|
250
269
|
|
|
251
270
|
@bot.command()
|
|
@@ -257,6 +276,8 @@ async def test_bot_does_not_execute_when_global_check_fails(bot, event):
|
|
|
257
276
|
async def global_check(ctx):
|
|
258
277
|
return False
|
|
259
278
|
|
|
279
|
+
bot._on_command_error = AsyncMock()
|
|
280
|
+
|
|
260
281
|
event = RoomMessageText.from_dict(
|
|
261
282
|
{
|
|
262
283
|
"content": {"body": "!greet", "msgtype": "m.text"},
|
|
@@ -267,18 +288,14 @@ async def test_bot_does_not_execute_when_global_check_fails(bot, event):
|
|
|
267
288
|
}
|
|
268
289
|
)
|
|
269
290
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
with patch("matrix.context.Context", autospec=True) as MockContext:
|
|
273
|
-
mock_ctx = MagicMock()
|
|
274
|
-
mock_ctx.body = "!greet"
|
|
275
|
-
mock_ctx.command = bot.commands["greet"]
|
|
276
|
-
MockContext.return_value = mock_ctx
|
|
291
|
+
matrix_room = MatrixRoom("!roomid", "alias")
|
|
292
|
+
room = Room(matrix_room, bot.client)
|
|
277
293
|
|
|
278
|
-
|
|
279
|
-
await bot._process_commands(room, event)
|
|
294
|
+
await bot._process_commands(room, event)
|
|
280
295
|
|
|
281
|
-
assert not called
|
|
296
|
+
assert not called
|
|
297
|
+
bot._on_command_error.assert_awaited_once()
|
|
298
|
+
assert isinstance(bot._on_command_error.call_args[0][1], CheckError)
|
|
282
299
|
|
|
283
300
|
|
|
284
301
|
@pytest.mark.asyncio
|
|
@@ -411,17 +428,14 @@ async def start_and_stop(coro):
|
|
|
411
428
|
|
|
412
429
|
|
|
413
430
|
@pytest.mark.asyncio
|
|
414
|
-
async def test_run_uses_token():
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
bot._client.sync_forever = AsyncMock()
|
|
419
|
-
bot._on_ready = AsyncMock()
|
|
431
|
+
async def test_run_uses_token(bot_with_token):
|
|
432
|
+
bot_with_token._client.sync_forever = AsyncMock()
|
|
433
|
+
bot_with_token._on_ready = AsyncMock()
|
|
420
434
|
|
|
421
435
|
# unblock readiness
|
|
422
|
-
|
|
436
|
+
bot_with_token._synced.set()
|
|
423
437
|
|
|
424
|
-
task = asyncio.create_task(
|
|
438
|
+
task = asyncio.create_task(bot_with_token.run())
|
|
425
439
|
|
|
426
440
|
await asyncio.sleep(0)
|
|
427
441
|
await asyncio.sleep(0)
|
|
@@ -429,14 +443,22 @@ async def test_run_uses_token():
|
|
|
429
443
|
task.cancel()
|
|
430
444
|
await asyncio.gather(task, return_exceptions=True)
|
|
431
445
|
|
|
432
|
-
assert
|
|
433
|
-
|
|
434
|
-
|
|
446
|
+
assert bot_with_token._client.access_token == "abc123"
|
|
447
|
+
bot_with_token._on_ready.assert_awaited_once()
|
|
448
|
+
bot_with_token._client.sync_forever.assert_awaited_once()
|
|
435
449
|
|
|
436
450
|
|
|
437
451
|
@pytest.mark.asyncio
|
|
438
452
|
async def test_run_with_username_and_password(bot):
|
|
439
|
-
bot.
|
|
453
|
+
assert bot.config.token is None
|
|
454
|
+
|
|
455
|
+
login_called = asyncio.Event()
|
|
456
|
+
|
|
457
|
+
async def mock_login(password):
|
|
458
|
+
login_called.set()
|
|
459
|
+
return "login_resp"
|
|
460
|
+
|
|
461
|
+
bot._client.login = AsyncMock(side_effect=mock_login)
|
|
440
462
|
bot._client.sync_forever = AsyncMock()
|
|
441
463
|
bot._on_ready = AsyncMock()
|
|
442
464
|
|
|
@@ -444,15 +466,13 @@ async def test_run_with_username_and_password(bot):
|
|
|
444
466
|
|
|
445
467
|
task = asyncio.create_task(bot.run())
|
|
446
468
|
|
|
447
|
-
await asyncio.
|
|
448
|
-
await asyncio.sleep(0)
|
|
469
|
+
await asyncio.wait_for(login_called.wait(), timeout=1.0)
|
|
449
470
|
|
|
450
471
|
task.cancel()
|
|
451
472
|
await asyncio.gather(task, return_exceptions=True)
|
|
452
473
|
|
|
453
474
|
bot._client.login.assert_awaited_once_with("grace1234")
|
|
454
475
|
bot._on_ready.assert_awaited_once()
|
|
455
|
-
bot._client.sync_forever.assert_awaited_once()
|
|
456
476
|
|
|
457
477
|
|
|
458
478
|
def test_start_handles_keyboard_interrupt(caplog):
|
|
@@ -463,7 +483,12 @@ def test_start_handles_keyboard_interrupt(caplog):
|
|
|
463
483
|
|
|
464
484
|
with patch.object(bot, "_load_config"):
|
|
465
485
|
with caplog.at_level("INFO"):
|
|
466
|
-
bot.start(
|
|
486
|
+
bot.start(
|
|
487
|
+
config=Config(
|
|
488
|
+
username="grace",
|
|
489
|
+
password="grace1234",
|
|
490
|
+
)
|
|
491
|
+
)
|
|
467
492
|
|
|
468
493
|
assert "bot interrupted by user" in caplog.text
|
|
469
494
|
bot._client.close.assert_awaited_once()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from matrix.config import Config
|
|
8
|
+
from matrix.errors import ConfigError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def config_default():
|
|
13
|
+
return Config(username="grace", password="secret")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def config_file(tmp_path):
|
|
18
|
+
config = tmp_path / "test.yaml"
|
|
19
|
+
config.write_text(
|
|
20
|
+
"USERNAME: '@bot:matrix.org'\n"
|
|
21
|
+
"PASSWORD: 'secret'\n"
|
|
22
|
+
"PREFIX: '!'\n"
|
|
23
|
+
"LOG_LEVEL: 'INFO'\n"
|
|
24
|
+
"bot:\n"
|
|
25
|
+
" main_channel: '!abc123:matrix.org'\n"
|
|
26
|
+
)
|
|
27
|
+
return config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def config(config_file):
|
|
32
|
+
return Config(config_path=str(config_file))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get__returns_top_level_value(config: Config) -> None:
|
|
36
|
+
assert config.get(key="LOG_LEVEL") == "INFO"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get__returns_none_when_key_missing_and_no_default(config: Config) -> None:
|
|
40
|
+
assert config.get(key="MISSING") is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_get__returns_default_when_key_missing(config: Config) -> None:
|
|
44
|
+
assert config.get(key="MISSING", default="fallback") == "fallback"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_get__returns_section_value(config: Config) -> None:
|
|
48
|
+
assert config.get(key="main_channel", section="bot") == "!abc123:matrix.org"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_get__returns_default_when_section_missing(config: Config) -> None:
|
|
52
|
+
assert (
|
|
53
|
+
config.get(key="main_channel", section="MISSING", default="fallback")
|
|
54
|
+
== "fallback"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_get__returns_default_when_section_key_missing(config: Config) -> None:
|
|
59
|
+
assert config.get(key="MISSING", section="bot", default="fallback") == "fallback"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_getitem__returns_value(config: Config) -> None:
|
|
63
|
+
assert config["LOG_LEVEL"] == "INFO"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_getitem__raises_key_error_when_missing(config: Config) -> None:
|
|
67
|
+
with pytest.raises(KeyError):
|
|
68
|
+
_ = config["MISSING"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize(
|
|
72
|
+
"attr,expected",
|
|
73
|
+
[
|
|
74
|
+
("homeserver", "https://matrix.org"),
|
|
75
|
+
("username", "grace"),
|
|
76
|
+
("password", "secret"),
|
|
77
|
+
("token", None),
|
|
78
|
+
("prefix", "!"),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_config_defaults_success(config_default, attr, expected):
|
|
82
|
+
assert getattr(config_default, attr) == expected
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_loading_valid_yaml(tmp_path):
|
|
86
|
+
yaml_text = """
|
|
87
|
+
HOMESERVER: https://matrix.org
|
|
88
|
+
USERNAME: "@grace:matrix.org"
|
|
89
|
+
PASSWORD: grace1234
|
|
90
|
+
PREFIX: "/"
|
|
91
|
+
"""
|
|
92
|
+
config_file = tmp_path / "good.yaml"
|
|
93
|
+
config_file.write_text(yaml_text)
|
|
94
|
+
|
|
95
|
+
cfg = Config(str(config_file))
|
|
96
|
+
|
|
97
|
+
assert cfg.username == "@grace:matrix.org"
|
|
98
|
+
assert cfg.password == "grace1234"
|
|
99
|
+
assert cfg.prefix == "/"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_file_not_found(tmp_path):
|
|
103
|
+
with pytest.raises(FileNotFoundError):
|
|
104
|
+
Config(str(tmp_path / "nope.yaml"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_bad_yaml_syntax(tmp_path):
|
|
108
|
+
bad = tmp_path / "bad.yaml"
|
|
109
|
+
bad.write_text("not: valid: : yaml")
|
|
110
|
+
with pytest.raises(yaml.YAMLError):
|
|
111
|
+
Config(str(bad))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_missing_credentials_raises_ConfigError_kwargs():
|
|
115
|
+
with pytest.raises(ConfigError) as exc:
|
|
116
|
+
Config(username="only_user")
|
|
117
|
+
# the assert make sure that the error is raised from
|
|
118
|
+
# the constructor and not load_from_file method
|
|
119
|
+
assert "username and password or token" in str(exc.value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_missing_credentials_raises_ConfigError_yaml(tmp_path):
|
|
123
|
+
yaml_text = "HOMESERVER: https://matrix.org\n" "PASSWORD: \n" "TOKEN: \n"
|
|
124
|
+
file = tmp_path / "err.yaml"
|
|
125
|
+
file.write_text(yaml_text)
|
|
126
|
+
|
|
127
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
128
|
+
with pytest.raises(ConfigError) as exc:
|
|
129
|
+
Config(config_path=str(file))
|
|
130
|
+
assert "USERNAME and PASSWORD or TOKEN" in str(exc.value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_token_only():
|
|
134
|
+
token = "my_very_secure_token"
|
|
135
|
+
cfg = Config(token=token)
|
|
136
|
+
|
|
137
|
+
assert cfg.token == token
|
|
138
|
+
assert cfg.password is None
|
|
139
|
+
assert cfg.homeserver == "https://matrix.org"
|
|
@@ -297,16 +297,6 @@ 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_fallback_error_handler_set(
|
|
301
|
-
registry: Registry,
|
|
302
|
-
):
|
|
303
|
-
@registry.error()
|
|
304
|
-
async def on_any_error(error):
|
|
305
|
-
pass
|
|
306
|
-
|
|
307
|
-
assert registry._fallback_error_handler is on_any_error
|
|
308
|
-
|
|
309
|
-
|
|
310
300
|
def test_register_error_handler_with_non_coroutine__expect_type_error(
|
|
311
301
|
registry: Registry,
|
|
312
302
|
):
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import yaml
|
|
2
|
-
from .errors import ConfigError
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class Config:
|
|
7
|
-
"""
|
|
8
|
-
Configuration handler for Matrix client settings. Including the following:
|
|
9
|
-
|
|
10
|
-
homeserver: Defaults to 'https://matrix.org'
|
|
11
|
-
user_id: The Matrix user ID (username).
|
|
12
|
-
password: (Optional) One of the password or token must be provided.
|
|
13
|
-
token: (Optional) One of the password or token must be provided.
|
|
14
|
-
prefix: Defaults to '!' if not specified in the config file.
|
|
15
|
-
|
|
16
|
-
:param config_path: Path to the YAML configuration file.
|
|
17
|
-
:param homeserver: The Matrix homeserver URL.
|
|
18
|
-
:param username: The Matrix user ID (username).
|
|
19
|
-
:param password: The password for the Matrix user.
|
|
20
|
-
:param token: The access token for the Matrix user.
|
|
21
|
-
:param prefix: The command prefix.
|
|
22
|
-
|
|
23
|
-
:raises FileNotFoundError: If the configuration file does not exist.
|
|
24
|
-
:raises yaml.YAMLError: If the configuration file cannot be parsed.
|
|
25
|
-
:raises ConfigError: If neither password or token has been provided.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def __init__(
|
|
29
|
-
self,
|
|
30
|
-
config_path: Optional[str] = None,
|
|
31
|
-
*,
|
|
32
|
-
homeserver: Optional[str] = None,
|
|
33
|
-
username: Optional[str] = None,
|
|
34
|
-
password: Optional[str] = None,
|
|
35
|
-
token: Optional[str] = None,
|
|
36
|
-
prefix: Optional[str] = None,
|
|
37
|
-
) -> None:
|
|
38
|
-
self.homeserver: str = homeserver or "https://matrix.org"
|
|
39
|
-
self.user_id: Optional[str] = username
|
|
40
|
-
self.password: Optional[str] = password
|
|
41
|
-
self.token: Optional[str] = token
|
|
42
|
-
self.prefix: str = prefix or "!"
|
|
43
|
-
|
|
44
|
-
if config_path:
|
|
45
|
-
self.load_from_file(config_path)
|
|
46
|
-
elif not (self.password or self.token):
|
|
47
|
-
raise ConfigError("username and password or token")
|
|
48
|
-
|
|
49
|
-
def load_from_file(self, config_path: str) -> None:
|
|
50
|
-
"""Load Matrix client settings via YAML config file."""
|
|
51
|
-
with open(config_path, "r") as f:
|
|
52
|
-
config = yaml.safe_load(f)
|
|
53
|
-
|
|
54
|
-
if not (config.get("PASSWORD") or config.get("TOKEN")):
|
|
55
|
-
raise ConfigError("USERNAME and PASSWORD or TOKEN")
|
|
56
|
-
|
|
57
|
-
self.homeserver = config.get("HOMESERVER", "https://matrix.org")
|
|
58
|
-
self.user_id = config.get("USERNAME")
|
|
59
|
-
self.password = config.get("PASSWORD", None)
|
|
60
|
-
self.token = config.get("TOKEN", None)
|
|
61
|
-
self.prefix = config.get("PREFIX", "!")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
TOKEN: "abc123"
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import yaml
|
|
3
|
-
|
|
4
|
-
from matrix.errors import ConfigError
|
|
5
|
-
from matrix.config import Config
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.fixture
|
|
9
|
-
def config_default():
|
|
10
|
-
return Config(username="grace", password="secret")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.mark.parametrize(
|
|
14
|
-
"attr,expected",
|
|
15
|
-
[
|
|
16
|
-
("homeserver", "https://matrix.org"),
|
|
17
|
-
("user_id", "grace"),
|
|
18
|
-
("password", "secret"),
|
|
19
|
-
("token", None),
|
|
20
|
-
("prefix", "!"),
|
|
21
|
-
],
|
|
22
|
-
)
|
|
23
|
-
def test_config_defaults_success(config_default, attr, expected):
|
|
24
|
-
assert getattr(config_default, attr) == expected
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_loading_valid_yaml(tmp_path):
|
|
28
|
-
yaml_text = """
|
|
29
|
-
HOMESERVER: https://matrix.org
|
|
30
|
-
USERNAME: "@grace:matrix.org"
|
|
31
|
-
PASSWORD: grace1234
|
|
32
|
-
PREFIX: "/"
|
|
33
|
-
"""
|
|
34
|
-
config_file = tmp_path / "good.yaml"
|
|
35
|
-
config_file.write_text(yaml_text)
|
|
36
|
-
|
|
37
|
-
cfg = Config(str(config_file))
|
|
38
|
-
|
|
39
|
-
assert cfg.user_id == "@grace:matrix.org"
|
|
40
|
-
assert cfg.password == "grace1234"
|
|
41
|
-
assert cfg.prefix == "/"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_file_not_found(tmp_path):
|
|
45
|
-
with pytest.raises(FileNotFoundError):
|
|
46
|
-
Config(str(tmp_path / "nope.yaml"))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_bad_yaml_syntax(tmp_path):
|
|
50
|
-
bad = tmp_path / "bad.yaml"
|
|
51
|
-
bad.write_text("not: valid: : yaml")
|
|
52
|
-
with pytest.raises(yaml.YAMLError):
|
|
53
|
-
Config(str(bad))
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_missing_credentials_raises_ConfigError_kwargs():
|
|
57
|
-
with pytest.raises(ConfigError) as exc:
|
|
58
|
-
Config(username="only_user")
|
|
59
|
-
# the assert make sure that the error is raised from
|
|
60
|
-
# the constructor and not load_from_file method
|
|
61
|
-
assert "username and password or token" in str(exc.value)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_missing_credentials_raises_ConfigError_yaml(tmp_path):
|
|
65
|
-
yaml_text = "HOMESERVER: https://matrix.org"
|
|
66
|
-
file = tmp_path / "err.yaml"
|
|
67
|
-
file.write_text(yaml_text)
|
|
68
|
-
with pytest.raises(ConfigError) as exc:
|
|
69
|
-
Config(str(file))
|
|
70
|
-
# the assert make sure that the error is raised from
|
|
71
|
-
# the load_from_file method and not the constructor
|
|
72
|
-
assert "USERNAME and PASSWORD or TOKEN" in str(exc.value)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_token_only():
|
|
76
|
-
token = "my_very_secure_token"
|
|
77
|
-
cfg = Config(token=token)
|
|
78
|
-
|
|
79
|
-
assert cfg.token == token
|
|
80
|
-
assert cfg.password is None
|
|
81
|
-
assert cfg.homeserver == "https://matrix.org"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|