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