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/context.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
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 contexts handlers and hooks answer interactions through."""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import enum
|
|
26
|
+
import typing
|
|
27
|
+
|
|
28
|
+
import hikari
|
|
29
|
+
import msgspec
|
|
30
|
+
|
|
31
|
+
from risa import errors
|
|
32
|
+
from risa.ui import build as build_
|
|
33
|
+
|
|
34
|
+
if typing.TYPE_CHECKING:
|
|
35
|
+
import collections.abc
|
|
36
|
+
|
|
37
|
+
from hikari.api import special_endpoints
|
|
38
|
+
|
|
39
|
+
from risa import ui
|
|
40
|
+
from risa import view as view_
|
|
41
|
+
from risa.internal import registry
|
|
42
|
+
from risa.state import backend
|
|
43
|
+
|
|
44
|
+
__all__ = (
|
|
45
|
+
"ClickFailure",
|
|
46
|
+
"ComponentContext",
|
|
47
|
+
"Context",
|
|
48
|
+
"ErrorHandler",
|
|
49
|
+
"FailureStage",
|
|
50
|
+
"Response",
|
|
51
|
+
"ResponseGate",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
type ErrorHandler = collections.abc.Callable[
|
|
55
|
+
typing.Concatenate[ClickFailure, ...],
|
|
56
|
+
collections.abc.Awaitable[bool],
|
|
57
|
+
]
|
|
58
|
+
"""A function `Client.error_handler` registers: it takes a `ClickFailure`, then anything to inject."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FailureStage(enum.StrEnum):
|
|
62
|
+
"""Where a click failed, as `ClickFailure.stage` names it.
|
|
63
|
+
|
|
64
|
+
Attributes
|
|
65
|
+
----------
|
|
66
|
+
HANDLER
|
|
67
|
+
The handler raised.
|
|
68
|
+
LOAD
|
|
69
|
+
The view's `load` raised, so the handler never ran.
|
|
70
|
+
ON_OUTDATED
|
|
71
|
+
`View.on_outdated` raised.
|
|
72
|
+
ON_STATE_MISSING
|
|
73
|
+
`View.on_state_missing` raised.
|
|
74
|
+
STATE
|
|
75
|
+
The view's state could not be reached before any of the bot's code ran: the store
|
|
76
|
+
failed or is missing, its lock timed out, or the codec's extra is not installed.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
HANDLER = enum.auto()
|
|
80
|
+
LOAD = enum.auto()
|
|
81
|
+
ON_OUTDATED = enum.auto()
|
|
82
|
+
ON_STATE_MISSING = enum.auto()
|
|
83
|
+
STATE = enum.auto()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _InitialResponse(enum.StrEnum):
|
|
87
|
+
NONE = enum.auto()
|
|
88
|
+
DEFERRED_UPDATE = enum.auto()
|
|
89
|
+
DEFERRED_THINKING = enum.auto()
|
|
90
|
+
MESSAGE_CREATE = enum.auto()
|
|
91
|
+
MESSAGE_UPDATE = enum.auto()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ResponseGate(msgspec.Struct):
|
|
95
|
+
"""Whether an interaction has been answered, shared by everything that answers it.
|
|
96
|
+
|
|
97
|
+
risa makes one per interaction, so a handler and the auto-defer watchdog never both
|
|
98
|
+
send the initial response.
|
|
99
|
+
|
|
100
|
+
Attributes
|
|
101
|
+
----------
|
|
102
|
+
lock
|
|
103
|
+
Held while the initial response is decided and sent.
|
|
104
|
+
acknowledged
|
|
105
|
+
Set once nothing more is owed to the REST listener: the initial response went out,
|
|
106
|
+
or risa has finished with the interaction.
|
|
107
|
+
response
|
|
108
|
+
Which initial response the interaction got.
|
|
109
|
+
adopted
|
|
110
|
+
Whether the click routed to a view, rather than being left to another listener.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
lock: asyncio.Lock = msgspec.field(default_factory=asyncio.Lock)
|
|
114
|
+
acknowledged: asyncio.Event = msgspec.field(default_factory=asyncio.Event)
|
|
115
|
+
response: _InitialResponse = msgspec.field(default=_InitialResponse.NONE)
|
|
116
|
+
adopted: bool = False
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def responded(self) -> bool:
|
|
120
|
+
"""Whether the interaction has its initial response."""
|
|
121
|
+
return self.response is not _InitialResponse.NONE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ViewSlot(msgspec.Struct):
|
|
125
|
+
view: view_.View | None = None
|
|
126
|
+
session: backend.StateSession | None = None
|
|
127
|
+
overflowed: set[bytes] = msgspec.field(default_factory=set[bytes])
|
|
128
|
+
replaced: bool = False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Response:
|
|
132
|
+
"""A message sent with `respond()`, to fetch, edit or delete later."""
|
|
133
|
+
|
|
134
|
+
__slots__ = ("_interaction", "_message", "_rest")
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
rest: hikari.api.RESTClient,
|
|
139
|
+
interaction: hikari.ComponentInteraction | hikari.ModalInteraction,
|
|
140
|
+
message: hikari.Message | None,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._rest = rest
|
|
143
|
+
self._interaction = interaction
|
|
144
|
+
self._message = message
|
|
145
|
+
|
|
146
|
+
async def fetch(self) -> hikari.Message:
|
|
147
|
+
"""Fetch the message as it is now."""
|
|
148
|
+
if self._message is not None:
|
|
149
|
+
return await self._rest.fetch_webhook_message(
|
|
150
|
+
self._interaction.application_id,
|
|
151
|
+
self._interaction.token,
|
|
152
|
+
self._message.id,
|
|
153
|
+
)
|
|
154
|
+
return await self._rest.fetch_interaction_response(self._interaction.application_id, self._interaction.token)
|
|
155
|
+
|
|
156
|
+
async def delete(self) -> None:
|
|
157
|
+
"""Delete the message."""
|
|
158
|
+
if self._message is not None:
|
|
159
|
+
await self._rest.delete_webhook_message(
|
|
160
|
+
self._interaction.application_id,
|
|
161
|
+
self._interaction.token,
|
|
162
|
+
self._message.id,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
await self._rest.delete_interaction_response(self._interaction.application_id, self._interaction.token)
|
|
166
|
+
|
|
167
|
+
async def edit( # ruff:ignore[too-many-arguments]
|
|
168
|
+
self,
|
|
169
|
+
content: hikari.UndefinedNoneOr[str] = hikari.UNDEFINED,
|
|
170
|
+
*,
|
|
171
|
+
attachment: hikari.UndefinedNoneOr[hikari.Resourceish | hikari.Attachment] = hikari.UNDEFINED,
|
|
172
|
+
attachments: hikari.UndefinedNoneOr[
|
|
173
|
+
collections.abc.Sequence[hikari.Resourceish | hikari.Attachment]
|
|
174
|
+
] = hikari.UNDEFINED,
|
|
175
|
+
component: hikari.UndefinedNoneOr[hikari.api.ComponentBuilder] = hikari.UNDEFINED,
|
|
176
|
+
components: hikari.UndefinedNoneOr[collections.abc.Sequence[hikari.api.ComponentBuilder]] = hikari.UNDEFINED,
|
|
177
|
+
embed: hikari.UndefinedNoneOr[hikari.Embed] = hikari.UNDEFINED,
|
|
178
|
+
embeds: hikari.UndefinedNoneOr[collections.abc.Sequence[hikari.Embed]] = hikari.UNDEFINED,
|
|
179
|
+
mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED,
|
|
180
|
+
user_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialUser] | bool] = hikari.UNDEFINED,
|
|
181
|
+
role_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialRole] | bool] = hikari.UNDEFINED,
|
|
182
|
+
) -> hikari.Message:
|
|
183
|
+
"""Edit the message.
|
|
184
|
+
|
|
185
|
+
The parameters are those of hikari's
|
|
186
|
+
[`edit_interaction_response`][hikari.api.rest.RESTClient.edit_interaction_response], which
|
|
187
|
+
describes them in full. Every edit sends the mention settings, so one left out switches
|
|
188
|
+
that mention parsing off.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
content
|
|
193
|
+
The new text. `None` removes it.
|
|
194
|
+
attachment
|
|
195
|
+
One file. Not together with `attachments`.
|
|
196
|
+
attachments
|
|
197
|
+
The files to keep or add. `None` removes them all.
|
|
198
|
+
component
|
|
199
|
+
One builder, sent as it is: risa adds no routing or state. Not together with
|
|
200
|
+
`components`.
|
|
201
|
+
components
|
|
202
|
+
The builders, sent as they are. `None` or an empty sequence removes them.
|
|
203
|
+
embed
|
|
204
|
+
One embed. Not together with `embeds`.
|
|
205
|
+
embeds
|
|
206
|
+
The embeds. `None` or an empty sequence removes them.
|
|
207
|
+
mentions_everyone
|
|
208
|
+
Whether `@everyone` and `@here` are parsed.
|
|
209
|
+
user_mentions
|
|
210
|
+
`True` for every user, or the users to parse.
|
|
211
|
+
role_mentions
|
|
212
|
+
`True` for every role, or the roles to parse.
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
:
|
|
217
|
+
The message as it is now.
|
|
218
|
+
"""
|
|
219
|
+
if self._message is not None:
|
|
220
|
+
return await self._rest.edit_webhook_message(
|
|
221
|
+
self._interaction.application_id,
|
|
222
|
+
self._interaction.token,
|
|
223
|
+
self._message.id,
|
|
224
|
+
content,
|
|
225
|
+
attachment=attachment,
|
|
226
|
+
attachments=attachments,
|
|
227
|
+
component=component,
|
|
228
|
+
components=components,
|
|
229
|
+
embed=embed,
|
|
230
|
+
embeds=embeds,
|
|
231
|
+
mentions_everyone=mentions_everyone,
|
|
232
|
+
user_mentions=user_mentions,
|
|
233
|
+
role_mentions=role_mentions,
|
|
234
|
+
)
|
|
235
|
+
return await self._rest.edit_interaction_response(
|
|
236
|
+
self._interaction.application_id,
|
|
237
|
+
self._interaction.token,
|
|
238
|
+
content,
|
|
239
|
+
attachment=attachment,
|
|
240
|
+
attachments=attachments,
|
|
241
|
+
component=component,
|
|
242
|
+
components=components,
|
|
243
|
+
embed=embed,
|
|
244
|
+
embeds=embeds,
|
|
245
|
+
mentions_everyone=mentions_everyone,
|
|
246
|
+
user_mentions=user_mentions,
|
|
247
|
+
role_mentions=role_mentions,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Context[T: hikari.ComponentInteraction | hikari.ModalInteraction]:
|
|
252
|
+
"""What a handler or hook gets for the interaction it answers."""
|
|
253
|
+
|
|
254
|
+
__slots__ = ("_gate", "_interaction", "_rest")
|
|
255
|
+
|
|
256
|
+
def __init__(self, interaction: T, *, rest: hikari.api.RESTClient, gate: ResponseGate | None = None) -> None:
|
|
257
|
+
self._interaction = interaction
|
|
258
|
+
self._rest = rest
|
|
259
|
+
self._gate = gate if gate is not None else ResponseGate()
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def responded(self) -> bool:
|
|
263
|
+
"""Whether the interaction has its initial response."""
|
|
264
|
+
return self._gate.responded
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def rest(self) -> hikari.api.RESTClient:
|
|
268
|
+
"""The REST client risa answers through."""
|
|
269
|
+
return self._rest
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def interaction(self) -> T:
|
|
273
|
+
"""The interaction being answered."""
|
|
274
|
+
return self._interaction
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def custom_id(self) -> str:
|
|
278
|
+
"""The `custom_id` of the component that was used."""
|
|
279
|
+
return self._interaction.custom_id
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def user(self) -> hikari.User:
|
|
283
|
+
"""The user who used the component."""
|
|
284
|
+
return self._interaction.user
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def member(self) -> hikari.InteractionMember | None:
|
|
288
|
+
"""The member who used the component, or `None` outside a guild."""
|
|
289
|
+
return self._interaction.member
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def channel_id(self) -> hikari.Snowflake:
|
|
293
|
+
"""The id of the channel the interaction happened in."""
|
|
294
|
+
return self._interaction.channel_id
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def guild_id(self) -> hikari.Snowflake | None:
|
|
298
|
+
"""The id of the guild the interaction happened in, or `None` outside a guild."""
|
|
299
|
+
return self._interaction.guild_id
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def message(self) -> hikari.Message | None:
|
|
303
|
+
"""The message the component is on, if any."""
|
|
304
|
+
return self._interaction.message
|
|
305
|
+
|
|
306
|
+
async def defer(self, *, thinking: bool = False, ephemeral: bool = False) -> None:
|
|
307
|
+
"""Acknowledge the interaction now and answer it later.
|
|
308
|
+
|
|
309
|
+
By default the acknowledgement is silent, and the answer is a `rerender()` or an
|
|
310
|
+
`edit()` of the message. With `thinking=True`, Discord shows a "thinking..." message
|
|
311
|
+
that the next `respond()` takes the place of.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
thinking
|
|
316
|
+
Show a "thinking..." message.
|
|
317
|
+
ephemeral
|
|
318
|
+
Show that message only to the user who clicked.
|
|
319
|
+
|
|
320
|
+
Raises
|
|
321
|
+
------
|
|
322
|
+
AlreadyRespondedError
|
|
323
|
+
If the interaction already has its initial response.
|
|
324
|
+
"""
|
|
325
|
+
async with self._gate.lock:
|
|
326
|
+
if self._gate.response is not _InitialResponse.NONE:
|
|
327
|
+
attempted = "defer"
|
|
328
|
+
raise errors.AlreadyRespondedError(attempted, self._gate.response.name)
|
|
329
|
+
await self._rest.create_interaction_response(
|
|
330
|
+
self._interaction.id,
|
|
331
|
+
self._interaction.token,
|
|
332
|
+
hikari.ResponseType.DEFERRED_MESSAGE_CREATE
|
|
333
|
+
if thinking
|
|
334
|
+
else hikari.ResponseType.DEFERRED_MESSAGE_UPDATE,
|
|
335
|
+
flags=hikari.MessageFlag.EPHEMERAL if ephemeral else hikari.UNDEFINED,
|
|
336
|
+
)
|
|
337
|
+
self._record_initial(_InitialResponse.DEFERRED_THINKING if thinking else _InitialResponse.DEFERRED_UPDATE)
|
|
338
|
+
|
|
339
|
+
async def acknowledge(self, *, thinking: bool = False, ephemeral: bool = False) -> None:
|
|
340
|
+
"""Acknowledge the interaction like `defer`, unless it already has its initial response.
|
|
341
|
+
|
|
342
|
+
Where `defer` raises, this does nothing, so it is safe to call whatever happened
|
|
343
|
+
before.
|
|
344
|
+
|
|
345
|
+
Parameters
|
|
346
|
+
----------
|
|
347
|
+
thinking
|
|
348
|
+
Show a "thinking..." message.
|
|
349
|
+
ephemeral
|
|
350
|
+
Show that message only to the user who clicked.
|
|
351
|
+
"""
|
|
352
|
+
async with self._gate.lock:
|
|
353
|
+
if self._gate.response is not _InitialResponse.NONE:
|
|
354
|
+
return
|
|
355
|
+
await self._rest.create_interaction_response(
|
|
356
|
+
self._interaction.id,
|
|
357
|
+
self._interaction.token,
|
|
358
|
+
hikari.ResponseType.DEFERRED_MESSAGE_CREATE
|
|
359
|
+
if thinking
|
|
360
|
+
else hikari.ResponseType.DEFERRED_MESSAGE_UPDATE,
|
|
361
|
+
flags=hikari.MessageFlag.EPHEMERAL if ephemeral else hikari.UNDEFINED,
|
|
362
|
+
)
|
|
363
|
+
self._record_initial(_InitialResponse.DEFERRED_THINKING if thinking else _InitialResponse.DEFERRED_UPDATE)
|
|
364
|
+
|
|
365
|
+
async def respond( # ruff:ignore[too-many-arguments]
|
|
366
|
+
self,
|
|
367
|
+
content: hikari.UndefinedOr[str] = hikari.UNDEFINED,
|
|
368
|
+
*,
|
|
369
|
+
ephemeral: bool = False,
|
|
370
|
+
tts: hikari.UndefinedOr[bool] = hikari.UNDEFINED,
|
|
371
|
+
attachment: hikari.UndefinedOr[hikari.Resourceish] = hikari.UNDEFINED,
|
|
372
|
+
attachments: hikari.UndefinedOr[collections.abc.Sequence[hikari.Resourceish]] = hikari.UNDEFINED,
|
|
373
|
+
component: hikari.UndefinedOr[hikari.api.ComponentBuilder] = hikari.UNDEFINED,
|
|
374
|
+
components: hikari.UndefinedOr[collections.abc.Sequence[hikari.api.ComponentBuilder]] = hikari.UNDEFINED,
|
|
375
|
+
embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED,
|
|
376
|
+
embeds: hikari.UndefinedOr[collections.abc.Sequence[hikari.Embed]] = hikari.UNDEFINED,
|
|
377
|
+
mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED,
|
|
378
|
+
user_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialUser] | bool] = hikari.UNDEFINED,
|
|
379
|
+
role_mentions: hikari.UndefinedOr[hikari.SnowflakeishSequence[hikari.PartialRole] | bool] = hikari.UNDEFINED,
|
|
380
|
+
) -> Response:
|
|
381
|
+
"""Send a message in answer to the interaction.
|
|
382
|
+
|
|
383
|
+
The first response answers the interaction itself; every later one is a follow-up
|
|
384
|
+
message. After a thinking message, the first one replaces it, and keeps its visibility
|
|
385
|
+
whatever `ephemeral` and `tts` say. Apart from `ephemeral`, the parameters are those of hikari's
|
|
386
|
+
[`create_interaction_response`][hikari.api.rest.RESTClient.create_interaction_response]
|
|
387
|
+
and [`execute_webhook`][hikari.api.rest.RESTClient.execute_webhook], which describe them
|
|
388
|
+
in full.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
content
|
|
393
|
+
The message text.
|
|
394
|
+
ephemeral
|
|
395
|
+
Show the message only to the user who clicked.
|
|
396
|
+
tts
|
|
397
|
+
Have Discord read the message aloud.
|
|
398
|
+
attachment
|
|
399
|
+
One file. Not together with `attachments`.
|
|
400
|
+
attachments
|
|
401
|
+
The files to attach.
|
|
402
|
+
component
|
|
403
|
+
One builder, sent as it is: risa adds no routing or state. To send a view, spread
|
|
404
|
+
`await client.build(view)`. Not together with `components`.
|
|
405
|
+
components
|
|
406
|
+
The builders, sent as they are.
|
|
407
|
+
embed
|
|
408
|
+
One embed. Not together with `embeds`.
|
|
409
|
+
embeds
|
|
410
|
+
The embeds.
|
|
411
|
+
mentions_everyone
|
|
412
|
+
Whether `@everyone` and `@here` are parsed.
|
|
413
|
+
user_mentions
|
|
414
|
+
`True` for every user, or the users to parse.
|
|
415
|
+
role_mentions
|
|
416
|
+
`True` for every role, or the roles to parse.
|
|
417
|
+
|
|
418
|
+
Returns
|
|
419
|
+
-------
|
|
420
|
+
:
|
|
421
|
+
The message, to fetch, edit or delete later.
|
|
422
|
+
"""
|
|
423
|
+
flags = hikari.MessageFlag.EPHEMERAL if ephemeral else hikari.UNDEFINED
|
|
424
|
+
|
|
425
|
+
async with self._gate.lock:
|
|
426
|
+
if self._gate.response is _InitialResponse.NONE:
|
|
427
|
+
await self._rest.create_interaction_response(
|
|
428
|
+
self._interaction.id,
|
|
429
|
+
self._interaction.token,
|
|
430
|
+
hikari.ResponseType.MESSAGE_CREATE,
|
|
431
|
+
content,
|
|
432
|
+
flags=flags,
|
|
433
|
+
tts=tts,
|
|
434
|
+
attachment=attachment,
|
|
435
|
+
attachments=attachments,
|
|
436
|
+
component=component,
|
|
437
|
+
components=components,
|
|
438
|
+
embed=embed,
|
|
439
|
+
embeds=embeds,
|
|
440
|
+
mentions_everyone=mentions_everyone,
|
|
441
|
+
user_mentions=user_mentions,
|
|
442
|
+
role_mentions=role_mentions,
|
|
443
|
+
)
|
|
444
|
+
self._record_initial(_InitialResponse.MESSAGE_CREATE)
|
|
445
|
+
return Response(self._rest, self._interaction, None)
|
|
446
|
+
|
|
447
|
+
if self._gate.response is _InitialResponse.DEFERRED_THINKING:
|
|
448
|
+
await self._rest.edit_interaction_response(
|
|
449
|
+
self._interaction.application_id,
|
|
450
|
+
self._interaction.token,
|
|
451
|
+
content,
|
|
452
|
+
attachment=attachment,
|
|
453
|
+
attachments=attachments,
|
|
454
|
+
component=component,
|
|
455
|
+
components=components,
|
|
456
|
+
embed=embed,
|
|
457
|
+
embeds=embeds,
|
|
458
|
+
mentions_everyone=mentions_everyone,
|
|
459
|
+
user_mentions=user_mentions,
|
|
460
|
+
role_mentions=role_mentions,
|
|
461
|
+
)
|
|
462
|
+
self._record_initial(_InitialResponse.MESSAGE_CREATE)
|
|
463
|
+
return Response(self._rest, self._interaction, None)
|
|
464
|
+
|
|
465
|
+
message = await self._rest.execute_webhook(
|
|
466
|
+
self._interaction.application_id,
|
|
467
|
+
self._interaction.token,
|
|
468
|
+
content,
|
|
469
|
+
flags=flags,
|
|
470
|
+
tts=tts,
|
|
471
|
+
attachment=attachment,
|
|
472
|
+
attachments=attachments,
|
|
473
|
+
component=component,
|
|
474
|
+
components=components,
|
|
475
|
+
embed=embed,
|
|
476
|
+
embeds=embeds,
|
|
477
|
+
mentions_everyone=mentions_everyone,
|
|
478
|
+
user_mentions=user_mentions,
|
|
479
|
+
role_mentions=role_mentions,
|
|
480
|
+
)
|
|
481
|
+
return Response(self._rest, self._interaction, message)
|
|
482
|
+
|
|
483
|
+
def _record_initial(self, response: _InitialResponse) -> None:
|
|
484
|
+
self._gate.response = response
|
|
485
|
+
self._gate.acknowledged.set()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class ComponentContext(Context[hikari.ComponentInteraction]):
|
|
489
|
+
"""The context of a click on one of a view's components.
|
|
490
|
+
|
|
491
|
+
A handler answers through it: `rerender()` redraws the message from the view's
|
|
492
|
+
state, `edit(...)` replaces it with another layout, and `respond(...)` sends a new
|
|
493
|
+
message. risa makes one per click.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
__slots__ = ("_build", "_meta", "_slot")
|
|
497
|
+
|
|
498
|
+
def __init__(
|
|
499
|
+
self,
|
|
500
|
+
interaction: hikari.ComponentInteraction,
|
|
501
|
+
*,
|
|
502
|
+
rest: hikari.api.RESTClient,
|
|
503
|
+
meta: registry.ViewMeta,
|
|
504
|
+
build: collections.abc.Callable[[view_.View, hikari.Snowflake], collections.abc.Awaitable[build_.Rendered]],
|
|
505
|
+
gate: ResponseGate | None = None,
|
|
506
|
+
slot: ViewSlot | None = None,
|
|
507
|
+
) -> None:
|
|
508
|
+
super().__init__(interaction, rest=rest, gate=gate)
|
|
509
|
+
self._meta = meta
|
|
510
|
+
self._build = build
|
|
511
|
+
self._slot = slot if slot is not None else ViewSlot()
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
@typing.override
|
|
515
|
+
def message(self) -> hikari.Message:
|
|
516
|
+
return self._interaction.message
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def values(self) -> collections.abc.Sequence[str]:
|
|
520
|
+
"""The values picked in a select menu; empty for a button."""
|
|
521
|
+
return self._interaction.values
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def resolved(self) -> hikari.ResolvedOptionData | None:
|
|
525
|
+
"""The users, members, roles and channels picked in an entity select menu, or `None`."""
|
|
526
|
+
return self._interaction.resolved
|
|
527
|
+
|
|
528
|
+
async def edit(self, layout: ui.Layout, /) -> None:
|
|
529
|
+
"""Replace the message with `layout`, keeping the view's state on it.
|
|
530
|
+
|
|
531
|
+
For a screen the view's `render` does not draw, such as a confirmation or a closing
|
|
532
|
+
message. A layout without interactive components ends the view, since nothing on it
|
|
533
|
+
can be clicked any more. Like `rerender()`, it saves the view's state.
|
|
534
|
+
|
|
535
|
+
Parameters
|
|
536
|
+
----------
|
|
537
|
+
layout
|
|
538
|
+
The new layout: one top-level component, or several in order.
|
|
539
|
+
|
|
540
|
+
Raises
|
|
541
|
+
------
|
|
542
|
+
NoViewError
|
|
543
|
+
If the layout has interactive components and the view has durable fields, but this
|
|
544
|
+
context has no view state, as in `on_outdated` and `on_state_missing`, or if
|
|
545
|
+
`replace()` gave the message to another view.
|
|
546
|
+
EphemeralOriginError
|
|
547
|
+
If the message is ephemeral and the interaction was answered with a new message.
|
|
548
|
+
StateOverflowError
|
|
549
|
+
If an `InMessage` view's state does not fit the layout's components.
|
|
550
|
+
StateConflictError
|
|
551
|
+
If another process saved the view's state first. The view then holds that state.
|
|
552
|
+
StateUnreadableError
|
|
553
|
+
If another process saved the view's state first, in a form this code cannot read.
|
|
554
|
+
"""
|
|
555
|
+
if self._slot.replaced:
|
|
556
|
+
raise errors.NoViewError(self._meta.name, "edit() after replace()")
|
|
557
|
+
plan = build_.plan(layout, self._meta)
|
|
558
|
+
view, session = self._slot.view, self._slot.session
|
|
559
|
+
if view is not None and session is not None:
|
|
560
|
+
await self._commit(view, session, plan)
|
|
561
|
+
return
|
|
562
|
+
if plan.interactive and self._meta.schema.durable:
|
|
563
|
+
raise errors.NoViewError(self._meta.name, "edit() with interactive components")
|
|
564
|
+
await self._update_origin(plan.emit(b""))
|
|
565
|
+
|
|
566
|
+
async def replace(self, view: view_.View, /) -> None:
|
|
567
|
+
"""Turn the message into another view, which answers its clicks from here on.
|
|
568
|
+
|
|
569
|
+
It builds `view` as `Client.build` does, so a view in a store gets a record of its own.
|
|
570
|
+
This view's state is not saved, also not when the handler returns, and its record stays
|
|
571
|
+
until its `ttl` runs out. If the message cannot be edited, the new view's record stays
|
|
572
|
+
the same way and this view keeps the message. Afterwards `rerender()` and `edit()` of
|
|
573
|
+
this context raise `NoViewError`, since the message is no longer this view's; `edit`
|
|
574
|
+
only ever shows this view's own components.
|
|
575
|
+
|
|
576
|
+
Parameters
|
|
577
|
+
----------
|
|
578
|
+
view
|
|
579
|
+
The view to show, an instance of a registered class.
|
|
580
|
+
|
|
581
|
+
Raises
|
|
582
|
+
------
|
|
583
|
+
EphemeralOriginError
|
|
584
|
+
If the message is ephemeral and the interaction was answered with a new message.
|
|
585
|
+
NotAViewError
|
|
586
|
+
If the view's class was never registered.
|
|
587
|
+
StateOverflowError
|
|
588
|
+
If an `InMessage` view's state does not fit its components.
|
|
589
|
+
ViewDeclarationError
|
|
590
|
+
If the view keeps its state in a store this client does not have.
|
|
591
|
+
"""
|
|
592
|
+
rendered = await self._build(view, self.message.id)
|
|
593
|
+
await self._update_origin(rendered.components)
|
|
594
|
+
self._slot.view = None
|
|
595
|
+
self._slot.session = None
|
|
596
|
+
self._slot.replaced = True
|
|
597
|
+
|
|
598
|
+
async def rerender(self) -> None:
|
|
599
|
+
"""Redraw the message from the view's current state, and save that state.
|
|
600
|
+
|
|
601
|
+
Every rerender is a commit. `InMessage` state is written into the message itself;
|
|
602
|
+
`InStore` state goes to the store first, then the message is edited. A change made
|
|
603
|
+
without calling it is saved when the handler returns, but that save can only log a
|
|
604
|
+
failure; call it yourself, before any `respond()`, where a failure must be handled.
|
|
605
|
+
|
|
606
|
+
Raises
|
|
607
|
+
------
|
|
608
|
+
NoViewError
|
|
609
|
+
If the view has durable fields but this context has no view state, as in
|
|
610
|
+
`on_outdated` and `on_state_missing`, or if `replace()` gave the message to
|
|
611
|
+
another view.
|
|
612
|
+
EphemeralOriginError
|
|
613
|
+
If the message is ephemeral and the interaction was answered with a new message.
|
|
614
|
+
StateOverflowError
|
|
615
|
+
If an `InMessage` view's state no longer fits its components.
|
|
616
|
+
StateConflictError
|
|
617
|
+
If another process saved the view's state first. The view then holds that state,
|
|
618
|
+
so the handler can apply its change again and rerender.
|
|
619
|
+
StateUnreadableError
|
|
620
|
+
If another process saved the view's state first, in a form this code cannot read.
|
|
621
|
+
StateNotFoundError
|
|
622
|
+
If an `InStore` view's record disappeared while the handler ran.
|
|
623
|
+
"""
|
|
624
|
+
if self._slot.replaced:
|
|
625
|
+
raise errors.NoViewError(self._meta.name, "rerender() after replace()")
|
|
626
|
+
view, session = self._slot.view, self._slot.session
|
|
627
|
+
if view is not None and session is not None:
|
|
628
|
+
await self._commit(view, session, build_.plan(view.render(), self._meta))
|
|
629
|
+
return
|
|
630
|
+
if self._meta.schema.durable:
|
|
631
|
+
raise errors.NoViewError(self._meta.name, "rerender()")
|
|
632
|
+
await self._update_origin(build_.plan(self._meta.cls().render(), self._meta).emit(b""))
|
|
633
|
+
|
|
634
|
+
async def _commit(self, view: view_.View, session: backend.StateSession, plan: build_.Plan) -> None:
|
|
635
|
+
if self._origin_is_out_of_reach():
|
|
636
|
+
raise errors.EphemeralOriginError(self._meta.name)
|
|
637
|
+
state = await session.stage(view)
|
|
638
|
+
try:
|
|
639
|
+
builders = plan.emit(state)
|
|
640
|
+
except errors.StateOverflowError:
|
|
641
|
+
self._slot.overflowed.add(self._meta.schema.pack_view(view))
|
|
642
|
+
raise
|
|
643
|
+
await self._update_origin(builders)
|
|
644
|
+
await session.published(view)
|
|
645
|
+
|
|
646
|
+
async def _update_origin(self, builders: collections.abc.Sequence[special_endpoints.ComponentBuilder]) -> None:
|
|
647
|
+
async with self._gate.lock:
|
|
648
|
+
if self._gate.response is _InitialResponse.NONE:
|
|
649
|
+
await self._rest.create_interaction_response(
|
|
650
|
+
self._interaction.id,
|
|
651
|
+
self._interaction.token,
|
|
652
|
+
hikari.ResponseType.MESSAGE_UPDATE,
|
|
653
|
+
components=builders,
|
|
654
|
+
)
|
|
655
|
+
self._record_initial(_InitialResponse.MESSAGE_UPDATE)
|
|
656
|
+
elif self._gate.response in {_InitialResponse.DEFERRED_UPDATE, _InitialResponse.MESSAGE_UPDATE}:
|
|
657
|
+
await self._rest.edit_interaction_response(
|
|
658
|
+
self._interaction.application_id,
|
|
659
|
+
self._interaction.token,
|
|
660
|
+
components=builders,
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
if self._origin_is_out_of_reach():
|
|
664
|
+
raise errors.EphemeralOriginError(self._meta.name)
|
|
665
|
+
await self._rest.edit_message(self.message.channel_id, self.message.id, components=builders)
|
|
666
|
+
|
|
667
|
+
def _origin_is_out_of_reach(self) -> bool:
|
|
668
|
+
answered_elsewhere = self._gate.response in {
|
|
669
|
+
_InitialResponse.DEFERRED_THINKING,
|
|
670
|
+
_InitialResponse.MESSAGE_CREATE,
|
|
671
|
+
}
|
|
672
|
+
return answered_elsewhere and hikari.MessageFlag.EPHEMERAL in self.message.flags
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
class ClickFailure(msgspec.Struct, frozen=True):
|
|
676
|
+
"""A click that failed, as the functions `Client.error_handler` registers receive it.
|
|
677
|
+
|
|
678
|
+
Attributes
|
|
679
|
+
----------
|
|
680
|
+
ctx
|
|
681
|
+
The context of the click. It has no view state, as in `View.on_outdated`: answer
|
|
682
|
+
with `respond`, `edit` without interactive components or `replace`, and on a view
|
|
683
|
+
with durable fields `rerender()` raises `NoViewError`. `respond` continues what was
|
|
684
|
+
sent before the failure, as it would in the handler.
|
|
685
|
+
error
|
|
686
|
+
The exception.
|
|
687
|
+
stage
|
|
688
|
+
Where it was raised.
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
ctx: ComponentContext
|
|
692
|
+
error: Exception
|
|
693
|
+
stage: FailureStage
|