osmium-chat 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ijsbol
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: osmium-chat
3
+ Version: 0.1.1
4
+ Summary: A Python API wrapper for Osmium.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: abigail phoebe
8
+ Author-email: abigail@phoebe.sh
9
+ Requires-Python: >=3.13,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: osmium-protos (>=2.0.0)
15
+ Requires-Dist: websockets (>=16.0)
16
+ Project-URL: Bug Tracker, https://github.com/ijsbol/osmium-chat/issues
17
+ Project-URL: Homepage, https://github.com/ijsbol/osmium-chat
18
+ Description-Content-Type: text/markdown
19
+
20
+ # osmiumchat
21
+
22
+
23
+ A Python API wrapper for [Osmium](https://osmium.chat).
24
+
25
+
26
+ ![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C)
27
+
28
+
29
+ > [!NOTE]
30
+ > the code was written by humans, the documentation was ai-assisted as i suck at writing documentation lol
31
+
@@ -0,0 +1,11 @@
1
+ # osmiumchat
2
+
3
+
4
+ A Python API wrapper for [Osmium](https://osmium.chat).
5
+
6
+
7
+ ![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C)
8
+
9
+
10
+ > [!NOTE]
11
+ > the code was written by humans, the documentation was ai-assisted as i suck at writing documentation lol
@@ -0,0 +1,39 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+
4
+
5
+ def _get_version() -> str:
6
+ """Read the package version from the project's ``pyproject.toml``.
7
+
8
+ Supports both PEP 621 (``[project]``) and Poetry (``[tool.poetry]``) layouts.
9
+ """
10
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
11
+ with pyproject.open("rb") as f:
12
+ data = tomllib.load(f)
13
+ project = data.get("project")
14
+ if project is not None and "version" in project:
15
+ return project["version"]
16
+ return data["tool"]["poetry"]["version"]
17
+
18
+
19
+ __version__: str = _get_version()
20
+
21
+
22
+ # Imported after ``__version__`` is defined, since the submodules read it at
23
+ # import time.
24
+ from osmium_chat.bot import Bot
25
+ from osmium_chat.channel import Channel
26
+ from osmium_chat.commands import Command
27
+ from osmium_chat.context import Context
28
+ from osmium_chat.message import Message
29
+ from osmium_chat.user.user import User
30
+
31
+ __all__: tuple[str, ...] = (
32
+ "__version__",
33
+ "Bot",
34
+ "Channel",
35
+ "Command",
36
+ "Context",
37
+ "Message",
38
+ "User",
39
+ )
@@ -0,0 +1,217 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from logging import Logger
4
+ from typing import Any, TypeVar
5
+
6
+ from osmium_protos import PB_UpdateMessageCreated, PB_UseInvite
7
+
8
+ from osmium_chat.channel import Channel
9
+ from osmium_chat.client import Client
10
+ from osmium_chat.commands import Command, CommandCallback, StringView
11
+ from osmium_chat.context import Context
12
+ from osmium_chat.errors import CommandError, CommandNotFound
13
+ from osmium_chat.message import Message
14
+ from osmium_chat.user.user import User
15
+
16
+
17
+ EventHandler = Callable[..., Awaitable[None]]
18
+ EH = TypeVar("EH", bound=EventHandler)
19
+
20
+
21
+ class Bot:
22
+ """The main entry point for an Osmium bot.
23
+
24
+ Holds connection state, the registered event listeners, and the
25
+ authenticated :class:`~osmium_chat.user.user.User` once connected.
26
+ """
27
+
28
+ __slots__: tuple[str, ...] = (
29
+ "prefix",
30
+ "_logger",
31
+ "_client",
32
+ "_listeners",
33
+ "_commands",
34
+ "user",
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ prefix: str,
40
+ client_id: int,
41
+ *,
42
+ logger: Logger | None = None,
43
+ ) -> None:
44
+ """Create a bot.
45
+
46
+ :param prefix: The command prefix the bot responds to (e.g. ``"!"``).
47
+ :param client_id: The Osmium client id this bot authenticates as.
48
+ :param logger: Optional logger; a default one is created if omitted.
49
+ """
50
+ self.prefix = prefix
51
+ self._logger = logger or Logger(__name__)
52
+ self._client: Client = Client(
53
+ client_id=client_id,
54
+ bot=self,
55
+ logger=self._logger,
56
+ )
57
+ self._listeners: dict[str, list[EventHandler]] = {}
58
+ self._commands: dict[str, Command] = {}
59
+ self.user: User | None = None
60
+
61
+ def on(self, event: str) -> Callable[[EH], EH]:
62
+ """Register a coroutine as a listener for the given event.
63
+
64
+ This is the generic primitive every ``on_*`` decorator is built on,
65
+ so new events only need a thin wrapper here plus a ``dispatch`` call
66
+ from wherever the event originates.
67
+
68
+ .. code-block:: python
69
+
70
+ @bot.on("connect")
71
+ async def handler() -> None:
72
+ ...
73
+ """
74
+ def decorator(func: EH) -> EH:
75
+ self._listeners.setdefault(event, []).append(func)
76
+ return func
77
+ return decorator
78
+
79
+ async def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None:
80
+ """Invoke every listener registered for ``event``.
81
+
82
+ Handler errors are logged and swallowed so one faulty listener can't
83
+ take down the connection or block the others.
84
+ """
85
+ for handler in self._listeners.get(event, []):
86
+ try:
87
+ await handler(*args, **kwargs)
88
+ except Exception:
89
+ self._logger.exception("Error in '%s' event handler", event)
90
+
91
+ def command(
92
+ self,
93
+ name: str | None = None,
94
+ *,
95
+ aliases: tuple[str, ...] = (),
96
+ ) -> Callable[[CommandCallback], Command]:
97
+ """Register a coroutine as a command.
98
+
99
+ The decorated coroutine receives a :class:`~osmium_chat.context.Context`
100
+ as its first argument; every parameter after that is parsed from the
101
+ message text and converted to its annotated type. Parameters with a
102
+ default become optional, a keyword-only parameter (after ``*``) consumes
103
+ the rest of the message, and ``*args`` collects all remaining tokens.
104
+
105
+ .. code-block:: python
106
+
107
+ @bot.command("say")
108
+ async def say(ctx: Context, *, words: str = "...") -> None:
109
+ await ctx.channel.send(words)
110
+
111
+ :param name: The command name; defaults to the function name.
112
+ :param aliases: Additional names the command also responds to.
113
+ """
114
+ def decorator(func: CommandCallback) -> Command:
115
+ command = Command(func, name=name, aliases=aliases)
116
+ self.add_command(command)
117
+ return command
118
+ return decorator
119
+
120
+ def add_command(self, command: Command) -> None:
121
+ """Register a command under its name and every alias.
122
+
123
+ :param command: The command to register.
124
+ :raises ValueError: If the name or an alias is already registered.
125
+ """
126
+ for key in (command.name, *command.aliases):
127
+ if key in self._commands:
128
+ raise ValueError(f"Command name {key!r} is already registered")
129
+ self._commands[key] = command
130
+
131
+ def get_command(self, name: str) -> Command | None:
132
+ """Look up a command by name or alias.
133
+
134
+ :param name: The name or alias to resolve.
135
+ """
136
+ return self._commands.get(name)
137
+
138
+ async def process_commands(self, update: PB_UpdateMessageCreated) -> None:
139
+ """Turn an inbound message into a command invocation.
140
+
141
+ Builds the :class:`~osmium_chat.context.Context`, fires the ``message``
142
+ event, and — if the message starts with the prefix and names a known
143
+ command — parses its arguments and invokes it. Command failures are
144
+ surfaced through the ``command_error`` event.
145
+
146
+ :param update: The decoded ``message_created`` payload from the gateway.
147
+ """
148
+ if update.message is None or update.message.chat_ref is None:
149
+ return
150
+
151
+ message = Message(update.message)
152
+ author = User(update.author) if update.author else None
153
+ channel = Channel(update.message.chat_ref, self._client)
154
+ ctx = Context(
155
+ bot=self,
156
+ message=message,
157
+ author=author,
158
+ channel=channel,
159
+ prefix=self.prefix,
160
+ )
161
+
162
+ await self.dispatch("message", ctx)
163
+
164
+ # Never react to our own messages, to avoid command loops.
165
+ if self.user is not None and message.author_id == self.user.id:
166
+ return
167
+
168
+ content = message.content
169
+ if not content.startswith(self.prefix):
170
+ return
171
+
172
+ view = StringView(content[len(self.prefix):])
173
+ name = view.get_word()
174
+ if not name:
175
+ return
176
+
177
+ ctx.invoked_with = name
178
+ command = self.get_command(name)
179
+ if command is None:
180
+ await self.dispatch("command_error", ctx, CommandNotFound(name))
181
+ return
182
+
183
+ try:
184
+ await command.invoke(ctx, view)
185
+ except CommandError as error:
186
+ await self.dispatch("command_error", ctx, error)
187
+ except Exception as error:
188
+ self._logger.exception("Unhandled error in command %r", name)
189
+ await self.dispatch("command_error", ctx, error)
190
+
191
+ async def use_invite(self, invite_code: str) -> None:
192
+ """Redeem an invite code on behalf of the bot.
193
+
194
+ :param invite_code: The invite code to redeem.
195
+ """
196
+ self._logger.info(f"Using invite with code: {invite_code}")
197
+ await self._client.send_pb(PB_UseInvite(code=invite_code))
198
+
199
+ async def connect(self, token: str) -> None:
200
+ """Connect to Osmium and run the bot until the connection closes.
201
+
202
+ This authenticates with ``token``, fires the ``connect`` event, then
203
+ blocks processing incoming messages.
204
+
205
+ :param token: The authorization token for this bot.
206
+ """
207
+ await self._client.connect(token)
208
+
209
+ def run(self, token: str) -> None:
210
+ """Start the bot's event loop and connect, blocking until it closes.
211
+
212
+ A synchronous convenience wrapper around :meth:`connect` for use as a
213
+ program's entry point.
214
+
215
+ :param token: The authorization token for this bot.
216
+ """
217
+ asyncio.run(self.connect(token))
@@ -0,0 +1,46 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from osmium_protos import PB_ChatRef, PB_SendMessage
4
+
5
+ if TYPE_CHECKING:
6
+ from osmium_chat.client import Client
7
+
8
+
9
+ __all__: tuple[str, ...] = (
10
+ "Channel",
11
+ )
12
+
13
+
14
+ class Channel:
15
+ """A conversation a message can be sent to.
16
+
17
+ Wraps the :class:`~osmium_protos.PB_ChatRef` that identifies a destination
18
+ (a user DM, community channel, group, or self) together with the client used
19
+ to deliver outbound messages, so callers can simply ``await channel.send(...)``.
20
+ """
21
+
22
+ __slots__: tuple[str, ...] = (
23
+ "_chat_ref",
24
+ "_client",
25
+ )
26
+
27
+ def __init__(self, chat_ref: PB_ChatRef, client: "Client") -> None:
28
+ """Bind a channel to a destination ref and the transport client.
29
+
30
+ :param chat_ref: The ref identifying where messages should be delivered.
31
+ :param client: The client used to send messages.
32
+ """
33
+ self._chat_ref = chat_ref
34
+ self._client = client
35
+
36
+ async def send(self, content: str, *, reply_to: int | None = None) -> None:
37
+ """Send a text message to this channel.
38
+
39
+ :param content: The message text to send.
40
+ :param reply_to: Optional id of a message this should reply to.
41
+ """
42
+ await self._client.send_pb(PB_SendMessage(
43
+ chat_ref=self._chat_ref,
44
+ message=content,
45
+ reply_to=reply_to,
46
+ ))
@@ -0,0 +1,152 @@
1
+ from logging import Logger
2
+ import platform
3
+ from typing import TYPE_CHECKING, Any, cast
4
+
5
+ from osmium_chat import __version__
6
+
7
+ from websockets.asyncio.client import ClientConnection, connect
8
+ from websockets.exceptions import ConnectionClosed
9
+ from osmium_protos import unwrap, wrap, PB_Initialize, PB_Authorization, PB_Authorize, PB_UpdateMessageCreated
10
+ from osmium_protos.osmium.client.auth import Authorization
11
+ from osmium_chat.user.user import User
12
+
13
+ if TYPE_CHECKING:
14
+ from osmium_chat.bot import Bot
15
+
16
+
17
+ class Client:
18
+ """Low-level WebSocket transport between a :class:`~osmium_chat.bot.Bot`
19
+ and the Osmium gateway.
20
+
21
+ Handles connecting, the initialize/authorize handshake, sending protobuf
22
+ messages, and reading the inbound message stream.
23
+ """
24
+
25
+ __slots__: tuple[str, ...] = (
26
+ "bot",
27
+ "id",
28
+ "_connection",
29
+ "__session_id",
30
+ "__token",
31
+ "_logger",
32
+ )
33
+
34
+ WS_URL: str = "wss://ws-0.osmium.chat"
35
+
36
+ def __init__(
37
+ self,
38
+ client_id: int,
39
+ bot: "Bot",
40
+ *,
41
+ logger: Logger | None = None,
42
+ ) -> None:
43
+ """Create a client bound to ``bot``.
44
+
45
+ :param client_id: The Osmium client id to identify as.
46
+ :param bot: The owning bot, used to dispatch events and store the user.
47
+ :param logger: Optional logger; a default one is created if omitted.
48
+ """
49
+ self.bot: "Bot" = bot
50
+ self.id: int = client_id
51
+ self._connection: ClientConnection | None = None
52
+ self.__session_id: int | None = None
53
+ self.__token: str | None = None
54
+ self._logger = logger or Logger(__name__)
55
+
56
+ async def _handle_msg(self, message: Any) -> None:
57
+ """Process a single decoded inbound message.
58
+
59
+ Routes ``message_created`` updates into the bot's command pipeline; all
60
+ other messages are logged for now.
61
+
62
+ :param message: The unwrapped protobuf message.
63
+ """
64
+ self._logger.debug(f"Received message: {message}")
65
+ if isinstance(message, PB_UpdateMessageCreated):
66
+ await self.bot.process_commands(message)
67
+
68
+ async def _handle_ws(self, **kwargs: Any) -> None:
69
+ """Read and dispatch inbound messages until the connection closes."""
70
+ assert self._connection is not None
71
+
72
+ async for data in self._connection:
73
+ try:
74
+ _, message = unwrap(cast(bytes, data))
75
+ await self._handle_msg(message)
76
+ except ConnectionClosed as e:
77
+ self._logger.error("WebSocket connection closed: %s", e)
78
+ raise ConnectionError("Connection closed while waiting for authorization") from e
79
+
80
+ async def send_pb(self, message: Any) -> None:
81
+ """Wrap, serialize, and send a protobuf message over the connection.
82
+
83
+ :param message: The protobuf message to send.
84
+ """
85
+ assert self._connection is not None
86
+ await self._connection.send(wrap(message).SerializeToString())
87
+
88
+ def _handle_authorization(self, message: Authorization) -> None:
89
+ """Store the session id/token and the authenticated user from an
90
+ authorization response.
91
+
92
+ :param message: The authorization payload from the gateway.
93
+ """
94
+ self.__session_id = message.session_id
95
+ self.__token = message.token
96
+ if message.user:
97
+ self.bot.user = User(message.user)
98
+
99
+ async def connect(self, token: str) -> None:
100
+ """Open the connection, run the handshake, and process messages.
101
+
102
+ Performs the initialize/authorize exchange, dispatches the bot's
103
+ ``connect`` event once authorized, then blocks reading messages until
104
+ the connection closes.
105
+
106
+ :param token: The authorization token for the bot.
107
+ :raises ConnectionError: If the connection closes before authorization.
108
+ """
109
+ self._logger.info("Connecting to WebSocket server...")
110
+ self._connection = await connect(
111
+ uri=self.WS_URL,
112
+ )
113
+
114
+ self._logger.info("Connected to WebSocket server, initializing...")
115
+ await self.send_pb(PB_Initialize(
116
+ client_id=self.id,
117
+ device_type="Library[Python/OsmiumChat]",
118
+ device_version=__version__,
119
+ app_version=f"OsmiumChat Python API Wrapper (Python {platform.python_version()}) (OsmiumChat {__version__})",
120
+ no_subscribe=False,
121
+ ))
122
+
123
+ self._logger.info("Received initialization response, getting entry points...")
124
+
125
+ # this will return entry points and vapidPublicKey, but we don't need them for now
126
+ await self._connection.recv()
127
+
128
+ self._logger.info("Received initialization response, sending authorization...")
129
+ await self.send_pb(PB_Authorize(
130
+ token=token,
131
+ ))
132
+
133
+ # wait for authorization
134
+ # most of the time it is the first or second message, but to be safe we will loop until we get it
135
+ self._logger.info("Waiting for authorization...")
136
+ async for data in self._connection:
137
+ try:
138
+ _, message = unwrap(cast(bytes, data))
139
+ if isinstance(message, PB_Authorization):
140
+ self._handle_authorization(message)
141
+ break
142
+ else:
143
+ await self._handle_msg(message)
144
+ except ConnectionClosed:
145
+ self._logger.error("Connection closed while waiting for authorization")
146
+ raise ConnectionError("Connection closed while waiting for authorization")
147
+
148
+ self._logger.info("Authorized successfully, dispatching connect event...")
149
+ await self.bot.dispatch("connect")
150
+
151
+ self._logger.info("Starting message handler...")
152
+ await self._handle_ws()
@@ -0,0 +1,366 @@
1
+ import inspect
2
+ from collections.abc import Awaitable, Callable
3
+ from types import UnionType
4
+ from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, get_type_hints
5
+
6
+ from osmium_chat.errors import BadArgument, MissingRequiredArgument, TooManyArguments
7
+
8
+ if TYPE_CHECKING:
9
+ from osmium_chat.context import Context
10
+
11
+
12
+ __all__: tuple[str, ...] = (
13
+ "Command",
14
+ "Parameter",
15
+ "StringView",
16
+ "CommandCallback",
17
+ )
18
+
19
+
20
+ CommandCallback = Callable[..., Awaitable[None]]
21
+
22
+ _TRUE = frozenset({"true", "t", "yes", "y", "1", "on", "enable", "enabled"})
23
+ _FALSE = frozenset({"false", "f", "no", "n", "0", "off", "disable", "disabled"})
24
+
25
+
26
+ def _convert_bool(value: str) -> bool:
27
+ """Convert a token to a bool, accepting common truthy/falsey spellings."""
28
+ lowered = value.lower()
29
+ if lowered in _TRUE:
30
+ return True
31
+ if lowered in _FALSE:
32
+ return False
33
+ raise ValueError(value)
34
+
35
+
36
+ # Maps an annotated argument type to the callable that parses a raw token into it.
37
+ # Extend this to teach the command parser about new argument types.
38
+ CONVERTERS: dict[type, Callable[[str], Any]] = {
39
+ str: str,
40
+ int: int,
41
+ float: float,
42
+ bool: _convert_bool,
43
+ }
44
+
45
+
46
+ class StringView:
47
+ """A cursor over a command's argument string.
48
+
49
+ Hands out whitespace-delimited words one at a time (respecting single and
50
+ double quotes so multi-word arguments can be passed), or the entire
51
+ remaining string for "consume rest" parameters.
52
+ """
53
+
54
+ __slots__: tuple[str, ...] = (
55
+ "text",
56
+ "index",
57
+ )
58
+
59
+ _QUOTES: frozenset[str] = frozenset({'"', "'"})
60
+
61
+ def __init__(self, text: str) -> None:
62
+ """:param text: The raw argument string to read from."""
63
+ self.text = text
64
+ self.index = 0
65
+
66
+ @property
67
+ def eof(self) -> bool:
68
+ """Whether the cursor has reached the end of the string."""
69
+ return self.index >= len(self.text)
70
+
71
+ def skip_whitespace(self) -> None:
72
+ """Advance the cursor past any run of whitespace."""
73
+ while not self.eof and self.text[self.index].isspace():
74
+ self.index += 1
75
+
76
+ def rest(self) -> str:
77
+ """Consume and return the remaining string, stripped of surrounding space."""
78
+ self.skip_whitespace()
79
+ remaining = self.text[self.index:]
80
+ self.index = len(self.text)
81
+ return remaining.strip()
82
+
83
+ def get_word(self) -> str | None:
84
+ """Consume and return the next word, or ``None`` if none remain.
85
+
86
+ A word is a run of non-whitespace characters, unless it is wrapped in
87
+ matching quotes, in which case everything up to the closing quote is
88
+ returned as a single word (with backslash escaping inside the quotes).
89
+ """
90
+ self.skip_whitespace()
91
+ if self.eof:
92
+ return None
93
+
94
+ char = self.text[self.index]
95
+ if char in self._QUOTES:
96
+ return self._read_quoted(char)
97
+
98
+ start = self.index
99
+ while not self.eof and not self.text[self.index].isspace():
100
+ self.index += 1
101
+ return self.text[start:self.index]
102
+
103
+ def _read_quoted(self, quote: str) -> str:
104
+ """Read a quoted word starting at the opening ``quote`` character."""
105
+ self.index += 1 # skip opening quote
106
+ chars: list[str] = []
107
+ while not self.eof:
108
+ char = self.text[self.index]
109
+ if char == "\\" and self.index + 1 < len(self.text):
110
+ self.index += 1
111
+ chars.append(self.text[self.index])
112
+ elif char == quote:
113
+ self.index += 1 # skip closing quote
114
+ return "".join(chars)
115
+ else:
116
+ chars.append(char)
117
+ self.index += 1
118
+ # Unterminated quote: treat the rest as literal content.
119
+ return "".join(chars)
120
+
121
+
122
+ class Parameter:
123
+ """A single command argument, resolved from the callback's signature."""
124
+
125
+ __slots__: tuple[str, ...] = (
126
+ "name",
127
+ "annotation",
128
+ "kind",
129
+ "default",
130
+ "optional",
131
+ )
132
+
133
+ def __init__(
134
+ self,
135
+ name: str,
136
+ annotation: Any,
137
+ kind: inspect._ParameterKind,
138
+ default: Any,
139
+ ) -> None:
140
+ """:param name: The parameter name.
141
+ :param annotation: The resolved leaf type to convert tokens to.
142
+ :param kind: The parameter kind (positional, keyword-only, var-positional).
143
+ :param default: The default value, or :data:`inspect.Parameter.empty`.
144
+ """
145
+ self.name = name
146
+ self.annotation = annotation
147
+ self.kind = kind
148
+ self.default = default
149
+ # A parameter is optional if it has a default or accepts ``None``.
150
+ self.optional = default is not inspect.Parameter.empty
151
+
152
+ @property
153
+ def required(self) -> bool:
154
+ """Whether a value must be supplied for this parameter."""
155
+ return not self.optional
156
+
157
+ def convert(self, value: str) -> Any:
158
+ """Convert a raw token to this parameter's annotated type.
159
+
160
+ :param value: The raw token from the message.
161
+ :raises BadArgument: If the token cannot be converted.
162
+ """
163
+ annotation = self.annotation
164
+ if annotation is inspect.Parameter.empty or annotation is str:
165
+ return value
166
+ converter = CONVERTERS.get(annotation)
167
+ try:
168
+ if converter is not None:
169
+ return converter(value)
170
+ return annotation(value)
171
+ except (ValueError, TypeError) as exc:
172
+ raise BadArgument(self.name, value, annotation) from exc
173
+
174
+
175
+ def _resolve_annotation(annotation: Any) -> tuple[Any, bool]:
176
+ """Reduce an annotation to a concrete leaf type and an optional flag.
177
+
178
+ Unwraps ``Optional[T]`` / ``T | None`` to ``(T, True)``; for a plain ``T``
179
+ returns ``(T, False)``. For unions of several non-``None`` types the first
180
+ member is used.
181
+ """
182
+ origin = get_origin(annotation)
183
+ if origin is Union or origin is UnionType:
184
+ args = [arg for arg in get_args(annotation) if arg is not type(None)]
185
+ accepts_none = len(args) != len(get_args(annotation))
186
+ leaf = args[0] if args else str
187
+ return leaf, accepts_none
188
+ return annotation, False
189
+
190
+
191
+ class Command:
192
+ """A registered command: a name, optional aliases, and a parsed callback.
193
+
194
+ The callback's signature drives argument parsing. The first parameter always
195
+ receives the :class:`~osmium_chat.context.Context`; each parameter after it
196
+ becomes a command argument, read from the message text following the command
197
+ name and converted according to the rules below.
198
+
199
+ **Automatic type conversion**
200
+
201
+ Each argument is converted to its annotated type before the callback runs.
202
+ The built-in converters are:
203
+
204
+ - ``str`` (or an un-annotated parameter) — the raw token, unchanged.
205
+ - ``int`` — parsed with :class:`int`.
206
+ - ``float`` — parsed with :class:`float`.
207
+ - ``bool`` — accepts ``true``/``false``, ``yes``/``no``, ``y``/``n``,
208
+ ``on``/``off``, ``1``/``0``, ``enable``/``disable`` (case-insensitive).
209
+
210
+ Any other annotation is called with the raw token (``annotation(token)``),
211
+ so a type whose constructor takes a single string just works. Conversion
212
+ failures raise :class:`~osmium_chat.errors.BadArgument`. New types can be
213
+ registered by adding to :data:`CONVERTERS`.
214
+
215
+ **Optional arguments**
216
+
217
+ A parameter with a default value is optional; if the invoker omits it, the
218
+ default is used. Otherwise a missing argument raises
219
+ :class:`~osmium_chat.errors.MissingRequiredArgument`. An annotation of
220
+ ``T | None`` (i.e. ``Optional[T]``) is converted as ``T``.
221
+
222
+ **Quoting** (``"..."``)
223
+
224
+ Arguments are split on whitespace, so each parameter normally receives a
225
+ single word. To pass a value that contains spaces as one argument, wrap it
226
+ in single or double quotes; the surrounding quotes are stripped and a
227
+ backslash escapes the next character inside them::
228
+
229
+ @bot.command("echo")
230
+ async def echo(ctx: Context, word: str) -> None:
231
+ await ctx.channel.send(word)
232
+
233
+ # !echo "hello world" -> word == "hello world"
234
+
235
+ **Consume-rest** (``*``) **and variadic** (``*args``)
236
+
237
+ A keyword-only parameter — one declared after a bare ``*`` — consumes the
238
+ entire remaining message as a single, unsplit string (quotes are kept
239
+ verbatim here). This is the idiomatic way to accept free-form text::
240
+
241
+ @bot.command("say")
242
+ async def say(ctx: Context, *, words: str) -> None:
243
+ await ctx.channel.send(words)
244
+
245
+ # !say hello there world -> words == "hello there world"
246
+
247
+ A variadic ``*args`` parameter instead collects every remaining token,
248
+ converting each one to the annotated element type::
249
+
250
+ @bot.command("sum")
251
+ async def sum_(ctx: Context, *numbers: int) -> None:
252
+ await ctx.channel.send(str(sum(numbers)))
253
+
254
+ # !sum 1 2 3 -> numbers == (1, 2, 3)
255
+
256
+ Any leftover text after all parameters are filled raises
257
+ :class:`~osmium_chat.errors.TooManyArguments`.
258
+ """
259
+
260
+ __slots__: tuple[str, ...] = (
261
+ "name",
262
+ "callback",
263
+ "aliases",
264
+ "params",
265
+ )
266
+
267
+ def __init__(
268
+ self,
269
+ callback: CommandCallback,
270
+ *,
271
+ name: str | None = None,
272
+ aliases: tuple[str, ...] = (),
273
+ ) -> None:
274
+ """:param callback: The coroutine invoked when the command runs.
275
+ :param name: The command name; defaults to the callback's name.
276
+ :param aliases: Additional names the command also responds to.
277
+ """
278
+ if not inspect.iscoroutinefunction(callback):
279
+ raise TypeError("Command callback must be a coroutine function")
280
+ self.callback = callback
281
+ self.name = name or callback.__name__
282
+ self.aliases = aliases
283
+ self.params = self._build_params(callback)
284
+
285
+ @staticmethod
286
+ def _build_params(callback: CommandCallback) -> list[Parameter]:
287
+ """Parse the callback signature into argument parameters (skipping ctx)."""
288
+ signature = inspect.signature(callback)
289
+ try:
290
+ hints = get_type_hints(callback)
291
+ except Exception:
292
+ # If annotations can't be resolved (e.g. a missing import) fall back
293
+ # to whatever raw annotations the signature carries.
294
+ hints = {}
295
+
296
+ params: list[Parameter] = []
297
+ for index, param in enumerate(signature.parameters.values()):
298
+ if index == 0:
299
+ continue # the context parameter
300
+ if param.kind in (
301
+ inspect.Parameter.VAR_KEYWORD,
302
+ ):
303
+ continue # **kwargs is not fed from the message
304
+ raw = hints.get(param.name, param.annotation)
305
+ annotation, _ = _resolve_annotation(raw)
306
+ params.append(Parameter(param.name, annotation, param.kind, param.default))
307
+ return params
308
+
309
+ def parse_arguments(self, view: StringView) -> tuple[list[Any], dict[str, Any]]:
310
+ """Convert the argument string into call arguments for the callback.
311
+
312
+ Returns the positional ``args`` and keyword-only ``kwargs`` to pass to
313
+ the callback alongside the context.
314
+
315
+ :param view: A view over the message text following the command name.
316
+ :raises MissingRequiredArgument: If a required argument is absent.
317
+ :raises BadArgument: If an argument fails type conversion.
318
+ :raises TooManyArguments: If unconsumed tokens remain at the end.
319
+ """
320
+ args: list[Any] = []
321
+ kwargs: dict[str, Any] = {}
322
+ for param in self.params:
323
+ if param.kind is inspect.Parameter.VAR_POSITIONAL:
324
+ # ``*args``: greedily convert every remaining token.
325
+ while True:
326
+ word = view.get_word()
327
+ if word is None:
328
+ break
329
+ args.append(param.convert(word))
330
+ return args, kwargs
331
+
332
+ if param.kind is inspect.Parameter.KEYWORD_ONLY:
333
+ # Keyword-only parameter: consume the rest of the message. It is
334
+ # passed by name since the callback won't accept it positionally.
335
+ remaining = view.rest()
336
+ if not remaining:
337
+ if param.required:
338
+ raise MissingRequiredArgument(param.name)
339
+ kwargs[param.name] = param.default
340
+ else:
341
+ kwargs[param.name] = param.convert(remaining)
342
+ return args, kwargs
343
+
344
+ word = view.get_word()
345
+ if word is None:
346
+ if param.required:
347
+ raise MissingRequiredArgument(param.name)
348
+ args.append(param.default)
349
+ else:
350
+ args.append(param.convert(word))
351
+
352
+ leftover = view.rest()
353
+ if leftover:
354
+ raise TooManyArguments(leftover)
355
+ return args, kwargs
356
+
357
+ async def invoke(self, ctx: "Context", view: StringView) -> None:
358
+ """Parse arguments from ``view`` and run the command callback.
359
+
360
+ :param ctx: The invocation context, passed as the first argument.
361
+ :param view: A view over the message text following the command name.
362
+ """
363
+ args, kwargs = self.parse_arguments(view)
364
+ ctx.command = self
365
+ ctx.args = args
366
+ await self.callback(ctx, *args, **kwargs)
@@ -0,0 +1,84 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
3
+ from osmium_chat.channel import Channel
4
+ from osmium_chat.message import Message
5
+ from osmium_chat.user.user import User
6
+
7
+ if TYPE_CHECKING:
8
+ from osmium_chat.bot import Bot
9
+ from osmium_chat.commands import Command
10
+
11
+
12
+ __all__: tuple[str, ...] = (
13
+ "Context",
14
+ )
15
+
16
+
17
+ class Context:
18
+ """The invocation context handed to a command callback.
19
+
20
+ Bundles everything a command needs: who sent the message
21
+ (:attr:`author`), where it came from (:attr:`channel`), the underlying
22
+ :attr:`message`, and the resolved command metadata. Reply with
23
+ ``await ctx.channel.send(...)`` or the :meth:`send` shortcut.
24
+ """
25
+
26
+ __slots__: tuple[str, ...] = (
27
+ "bot",
28
+ "message",
29
+ "author",
30
+ "channel",
31
+ "prefix",
32
+ "command",
33
+ "invoked_with",
34
+ "args",
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ bot: "Bot",
41
+ message: Message,
42
+ author: User | None,
43
+ channel: Channel,
44
+ prefix: str,
45
+ command: "Command | None" = None,
46
+ invoked_with: str | None = None,
47
+ args: list[Any] | None = None,
48
+ ) -> None:
49
+ """Create a context.
50
+
51
+ :param bot: The bot handling the message.
52
+ :param message: The message that triggered the command.
53
+ :param author: The user who sent the message, if known.
54
+ :param channel: The channel the message was sent in.
55
+ :param prefix: The prefix the message was invoked with.
56
+ :param command: The resolved command, if one matched.
57
+ :param invoked_with: The name or alias actually used to invoke it.
58
+ :param args: The converted positional arguments passed to the callback.
59
+ """
60
+ self.bot = bot
61
+ self.message = message
62
+ self.author = author
63
+ self.channel = channel
64
+ self.prefix = prefix
65
+ self.command = command
66
+ self.invoked_with = invoked_with
67
+ self.args: list[Any] = args if args is not None else []
68
+
69
+ async def send(self, content: str, *, reply_to: int | None = None) -> None:
70
+ """Send a message to the channel this command was invoked in.
71
+
72
+ A shortcut for ``ctx.channel.send(...)``.
73
+
74
+ :param content: The message text to send.
75
+ :param reply_to: Optional id of a message this should reply to.
76
+ """
77
+ await self.channel.send(content, reply_to=reply_to)
78
+
79
+ async def reply(self, content: str) -> None:
80
+ """Reply to the invoking message, threading it as a reply.
81
+
82
+ :param content: The message text to send.
83
+ """
84
+ await self.channel.send(content, reply_to=self.message.id)
@@ -0,0 +1,67 @@
1
+ """Exceptions raised while parsing and invoking commands."""
2
+
3
+
4
+ __all__: tuple[str, ...] = (
5
+ "OsmiumChatError",
6
+ "CommandError",
7
+ "CommandNotFound",
8
+ "ArgumentError",
9
+ "MissingRequiredArgument",
10
+ "BadArgument",
11
+ "TooManyArguments",
12
+ )
13
+
14
+
15
+ class OsmiumChatError(Exception):
16
+ """Base class for every exception raised by ``osmium_chat``."""
17
+
18
+
19
+ class CommandError(OsmiumChatError):
20
+ """Base class for errors that occur while handling a command."""
21
+
22
+
23
+ class CommandNotFound(CommandError):
24
+ """No command was registered under the invoked name or alias."""
25
+
26
+ def __init__(self, name: str) -> None:
27
+ """:param name: The name the user tried to invoke."""
28
+ self.name = name
29
+ super().__init__(f"Command {name!r} is not registered")
30
+
31
+
32
+ class ArgumentError(CommandError):
33
+ """Base class for problems converting or supplying command arguments."""
34
+
35
+
36
+ class MissingRequiredArgument(ArgumentError):
37
+ """A required argument was not supplied by the invoker."""
38
+
39
+ def __init__(self, name: str) -> None:
40
+ """:param name: The name of the parameter that was missing."""
41
+ self.name = name
42
+ super().__init__(f"Missing required argument: {name!r}")
43
+
44
+
45
+ class BadArgument(ArgumentError):
46
+ """An argument could not be converted to the parameter's annotated type."""
47
+
48
+ def __init__(self, name: str, value: str, expected: type) -> None:
49
+ """:param name: The parameter the value was being converted for.
50
+ :param value: The raw token that failed to convert.
51
+ :param expected: The type conversion was attempted against.
52
+ """
53
+ self.name = name
54
+ self.value = value
55
+ self.expected = expected
56
+ super().__init__(
57
+ f"Could not convert {value!r} to {expected.__name__} for argument {name!r}",
58
+ )
59
+
60
+
61
+ class TooManyArguments(ArgumentError):
62
+ """The invoker supplied more arguments than the command accepts."""
63
+
64
+ def __init__(self, extra: str) -> None:
65
+ """:param extra: The leftover, unconsumed portion of the message."""
66
+ self.extra = extra
67
+ super().__init__(f"Too many arguments supplied: {extra!r}")
@@ -0,0 +1,33 @@
1
+ from osmium_protos import PB_ChatRef, PB_Message
2
+
3
+
4
+ __all__: tuple[str, ...] = (
5
+ "Message",
6
+ )
7
+
8
+
9
+ class Message:
10
+ """A chat message, parsed from its protobuf representation.
11
+
12
+ Holds where the message came from (``chat_ref``) so replies can be routed
13
+ back to the same conversation, alongside its text content and metadata.
14
+ """
15
+
16
+ __slots__: tuple[str, ...] = (
17
+ "id",
18
+ "content",
19
+ "author_id",
20
+ "chat_ref",
21
+ "reply_to",
22
+ )
23
+
24
+ def __init__(self, message: PB_Message) -> None:
25
+ """Build a message from a protobuf payload.
26
+
27
+ :param message: The raw ``PB_Message`` to read fields from.
28
+ """
29
+ self.id: int = message.message_id
30
+ self.content: str = message.message
31
+ self.author_id: int = message.author_id
32
+ self.chat_ref: PB_ChatRef | None = message.chat_ref
33
+ self.reply_to: int | None = message.reply_to
@@ -0,0 +1,23 @@
1
+ from osmium_protos import PB_ChatPhoto
2
+
3
+
4
+ __all__: tuple[str, ...] = (
5
+ "Photo",
6
+ )
7
+
8
+
9
+ class Photo:
10
+ """A chat photo: its file id and an inline preview blob."""
11
+
12
+ __slots__: tuple[str, ...] = (
13
+ "file_id",
14
+ "preview",
15
+ )
16
+
17
+ def __init__(self, photo: PB_ChatPhoto) -> None:
18
+ """Build a photo from a protobuf payload.
19
+
20
+ :param photo: The raw ``PB_ChatPhoto`` to read fields from.
21
+ """
22
+ self.file_id: int = photo.file_id
23
+ self.preview: bytes = photo.preview
File without changes
@@ -0,0 +1,12 @@
1
+ from osmium_chat.user.activity import UserActivityType, UserStatusActivity
2
+ from osmium_chat.user.status import UserStatus, UserStatusStatus
3
+ from osmium_chat.user.user import User
4
+
5
+
6
+ __all__: tuple[str, ...] = (
7
+ "User",
8
+ "UserActivityType",
9
+ "UserStatus",
10
+ "UserStatusActivity",
11
+ "UserStatusStatus",
12
+ )
@@ -0,0 +1,41 @@
1
+ from enum import IntEnum
2
+
3
+ from osmium_protos import PB_UserStatusActivity
4
+
5
+
6
+ __all__: tuple[str, ...] = (
7
+ "UserActivityType",
8
+ "UserStatusActivity",
9
+ )
10
+
11
+
12
+ class UserActivityType(IntEnum):
13
+ """The kind of activity a user is engaged in."""
14
+
15
+ GAME = 0
16
+ STREAMING = 1
17
+ LISTENING = 2
18
+ WATCHING = 3
19
+
20
+
21
+ class UserStatusActivity:
22
+ """A single activity shown in a user's status (e.g. a game being played)."""
23
+
24
+ __slots__: tuple[str, ...] = (
25
+ "title",
26
+ "type",
27
+ "start_time",
28
+ "end_time",
29
+ "state",
30
+ )
31
+
32
+ def __init__(self, activity: PB_UserStatusActivity) -> None:
33
+ """Build an activity from a protobuf payload.
34
+
35
+ :param activity: The raw ``PB_UserStatusActivity`` to read fields from.
36
+ """
37
+ self.title: str = activity.title
38
+ self.type: UserActivityType = UserActivityType(activity.type)
39
+ self.start_time: int = activity.start_time
40
+ self.end_time: int | None = activity.end_time
41
+ self.state: str | None = activity.state
@@ -0,0 +1,37 @@
1
+ from enum import IntEnum
2
+
3
+ from osmium_protos import PB_UserStatus
4
+
5
+ from osmium_chat.user.activity import UserStatusActivity
6
+
7
+
8
+ __all__: tuple[str, ...] = (
9
+ "UserStatusStatus",
10
+ "UserStatus",
11
+ )
12
+
13
+
14
+ class UserStatusStatus(IntEnum):
15
+ """A user's availability state."""
16
+
17
+ ONLINE = 0
18
+ IDLE = 1
19
+
20
+
21
+ class UserStatus:
22
+ """A user's presence: online flag, status state, and activities."""
23
+
24
+ __slots__: tuple[str, ...] = (
25
+ "online",
26
+ "status",
27
+ "activities",
28
+ )
29
+
30
+ def __init__(self, status: PB_UserStatus) -> None:
31
+ """Build a status from a protobuf payload.
32
+
33
+ :param status: The raw ``PB_UserStatus`` to read fields from.
34
+ """
35
+ self.online: bool = status.online
36
+ self.status: UserStatusStatus | None = UserStatusStatus(status.status) if status.status else None
37
+ self.activities: list[UserStatusActivity] = [UserStatusActivity(activity) for activity in status.activities]
@@ -0,0 +1,36 @@
1
+ from osmium_protos import PB_User
2
+
3
+ from osmium_chat.photo import Photo
4
+ from osmium_chat.user.status import UserStatus
5
+
6
+
7
+ __all__: tuple[str, ...] = (
8
+ "User",
9
+ )
10
+
11
+
12
+ class User:
13
+ """An Osmium user, parsed from its protobuf representation."""
14
+
15
+ __slots__: tuple[str, ...] = (
16
+ "id",
17
+ "name",
18
+ "username",
19
+ "status",
20
+ "photo",
21
+ "icon",
22
+ "color",
23
+ )
24
+
25
+ def __init__(self, user: PB_User) -> None:
26
+ """Build a user from a protobuf payload.
27
+
28
+ :param user: The raw ``PB_User`` to read fields from.
29
+ """
30
+ self.id: int = user.id
31
+ self.name: str = user.name
32
+ self.username: str | None = user.username
33
+ self.status: UserStatus | None = UserStatus(user.status) if user.status else None
34
+ self.photo: Photo | None = Photo(user.photo) if user.photo else None
35
+ self.icon: int | None = user.icon
36
+ self.color: int | None = user.color
@@ -0,0 +1,35 @@
1
+ [tool.poetry]
2
+ name = "osmium-chat"
3
+ version = "0.1.1"
4
+ description = "A Python API wrapper for Osmium."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = ["abigail phoebe <abigail@phoebe.sh>"]
8
+ packages = [{ include = "osmium_chat" }]
9
+
10
+ [tool.poetry.urls]
11
+ Homepage = "https://github.com/ijsbol/osmium-chat"
12
+ "Bug Tracker" = "https://github.com/ijsbol/osmium-chat/issues"
13
+
14
+ [tool.poetry.dependencies]
15
+ python = ">=3.13,<4.0"
16
+ osmium-protos = ">=2.0.0"
17
+ websockets = ">=16.0"
18
+
19
+ [tool.poetry.group.dev.dependencies]
20
+ isort = ">=8.0.1"
21
+ ruff = ">=0.15.15"
22
+ twine = ">=6.2.0,<7.0.0"
23
+ build = ">=1.4.0,<2.0.0"
24
+ flake8 = ">=7.3.0,<8.0.0"
25
+ pytest = "^9.0.3"
26
+
27
+ [build-system]
28
+ requires = ["poetry-core>=1.0.0"]
29
+ build-backend = "poetry.core.masonry.api"
30
+
31
+ [tool.ruff.lint]
32
+ select = ["COM812"]
33
+
34
+ [tool.ruff.format]
35
+ skip-magic-trailing-comma = false