kimi-agent-module-api 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.
- kimi_agent_module_api/__init__.py +175 -0
- kimi_agent_module_api/contracts.py +1127 -0
- kimi_agent_module_api/events.py +140 -0
- kimi_agent_module_api/images.py +34 -0
- kimi_agent_module_api/py.typed +1 -0
- kimi_agent_module_api/settings.py +33 -0
- kimi_agent_module_api/testing.py +1036 -0
- kimi_agent_module_api/tools.py +63 -0
- kimi_agent_module_api/trust.py +36 -0
- kimi_agent_module_api-1.0.0.dist-info/METADATA +51 -0
- kimi_agent_module_api-1.0.0.dist-info/RECORD +14 -0
- kimi_agent_module_api-1.0.0.dist-info/WHEEL +5 -0
- kimi_agent_module_api-1.0.0.dist-info/licenses/LICENSE +21 -0
- kimi_agent_module_api-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
"""Module API contracts: declarations, service ports, and validation rules.
|
|
2
|
+
|
|
3
|
+
Everything here is a shape or a pure rule. Core implements the Protocols in
|
|
4
|
+
``modules/``; external packages import only this module and its siblings. This
|
|
5
|
+
file must stay free of Discord SDK, database, and core runtime imports so a
|
|
6
|
+
module's declarations can be validated without booting anything.
|
|
7
|
+
|
|
8
|
+
Modules are trusted, in-process code. Declarations are audited through the
|
|
9
|
+
owner manifest and enforced through the ports below; they are not a sandbox.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Literal, Protocol, TypeVar, overload
|
|
20
|
+
|
|
21
|
+
_T = TypeVar("_T")
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------
|
|
24
|
+
# Errors
|
|
25
|
+
# --------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModuleContractError(ValueError):
|
|
29
|
+
"""A module declaration or call violates the module contract."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UndeclaredDiscordAction(ModuleContractError):
|
|
33
|
+
def __init__(self, module_name: str, action: str) -> None:
|
|
34
|
+
super().__init__(f"module {module_name!r} did not declare Discord action {action!r}")
|
|
35
|
+
self.module_name = module_name
|
|
36
|
+
self.action = action
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EventTopicError(ModuleContractError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HostNotAllowed(ModuleContractError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ResponseTooLarge(ModuleContractError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ServiceUnavailable(RuntimeError):
|
|
52
|
+
"""Raised through a service proxy after its provider module closed."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# Declarations carried on ModuleSpec
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
type DiscordAction = Literal[
|
|
60
|
+
"send_message",
|
|
61
|
+
"send_dm",
|
|
62
|
+
"edit_message",
|
|
63
|
+
"delete_message",
|
|
64
|
+
"ban",
|
|
65
|
+
"kick",
|
|
66
|
+
"timeout",
|
|
67
|
+
"fetch_message",
|
|
68
|
+
"fetch_member",
|
|
69
|
+
"fetch_channel",
|
|
70
|
+
"fetch_messages",
|
|
71
|
+
"fetch_pins",
|
|
72
|
+
"fetch_public_threads",
|
|
73
|
+
"fetch_roles",
|
|
74
|
+
"fetch_invites",
|
|
75
|
+
"can_view_channel",
|
|
76
|
+
]
|
|
77
|
+
ALL_DISCORD_ACTIONS: frozenset[str] = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"send_message",
|
|
80
|
+
"send_dm",
|
|
81
|
+
"edit_message",
|
|
82
|
+
"delete_message",
|
|
83
|
+
"ban",
|
|
84
|
+
"kick",
|
|
85
|
+
"timeout",
|
|
86
|
+
"fetch_message",
|
|
87
|
+
"fetch_member",
|
|
88
|
+
"fetch_channel",
|
|
89
|
+
"fetch_messages",
|
|
90
|
+
"fetch_pins",
|
|
91
|
+
"fetch_public_threads",
|
|
92
|
+
"fetch_roles",
|
|
93
|
+
"fetch_invites",
|
|
94
|
+
"can_view_channel",
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
# Actions that act on a member and therefore run the core target policy.
|
|
98
|
+
TARGETED_DISCORD_ACTIONS: frozenset[str] = frozenset({"ban", "kick", "timeout"})
|
|
99
|
+
|
|
100
|
+
type NetworkPolicy = Literal["public", "private"]
|
|
101
|
+
|
|
102
|
+
_HOST_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9-]{1,63}$")
|
|
103
|
+
_SETTING_REF_RE = re.compile(r"^\$\{([a-z][a-z0-9_]{0,63})\}$")
|
|
104
|
+
DISCORD_CDN_TOKEN = "discord-cdn"
|
|
105
|
+
DISCORD_CDN_HOSTS: frozenset[str] = frozenset({"cdn.discordapp.com", "media.discordapp.net"})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class HttpHostRule:
|
|
110
|
+
"""One outbound destination a module declares.
|
|
111
|
+
|
|
112
|
+
``host`` is an exact lowercase hostname, the literal ``discord-cdn`` token,
|
|
113
|
+
or ``${setting_name}`` resolved from the module's prepared settings at load.
|
|
114
|
+
``private`` permits an exact non-public address only for that host; it never
|
|
115
|
+
widens to a network range. Cloud metadata endpoints stay blocked regardless.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
host: str
|
|
119
|
+
schemes: tuple[str, ...] = ("https",)
|
|
120
|
+
ports: tuple[int, ...] = ()
|
|
121
|
+
network: NetworkPolicy = "public"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def setting_name(self) -> str | None:
|
|
125
|
+
match = _SETTING_REF_RE.match(self.host)
|
|
126
|
+
return match.group(1) if match else None
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def is_discord_cdn(self) -> bool:
|
|
130
|
+
return self.host == DISCORD_CDN_TOKEN
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class ModulePermissions:
|
|
135
|
+
discord_actions: frozenset[str] = frozenset()
|
|
136
|
+
event_topics: tuple[str, ...] = ()
|
|
137
|
+
http_hosts: tuple[HttpHostRule, ...] = ()
|
|
138
|
+
override_target_policy: bool = False
|
|
139
|
+
raw_bot: bool = False
|
|
140
|
+
raw_storage: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class ServiceDeclaration:
|
|
145
|
+
name: str
|
|
146
|
+
version: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class ServiceRequirement:
|
|
151
|
+
name: str
|
|
152
|
+
version: int
|
|
153
|
+
provider: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
type GuildSettingKind = Literal["int", "id", "id_list", "str", "str_list", "enum", "bool"]
|
|
157
|
+
type InvalidPolicy = Literal["disable_module", "disable_guild"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class GuildSettingField:
|
|
162
|
+
name: str
|
|
163
|
+
kind: GuildSettingKind
|
|
164
|
+
required: bool = False
|
|
165
|
+
default: Any = None
|
|
166
|
+
choices: tuple[str, ...] = ()
|
|
167
|
+
help: str = ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class GuildSettingsSchema:
|
|
172
|
+
fields: tuple[GuildSettingField, ...]
|
|
173
|
+
invalid_policy: InvalidPolicy = "disable_guild"
|
|
174
|
+
validate: Callable[[Mapping[str, Any]], Sequence[str]] | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_GUILD_ID_RE = re.compile(r"^\d{1,25}$")
|
|
178
|
+
_GUILD_SETTING_LIST_MAX = 512
|
|
179
|
+
_GUILD_SETTING_STRING_MAX = 2_000
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _render_scalar(key: str, value: Any) -> str:
|
|
183
|
+
if isinstance(value, bool):
|
|
184
|
+
return "true" if value else "false"
|
|
185
|
+
if isinstance(value, int):
|
|
186
|
+
return str(value)
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
return _quote(value)
|
|
189
|
+
raise TypeError(f"guild setting {key!r} has unrenderable value {value!r}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _quote(text: str) -> str:
|
|
193
|
+
"""YAML double-quoted scalar for any Python string.
|
|
194
|
+
|
|
195
|
+
JSON string syntax is valid YAML double-quoted syntax, which handles
|
|
196
|
+
colons, hashes, quotes, newlines, and words like ``true``. YAML also
|
|
197
|
+
forbids raw C1 controls, DEL, surrogates, and the two non-characters
|
|
198
|
+
that JSON leaves unescaped, so those are written as ``\\uXXXX`` too.
|
|
199
|
+
"""
|
|
200
|
+
escaped = json.dumps(text, ensure_ascii=False)
|
|
201
|
+
return "".join(f"\\u{ord(ch):04x}" if _yaml_unprintable(ch) else ch for ch in escaped)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _yaml_unprintable(ch: str) -> bool:
|
|
205
|
+
"""Characters a YAML double-quoted scalar cannot carry literally.
|
|
206
|
+
|
|
207
|
+
C1 controls and DEL are not printable; surrogates and the two
|
|
208
|
+
non-characters are invalid; U+2028/U+2029 are YAML line breaks that would
|
|
209
|
+
be folded together with surrounding spaces; the BOM is a stream marker.
|
|
210
|
+
"""
|
|
211
|
+
code = ord(ch)
|
|
212
|
+
return (
|
|
213
|
+
0x7F <= code <= 0x9F
|
|
214
|
+
or 0xD800 <= code <= 0xDFFF
|
|
215
|
+
or code in (0x2028, 0x2029, 0xFEFF, 0xFFFE, 0xFFFF)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_guild_settings(values: Mapping[str, Any]) -> str:
|
|
220
|
+
"""Render guild settings as the frontmatter-only document the host stores.
|
|
221
|
+
|
|
222
|
+
This is the format of ``<CONFIG_DIR>/guild-modules/<guild_id>/<module>.md``
|
|
223
|
+
and the content a module passes to ``ProposalService.propose`` for a
|
|
224
|
+
``guild:<id>:<module>`` target. Pass the snapshot's ``values`` with your
|
|
225
|
+
change applied: keys are emitted sorted, ``None`` (an unset optional
|
|
226
|
+
field) is omitted, booleans become ``true``/``false``, ids and ints are
|
|
227
|
+
bare, strings are always quoted, and lists use flow style. Invalid field
|
|
228
|
+
names raise ``ValueError``; unsupported values raise ``TypeError``. The
|
|
229
|
+
schema kinds cover every value a snapshot holds.
|
|
230
|
+
"""
|
|
231
|
+
keys = tuple(values)
|
|
232
|
+
for key in keys:
|
|
233
|
+
if not isinstance(key, str) or not _SETTING_NAME_RE.fullmatch(key):
|
|
234
|
+
raise ValueError(f"invalid guild setting name {key!r}")
|
|
235
|
+
|
|
236
|
+
lines = ["---"]
|
|
237
|
+
for key in sorted(keys):
|
|
238
|
+
value = values[key]
|
|
239
|
+
if value is None:
|
|
240
|
+
continue
|
|
241
|
+
if isinstance(value, (list, tuple)):
|
|
242
|
+
rendered = "[" + ", ".join(_render_scalar(key, item) for item in value) + "]"
|
|
243
|
+
else:
|
|
244
|
+
rendered = _render_scalar(key, value)
|
|
245
|
+
lines.append(f"{key}: {rendered}")
|
|
246
|
+
lines.append("---")
|
|
247
|
+
return "\n".join(lines) + "\n"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def coerce_guild_setting_value(field_spec: GuildSettingField, raw: Any) -> tuple[Any, str | None]:
|
|
251
|
+
"""Validate and normalize a configured value or the field's default."""
|
|
252
|
+
if raw is None:
|
|
253
|
+
if field_spec.required:
|
|
254
|
+
return None, f"{field_spec.name} is required"
|
|
255
|
+
if field_spec.default is None:
|
|
256
|
+
return None, None
|
|
257
|
+
raw = field_spec.default
|
|
258
|
+
kind = field_spec.kind
|
|
259
|
+
if kind == "bool":
|
|
260
|
+
if isinstance(raw, bool):
|
|
261
|
+
return raw, None
|
|
262
|
+
return None, f"{field_spec.name} must be true or false"
|
|
263
|
+
if kind == "int":
|
|
264
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
265
|
+
return None, f"{field_spec.name} must be an integer"
|
|
266
|
+
return raw, None
|
|
267
|
+
if kind == "id":
|
|
268
|
+
token = str(raw).strip()
|
|
269
|
+
if not _GUILD_ID_RE.match(token):
|
|
270
|
+
return None, f"{field_spec.name} must be a numeric Discord id"
|
|
271
|
+
return int(token), None
|
|
272
|
+
if kind == "id_list":
|
|
273
|
+
if not isinstance(raw, (list, tuple)):
|
|
274
|
+
return None, f"{field_spec.name} must be a list of Discord ids"
|
|
275
|
+
if len(raw) > _GUILD_SETTING_LIST_MAX:
|
|
276
|
+
return None, f"{field_spec.name} has more than {_GUILD_SETTING_LIST_MAX} entries"
|
|
277
|
+
ids: list[int] = []
|
|
278
|
+
for entry in raw:
|
|
279
|
+
token = str(entry).strip()
|
|
280
|
+
if not _GUILD_ID_RE.match(token):
|
|
281
|
+
return None, f"{field_spec.name} contains a non-numeric id {entry!r}"
|
|
282
|
+
ids.append(int(token))
|
|
283
|
+
return tuple(ids), None
|
|
284
|
+
if kind == "str":
|
|
285
|
+
if not isinstance(raw, str):
|
|
286
|
+
return None, f"{field_spec.name} must be text"
|
|
287
|
+
if len(raw) > _GUILD_SETTING_STRING_MAX:
|
|
288
|
+
return None, (
|
|
289
|
+
f"{field_spec.name} is longer than {_GUILD_SETTING_STRING_MAX} characters"
|
|
290
|
+
)
|
|
291
|
+
return raw, None
|
|
292
|
+
if kind == "str_list":
|
|
293
|
+
if not isinstance(raw, (list, tuple)):
|
|
294
|
+
return None, f"{field_spec.name} must be a list of text values"
|
|
295
|
+
if len(raw) > _GUILD_SETTING_LIST_MAX:
|
|
296
|
+
return None, f"{field_spec.name} has more than {_GUILD_SETTING_LIST_MAX} entries"
|
|
297
|
+
items: list[str] = []
|
|
298
|
+
for entry in raw:
|
|
299
|
+
if not isinstance(entry, str) or len(entry) > _GUILD_SETTING_STRING_MAX:
|
|
300
|
+
return None, f"{field_spec.name} contains an invalid entry {entry!r}"
|
|
301
|
+
items.append(entry)
|
|
302
|
+
return tuple(items), None
|
|
303
|
+
if kind == "enum":
|
|
304
|
+
token = str(raw).strip()
|
|
305
|
+
if token not in field_spec.choices:
|
|
306
|
+
return None, f"{field_spec.name} must be one of {', '.join(field_spec.choices)}"
|
|
307
|
+
return token, None
|
|
308
|
+
return None, f"{field_spec.name} has unsupported kind {kind!r}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --------------------------------------------------------------------------
|
|
312
|
+
# Naming rules shared by declarations and runtime ports
|
|
313
|
+
# --------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
_MODULE_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
316
|
+
# Logical table names a module may ask ``storage.table()`` for.
|
|
317
|
+
TABLE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
318
|
+
_TOPIC_SEGMENT_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
319
|
+
_SERVICE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
|
|
320
|
+
_SETTING_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
321
|
+
CORE_TOPIC_PREFIX = "discord"
|
|
322
|
+
_CORE_RESERVED_MODULE_NAMES = frozenset({CORE_TOPIC_PREFIX, "proposals"})
|
|
323
|
+
CUSTOM_ID_PREFIX = "m"
|
|
324
|
+
CUSTOM_ID_MAX_LENGTH = 100
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def table_prefix(module_name: str) -> str:
|
|
328
|
+
return module_name.replace("-", "_")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def validate_module_name(name: str) -> None:
|
|
332
|
+
if not _MODULE_NAME_RE.match(name):
|
|
333
|
+
raise ModuleContractError(f"invalid module name {name!r}")
|
|
334
|
+
if table_prefix(name) in _CORE_RESERVED_MODULE_NAMES:
|
|
335
|
+
raise ModuleContractError(f"module name {name!r} is reserved by core")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def split_topic(topic: str, *, allow_wildcard: bool = False) -> tuple[str, str]:
|
|
339
|
+
"""Split ``<namespace>.<name>``; ``<namespace>.*`` only where patterns are legal."""
|
|
340
|
+
namespace, sep, name = topic.partition(".")
|
|
341
|
+
name_ok = _TOPIC_SEGMENT_RE.match(name) or (allow_wildcard and name == "*")
|
|
342
|
+
if not sep or not _TOPIC_SEGMENT_RE.match(namespace) or not name_ok:
|
|
343
|
+
raise EventTopicError(f"invalid event topic {topic!r}; expected '<namespace>.<name>'")
|
|
344
|
+
return namespace, name
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def validate_publish_topic(module_name: str, topic: str) -> None:
|
|
348
|
+
namespace, _ = split_topic(topic)
|
|
349
|
+
if namespace == CORE_TOPIC_PREFIX:
|
|
350
|
+
raise EventTopicError(f"event namespace {CORE_TOPIC_PREFIX!r} is reserved by core")
|
|
351
|
+
if namespace != table_prefix(module_name):
|
|
352
|
+
raise EventTopicError(
|
|
353
|
+
f"module {module_name!r} may only publish under {table_prefix(module_name)!r}.*"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def validate_subscription(module_name: str, permissions: ModulePermissions, pattern: str) -> None:
|
|
358
|
+
"""A module may always hear itself; other namespaces need a declaration.
|
|
359
|
+
|
|
360
|
+
``pattern`` is a topic or ``<namespace>.*``. Declared topics use the same
|
|
361
|
+
forms, so a subscription must be covered by an equal or wider declaration.
|
|
362
|
+
"""
|
|
363
|
+
namespace, name = split_topic(pattern, allow_wildcard=True)
|
|
364
|
+
if namespace == table_prefix(module_name):
|
|
365
|
+
return
|
|
366
|
+
for declared in permissions.event_topics:
|
|
367
|
+
declared_namespace, declared_name = split_topic(declared, allow_wildcard=True)
|
|
368
|
+
if declared_namespace != namespace:
|
|
369
|
+
continue
|
|
370
|
+
if declared_name == "*" or declared_name == name:
|
|
371
|
+
return
|
|
372
|
+
raise EventTopicError(f"module {module_name!r} did not declare event topic {pattern!r}")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_custom_id(module_name: str, key: str, *parts: str) -> str:
|
|
376
|
+
if not _TOPIC_SEGMENT_RE.match(key):
|
|
377
|
+
raise ModuleContractError(f"invalid component key {key!r}")
|
|
378
|
+
for part in parts:
|
|
379
|
+
if ":" in part:
|
|
380
|
+
raise ModuleContractError("custom_id parts may not contain ':'")
|
|
381
|
+
custom_id = ":".join((CUSTOM_ID_PREFIX, module_name, key, *parts))
|
|
382
|
+
if len(custom_id) > CUSTOM_ID_MAX_LENGTH:
|
|
383
|
+
raise ModuleContractError(f"custom_id exceeds {CUSTOM_ID_MAX_LENGTH} characters")
|
|
384
|
+
return custom_id
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def parse_custom_id(custom_id: str) -> tuple[str, str, tuple[str, ...]] | None:
|
|
388
|
+
"""Return (module_name, key, parts) for a module-owned ID, else None."""
|
|
389
|
+
pieces = custom_id.split(":")
|
|
390
|
+
if len(pieces) < 3 or pieces[0] != CUSTOM_ID_PREFIX:
|
|
391
|
+
return None
|
|
392
|
+
return pieces[1], pieces[2], tuple(pieces[3:])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def validate_host_rule(rule: HttpHostRule) -> None:
|
|
396
|
+
if rule.is_discord_cdn or rule.setting_name is not None:
|
|
397
|
+
pass
|
|
398
|
+
elif not _HOST_RE.match(rule.host):
|
|
399
|
+
raise ModuleContractError(f"invalid HTTP host {rule.host!r}; wildcards are not supported")
|
|
400
|
+
if rule.is_discord_cdn and rule.network != "public":
|
|
401
|
+
raise ModuleContractError("discord-cdn is always public")
|
|
402
|
+
if not rule.schemes or any(scheme not in ("http", "https") for scheme in rule.schemes):
|
|
403
|
+
raise ModuleContractError(f"invalid schemes {rule.schemes!r} for host {rule.host!r}")
|
|
404
|
+
if any(port <= 0 or port > 65535 for port in rule.ports):
|
|
405
|
+
raise ModuleContractError(f"invalid ports {rule.ports!r} for host {rule.host!r}")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def validate_permissions(module_name: str, permissions: ModulePermissions) -> None:
|
|
409
|
+
unknown = permissions.discord_actions - ALL_DISCORD_ACTIONS
|
|
410
|
+
if unknown:
|
|
411
|
+
raise ModuleContractError(
|
|
412
|
+
f"module {module_name!r} declares unknown Discord actions {sorted(unknown)!r}"
|
|
413
|
+
)
|
|
414
|
+
if permissions.override_target_policy and not (
|
|
415
|
+
permissions.discord_actions & TARGETED_DISCORD_ACTIONS
|
|
416
|
+
):
|
|
417
|
+
raise ModuleContractError(
|
|
418
|
+
f"module {module_name!r} overrides the target policy without a targeted action"
|
|
419
|
+
)
|
|
420
|
+
for topic in permissions.event_topics:
|
|
421
|
+
namespace, _ = split_topic(topic, allow_wildcard=True)
|
|
422
|
+
if namespace == table_prefix(module_name):
|
|
423
|
+
raise EventTopicError(
|
|
424
|
+
f"module {module_name!r} need not declare its own topic {topic!r}"
|
|
425
|
+
)
|
|
426
|
+
for rule in permissions.http_hosts:
|
|
427
|
+
validate_host_rule(rule)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def validate_services(
|
|
431
|
+
module_name: str,
|
|
432
|
+
dependencies: Sequence[str],
|
|
433
|
+
provides: Sequence[ServiceDeclaration],
|
|
434
|
+
consumes: Sequence[ServiceRequirement],
|
|
435
|
+
) -> None:
|
|
436
|
+
seen: set[tuple[str, int]] = set()
|
|
437
|
+
for declaration in provides:
|
|
438
|
+
if not _SERVICE_NAME_RE.match(declaration.name) or declaration.version < 1:
|
|
439
|
+
raise ModuleContractError(
|
|
440
|
+
f"module {module_name!r} provides invalid service {declaration!r}"
|
|
441
|
+
)
|
|
442
|
+
key = (declaration.name, declaration.version)
|
|
443
|
+
if key in seen:
|
|
444
|
+
raise ModuleContractError(f"module {module_name!r} provides {key!r} twice")
|
|
445
|
+
seen.add(key)
|
|
446
|
+
required: dict[tuple[str, int], str] = {}
|
|
447
|
+
for requirement in consumes:
|
|
448
|
+
if not _SERVICE_NAME_RE.match(requirement.name) or requirement.version < 1:
|
|
449
|
+
raise ModuleContractError(
|
|
450
|
+
f"module {module_name!r} consumes invalid service {requirement!r}"
|
|
451
|
+
)
|
|
452
|
+
if requirement.provider == module_name:
|
|
453
|
+
raise ModuleContractError(f"module {module_name!r} cannot consume its own service")
|
|
454
|
+
if requirement.provider not in dependencies:
|
|
455
|
+
raise ModuleContractError(
|
|
456
|
+
f"module {module_name!r} consumes {requirement.name!r} from "
|
|
457
|
+
f"{requirement.provider!r} without depending on it"
|
|
458
|
+
)
|
|
459
|
+
key = (requirement.name, requirement.version)
|
|
460
|
+
previous = required.get(key)
|
|
461
|
+
if previous is not None:
|
|
462
|
+
detail = "twice" if previous == requirement.provider else "from multiple providers"
|
|
463
|
+
raise ModuleContractError(
|
|
464
|
+
f"module {module_name!r} consumes {requirement.name}@{requirement.version} {detail}"
|
|
465
|
+
)
|
|
466
|
+
required[key] = requirement.provider
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def validate_guild_settings_schema(module_name: str, schema: GuildSettingsSchema) -> None:
|
|
470
|
+
if schema.invalid_policy not in ("disable_module", "disable_guild"):
|
|
471
|
+
raise ModuleContractError(
|
|
472
|
+
f"module {module_name!r} guild settings has invalid policy {schema.invalid_policy!r}"
|
|
473
|
+
)
|
|
474
|
+
names: set[str] = set()
|
|
475
|
+
for field_spec in schema.fields:
|
|
476
|
+
if not _SETTING_NAME_RE.match(field_spec.name):
|
|
477
|
+
raise ModuleContractError(
|
|
478
|
+
f"module {module_name!r} guild setting {field_spec.name!r} has an invalid name"
|
|
479
|
+
)
|
|
480
|
+
if field_spec.name in names:
|
|
481
|
+
raise ModuleContractError(
|
|
482
|
+
f"module {module_name!r} declares guild setting {field_spec.name!r} twice"
|
|
483
|
+
)
|
|
484
|
+
names.add(field_spec.name)
|
|
485
|
+
if field_spec.kind == "enum" and not field_spec.choices:
|
|
486
|
+
raise ModuleContractError(
|
|
487
|
+
f"module {module_name!r} enum setting {field_spec.name!r} needs choices"
|
|
488
|
+
)
|
|
489
|
+
if field_spec.kind != "enum" and field_spec.choices:
|
|
490
|
+
raise ModuleContractError(
|
|
491
|
+
f"module {module_name!r} setting {field_spec.name!r} has choices but is not enum"
|
|
492
|
+
)
|
|
493
|
+
if field_spec.required and field_spec.default is not None:
|
|
494
|
+
raise ModuleContractError(
|
|
495
|
+
f"module {module_name!r} setting {field_spec.name!r} is required with a default"
|
|
496
|
+
)
|
|
497
|
+
if field_spec.default is not None:
|
|
498
|
+
_value, error = coerce_guild_setting_value(field_spec, field_spec.default)
|
|
499
|
+
if error is not None:
|
|
500
|
+
raise ModuleContractError(
|
|
501
|
+
f"module {module_name!r} setting {field_spec.name!r} has an invalid "
|
|
502
|
+
f"default: {error}"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# --------------------------------------------------------------------------
|
|
507
|
+
# Runtime ports (implemented by core in modules/)
|
|
508
|
+
# --------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
type HealthState = Literal["starting", "healthy", "degraded", "failed"]
|
|
511
|
+
HEALTH_DETAIL_MAX_LENGTH = 500
|
|
512
|
+
HEALTH_METRICS_MAX_KEYS = 32
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@dataclass(frozen=True, slots=True)
|
|
516
|
+
class ModuleHealth:
|
|
517
|
+
state: HealthState
|
|
518
|
+
detail: str = ""
|
|
519
|
+
metrics: Mapping[str, float] = field(default_factory=dict)
|
|
520
|
+
updated_at: float = 0.0
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class HealthReporter(Protocol):
|
|
524
|
+
"""Module-side health reporting.
|
|
525
|
+
|
|
526
|
+
``report(...)`` without ``key`` sets the module's overall state and replaces
|
|
527
|
+
the previous unkeyed report. ``report(..., key="digest")`` sets one named
|
|
528
|
+
concern that is tracked independently: the module's visible state is the
|
|
529
|
+
worst of every keyed concern plus the unkeyed report, so one subsystem
|
|
530
|
+
going ``degraded`` is not erased by another reporting ``healthy``. A keyed
|
|
531
|
+
``healthy`` report with no detail and no metrics clears that concern.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
def report(
|
|
535
|
+
self,
|
|
536
|
+
state: HealthState,
|
|
537
|
+
detail: str = "",
|
|
538
|
+
metrics: Mapping[str, float] | None = None,
|
|
539
|
+
*,
|
|
540
|
+
key: str | None = None,
|
|
541
|
+
) -> None: ...
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@dataclass(frozen=True, slots=True)
|
|
545
|
+
class Event:
|
|
546
|
+
topic: str
|
|
547
|
+
payload: Any
|
|
548
|
+
source_module: str
|
|
549
|
+
published_at: float
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
type EventHandler = Callable[[Event], Awaitable[None]]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class Subscription(Protocol):
|
|
556
|
+
def close(self) -> None: ...
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class EventBus(Protocol):
|
|
560
|
+
def publish(self, topic: str, payload: Any) -> None: ...
|
|
561
|
+
|
|
562
|
+
def subscribe(self, pattern: str, handler: EventHandler) -> Subscription: ...
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@dataclass(frozen=True, slots=True)
|
|
566
|
+
class Backoff:
|
|
567
|
+
base_seconds: float = 30.0
|
|
568
|
+
max_seconds: float = 3600.0
|
|
569
|
+
multiplier: float = 2.0
|
|
570
|
+
|
|
571
|
+
def __post_init__(self) -> None:
|
|
572
|
+
for name, value in (
|
|
573
|
+
("base_seconds", self.base_seconds),
|
|
574
|
+
("max_seconds", self.max_seconds),
|
|
575
|
+
("multiplier", self.multiplier),
|
|
576
|
+
):
|
|
577
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
578
|
+
raise ModuleContractError(f"backoff {name} must be a finite number")
|
|
579
|
+
if not math.isfinite(value):
|
|
580
|
+
raise ModuleContractError(f"backoff {name} must be finite")
|
|
581
|
+
if self.base_seconds <= 0:
|
|
582
|
+
raise ModuleContractError("backoff base_seconds must be positive")
|
|
583
|
+
if self.max_seconds <= 0:
|
|
584
|
+
raise ModuleContractError("backoff max_seconds must be positive")
|
|
585
|
+
if self.multiplier < 1:
|
|
586
|
+
raise ModuleContractError("backoff multiplier must be at least 1")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@dataclass(frozen=True, slots=True)
|
|
590
|
+
class JobRun:
|
|
591
|
+
job_id: str
|
|
592
|
+
key: str
|
|
593
|
+
payload: Mapping[str, Any]
|
|
594
|
+
attempt: int
|
|
595
|
+
scheduled_for: float
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@dataclass(frozen=True, slots=True)
|
|
599
|
+
class JobInfo:
|
|
600
|
+
key: str
|
|
601
|
+
handler: str
|
|
602
|
+
next_run_at: float
|
|
603
|
+
interval_seconds: float | None
|
|
604
|
+
attempt: int
|
|
605
|
+
last_error: str | None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
type JobHandler = Callable[[JobRun], Awaitable[None]]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class Scheduler(Protocol):
|
|
612
|
+
def register(self, handler_name: str, handler: JobHandler) -> None: ...
|
|
613
|
+
|
|
614
|
+
async def run_at(
|
|
615
|
+
self,
|
|
616
|
+
key: str,
|
|
617
|
+
when: float,
|
|
618
|
+
handler_name: str,
|
|
619
|
+
payload: Mapping[str, Any] | None = None,
|
|
620
|
+
) -> None: ...
|
|
621
|
+
|
|
622
|
+
async def run_every(
|
|
623
|
+
self,
|
|
624
|
+
key: str,
|
|
625
|
+
interval_seconds: float,
|
|
626
|
+
handler_name: str,
|
|
627
|
+
payload: Mapping[str, Any] | None = None,
|
|
628
|
+
*,
|
|
629
|
+
jitter_seconds: float = 0.0,
|
|
630
|
+
backoff: Backoff | None = None,
|
|
631
|
+
) -> None: ...
|
|
632
|
+
|
|
633
|
+
async def cancel(self, key: str) -> bool: ...
|
|
634
|
+
|
|
635
|
+
async def list(self) -> Sequence[JobInfo]: ...
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class ModuleStorage(Protocol):
|
|
639
|
+
@property
|
|
640
|
+
def connection(self) -> Any: ...
|
|
641
|
+
|
|
642
|
+
def table(self, name: str) -> str: ...
|
|
643
|
+
|
|
644
|
+
def write_transaction(self) -> Any: ...
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@dataclass(frozen=True, slots=True)
|
|
648
|
+
class MigrationContext:
|
|
649
|
+
connection: Any
|
|
650
|
+
table: Callable[[str], str]
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
type ScopedModuleMigration = tuple[str, Callable[[MigrationContext], Awaitable[None]]]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@dataclass(frozen=True, slots=True)
|
|
657
|
+
class MessageRef:
|
|
658
|
+
guild_id: int
|
|
659
|
+
channel_id: int
|
|
660
|
+
message_id: int
|
|
661
|
+
# Parent channel when the message is in a thread; None otherwise.
|
|
662
|
+
parent_channel_id: int | None = None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
type ProposalState = Literal["pending", "applied", "rejected"]
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class ProposalError(RuntimeError):
|
|
669
|
+
"""A configuration proposal could not be read, created, or decided."""
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@dataclass(frozen=True, slots=True)
|
|
673
|
+
class ProposalActor:
|
|
674
|
+
user_id: str
|
|
675
|
+
source: str
|
|
676
|
+
guild_id: str | None = None
|
|
677
|
+
channel_id: str | None = None
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@dataclass(frozen=True, slots=True)
|
|
681
|
+
class ConfigSnapshot:
|
|
682
|
+
target: str
|
|
683
|
+
revision: str
|
|
684
|
+
content: str
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@dataclass(frozen=True, slots=True)
|
|
688
|
+
class ProposalRef:
|
|
689
|
+
proposal_id: str
|
|
690
|
+
target: str
|
|
691
|
+
state: ProposalState
|
|
692
|
+
message: MessageRef | None = None
|
|
693
|
+
decided_by: str | None = None
|
|
694
|
+
decision_reason: str = ""
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class ProposalService(Protocol):
|
|
698
|
+
"""Guild-scoped fragment proposals, already bound to one module."""
|
|
699
|
+
|
|
700
|
+
async def snapshot(self, target: str, *, actor: ProposalActor) -> ConfigSnapshot: ...
|
|
701
|
+
|
|
702
|
+
async def propose(
|
|
703
|
+
self,
|
|
704
|
+
*,
|
|
705
|
+
target: str,
|
|
706
|
+
content: str,
|
|
707
|
+
summary: str,
|
|
708
|
+
actor: ProposalActor,
|
|
709
|
+
expected_revision: str | None = None,
|
|
710
|
+
) -> ProposalRef: ...
|
|
711
|
+
|
|
712
|
+
async def get(self, proposal_id: str, *, actor: ProposalActor) -> ProposalRef | None: ...
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@dataclass(frozen=True, slots=True)
|
|
716
|
+
class AttachmentSnapshot:
|
|
717
|
+
attachment_id: int
|
|
718
|
+
filename: str
|
|
719
|
+
url: str
|
|
720
|
+
size: int
|
|
721
|
+
content_type: str | None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@dataclass(frozen=True, slots=True)
|
|
725
|
+
class MessageSnapshot:
|
|
726
|
+
ref: MessageRef
|
|
727
|
+
author_id: int
|
|
728
|
+
content: str
|
|
729
|
+
attachments: tuple[AttachmentSnapshot, ...]
|
|
730
|
+
jump_url: str
|
|
731
|
+
created_at: float
|
|
732
|
+
author_display_name: str = ""
|
|
733
|
+
author_is_bot: bool = False
|
|
734
|
+
# Image URLs from the message's embeds (proxy URLs when Discord provides them).
|
|
735
|
+
embed_image_urls: tuple[str, ...] = ()
|
|
736
|
+
reply_to_message_id: int | None = None
|
|
737
|
+
pinned: bool = False
|
|
738
|
+
edited_at: float | None = None
|
|
739
|
+
embed_texts: tuple[str, ...] = ()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True, slots=True)
|
|
743
|
+
class InviteSnapshot:
|
|
744
|
+
"""Discord invite metadata available through gateway events or a guild fetch.
|
|
745
|
+
|
|
746
|
+
Gateway delete events are intentionally partial, so every field except the
|
|
747
|
+
guild and code may be absent. ``fetch_invites`` returns the richer form used
|
|
748
|
+
for best-effort join attribution by comparing ``uses`` counters.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
guild_id: int
|
|
752
|
+
code: str
|
|
753
|
+
channel_id: int | None = None
|
|
754
|
+
inviter_id: int | None = None
|
|
755
|
+
uses: int | None = None
|
|
756
|
+
max_uses: int | None = None
|
|
757
|
+
max_age_seconds: int | None = None
|
|
758
|
+
temporary: bool | None = None
|
|
759
|
+
created_at: float | None = None
|
|
760
|
+
expires_at: float | None = None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
type ChannelKind = Literal["text", "forum", "thread"]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@dataclass(frozen=True, slots=True)
|
|
767
|
+
class ChannelSnapshot:
|
|
768
|
+
guild_id: int
|
|
769
|
+
channel_id: int
|
|
770
|
+
kind: ChannelKind
|
|
771
|
+
name: str
|
|
772
|
+
parent_channel_id: int | None = None
|
|
773
|
+
topic: str = ""
|
|
774
|
+
archived: bool = False
|
|
775
|
+
private: bool = False
|
|
776
|
+
applied_tags: tuple[str, ...] = ()
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@dataclass(frozen=True, slots=True)
|
|
780
|
+
class MessagePage:
|
|
781
|
+
messages: tuple[MessageSnapshot, ...]
|
|
782
|
+
next_cursor: int | None
|
|
783
|
+
has_more: bool
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@dataclass(frozen=True, slots=True)
|
|
787
|
+
class MemberSnapshot:
|
|
788
|
+
guild_id: int
|
|
789
|
+
user_id: int
|
|
790
|
+
display_name: str
|
|
791
|
+
role_ids: tuple[int, ...]
|
|
792
|
+
is_bot: bool
|
|
793
|
+
joined_at: float | None
|
|
794
|
+
timed_out_until: float | None
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass(frozen=True, slots=True)
|
|
798
|
+
class RoleSnapshot:
|
|
799
|
+
guild_id: int
|
|
800
|
+
role_id: int
|
|
801
|
+
name: str
|
|
802
|
+
# Higher positions sit above lower ones in the guild's role list.
|
|
803
|
+
position: int
|
|
804
|
+
# Managed roles belong to an integration or bot and cannot be assigned by hand.
|
|
805
|
+
managed: bool = False
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@dataclass(frozen=True, slots=True)
|
|
809
|
+
class OutgoingEmbed:
|
|
810
|
+
title: str | None = None
|
|
811
|
+
description: str | None = None
|
|
812
|
+
color: int | None = None
|
|
813
|
+
fields: tuple[tuple[str, str, bool], ...] = ()
|
|
814
|
+
footer: str | None = None
|
|
815
|
+
timestamp: bool = False
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class DiscordActions(Protocol):
|
|
819
|
+
"""Declared Discord operations on stable IDs.
|
|
820
|
+
|
|
821
|
+
``actor_id`` on ban/kick/timeout is the staff member acting through the
|
|
822
|
+
module, or ``None`` when the module acts on its own (automated
|
|
823
|
+
enforcement). The target policy then requires the target to be below
|
|
824
|
+
staff tier instead of below the actor's tier.
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
async def send_message(
|
|
828
|
+
self,
|
|
829
|
+
channel_id: int,
|
|
830
|
+
content: str | None = None,
|
|
831
|
+
*,
|
|
832
|
+
embed: OutgoingEmbed | None = None,
|
|
833
|
+
reply_to: MessageRef | None = None,
|
|
834
|
+
components: Sequence[Any] = (),
|
|
835
|
+
) -> MessageRef: ...
|
|
836
|
+
|
|
837
|
+
async def send_dm(
|
|
838
|
+
self, user_id: int, content: str, *, embed: OutgoingEmbed | None = None
|
|
839
|
+
) -> bool: ...
|
|
840
|
+
|
|
841
|
+
async def edit_message(
|
|
842
|
+
self, ref: MessageRef, content: str | None = None, *, embed: OutgoingEmbed | None = None
|
|
843
|
+
) -> None: ...
|
|
844
|
+
|
|
845
|
+
async def delete_message(self, ref: MessageRef, *, reason: str = "") -> None: ...
|
|
846
|
+
|
|
847
|
+
async def ban(
|
|
848
|
+
self,
|
|
849
|
+
guild_id: int,
|
|
850
|
+
user_id: int,
|
|
851
|
+
*,
|
|
852
|
+
actor_id: int | None,
|
|
853
|
+
reason: str,
|
|
854
|
+
delete_message_seconds: int = 0,
|
|
855
|
+
) -> None: ...
|
|
856
|
+
|
|
857
|
+
async def kick(
|
|
858
|
+
self, guild_id: int, user_id: int, *, actor_id: int | None, reason: str
|
|
859
|
+
) -> None: ...
|
|
860
|
+
|
|
861
|
+
async def timeout(
|
|
862
|
+
self,
|
|
863
|
+
guild_id: int,
|
|
864
|
+
user_id: int,
|
|
865
|
+
*,
|
|
866
|
+
actor_id: int | None,
|
|
867
|
+
reason: str,
|
|
868
|
+
duration_seconds: int,
|
|
869
|
+
) -> None: ...
|
|
870
|
+
|
|
871
|
+
async def fetch_message(self, ref: MessageRef) -> MessageSnapshot | None: ...
|
|
872
|
+
|
|
873
|
+
async def fetch_member(self, guild_id: int, user_id: int) -> MemberSnapshot | None: ...
|
|
874
|
+
|
|
875
|
+
async def fetch_channel(self, guild_id: int, channel_id: int) -> ChannelSnapshot | None: ...
|
|
876
|
+
|
|
877
|
+
async def fetch_messages(
|
|
878
|
+
self,
|
|
879
|
+
guild_id: int,
|
|
880
|
+
channel_id: int,
|
|
881
|
+
*,
|
|
882
|
+
after_message_id: int | None = None,
|
|
883
|
+
before_message_id: int | None = None,
|
|
884
|
+
limit: int = 100,
|
|
885
|
+
) -> MessagePage: ...
|
|
886
|
+
|
|
887
|
+
async def fetch_pins(self, guild_id: int, channel_id: int) -> tuple[MessageSnapshot, ...]: ...
|
|
888
|
+
|
|
889
|
+
async def fetch_public_threads(
|
|
890
|
+
self, guild_id: int, parent_channel_id: int
|
|
891
|
+
) -> tuple[ChannelSnapshot, ...]: ...
|
|
892
|
+
|
|
893
|
+
async def fetch_roles(self, guild_id: int) -> tuple[RoleSnapshot, ...]: ...
|
|
894
|
+
|
|
895
|
+
async def fetch_invites(self, guild_id: int) -> tuple[InviteSnapshot, ...]: ...
|
|
896
|
+
|
|
897
|
+
async def can_view_channel(self, guild_id: int, user_id: int, channel_id: int) -> bool: ...
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
type TrustTierName = Literal["member", "regular", "staff"]
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class TrustLookup(Protocol):
|
|
904
|
+
"""Read-only trust tier lookup, mirroring core's member < regular < staff."""
|
|
905
|
+
|
|
906
|
+
async def tier(self, guild_id: int, user_id: int) -> TrustTierName: ...
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
type CommandOptionKind = Literal["string", "integer", "boolean", "user", "channel", "role"]
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@dataclass(frozen=True, slots=True)
|
|
913
|
+
class CommandOption:
|
|
914
|
+
name: str
|
|
915
|
+
kind: CommandOptionKind
|
|
916
|
+
description: str
|
|
917
|
+
required: bool = False
|
|
918
|
+
choices: tuple[tuple[str, str | int], ...] = ()
|
|
919
|
+
min_value: int | None = None
|
|
920
|
+
max_value: int | None = None
|
|
921
|
+
autocomplete: bool = False
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@dataclass(frozen=True, slots=True)
|
|
925
|
+
class CommandSpec:
|
|
926
|
+
name: str
|
|
927
|
+
description: str
|
|
928
|
+
options: tuple[CommandOption, ...] = ()
|
|
929
|
+
min_tier: TrustTierName = "staff"
|
|
930
|
+
group: str | None = None
|
|
931
|
+
group_description: str = ""
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
class ModuleInteraction(Protocol):
|
|
935
|
+
@property
|
|
936
|
+
def guild_id(self) -> int: ...
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def channel_id(self) -> int: ...
|
|
940
|
+
|
|
941
|
+
@property
|
|
942
|
+
def user_id(self) -> int: ...
|
|
943
|
+
|
|
944
|
+
@property
|
|
945
|
+
def guild_name(self) -> str | None: ...
|
|
946
|
+
|
|
947
|
+
@property
|
|
948
|
+
def options(self) -> Mapping[str, Any]: ...
|
|
949
|
+
|
|
950
|
+
@property
|
|
951
|
+
def custom_id(self) -> str | None: ...
|
|
952
|
+
|
|
953
|
+
@property
|
|
954
|
+
def values(self) -> tuple[str, ...]: ...
|
|
955
|
+
|
|
956
|
+
@property
|
|
957
|
+
def message(self) -> MessageRef | None:
|
|
958
|
+
"""The message a button or select lives on; ``None`` for slash commands."""
|
|
959
|
+
...
|
|
960
|
+
|
|
961
|
+
async def respond(
|
|
962
|
+
self,
|
|
963
|
+
content: str | None = None,
|
|
964
|
+
*,
|
|
965
|
+
embed: OutgoingEmbed | None = None,
|
|
966
|
+
ephemeral: bool = False,
|
|
967
|
+
components: Sequence[Any] = (),
|
|
968
|
+
) -> None: ...
|
|
969
|
+
|
|
970
|
+
async def defer(self, *, ephemeral: bool = False) -> None: ...
|
|
971
|
+
|
|
972
|
+
async def edit_original(
|
|
973
|
+
self,
|
|
974
|
+
content: str | None = None,
|
|
975
|
+
*,
|
|
976
|
+
embed: OutgoingEmbed | None = None,
|
|
977
|
+
components: Sequence[Any] = (),
|
|
978
|
+
) -> None: ...
|
|
979
|
+
|
|
980
|
+
async def follow_up(
|
|
981
|
+
self, content: str, *, embed: OutgoingEmbed | None = None, ephemeral: bool = False
|
|
982
|
+
) -> None: ...
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
type ButtonStyle = Literal["primary", "secondary", "success", "danger"]
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
@dataclass(frozen=True, slots=True)
|
|
989
|
+
class ButtonSpec:
|
|
990
|
+
"""A persistent button; ``key`` names the handler registered for it."""
|
|
991
|
+
|
|
992
|
+
key: str
|
|
993
|
+
label: str
|
|
994
|
+
style: ButtonStyle = "secondary"
|
|
995
|
+
parts: tuple[str, ...] = ()
|
|
996
|
+
disabled: bool = False
|
|
997
|
+
emoji: str | None = None
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@dataclass(frozen=True, slots=True)
|
|
1001
|
+
class SelectSpec:
|
|
1002
|
+
"""A persistent single/multi select; options are (label, value, description)."""
|
|
1003
|
+
|
|
1004
|
+
key: str
|
|
1005
|
+
options: tuple[tuple[str, str, str | None], ...]
|
|
1006
|
+
placeholder: str | None = None
|
|
1007
|
+
parts: tuple[str, ...] = ()
|
|
1008
|
+
min_values: int = 1
|
|
1009
|
+
max_values: int = 1
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
type CommandHandler = Callable[[ModuleInteraction], Awaitable[None]]
|
|
1013
|
+
type AutocompleteHandler = Callable[
|
|
1014
|
+
[ModuleInteraction, str, str], Awaitable[Sequence[tuple[str, str | int]]]
|
|
1015
|
+
]
|
|
1016
|
+
type ComponentKind = Literal["button", "select"]
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
class Registration(Protocol):
|
|
1020
|
+
def close(self) -> None: ...
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
class InteractionRouter(Protocol):
|
|
1024
|
+
def add_command(
|
|
1025
|
+
self,
|
|
1026
|
+
spec: CommandSpec,
|
|
1027
|
+
handler: CommandHandler,
|
|
1028
|
+
*,
|
|
1029
|
+
autocomplete: AutocompleteHandler | None = None,
|
|
1030
|
+
) -> Registration: ...
|
|
1031
|
+
|
|
1032
|
+
def register_component(
|
|
1033
|
+
self,
|
|
1034
|
+
kind: ComponentKind,
|
|
1035
|
+
key: str,
|
|
1036
|
+
handler: CommandHandler,
|
|
1037
|
+
*,
|
|
1038
|
+
expires_after_seconds: float | None = None,
|
|
1039
|
+
min_tier: TrustTierName = "member",
|
|
1040
|
+
) -> Registration: ...
|
|
1041
|
+
|
|
1042
|
+
def custom_id(self, key: str, *parts: str) -> str: ...
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
@dataclass(frozen=True, slots=True)
|
|
1046
|
+
class GuildSettingsSnapshot:
|
|
1047
|
+
values: Mapping[str, Any]
|
|
1048
|
+
valid: bool
|
|
1049
|
+
errors: tuple[str, ...]
|
|
1050
|
+
revision: str
|
|
1051
|
+
legacy: bool = False
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class GuildSettings(Protocol):
|
|
1055
|
+
def guild_ids(self) -> Sequence[int]: ...
|
|
1056
|
+
|
|
1057
|
+
def get(self, guild_id: int) -> GuildSettingsSnapshot: ...
|
|
1058
|
+
|
|
1059
|
+
def is_enabled(self, guild_id: int) -> bool: ...
|
|
1060
|
+
|
|
1061
|
+
def on_change(self, callback: Callable[[int], None]) -> Registration: ...
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
@dataclass(frozen=True, slots=True)
|
|
1065
|
+
class HttpResponse:
|
|
1066
|
+
status: int
|
|
1067
|
+
headers: Mapping[str, str]
|
|
1068
|
+
body: bytes
|
|
1069
|
+
|
|
1070
|
+
def json(self) -> Any:
|
|
1071
|
+
import json
|
|
1072
|
+
|
|
1073
|
+
return json.loads(self.body)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
class ModuleHttp(Protocol):
|
|
1077
|
+
async def get(
|
|
1078
|
+
self,
|
|
1079
|
+
url: str,
|
|
1080
|
+
*,
|
|
1081
|
+
headers: Mapping[str, str] | None = None,
|
|
1082
|
+
timeout_seconds: float = 20.0,
|
|
1083
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
1084
|
+
) -> HttpResponse: ...
|
|
1085
|
+
|
|
1086
|
+
async def post_json(
|
|
1087
|
+
self,
|
|
1088
|
+
url: str,
|
|
1089
|
+
payload: Any,
|
|
1090
|
+
*,
|
|
1091
|
+
headers: Mapping[str, str] | None = None,
|
|
1092
|
+
timeout_seconds: float = 20.0,
|
|
1093
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
1094
|
+
) -> HttpResponse: ...
|
|
1095
|
+
|
|
1096
|
+
def download(
|
|
1097
|
+
self,
|
|
1098
|
+
url: str,
|
|
1099
|
+
*,
|
|
1100
|
+
headers: Mapping[str, str] | None = None,
|
|
1101
|
+
timeout_seconds: float = 30.0,
|
|
1102
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
1103
|
+
) -> AsyncIterator[bytes]: ...
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class ServiceRegistry(Protocol):
|
|
1107
|
+
def provide(self, name: str, version: int, implementation: object) -> Registration: ...
|
|
1108
|
+
|
|
1109
|
+
@overload
|
|
1110
|
+
def get(self, name: str, version: int) -> object: ...
|
|
1111
|
+
|
|
1112
|
+
@overload
|
|
1113
|
+
def get(self, name: str, version: int, type_: type[_T]) -> _T: ...
|
|
1114
|
+
|
|
1115
|
+
def get(self, name: str, version: int, type_: type[_T] | None = None) -> object:
|
|
1116
|
+
"""Resolve a consumed service; with ``type_`` the result is checked and typed.
|
|
1117
|
+
|
|
1118
|
+
The result is always a proxy that forwards attribute access and raises
|
|
1119
|
+
``ServiceUnavailable`` once the provider closes. ``type_`` is checked
|
|
1120
|
+
against the provided object at resolution time, so a provider that
|
|
1121
|
+
changed its class fails here instead of at the first call, and the
|
|
1122
|
+
proxy is then typed as ``type_`` for method calls. Because it is a
|
|
1123
|
+
proxy, ``isinstance`` on the result is false and special methods
|
|
1124
|
+
(``__call__``, ``__getitem__``, ...) are not forwarded: a service is an
|
|
1125
|
+
object with ordinary methods, nothing more.
|
|
1126
|
+
"""
|
|
1127
|
+
...
|