matrix-python 1.0.4a0__tar.gz → 1.1.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 (61) hide show
  1. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/.github/workflows/publish.yml +2 -0
  2. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/.gitignore +2 -1
  3. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/PKG-INFO +1 -1
  4. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/__init__.py +6 -1
  5. matrix_python-1.1.0a0/matrix/_version.py +34 -0
  6. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/bot.py +11 -15
  7. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/command.py +42 -11
  8. matrix_python-1.1.0a0/matrix/content.py +174 -0
  9. matrix_python-1.1.0a0/matrix/context.py +142 -0
  10. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/group.py +1 -1
  11. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/help/help_command.py +24 -18
  12. matrix_python-1.1.0a0/matrix/message.py +131 -0
  13. matrix_python-1.1.0a0/matrix/room.py +413 -0
  14. matrix_python-1.1.0a0/matrix/types.py +26 -0
  15. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix_python.egg-info/PKG-INFO +1 -1
  16. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix_python.egg-info/SOURCES.txt +3 -0
  17. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/pyproject.toml +4 -1
  18. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/test_command.py +103 -0
  19. matrix_python-1.1.0a0/tests/test_context.py +225 -0
  20. matrix_python-1.1.0a0/tests/test_message.py +139 -0
  21. matrix_python-1.1.0a0/tests/test_room.py +356 -0
  22. matrix_python-1.0.4a0/matrix/context.py +0 -96
  23. matrix_python-1.0.4a0/matrix/message.py +0 -134
  24. matrix_python-1.0.4a0/matrix/room.py +0 -119
  25. matrix_python-1.0.4a0/tests/test_context.py +0 -96
  26. matrix_python-1.0.4a0/tests/test_message.py +0 -91
  27. matrix_python-1.0.4a0/tests/test_room.py +0 -151
  28. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/.github/workflows/codeql.yml +0 -0
  29. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/.github/workflows/scorecard.yml +0 -0
  30. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/.github/workflows/tests.yml +0 -0
  31. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/CODE_OF_CONDUCT.md +0 -0
  32. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/CONTRIBUTING.md +0 -0
  33. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/LICENSE +0 -0
  34. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/README.md +0 -0
  35. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/README.md +0 -0
  36. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/checks.py +0 -0
  37. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/config.yaml +0 -0
  38. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/cooldown.py +0 -0
  39. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/error_handling.py +0 -0
  40. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/ping.py +0 -0
  41. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/reaction.py +0 -0
  42. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/examples/scheduler.py +0 -0
  43. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/checks.py +0 -0
  44. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/config.py +0 -0
  45. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/errors.py +0 -0
  46. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/help/__init__.py +0 -0
  47. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/help/pagination.py +0 -0
  48. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix/scheduler.py +0 -0
  49. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  50. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix_python.egg-info/requires.txt +0 -0
  51. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/matrix_python.egg-info/top_level.txt +0 -0
  52. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/mypy.ini +0 -0
  53. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/setup.cfg +0 -0
  54. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/config_fixture.yaml +0 -0
  55. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/config_fixture_token.yaml +0 -0
  56. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/help/test_default_help_command.py +0 -0
  57. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/help/test_help_command.py +0 -0
  58. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/help/test_pagination.py +0 -0
  59. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/test_bot.py +0 -0
  60. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/test_config.py +0 -0
  61. {matrix_python-1.0.4a0 → matrix_python-1.1.0a0}/tests/test_group.py +0 -0
@@ -13,6 +13,8 @@ jobs:
13
13
 
14
14
  steps:
15
15
  - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
16
18
 
17
19
  - uses: actions/setup-python@v5
18
20
  with:
@@ -175,4 +175,5 @@ cython_debug/
175
175
 
176
176
  .DS_Store
177
177
 
178
- .vscode
178
+ .vscode
179
+ matrix/_version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.0.4a0
3
+ Version: 1.1.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>
@@ -1,6 +1,11 @@
1
1
  """A simple, developer-friendly library to create powerful Matrix bots."""
2
2
 
3
- __version__ = "1.0.4-alpha"
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("matrix-python")
7
+ except PackageNotFoundError:
8
+ from matrix._version import version as __version__
4
9
 
5
10
  from .bot import Bot
6
11
  from .group import Group
@@ -0,0 +1,34 @@
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.1.0a0'
32
+ __version_tuple__ = version_tuple = (1, 1, 0, 'a0')
33
+
34
+ __commit_id__ = commit_id = 'gcd36e40dc'
@@ -1,4 +1,5 @@
1
1
  import time
2
+ import inspect
2
3
  import asyncio
3
4
  import logging
4
5
 
@@ -113,7 +114,7 @@ class Bot:
113
114
 
114
115
  :raises TypeError: If the function is not a coroutine.
115
116
  """
116
- if not asyncio.iscoroutinefunction(func):
117
+ if not inspect.iscoroutinefunction(func):
117
118
  raise TypeError("Checks must be coroutine")
118
119
 
119
120
  self.checks.append(func)
@@ -161,7 +162,7 @@ class Bot:
161
162
  """
162
163
 
163
164
  def wrapper(f: Callback) -> Callback:
164
- if not asyncio.iscoroutinefunction(f):
165
+ if not inspect.iscoroutinefunction(f):
165
166
  raise TypeError("Event handlers must be coroutines")
166
167
 
167
168
  if event_spec:
@@ -285,7 +286,7 @@ class Bot:
285
286
  """
286
287
 
287
288
  def wrapper(f: Callback) -> Callback:
288
- if not asyncio.iscoroutinefunction(f):
289
+ if not inspect.iscoroutinefunction(f):
289
290
  raise TypeError("Scheduled tasks must be coroutines")
290
291
 
291
292
  self.scheduler.schedule(cron, f)
@@ -324,7 +325,7 @@ class Bot:
324
325
  """
325
326
 
326
327
  def wrapper(func: ErrorCallback) -> Callable:
327
- if not asyncio.iscoroutinefunction(func):
328
+ if not inspect.iscoroutinefunction(func):
328
329
  raise TypeError("The error handler must be a coroutine.")
329
330
 
330
331
  if exception:
@@ -336,22 +337,16 @@ class Bot:
336
337
  return wrapper
337
338
 
338
339
  def get_room(self, room_id: str) -> Room:
339
- """
340
- Retrieve a Room instance based on the room_id.
341
-
342
- :param room_id: The ID of the room to retrieve.
343
- :type room_id: str
344
- :return: An instance of the Room class.
345
- :rtype: Room
346
- """
347
- return Room(room_id=room_id, bot=self)
340
+ """Retrieve a Room instance based on the room_id."""
341
+ matrix_room = self.client.rooms[room_id]
342
+ return Room(matrix_room=matrix_room, client=self.client)
348
343
 
349
344
  def _auto_register_events(self) -> None:
350
345
  for attr in dir(self):
351
346
  if not attr.startswith("on_"):
352
347
  continue
353
348
  coro = getattr(self, attr, None)
354
- if asyncio.iscoroutinefunction(coro):
349
+ if inspect.iscoroutinefunction(coro):
355
350
  try:
356
351
  self.event(coro)
357
352
  except ValueError: # ignore unknown name
@@ -389,8 +384,9 @@ class Bot:
389
384
 
390
385
  await ctx.command(ctx)
391
386
 
392
- async def _build_context(self, room: MatrixRoom, event: Event) -> Context:
387
+ async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
393
388
  """Builds the base context and extracts the command from the event"""
389
+ room = self.get_room(matrix_room.room_id)
394
390
  ctx = Context(bot=self, room=room, event=event)
395
391
 
396
392
  if not self.prefix or not ctx.body.startswith(self.prefix):
@@ -1,15 +1,18 @@
1
- import asyncio
2
1
  import inspect
2
+ import types
3
3
 
4
4
  from typing import (
5
5
  TYPE_CHECKING,
6
6
  Any,
7
+ Union,
7
8
  Optional,
8
9
  Callable,
9
10
  Coroutine,
10
11
  List,
11
12
  get_type_hints,
12
13
  DefaultDict,
14
+ get_args,
15
+ get_origin,
13
16
  )
14
17
 
15
18
  from .errors import MissingArgumentError, CheckError, CooldownError
@@ -98,7 +101,7 @@ class Command:
98
101
  :type func: Callback
99
102
  :raises TypeError: If the provided function is not a coroutine.
100
103
  """
101
- if not asyncio.iscoroutinefunction(func):
104
+ if not inspect.iscoroutinefunction(func):
102
105
  raise TypeError("Commands must be coroutines")
103
106
 
104
107
  self._callback = func
@@ -134,23 +137,51 @@ class Command:
134
137
  return f"{self.prefix}{command_name} {params}"
135
138
 
136
139
  def _parse_arguments(self, ctx: "Context") -> list[Any]:
140
+ args = ctx.args
137
141
  parsed_args = []
138
142
 
139
143
  for i, param in enumerate(self.params):
140
144
  param_type = self.type_hints.get(param.name, str)
141
145
 
142
- if i >= len(ctx.args):
146
+ if i >= len(args):
143
147
  if param.default is not inspect.Parameter.empty:
144
148
  parsed_args.append(param.default)
145
- else:
146
- raise MissingArgumentError(param)
147
- continue
149
+ continue
150
+ raise MissingArgumentError(param)
148
151
 
149
- converted_arg = param_type(ctx.args[i])
152
+ if param.kind is inspect.Parameter.VAR_POSITIONAL:
153
+ parsed_args.extend(
154
+ self._convert_type(param_type, arg) for arg in args[i:]
155
+ )
156
+ return parsed_args
157
+
158
+ converted_arg = self._convert_type(param_type, args[i])
150
159
  parsed_args.append(converted_arg)
151
160
 
152
161
  return parsed_args
153
162
 
163
+ def _convert_type(self, param_type: type, value: str) -> Any:
164
+ origin = get_origin(param_type)
165
+
166
+ if origin is Union or isinstance(param_type, types.UnionType):
167
+ union_types = get_args(param_type)
168
+
169
+ for union_type in union_types:
170
+ if union_type is type(None):
171
+ continue
172
+
173
+ try:
174
+ return union_type(value)
175
+ except (ValueError, TypeError):
176
+ continue
177
+
178
+ return value
179
+
180
+ try:
181
+ return param_type(value)
182
+ except (ValueError, TypeError):
183
+ return value
184
+
154
185
  def check(self, func: Callback) -> None:
155
186
  """
156
187
  Register a check callback
@@ -160,7 +191,7 @@ class Command:
160
191
 
161
192
  :raises TypeError: If the function is not a coroutine.
162
193
  """
163
- if not asyncio.iscoroutinefunction(func):
194
+ if not inspect.iscoroutinefunction(func):
164
195
  raise TypeError("Checks must be coroutine")
165
196
 
166
197
  self.checks.append(func)
@@ -202,7 +233,7 @@ class Command:
202
233
  :raises TypeError: If the function is not a coroutine.
203
234
  """
204
235
 
205
- if not asyncio.iscoroutinefunction(func):
236
+ if not inspect.iscoroutinefunction(func):
206
237
  raise TypeError("The hook must be a coroutine.")
207
238
 
208
239
  self._before_invoke_callback = func
@@ -217,7 +248,7 @@ class Command:
217
248
  :raises TypeError: If the function is not a coroutine.
218
249
  """
219
250
 
220
- if not asyncio.iscoroutinefunction(func):
251
+ if not inspect.iscoroutinefunction(func):
221
252
  raise TypeError("The hook must be a coroutine.")
222
253
 
223
254
  self._after_invoke_callback = func
@@ -234,7 +265,7 @@ class Command:
234
265
  """
235
266
 
236
267
  def wrapper(func: ErrorCallback) -> Callable:
237
- if not asyncio.iscoroutinefunction(func):
268
+ if not inspect.iscoroutinefunction(func):
238
269
  raise TypeError("The error handler must be a coroutine.")
239
270
 
240
271
  if exception:
@@ -0,0 +1,174 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from markdown import markdown
4
+ from typing import Any
5
+
6
+
7
+ class BaseMessageContent(ABC):
8
+ """Base class for outgoing message payloads."""
9
+
10
+ msgtype: str
11
+
12
+ @abstractmethod
13
+ def build(self) -> dict[str, Any]:
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class TextContent(BaseMessageContent):
19
+ msgtype = "m.text"
20
+ body: str
21
+
22
+ def build(self) -> dict:
23
+ return {"msgtype": self.msgtype, "body": self.body}
24
+
25
+
26
+ @dataclass
27
+ class MarkdownMessage(TextContent):
28
+ def build(self) -> dict:
29
+ return {
30
+ "msgtype": self.msgtype,
31
+ "body": self.body,
32
+ "format": "org.matrix.custom.html",
33
+ "formatted_body": markdown(self.body, extensions=["nl2br"]),
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class NoticeContent(TextContent):
39
+ msgtype = "m.notice"
40
+
41
+
42
+ @dataclass
43
+ class ReplyContent(TextContent):
44
+ reply_to_event_id: str
45
+
46
+ def build(self) -> dict:
47
+ return {
48
+ "msgtype": self.msgtype,
49
+ "body": self.body,
50
+ "m.relates_to": {"m.in_reply_to": {"event_id": self.reply_to_event_id}},
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class EditContent(TextContent):
56
+ original_event_id: str
57
+
58
+ def build(self) -> dict:
59
+ return {
60
+ "msgtype": self.msgtype,
61
+ "body": f"* {self.body}",
62
+ "m.new_content": {
63
+ "msgtype": "m.text",
64
+ "body": self.body,
65
+ },
66
+ "m.relates_to": {
67
+ "rel_type": "m.replace",
68
+ "event_id": self.original_event_id,
69
+ },
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class FileContent(BaseMessageContent):
75
+ msgtype = "m.file"
76
+ filename: str
77
+ url: str
78
+ mimetype: str
79
+
80
+ def build(self) -> dict:
81
+ return {
82
+ "msgtype": self.msgtype,
83
+ "body": self.filename,
84
+ "url": self.url,
85
+ "info": {"mimetype": self.mimetype},
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class ImageContent(FileContent):
91
+ msgtype = "m.image"
92
+ height: int = 0
93
+ width: int = 0
94
+
95
+ def build(self) -> dict:
96
+ return {
97
+ "msgtype": self.msgtype,
98
+ "body": self.filename,
99
+ "url": self.url,
100
+ "info": {
101
+ "mimetype": self.mimetype,
102
+ "h": self.height,
103
+ "w": self.width,
104
+ },
105
+ }
106
+
107
+
108
+ @dataclass
109
+ class AudioContent(FileContent):
110
+ msgtype = "m.audio"
111
+ duration: int = 0
112
+
113
+ def build(self) -> dict:
114
+ return {
115
+ "msgtype": self.msgtype,
116
+ "body": self.filename,
117
+ "url": self.url,
118
+ "info": {
119
+ "mimetype": self.mimetype,
120
+ "duration": self.duration,
121
+ },
122
+ }
123
+
124
+
125
+ @dataclass
126
+ class VideoContent(FileContent):
127
+ msgtype = "m.video"
128
+ height: int = 0
129
+ width: int = 0
130
+ duration: int = 0
131
+
132
+ def build(self) -> dict:
133
+ return {
134
+ "msgtype": self.msgtype,
135
+ "body": self.filename,
136
+ "url": self.url,
137
+ "info": {
138
+ "mimetype": self.mimetype,
139
+ "h": self.height,
140
+ "w": self.width,
141
+ "duration": self.duration,
142
+ },
143
+ }
144
+
145
+
146
+ @dataclass
147
+ class LocationContent(BaseMessageContent):
148
+ msgtype = "m.location"
149
+ geo_uri: str
150
+ description: str = ""
151
+
152
+ def build(self) -> dict:
153
+ return {
154
+ "msgtype": self.msgtype,
155
+ "body": self.description or self.geo_uri,
156
+ "geo_uri": self.geo_uri,
157
+ }
158
+
159
+
160
+ @dataclass
161
+ class ReactionContent(BaseMessageContent):
162
+ """For sending reactions to an event."""
163
+
164
+ event_id: str
165
+ emoji: str
166
+
167
+ def build(self) -> dict:
168
+ return {
169
+ "m.relates_to": {
170
+ "rel_type": "m.annotation",
171
+ "event_id": self.event_id,
172
+ "key": self.emoji,
173
+ }
174
+ }
@@ -0,0 +1,142 @@
1
+ import shlex
2
+
3
+ from nio import Event
4
+ from typing import TYPE_CHECKING, Optional, Any, List
5
+
6
+ from .errors import MatrixError
7
+ from .message import Message
8
+ from .room import Room
9
+ from .types import File, Image
10
+
11
+ if TYPE_CHECKING:
12
+ from .bot import Bot # pragma: no cover
13
+ from .command import Command # pragma: no cover
14
+
15
+
16
+ class Context:
17
+ """Represents the context in which a command is executed. Provides
18
+ access to the bot instance, room and event metadata, parsed arguments,
19
+ and other utilities.
20
+ """
21
+
22
+ def __init__(self, bot: "Bot", room: Room, event: Event):
23
+ self.bot = bot
24
+ self.room = room
25
+ self.event = event
26
+
27
+ self.body: str = getattr(event, "body", "")
28
+ self.sender: str = event.sender
29
+
30
+ # Command metadata
31
+ self.prefix: str = bot.prefix
32
+ self.command: Optional[Command] = None
33
+ self.subcommand: Optional[Command] = None
34
+ self._args: List[str] = shlex.split(self.body)
35
+
36
+ @property
37
+ def args(self) -> List[str]:
38
+ """
39
+ Returns the list of parsed arguments from the message body.
40
+
41
+ If a command is present, the command name is excluded.
42
+
43
+ :return: The list of arguments.
44
+ :rtype: List[str]
45
+ """
46
+ if self.subcommand:
47
+ return self._args[2:]
48
+
49
+ if self.command:
50
+ return self._args[1:]
51
+
52
+ return self._args
53
+
54
+ @property
55
+ def logger(self) -> Any:
56
+ """Logger for instance specific to the current room or event."""
57
+ return self.bot.log.getChild(self.room.room_id)
58
+
59
+ async def reply(
60
+ self,
61
+ content: str | None = None,
62
+ *,
63
+ raw: bool = False,
64
+ notice: bool = False,
65
+ file: File | None = None,
66
+ ) -> Message:
67
+ """Reply to the command with a message.
68
+
69
+ This is a convenience method that sends a message to the room where the
70
+ command was invoked. Supports text messages (with optional markdown
71
+ formatting) and file uploads (including images, videos, and audio).
72
+
73
+ See `Room.send()` for detailed usage examples and documentation.
74
+
75
+ ## Example
76
+
77
+ ```python
78
+ @bot.command()
79
+ async def hello(ctx: Context):
80
+ await ctx.reply("Hello **world**!")
81
+
82
+ @bot.command()
83
+ async def status(ctx: Context):
84
+ await ctx.reply("Bot is online!", notice=True)
85
+
86
+ @bot.command()
87
+ async def cat(ctx: Context):
88
+ # Upload and send an image
89
+ from PIL import Image as PILImage
90
+
91
+ with PILImage.open("cat.jpg") as img:
92
+ width, height = img.size
93
+
94
+ with open("cat.jpg", "rb") as f:
95
+ resp, _ = await ctx.room.client.upload(f, content_type="image/jpeg")
96
+
97
+ image = Image(
98
+ path=resp.content_uri,
99
+ filename="cat.jpg",
100
+ mimetype="image/jpeg",
101
+ width=width,
102
+ height=height
103
+ )
104
+ await ctx.reply(file=image)
105
+ ```
106
+ """
107
+
108
+ try:
109
+ return await self.room.send(
110
+ content,
111
+ raw=raw,
112
+ notice=notice,
113
+ file=file,
114
+ )
115
+ except Exception as e:
116
+ raise MatrixError(f"Failed to send message: {e}")
117
+
118
+ async def send_help(self) -> None:
119
+ """Send help from the current command context.
120
+
121
+ Displays help text for the current subcommand, command, or the bot's
122
+ general help menu depending on what's available in the context. The help
123
+ hierarchy is: subcommand help > command help > bot help.
124
+
125
+ ## Example
126
+
127
+ ```python
128
+ @bot.group()
129
+ async def config(ctx: Context):
130
+ # If user runs just "!config" with no subcommand
131
+ await ctx.send_help()
132
+ ```
133
+ """
134
+ if self.subcommand:
135
+ await self.reply(self.subcommand.help)
136
+ return
137
+
138
+ if self.command:
139
+ await self.reply(self.command.help)
140
+ return
141
+
142
+ await self.bot.help.execute(self)
@@ -77,7 +77,7 @@ class Group(Command):
77
77
  return cmd
78
78
 
79
79
  async def invoke(self, ctx: "Context") -> None:
80
- if subcommand := ctx.args.pop(0):
80
+ if ctx.args and (subcommand := ctx.args.pop(0)):
81
81
  ctx.subcommand = self.get_command(subcommand)
82
82
  await ctx.subcommand(ctx)
83
83
  else:
@@ -1,4 +1,4 @@
1
- from typing import Optional, List
1
+ from typing import Union, Optional, List
2
2
  from abc import ABC, abstractmethod
3
3
 
4
4
  from matrix.context import Context
@@ -187,13 +187,9 @@ class HelpCommand(Command, ABC):
187
187
  await ctx.reply(help_message)
188
188
 
189
189
  def parse_help_arguments(
190
- self, args: List[str]
190
+ self, args: List[str | int]
191
191
  ) -> tuple[Optional[str], Optional[str], int]:
192
- """Parse help command arguments to determine what to show.
193
-
194
- :param args: List of arguments passed to help command
195
- :return: Tuple of (command_name, subcommand_name, page_number)
196
- """
192
+ """Parse help command arguments to determine what to show."""
197
193
  command_name = None
198
194
  subcommand_name = None
199
195
  page_number = 1
@@ -201,29 +197,39 @@ class HelpCommand(Command, ABC):
201
197
  if not args:
202
198
  return command_name, subcommand_name, page_number
203
199
 
204
- # Check if first argument is a page number
205
- if len(args) == 1 and args[0].isdigit():
206
- page_number = int(args[0])
200
+ first_arg = args[0]
201
+ if len(args) == 1 and (
202
+ isinstance(first_arg, int)
203
+ or (isinstance(first_arg, str) and first_arg.isdigit())
204
+ ):
205
+ page_number = int(first_arg)
207
206
  return command_name, subcommand_name, page_number
208
207
 
209
- command_name = args[0]
208
+ command_name = str(first_arg)
210
209
 
211
210
  if len(args) >= 2:
212
- if args[1].isdigit():
213
- page_number = int(args[1])
211
+ second_arg = args[1]
212
+ if isinstance(second_arg, int) or (
213
+ isinstance(second_arg, str) and second_arg.isdigit()
214
+ ):
215
+ page_number = int(second_arg)
214
216
  else:
215
- subcommand_name = args[1]
217
+ subcommand_name = str(second_arg)
216
218
 
217
- if len(args) >= 3 and args[2].isdigit():
218
- page_number = int(args[2])
219
+ if len(args) >= 3:
220
+ third_arg = args[2]
221
+ if isinstance(third_arg, int) or (
222
+ isinstance(third_arg, str) and third_arg.isdigit()
223
+ ):
224
+ page_number = int(third_arg)
219
225
 
220
226
  return command_name, subcommand_name, page_number
221
227
 
222
228
  async def execute(
223
229
  self,
224
230
  ctx: Context,
225
- cmd_or_page: str | None = None,
226
- subcommand: str | None = None,
231
+ cmd_or_page: Union[str, int, None] = None,
232
+ subcommand: Union[str | None] = None,
227
233
  ) -> None:
228
234
  """
229
235
  Execute the help command using show_command_help and show_group_help.