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.
- osmium_chat-0.1.1/LICENSE +21 -0
- osmium_chat-0.1.1/PKG-INFO +31 -0
- osmium_chat-0.1.1/README.md +11 -0
- osmium_chat-0.1.1/osmium_chat/__init__.py +39 -0
- osmium_chat-0.1.1/osmium_chat/bot.py +217 -0
- osmium_chat-0.1.1/osmium_chat/channel.py +46 -0
- osmium_chat-0.1.1/osmium_chat/client.py +152 -0
- osmium_chat-0.1.1/osmium_chat/commands.py +366 -0
- osmium_chat-0.1.1/osmium_chat/context.py +84 -0
- osmium_chat-0.1.1/osmium_chat/errors.py +67 -0
- osmium_chat-0.1.1/osmium_chat/message.py +33 -0
- osmium_chat-0.1.1/osmium_chat/photo.py +23 -0
- osmium_chat-0.1.1/osmium_chat/py.typed +0 -0
- osmium_chat-0.1.1/osmium_chat/user/__init__.py +12 -0
- osmium_chat-0.1.1/osmium_chat/user/activity.py +41 -0
- osmium_chat-0.1.1/osmium_chat/user/status.py +37 -0
- osmium_chat-0.1.1/osmium_chat/user/user.py +36 -0
- osmium_chat-0.1.1/pyproject.toml +35 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|