hikari-risa 1.0.0__py3-none-any.whl
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.
- hikari_risa-1.0.0.dist-info/METADATA +83 -0
- hikari_risa-1.0.0.dist-info/RECORD +43 -0
- hikari_risa-1.0.0.dist-info/WHEEL +4 -0
- hikari_risa-1.0.0.dist-info/entry_points.txt +2 -0
- hikari_risa-1.0.0.dist-info/licenses/LICENSE +21 -0
- risa/__init__.py +49 -0
- risa/__main__.py +145 -0
- risa/_about.py +31 -0
- risa/binding.py +251 -0
- risa/client.py +657 -0
- risa/codecs.py +204 -0
- risa/context.py +693 -0
- risa/di.py +75 -0
- risa/dispatch.py +828 -0
- risa/errors.py +603 -0
- risa/internal/__init__.py +22 -0
- risa/internal/codec.py +547 -0
- risa/internal/constants.py +36 -0
- risa/internal/leases.py +197 -0
- risa/internal/locks.py +87 -0
- risa/internal/reader.py +88 -0
- risa/internal/registry.py +208 -0
- risa/internal/wire.py +118 -0
- risa/py.typed +0 -0
- risa/state/__init__.py +25 -0
- risa/state/anchor.py +279 -0
- risa/state/backend.py +155 -0
- risa/state/message.py +278 -0
- risa/state/placement.py +104 -0
- risa/state/schema.py +447 -0
- risa/state/serde.py +69 -0
- risa/state/stateless.py +94 -0
- risa/state/stored.py +444 -0
- risa/store/__init__.py +30 -0
- risa/store/memory.py +152 -0
- risa/store/postgres.py +311 -0
- risa/store/protocol.py +172 -0
- risa/store/redis.py +254 -0
- risa/store/testing.py +341 -0
- risa/ui/__init__.py +26 -0
- risa/ui/build.py +353 -0
- risa/ui/nodes.py +918 -0
- risa/view.py +466 -0
risa/client.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# Copyright (c) 2025 Rayakame
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
"""The client that routes clicks to views, and the functions that make one."""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import abc
|
|
25
|
+
import asyncio
|
|
26
|
+
import collections.abc
|
|
27
|
+
import logging
|
|
28
|
+
import typing
|
|
29
|
+
|
|
30
|
+
import hikari
|
|
31
|
+
import linkd
|
|
32
|
+
import linkd.solver
|
|
33
|
+
|
|
34
|
+
from risa import context
|
|
35
|
+
from risa import di as di_
|
|
36
|
+
from risa import dispatch as dispatch_
|
|
37
|
+
from risa import errors
|
|
38
|
+
from risa import view as view_
|
|
39
|
+
from risa.internal import constants
|
|
40
|
+
from risa.internal import registry
|
|
41
|
+
from risa.state import message
|
|
42
|
+
from risa.state import placement as placement_
|
|
43
|
+
from risa.state import stateless
|
|
44
|
+
from risa.state import stored
|
|
45
|
+
from risa.store import memory
|
|
46
|
+
from risa.ui import build as build_
|
|
47
|
+
|
|
48
|
+
if typing.TYPE_CHECKING:
|
|
49
|
+
from hikari.api import interaction_server as interaction_server_
|
|
50
|
+
|
|
51
|
+
from risa.state import backend as backend_
|
|
52
|
+
from risa.store import protocol
|
|
53
|
+
|
|
54
|
+
__all__ = (
|
|
55
|
+
"Client",
|
|
56
|
+
"GatewayClientAppT",
|
|
57
|
+
"GatewayEnabledClient",
|
|
58
|
+
"LightbulbClient",
|
|
59
|
+
"RestClientAppT",
|
|
60
|
+
"RestEnabledClient",
|
|
61
|
+
"client_from_app",
|
|
62
|
+
"client_from_lightbulb",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_LOGGER: typing.Final[logging.Logger] = logging.getLogger("risa.client")
|
|
66
|
+
|
|
67
|
+
type _ComponentResponse = (
|
|
68
|
+
hikari.api.InteractionDeferredBuilder | hikari.api.InteractionMessageBuilder | hikari.api.InteractionModalBuilder
|
|
69
|
+
)
|
|
70
|
+
type _ComponentListener = interaction_server_.ListenerT[hikari.ComponentInteraction, _ComponentResponse]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _ErrorHandlerDecorator(typing.Protocol):
|
|
74
|
+
def __call__[T: context.ErrorHandler](self, func: T, /) -> T: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@typing.runtime_checkable
|
|
78
|
+
class GatewayClientAppT(hikari.EventManagerAware, hikari.RESTAware, typing.Protocol):
|
|
79
|
+
"""A gateway app, with an event manager and a REST client, such as `hikari.GatewayBot`."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@typing.runtime_checkable
|
|
83
|
+
class RestClientAppT(hikari.InteractionServerAware, hikari.RESTAware, typing.Protocol):
|
|
84
|
+
"""A REST app, with an interaction server and a REST client, such as `hikari.RESTBot`."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@typing.runtime_checkable
|
|
88
|
+
class LightbulbClient(typing.Protocol):
|
|
89
|
+
"""The part of a lightbulb client risa uses."""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def app(self) -> hikari.RESTAware:
|
|
93
|
+
"""The hikari app the lightbulb client runs on."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def di(self) -> linkd.solver.DependencyInjectionManager:
|
|
98
|
+
"""The lightbulb client's dependency manager."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _LightbulbOptions(typing.TypedDict, total=False):
|
|
103
|
+
use_global: bool
|
|
104
|
+
auto_defer: view_.AutoDefer
|
|
105
|
+
auto_defer_delay: float
|
|
106
|
+
store: protocol.Store | None
|
|
107
|
+
stores: collections.abc.Mapping[str, protocol.Store]
|
|
108
|
+
key_prefix: str | None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _ClientOptions(_LightbulbOptions, total=False):
|
|
112
|
+
di: linkd.solver.DependencyInjectionManager | None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Client(abc.ABC):
|
|
116
|
+
"""Routes clicks on registered views to their handlers.
|
|
117
|
+
|
|
118
|
+
Make one with `client_from_app` or `client_from_lightbulb`, add views with
|
|
119
|
+
`add_view`, and send a view with `build`. A client answers only `custom_id`s risa
|
|
120
|
+
wrote, so it can share a bot with other component handlers.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
rest
|
|
125
|
+
The REST client risa answers interactions through.
|
|
126
|
+
use_global
|
|
127
|
+
Also route views registered with `@risa.register` but never added to this client.
|
|
128
|
+
di
|
|
129
|
+
The linkd manager handlers are injected from; a new one by default.
|
|
130
|
+
register_app_dependencies
|
|
131
|
+
Register the app, its REST client, and its event manager or interaction server
|
|
132
|
+
for injection.
|
|
133
|
+
auto_defer
|
|
134
|
+
The `AutoDefer` of views and handlers that set none, `OFF` by default.
|
|
135
|
+
auto_defer_delay
|
|
136
|
+
Seconds after which auto-defer answers a click in its handler's place.
|
|
137
|
+
store
|
|
138
|
+
The store `InStore` views use unless they name another.
|
|
139
|
+
stores
|
|
140
|
+
Further stores, by the name `InStore(store=...)` refers to them by.
|
|
141
|
+
key_prefix
|
|
142
|
+
The prefix of every store key, `risa:{application id}` by default. Set it to
|
|
143
|
+
fit a Redis ACL pattern, or to separate bots that share one store.
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
If `key_prefix` is blank, or the default store is given both as `store` and in
|
|
149
|
+
`stores`.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
__slots__ = (
|
|
153
|
+
"_auto_defer",
|
|
154
|
+
"_auto_defer_delay",
|
|
155
|
+
"_di",
|
|
156
|
+
"_dispatcher",
|
|
157
|
+
"_in_message",
|
|
158
|
+
"_memory_warned",
|
|
159
|
+
"_registry",
|
|
160
|
+
"_rest",
|
|
161
|
+
"_stateless",
|
|
162
|
+
"_stored",
|
|
163
|
+
"_unanswered_warned",
|
|
164
|
+
"_use_global",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def __init__( # ruff:ignore[too-many-arguments]
|
|
168
|
+
self,
|
|
169
|
+
rest: hikari.api.RESTClient,
|
|
170
|
+
*,
|
|
171
|
+
use_global: bool = False,
|
|
172
|
+
di: linkd.solver.DependencyInjectionManager | None = None,
|
|
173
|
+
register_app_dependencies: bool = True,
|
|
174
|
+
auto_defer: view_.AutoDefer = view_.AutoDefer.OFF,
|
|
175
|
+
auto_defer_delay: float = constants.AUTO_DEFER_DELAY,
|
|
176
|
+
store: protocol.Store | None = None,
|
|
177
|
+
stores: collections.abc.Mapping[str, protocol.Store] | None = None,
|
|
178
|
+
key_prefix: str | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
if key_prefix is not None and not key_prefix.strip():
|
|
181
|
+
msg = "key_prefix must not be blank; leave it out to namespace store keys by the application id"
|
|
182
|
+
raise ValueError(msg)
|
|
183
|
+
self._di = di if di is not None else linkd.DependencyInjectionManager()
|
|
184
|
+
self._registry = registry.Registry()
|
|
185
|
+
self._rest = rest
|
|
186
|
+
self._use_global = use_global
|
|
187
|
+
self._auto_defer = auto_defer
|
|
188
|
+
self._auto_defer_delay = auto_defer_delay
|
|
189
|
+
namespace = stored.KeyNamespace(rest, key_prefix=key_prefix)
|
|
190
|
+
self._stored = {
|
|
191
|
+
name: stored.StoredBackend(instance, namespace=namespace)
|
|
192
|
+
for name, instance in _store_slots(store, stores or {}).items()
|
|
193
|
+
}
|
|
194
|
+
self._in_message = message.MessageBackend()
|
|
195
|
+
self._stateless = stateless.StatelessBackend()
|
|
196
|
+
self._memory_warned: set[str] = set()
|
|
197
|
+
self._unanswered_warned: set[str] = set()
|
|
198
|
+
|
|
199
|
+
if Client in self._di.registry_for(di_.Contexts.DEFAULT):
|
|
200
|
+
_LOGGER.warning(
|
|
201
|
+
"another risa client is already registered on this dependency manager;"
|
|
202
|
+
" `client: risa.Client` injections will resolve to this newest one",
|
|
203
|
+
)
|
|
204
|
+
self._register_dependency(Client, self)
|
|
205
|
+
if register_app_dependencies:
|
|
206
|
+
self._register_dependency(hikari.api.RESTClient, rest)
|
|
207
|
+
self._dispatcher = dispatch_.Dispatcher(
|
|
208
|
+
rest,
|
|
209
|
+
self._di,
|
|
210
|
+
self._resolve,
|
|
211
|
+
self._require_backend,
|
|
212
|
+
auto_defer=auto_defer,
|
|
213
|
+
auto_defer_delay=auto_defer_delay,
|
|
214
|
+
build=self._build_over,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
@abc.abstractmethod
|
|
219
|
+
def app(self) -> hikari.RESTAware:
|
|
220
|
+
"""The hikari app the client receives interactions from."""
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def di(self) -> linkd.solver.DependencyInjectionManager:
|
|
224
|
+
"""The dependency manager handlers are injected from."""
|
|
225
|
+
return self._di
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def rest(self) -> hikari.api.RESTClient:
|
|
229
|
+
"""The REST client risa answers interactions through."""
|
|
230
|
+
return self._rest
|
|
231
|
+
|
|
232
|
+
async def build(self, view: view_.View) -> build_.Rendered:
|
|
233
|
+
"""Render `view` into the components of a new message, ready to send.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
await ctx.respond(**await client.build(Poll(question="Ship it?")))
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
It runs the view's `load`, renders it, and writes an `InStore` view's first record.
|
|
240
|
+
The result spreads into any hikari or lightbulb call that takes `components=`. If
|
|
241
|
+
that send fails, the record stays behind until its TTL reaps it, or for good under
|
|
242
|
+
`InStore(ttl=None)`.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
view
|
|
247
|
+
The view to render, an instance of a registered class.
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
:
|
|
252
|
+
The components, as a mapping that spreads into a send.
|
|
253
|
+
|
|
254
|
+
Raises
|
|
255
|
+
------
|
|
256
|
+
NotAViewError
|
|
257
|
+
If the view's class was never registered.
|
|
258
|
+
ViewDeclarationError
|
|
259
|
+
If the view keeps its state in a store this client does not have.
|
|
260
|
+
StateOverflowError
|
|
261
|
+
If an `InMessage` view's state does not fit its components.
|
|
262
|
+
"""
|
|
263
|
+
meta = registry.require_meta(view)
|
|
264
|
+
if meta.loader is not None:
|
|
265
|
+
async with self._di.enter_context(di_.Contexts.DEFAULT):
|
|
266
|
+
await meta.loader(view)
|
|
267
|
+
plan = build_.plan(view.render(), meta)
|
|
268
|
+
if not plan.interactive:
|
|
269
|
+
return build_.Rendered(plan.emit(b""))
|
|
270
|
+
self._warn_if_unanswered(meta)
|
|
271
|
+
state = self._require_backend(meta)
|
|
272
|
+
anchor = state.first_anchor(meta=meta, view=view)
|
|
273
|
+
builders = plan.emit(anchor)
|
|
274
|
+
await state.commit_first(meta=meta, view=view, anchor=anchor)
|
|
275
|
+
return build_.Rendered(builders)
|
|
276
|
+
|
|
277
|
+
@typing.overload
|
|
278
|
+
def error_handler[T: context.ErrorHandler](self, func: T, /, *, priority: int = 0) -> T: ...
|
|
279
|
+
|
|
280
|
+
@typing.overload
|
|
281
|
+
def error_handler(self, /, *, priority: int = 0) -> _ErrorHandlerDecorator: ...
|
|
282
|
+
|
|
283
|
+
def error_handler(
|
|
284
|
+
self,
|
|
285
|
+
func: context.ErrorHandler | None = None,
|
|
286
|
+
/,
|
|
287
|
+
*,
|
|
288
|
+
priority: int = 0,
|
|
289
|
+
) -> context.ErrorHandler | _ErrorHandlerDecorator:
|
|
290
|
+
"""Register a function that answers clicks that failed.
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
@client.error_handler
|
|
294
|
+
async def on_error(failure: risa.ClickFailure) -> bool:
|
|
295
|
+
if isinstance(failure.error, risa.StoreUnavailableError):
|
|
296
|
+
await failure.ctx.respond("Try again in a moment.", ephemeral=True)
|
|
297
|
+
return True
|
|
298
|
+
return False
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
It runs when a handler, `load`, `View.on_outdated` or `View.on_state_missing` raises,
|
|
302
|
+
or when the view's state cannot be reached before them; `ClickFailure.stage` says
|
|
303
|
+
which. It returns `True` once it has dealt with the failure, and risa then logs
|
|
304
|
+
nothing. Otherwise the next one runs, and if none returns `True`, risa logs the
|
|
305
|
+
failure as it would without them. One that raises is logged and counts as `False`.
|
|
306
|
+
Its parameters after the first are injected, as a handler's are. It runs without
|
|
307
|
+
auto-defer, and Discord's three seconds count from the click, so a failure that comes
|
|
308
|
+
late, such as a lock timeout, may leave it no time unless auto-defer answered first.
|
|
309
|
+
|
|
310
|
+
Parameters
|
|
311
|
+
----------
|
|
312
|
+
func
|
|
313
|
+
The function to register. Left out, the call takes `priority` and returns the
|
|
314
|
+
decorator.
|
|
315
|
+
priority
|
|
316
|
+
Higher runs first; functions of one priority run in the order they were
|
|
317
|
+
registered.
|
|
318
|
+
|
|
319
|
+
Returns
|
|
320
|
+
-------
|
|
321
|
+
:
|
|
322
|
+
The function, unchanged, or the decorator that registers one.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def register[T: context.ErrorHandler](handler: T) -> T:
|
|
326
|
+
self._dispatcher.add_error_handler(handler, priority=priority)
|
|
327
|
+
return handler
|
|
328
|
+
|
|
329
|
+
return register(func) if func is not None else register
|
|
330
|
+
|
|
331
|
+
def add_view[T: view_.View](self, cls: type[T]) -> type[T]:
|
|
332
|
+
"""Route clicks on the components of `cls` through this client.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
cls
|
|
337
|
+
The view class, already registered with `@risa.register`.
|
|
338
|
+
|
|
339
|
+
Returns
|
|
340
|
+
-------
|
|
341
|
+
:
|
|
342
|
+
The class, so that this also works as a decorator above `@risa.register`.
|
|
343
|
+
|
|
344
|
+
Raises
|
|
345
|
+
------
|
|
346
|
+
NotAViewError
|
|
347
|
+
If the class was never registered.
|
|
348
|
+
ViewDeclarationError
|
|
349
|
+
If the view keeps its state in a store this client does not have.
|
|
350
|
+
"""
|
|
351
|
+
meta = registry.require_meta(cls)
|
|
352
|
+
self._check_store(meta)
|
|
353
|
+
_LOGGER.debug("registering view %s to client", meta.name)
|
|
354
|
+
self._registry.register(meta)
|
|
355
|
+
return cls
|
|
356
|
+
|
|
357
|
+
async def _build_over(self, view: view_.View, message_id: hikari.Snowflake) -> build_.Rendered:
|
|
358
|
+
rendered = await self.build(view)
|
|
359
|
+
# The new state starts at sequence 0, so a later commit remembered for the message would win over it.
|
|
360
|
+
self._in_message.forget(message_id)
|
|
361
|
+
return rendered
|
|
362
|
+
|
|
363
|
+
def _warn_if_unanswered(self, meta: registry.ViewMeta) -> None:
|
|
364
|
+
if self._resolve(meta.cookie) is None and meta.cookie not in self._unanswered_warned:
|
|
365
|
+
self._unanswered_warned.add(meta.cookie)
|
|
366
|
+
_LOGGER.warning(
|
|
367
|
+
"view %s was built, but this client does not answer its clicks: pass the class to"
|
|
368
|
+
" client.add_view, or make the client with use_global=True",
|
|
369
|
+
meta.name,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _check_store(self, meta: registry.ViewMeta) -> None:
|
|
373
|
+
state = meta.state
|
|
374
|
+
if not isinstance(state, placement_.InStore):
|
|
375
|
+
return
|
|
376
|
+
slot = self._stored.get(state.store)
|
|
377
|
+
if slot is None:
|
|
378
|
+
raise errors.ViewDeclarationError(meta.name, _missing_store(state.store))
|
|
379
|
+
if isinstance(slot.store, memory.MemoryStore) and state.store not in self._memory_warned:
|
|
380
|
+
self._memory_warned.add(state.store)
|
|
381
|
+
_LOGGER.warning(
|
|
382
|
+
"view %s keeps its state in store %r, which is a MemoryStore: the state lives in this"
|
|
383
|
+
" process only, so it is lost on every restart, and on a REST bot with more than one"
|
|
384
|
+
" process most clicks will not find it - use it for development only",
|
|
385
|
+
meta.name,
|
|
386
|
+
state.store,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _require_backend(self, meta: registry.ViewMeta) -> backend_.StateBackend:
|
|
390
|
+
found = self._backend_for(meta)
|
|
391
|
+
if found is None:
|
|
392
|
+
name = meta.state.store if isinstance(meta.state, placement_.InStore) else placement_.DEFAULT_STORE
|
|
393
|
+
raise errors.ViewDeclarationError(meta.name, _missing_store(name))
|
|
394
|
+
return found
|
|
395
|
+
|
|
396
|
+
def _backend_for(self, meta: registry.ViewMeta) -> backend_.StateBackend | None:
|
|
397
|
+
if not meta.schema.durable:
|
|
398
|
+
return self._stateless
|
|
399
|
+
if isinstance(meta.state, placement_.InStore):
|
|
400
|
+
return self._stored.get(meta.state.store)
|
|
401
|
+
return self._in_message
|
|
402
|
+
|
|
403
|
+
def _register_dependency[T](self, dependency_type: type[T], value: T) -> None:
|
|
404
|
+
try:
|
|
405
|
+
self._di.registry_for(di_.Contexts.DEFAULT).register_value(dependency_type, value)
|
|
406
|
+
except linkd.RegistryFrozenException:
|
|
407
|
+
_LOGGER.warning(
|
|
408
|
+
"could not register %s: the dependency manager is already in use. Build the client before"
|
|
409
|
+
" the manager opens its first container, or pass register_app_dependencies=False if its"
|
|
410
|
+
" owner registers these already.",
|
|
411
|
+
dependency_type,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _resolve(self, cookie: str) -> registry.ViewMeta | None:
|
|
415
|
+
meta = self._registry.get(cookie)
|
|
416
|
+
if meta is None and self._use_global:
|
|
417
|
+
meta = registry.global_registry().get(cookie)
|
|
418
|
+
return meta
|
|
419
|
+
|
|
420
|
+
async def _process_interaction(
|
|
421
|
+
self,
|
|
422
|
+
interaction: hikari.ComponentInteraction,
|
|
423
|
+
gate: context.ResponseGate | None = None,
|
|
424
|
+
) -> None:
|
|
425
|
+
await self._dispatcher.process(interaction, gate)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class GatewayEnabledClient(Client):
|
|
429
|
+
"""A client that receives clicks as gateway events. `client_from_app` or `client_from_lightbulb` makes one."""
|
|
430
|
+
|
|
431
|
+
__slots__ = ("_app",)
|
|
432
|
+
|
|
433
|
+
def __init__(
|
|
434
|
+
self,
|
|
435
|
+
app: GatewayClientAppT,
|
|
436
|
+
*,
|
|
437
|
+
register_app_dependencies: bool = True,
|
|
438
|
+
**options: typing.Unpack[_ClientOptions],
|
|
439
|
+
) -> None:
|
|
440
|
+
super().__init__(app.rest, register_app_dependencies=register_app_dependencies, **options)
|
|
441
|
+
self._app = app
|
|
442
|
+
|
|
443
|
+
if register_app_dependencies:
|
|
444
|
+
self._register_dependency(hikari.api.EventManager, app.event_manager)
|
|
445
|
+
if isinstance(app, hikari.GatewayBot):
|
|
446
|
+
self._register_dependency(hikari.GatewayBot, app)
|
|
447
|
+
|
|
448
|
+
app.event_manager.subscribe(hikari.ComponentInteractionCreateEvent, self._on_interaction_create)
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
@typing.override
|
|
452
|
+
def app(self) -> GatewayClientAppT:
|
|
453
|
+
"""The gateway app the client receives interactions from."""
|
|
454
|
+
return self._app
|
|
455
|
+
|
|
456
|
+
async def _on_interaction_create(self, event: hikari.ComponentInteractionCreateEvent) -> None:
|
|
457
|
+
await self._process_interaction(event.interaction)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class RestEnabledClient(Client):
|
|
461
|
+
"""A client that receives clicks from hikari's interaction server.
|
|
462
|
+
|
|
463
|
+
The interaction server has a single component listener. The client takes it over and
|
|
464
|
+
passes every interaction it does not handle on to the listener it replaced, and it
|
|
465
|
+
logs a warning naming that listener unless `warn_shared_listener=False`.
|
|
466
|
+
`client_from_app` or `client_from_lightbulb` makes one.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
__slots__ = ("_app", "_replaced")
|
|
470
|
+
|
|
471
|
+
def __init__(
|
|
472
|
+
self,
|
|
473
|
+
app: RestClientAppT,
|
|
474
|
+
*,
|
|
475
|
+
register_app_dependencies: bool = True,
|
|
476
|
+
warn_shared_listener: bool = True,
|
|
477
|
+
**options: typing.Unpack[_ClientOptions],
|
|
478
|
+
) -> None:
|
|
479
|
+
super().__init__(app.rest, register_app_dependencies=register_app_dependencies, **options)
|
|
480
|
+
self._app = app
|
|
481
|
+
|
|
482
|
+
if register_app_dependencies:
|
|
483
|
+
self._register_dependency(hikari.api.InteractionServer, app.interaction_server)
|
|
484
|
+
if isinstance(app, hikari.RESTBot):
|
|
485
|
+
self._register_dependency(hikari.RESTBot, app)
|
|
486
|
+
|
|
487
|
+
self._replaced = typing.cast(
|
|
488
|
+
"_ComponentListener | None",
|
|
489
|
+
app.interaction_server.get_listener(hikari.ComponentInteraction),
|
|
490
|
+
)
|
|
491
|
+
if self._replaced is not None and warn_shared_listener:
|
|
492
|
+
_LOGGER.warning(
|
|
493
|
+
"the interaction server already had a component listener, %r; risa now receives every"
|
|
494
|
+
" component interaction first and passes each one it does not handle on to that listener."
|
|
495
|
+
" Pass warn_shared_listener=False to the risa client once this is intended",
|
|
496
|
+
self._replaced,
|
|
497
|
+
)
|
|
498
|
+
app.interaction_server.set_listener(hikari.ComponentInteraction, self._on_interaction, replace=True)
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
@typing.override
|
|
502
|
+
def app(self) -> RestClientAppT:
|
|
503
|
+
"""The REST app the client receives interactions from."""
|
|
504
|
+
return self._app
|
|
505
|
+
|
|
506
|
+
async def _on_interaction(
|
|
507
|
+
self,
|
|
508
|
+
interaction: hikari.ComponentInteraction,
|
|
509
|
+
) -> collections.abc.AsyncGenerator[_ComponentResponse | None, None]:
|
|
510
|
+
gate = context.ResponseGate()
|
|
511
|
+
task = asyncio.create_task(self._process_interaction(interaction, gate))
|
|
512
|
+
try:
|
|
513
|
+
await asyncio.wait_for(gate.acknowledged.wait(), timeout=constants.INTERACTION_WINDOW)
|
|
514
|
+
except TimeoutError:
|
|
515
|
+
_LOGGER.error( # ruff:ignore[error-instead-of-exception]
|
|
516
|
+
"interaction %s received no initial response within %.1fs on the REST transport; the"
|
|
517
|
+
" interaction is lost - respond, defer, or enable auto_defer. The handler was not"
|
|
518
|
+
" cancelled and continues in the background.",
|
|
519
|
+
interaction.id,
|
|
520
|
+
constants.INTERACTION_WINDOW,
|
|
521
|
+
)
|
|
522
|
+
if not gate.adopted and self._replaced is not None:
|
|
523
|
+
await task
|
|
524
|
+
call = self._replaced(interaction)
|
|
525
|
+
if isinstance(call, collections.abc.AsyncGenerator):
|
|
526
|
+
async for response in call:
|
|
527
|
+
yield response
|
|
528
|
+
else:
|
|
529
|
+
yield await call
|
|
530
|
+
return
|
|
531
|
+
yield None
|
|
532
|
+
await task
|
|
533
|
+
if gate.adopted and not gate.responded:
|
|
534
|
+
_LOGGER.debug(
|
|
535
|
+
"interaction %s was answered with 204 and no response was sent through risa;"
|
|
536
|
+
" Discord shows it as failed unless the handler answered it another way",
|
|
537
|
+
interaction.id,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@typing.overload
|
|
542
|
+
def client_from_app(
|
|
543
|
+
app: GatewayClientAppT,
|
|
544
|
+
**options: typing.Unpack[_ClientOptions],
|
|
545
|
+
) -> GatewayEnabledClient: ...
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@typing.overload
|
|
549
|
+
def client_from_app(
|
|
550
|
+
app: RestClientAppT,
|
|
551
|
+
*,
|
|
552
|
+
warn_shared_listener: bool = True,
|
|
553
|
+
**options: typing.Unpack[_ClientOptions],
|
|
554
|
+
) -> RestEnabledClient: ...
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def client_from_app(
|
|
558
|
+
app: GatewayClientAppT | RestClientAppT,
|
|
559
|
+
*,
|
|
560
|
+
warn_shared_listener: bool = True,
|
|
561
|
+
**options: typing.Unpack[_ClientOptions],
|
|
562
|
+
) -> Client:
|
|
563
|
+
"""Make a client for a hikari app, gateway or REST.
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
bot = hikari.GatewayBot(TOKEN)
|
|
567
|
+
client = risa.client_from_app(bot, store=risa.MemoryStore())
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Parameters
|
|
571
|
+
----------
|
|
572
|
+
app
|
|
573
|
+
The app to receive interactions from.
|
|
574
|
+
warn_shared_listener
|
|
575
|
+
REST only: warn when the client takes over a component listener another library
|
|
576
|
+
set.
|
|
577
|
+
**options
|
|
578
|
+
The options of `Client`.
|
|
579
|
+
|
|
580
|
+
Returns
|
|
581
|
+
-------
|
|
582
|
+
:
|
|
583
|
+
A `GatewayEnabledClient` or a `RestEnabledClient`, depending on the app.
|
|
584
|
+
"""
|
|
585
|
+
if isinstance(app, GatewayClientAppT):
|
|
586
|
+
return GatewayEnabledClient(app, **options)
|
|
587
|
+
return RestEnabledClient(app, warn_shared_listener=warn_shared_listener, **options)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def client_from_lightbulb(
|
|
591
|
+
lightbulb_client: LightbulbClient,
|
|
592
|
+
*,
|
|
593
|
+
warn_shared_listener: bool = True,
|
|
594
|
+
**options: typing.Unpack[_LightbulbOptions],
|
|
595
|
+
) -> Client:
|
|
596
|
+
"""Make a client that shares a lightbulb client's app and dependency manager.
|
|
597
|
+
|
|
598
|
+
Handlers can then be injected with anything registered with lightbulb. The app's own
|
|
599
|
+
dependencies are left to lightbulb, which registers them already.
|
|
600
|
+
|
|
601
|
+
Parameters
|
|
602
|
+
----------
|
|
603
|
+
lightbulb_client
|
|
604
|
+
The lightbulb client to share.
|
|
605
|
+
warn_shared_listener
|
|
606
|
+
REST only: warn when the client takes over lightbulb's component listener.
|
|
607
|
+
**options
|
|
608
|
+
The options of `Client`, apart from `di`.
|
|
609
|
+
|
|
610
|
+
Returns
|
|
611
|
+
-------
|
|
612
|
+
:
|
|
613
|
+
A `GatewayEnabledClient` or a `RestEnabledClient`, depending on the app.
|
|
614
|
+
|
|
615
|
+
Raises
|
|
616
|
+
------
|
|
617
|
+
TypeError
|
|
618
|
+
If the lightbulb client's app is neither a gateway nor a REST app.
|
|
619
|
+
"""
|
|
620
|
+
app = lightbulb_client.app
|
|
621
|
+
if isinstance(app, GatewayClientAppT):
|
|
622
|
+
return GatewayEnabledClient(app, di=lightbulb_client.di, register_app_dependencies=False, **options)
|
|
623
|
+
if isinstance(app, RestClientAppT):
|
|
624
|
+
return RestEnabledClient(
|
|
625
|
+
app,
|
|
626
|
+
di=lightbulb_client.di,
|
|
627
|
+
register_app_dependencies=False,
|
|
628
|
+
warn_shared_listener=warn_shared_listener,
|
|
629
|
+
**options,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
msg = "the lightbulb client's app has neither an event manager nor an interaction server"
|
|
633
|
+
raise TypeError(msg)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _store_slots(
|
|
637
|
+
store: protocol.Store | None,
|
|
638
|
+
stores: collections.abc.Mapping[str, protocol.Store],
|
|
639
|
+
) -> dict[str, protocol.Store]:
|
|
640
|
+
if store is None:
|
|
641
|
+
return dict(stores)
|
|
642
|
+
if placement_.DEFAULT_STORE in stores:
|
|
643
|
+
msg = f"the {placement_.DEFAULT_STORE!r} store was given twice, as store= and in stores=; pass it one way"
|
|
644
|
+
raise ValueError(msg)
|
|
645
|
+
return {placement_.DEFAULT_STORE: store, **stores}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _missing_store(name: str) -> str:
|
|
649
|
+
if name == placement_.DEFAULT_STORE:
|
|
650
|
+
return (
|
|
651
|
+
"it keeps its state in the default store, but this client has none; pass store=... to the"
|
|
652
|
+
" client - store=risa.MemoryStore() for development"
|
|
653
|
+
)
|
|
654
|
+
return (
|
|
655
|
+
f"it keeps its state in store {name!r}, but this client has no store of that name; pass"
|
|
656
|
+
f" stores={{{name!r}: ...}} to the client"
|
|
657
|
+
)
|