kimi-agent-module-api 1.2.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {kimi_agent_module_api-1.2.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-2.0.0}/PKG-INFO +40 -5
  2. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/README.md +39 -4
  3. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/pyproject.toml +8 -1
  4. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/__init__.py +17 -24
  5. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/contracts.py +211 -1
  6. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/events.py +2 -0
  7. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/testing.py +216 -4
  8. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/tools.py +4 -0
  9. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +40 -5
  10. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +4 -1
  11. kimi_agent_module_api-2.0.0/tests/test_contracts.py +203 -0
  12. kimi_agent_module_api-2.0.0/tests/test_public_api.py +59 -0
  13. kimi_agent_module_api-2.0.0/tests/test_testing.py +114 -0
  14. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/LICENSE +0 -0
  15. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/setup.cfg +0 -0
  16. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/images.py +0 -0
  17. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/py.typed +0 -0
  18. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/settings.py +0 -0
  19. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api/trust.py +0 -0
  20. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  21. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  22. {kimi_agent_module_api-1.2.0 → kimi_agent_module_api-2.0.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -39,23 +39,58 @@ group:
39
39
 
40
40
  ```toml
41
41
  [project]
42
- dependencies = ["kimi-agent-module-api>=1,<2"]
42
+ dependencies = ["kimi-agent-module-api>=2,<3"]
43
43
 
44
44
  [project.entry-points."kimi_agent.modules"]
45
45
  my_module = "my_module_package:SPEC"
46
46
  ```
47
47
 
48
+ The source must pin the API contract it implements when constructing the
49
+ specification:
50
+
51
+ ```python
52
+ from kimi_agent_module_api import ModuleSpec
53
+
54
+ SPEC = ModuleSpec(
55
+ name="my_module",
56
+ version="0.1.0",
57
+ create=create,
58
+ api_version=2,
59
+ )
60
+ ```
61
+
62
+ `api_version` is a required keyword. Keep it as a literal rather than deriving
63
+ it from the installed SDK's `MODULE_API_VERSION`; unchanged module source must
64
+ not silently claim compatibility merely because it was rebuilt with a newer
65
+ SDK.
66
+
48
67
  The [module guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/modules.md)
49
68
  documents installation, declarations, lifecycle, and every runtime port. The
50
69
  [reference module](https://github.com/webhead2oo9/kimi-agent/tree/main/bot/modules/example)
51
70
  is a complete, commented example that exercises most ports; start there.
52
71
 
53
72
  Guild-scoped live command replacement was added in 1.1. Modules using
54
- `InteractionRouter.replace_guild_commands()` should depend on
55
- `kimi-agent-module-api>=1.1,<2` and require the host capability
73
+ `InteractionRouter.replace_guild_commands()` should require the host capability
56
74
  `discord.guild_commands.v1`.
57
75
 
58
76
  Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
59
77
  that layout model, Discord requires every later edit of the same message to remain a layout.
60
- Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
78
+ Modules using them should require
61
79
  `discord.modals.v1` and/or `discord.components_v2.v1`.
80
+
81
+ Version 1.3 adds cached author classification to message-deletion events:
82
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
83
+ The values remain unknown for messages that were absent from Discord's cache.
84
+
85
+ Version 2 requires an explicit, source-pinned `ModuleSpec.api_version`, and
86
+ removes the temporary guild-settings legacy flag and module table aliases.
87
+ Modules must use namespaced guild documents and migrate legacy tables to the
88
+ physical names returned by `ctx.storage.table()` before upgrading.
89
+
90
+ ## Testing the SDK
91
+
92
+ From this package directory, run its tests without installing the Kimi application:
93
+
94
+ ```console
95
+ uv run --isolated --group test python -m pytest -q
96
+ ```
@@ -22,23 +22,58 @@ group:
22
22
 
23
23
  ```toml
24
24
  [project]
25
- dependencies = ["kimi-agent-module-api>=1,<2"]
25
+ dependencies = ["kimi-agent-module-api>=2,<3"]
26
26
 
27
27
  [project.entry-points."kimi_agent.modules"]
28
28
  my_module = "my_module_package:SPEC"
29
29
  ```
30
30
 
31
+ The source must pin the API contract it implements when constructing the
32
+ specification:
33
+
34
+ ```python
35
+ from kimi_agent_module_api import ModuleSpec
36
+
37
+ SPEC = ModuleSpec(
38
+ name="my_module",
39
+ version="0.1.0",
40
+ create=create,
41
+ api_version=2,
42
+ )
43
+ ```
44
+
45
+ `api_version` is a required keyword. Keep it as a literal rather than deriving
46
+ it from the installed SDK's `MODULE_API_VERSION`; unchanged module source must
47
+ not silently claim compatibility merely because it was rebuilt with a newer
48
+ SDK.
49
+
31
50
  The [module guide](https://github.com/webhead2oo9/kimi-agent/blob/main/docs/modules.md)
32
51
  documents installation, declarations, lifecycle, and every runtime port. The
33
52
  [reference module](https://github.com/webhead2oo9/kimi-agent/tree/main/bot/modules/example)
34
53
  is a complete, commented example that exercises most ports; start there.
35
54
 
36
55
  Guild-scoped live command replacement was added in 1.1. Modules using
37
- `InteractionRouter.replace_guild_commands()` should depend on
38
- `kimi-agent-module-api>=1.1,<2` and require the host capability
56
+ `InteractionRouter.replace_guild_commands()` should require the host capability
39
57
  `discord.guild_commands.v1`.
40
58
 
41
59
  Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
42
60
  that layout model, Discord requires every later edit of the same message to remain a layout.
43
- Modules using them should depend on `kimi-agent-module-api>=1.2,<2` and require
61
+ Modules using them should require
44
62
  `discord.modals.v1` and/or `discord.components_v2.v1`.
63
+
64
+ Version 1.3 adds cached author classification to message-deletion events:
65
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
66
+ The values remain unknown for messages that were absent from Discord's cache.
67
+
68
+ Version 2 requires an explicit, source-pinned `ModuleSpec.api_version`, and
69
+ removes the temporary guild-settings legacy flag and module table aliases.
70
+ Modules must use namespaced guild documents and migrate legacy tables to the
71
+ physical names returned by `ctx.storage.table()` before upgrading.
72
+
73
+ ## Testing the SDK
74
+
75
+ From this package directory, run its tests without installing the Kimi application:
76
+
77
+ ```console
78
+ uv run --isolated --group test python -m pytest -q
79
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "1.2.0"
3
+ version = "2.0.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -18,6 +18,9 @@ Repository = "https://github.com/webhead2oo9/kimi-agent"
18
18
  # kimi_agent_module_api.testing.MemoryStorage runs real SQL in unit tests.
19
19
  testing = ["aiosqlite"]
20
20
 
21
+ [dependency-groups]
22
+ test = ["aiosqlite", "pytest", "pytest-asyncio"]
23
+
21
24
  [build-system]
22
25
  requires = ["setuptools>=84"]
23
26
  build-backend = "setuptools.build_meta"
@@ -27,3 +30,7 @@ where = ["src"]
27
30
 
28
31
  [tool.setuptools.package-data]
29
32
  kimi_agent_module_api = ["py.typed"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ asyncio_default_fixture_loop_scope = "function"
@@ -3,23 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable, Mapping, Sequence
6
- from dataclasses import dataclass, field
6
+ from dataclasses import KW_ONLY, dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import Any, Protocol, TypeVar
9
9
 
10
10
  from pydantic_settings import BaseSettings
11
11
 
12
+ from kimi_agent_module_api import contracts as _contracts
12
13
  from kimi_agent_module_api.contracts import (
13
14
  ConfigSnapshot,
14
15
  RoleSnapshot,
15
16
  render_guild_settings,
16
- DiscordActions,
17
- EventBus,
18
- GuildSettings,
19
17
  GuildSettingsSchema,
20
- HealthReporter,
21
18
  InviteSnapshot,
22
- InteractionRouter,
23
19
  LayoutGallery,
24
20
  LayoutItem,
25
21
  LayoutSection,
@@ -27,9 +23,7 @@ from kimi_agent_module_api.contracts import (
27
23
  LayoutSeparatorSpacing,
28
24
  LayoutText,
29
25
  ModalSpec,
30
- ModuleHttp,
31
26
  ModulePermissions,
32
- ModuleStorage,
33
27
  OutgoingLayout,
34
28
  ScopedModuleMigration,
35
29
  ProposalActor,
@@ -37,11 +31,8 @@ from kimi_agent_module_api.contracts import (
37
31
  ProposalRef,
38
32
  ProposalService,
39
33
  ProposalState,
40
- Scheduler,
41
34
  ServiceDeclaration,
42
- ServiceRegistry,
43
35
  ServiceRequirement,
44
- TrustLookup,
45
36
  TextInputSpec,
46
37
  TextInputStyle,
47
38
  )
@@ -53,7 +44,7 @@ from kimi_agent_module_api.tools import (
53
44
  )
54
45
  from kimi_agent_module_api.trust import TrustTier
55
46
 
56
- MODULE_API_VERSION = 1
47
+ MODULE_API_VERSION = 2
57
48
  MODULE_ENTRYPOINT_GROUP = "kimi_agent.modules"
58
49
  # Capabilities every compatible host advertises regardless of configuration.
59
50
  BASELINE_CAPABILITIES: frozenset[str] = frozenset({"discord.history.v1", "proposals.v2"})
@@ -82,10 +73,13 @@ class AppModule(Protocol):
82
73
 
83
74
  @dataclass(frozen=True)
84
75
  class ModuleSpec:
76
+ """A module declaration with an explicit, source-pinned host API version."""
77
+
85
78
  name: str
86
79
  version: str
87
80
  create: Callable[[ModuleLoadContext], AppModule]
88
- api_version: int = MODULE_API_VERSION
81
+ _: KW_ONLY
82
+ api_version: int
89
83
  dependencies: tuple[str, ...] = ()
90
84
  settings: ModuleSettingsDefinition | None = None
91
85
  requires_capabilities: tuple[str, ...] = ()
@@ -94,7 +88,6 @@ class ModuleSpec:
94
88
  guild_settings: GuildSettingsSchema | None = None
95
89
  provides: tuple[ServiceDeclaration, ...] = ()
96
90
  consumes: tuple[ServiceRequirement, ...] = ()
97
- table_aliases: Mapping[str, str] = field(default_factory=dict)
98
91
 
99
92
 
100
93
  @dataclass(frozen=True)
@@ -138,16 +131,16 @@ class ModuleRuntimeContext:
138
131
  is_guild_active: Callable[[int], bool]
139
132
  current_config_dir: Callable[[], Path]
140
133
  capabilities: ModuleCapabilities
141
- events: EventBus
142
- scheduler: Scheduler
143
- storage: ModuleStorage
144
- health: HealthReporter
145
- discord: DiscordActions
146
- interactions: InteractionRouter
147
- http: ModuleHttp
148
- services: ServiceRegistry
149
- trust: TrustLookup
150
- guild_settings: GuildSettings | None = None
134
+ events: _contracts.EventBus
135
+ scheduler: _contracts.Scheduler
136
+ storage: _contracts.ModuleStorage
137
+ health: _contracts.HealthReporter
138
+ discord: _contracts.DiscordActions
139
+ interactions: _contracts.InteractionRouter
140
+ http: _contracts.ModuleHttp
141
+ services: _contracts.ServiceRegistry
142
+ trust: _contracts.TrustLookup
143
+ guild_settings: _contracts.GuildSettings | None = None
151
144
  proposals: ProposalService | None = None
152
145
  raw_bot: Any = None
153
146
  raw_storage: Any = None
@@ -12,6 +12,7 @@ owner manifest and enforced through the ports below; they are not a sandbox.
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import keyword
15
16
  import math
16
17
  import re
17
18
  from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
@@ -326,6 +327,13 @@ CORE_TOPIC_PREFIX = "discord"
326
327
  _CORE_RESERVED_MODULE_NAMES = frozenset({CORE_TOPIC_PREFIX, "proposals"})
327
328
  CUSTOM_ID_PREFIX = "m"
328
329
  CUSTOM_ID_MAX_LENGTH = 100
330
+ # A modal ID also carries a fixed-width per-open suffix so two people opening the
331
+ # same form do not share one entry in the host's modal table. That suffix is not
332
+ # the module's to spend, so a modal's declared ID has a smaller budget than a
333
+ # button's. Fixed width on purpose: a variable one would shrink the budget as the
334
+ # host ran, letting a modal open successfully and then fail later.
335
+ MODAL_NONCE_CHARS = 8
336
+ MODAL_CUSTOM_ID_MAX_LENGTH = CUSTOM_ID_MAX_LENGTH - 1 - MODAL_NONCE_CHARS
329
337
 
330
338
 
331
339
  def table_prefix(module_name: str) -> str:
@@ -987,6 +995,7 @@ class TrustLookup(Protocol):
987
995
 
988
996
 
989
997
  type CommandOptionKind = Literal["string", "integer", "boolean", "user", "channel", "role"]
998
+ _OPTION_KINDS = frozenset({"string", "integer", "boolean", "user", "channel", "role"})
990
999
 
991
1000
 
992
1001
  @dataclass(frozen=True, slots=True)
@@ -1147,6 +1156,208 @@ class ModalSpec:
1147
1156
  parts: tuple[str, ...] = ()
1148
1157
 
1149
1158
 
1159
+ _OPTIONS_WITH_CHOICES = frozenset({"string", "integer"})
1160
+ # Keep command and group name validation in the SDK so the public fakes reject
1161
+ # the same payloads discord.py rejects while constructing the live tree.
1162
+ _COMMAND_NAME_RE = re.compile(
1163
+ r"^[-_\w\u0e31-\u0e3a\u0e47-\u0e4e\u0900-\u0903\u093a\u093b\u093c"
1164
+ r"\u093e\u093f\u0940-\u094f\u0955\u0956\u0957\u0962\u0963]{1,32}$"
1165
+ )
1166
+ _BUTTON_STYLES = frozenset({"primary", "secondary", "success", "danger"})
1167
+ _DISCORD_INTEGER_MAX = (1 << 53) - 1
1168
+ _DISCORD_INTEGER_MIN = -_DISCORD_INTEGER_MAX
1169
+
1170
+
1171
+ def validate_command_spec(spec: CommandSpec) -> None:
1172
+ """Validate the Discord limits a command payload must satisfy.
1173
+
1174
+ A whole ``tree.sync()`` is one bulk PUT, so a single malformed command
1175
+ rejects every command in that scope. Discord.py checks command and group
1176
+ *names* itself and reorders required options ahead of optional ones; these
1177
+ are the limits nothing else enforces before the payload reaches Discord.
1178
+ """
1179
+
1180
+ for label, name in (("command", spec.name), ("command group", spec.group)):
1181
+ if name is None:
1182
+ continue
1183
+ if (
1184
+ not isinstance(name, str)
1185
+ or _COMMAND_NAME_RE.fullmatch(name) is None
1186
+ or name.lower() != name
1187
+ ):
1188
+ raise ModuleContractError(f"invalid {label} name {name!r}")
1189
+ if not isinstance(spec.description, str) or not 1 <= len(spec.description) <= 100:
1190
+ raise ModuleContractError("a command description must contain 1 to 100 characters")
1191
+ if spec.group is not None and (
1192
+ not isinstance(spec.group_description, str) or len(spec.group_description) > 100
1193
+ ):
1194
+ raise ModuleContractError("a command group description cannot exceed 100 characters")
1195
+ if len(spec.options) > 25:
1196
+ raise ModuleContractError("a command cannot declare more than 25 options")
1197
+
1198
+ names: set[str] = set()
1199
+ for option in spec.options:
1200
+ # Discord applies the slash-command name grammar to option names, while
1201
+ # the host also builds each option into a Python parameter. Enforce the
1202
+ # intersection here: this keeps valid leading underscores and lowercase
1203
+ # Unicode identifiers while rejecting Discord-only names such as ``a-b``.
1204
+ if (
1205
+ not isinstance(option.name, str)
1206
+ or _COMMAND_NAME_RE.fullmatch(option.name) is None
1207
+ or option.name.lower() != option.name
1208
+ or not option.name.isidentifier()
1209
+ or keyword.iskeyword(option.name)
1210
+ ):
1211
+ raise ModuleContractError(f"invalid command option name {option.name!r}")
1212
+ if option.name in names:
1213
+ raise ModuleContractError("command option names must be unique")
1214
+ names.add(option.name)
1215
+ if option.kind not in _OPTION_KINDS:
1216
+ raise ModuleContractError(f"unsupported command option kind {option.kind!r}")
1217
+ if not isinstance(option.description, str) or not 1 <= len(option.description) <= 100:
1218
+ raise ModuleContractError(
1219
+ f"the description for option {option.name!r} must contain 1 to 100 characters"
1220
+ )
1221
+ if option.choices and option.autocomplete:
1222
+ # Discord rejects a payload carrying both, and discord.py emits both.
1223
+ raise ModuleContractError(
1224
+ f"option {option.name!r} cannot use choices and autocomplete together"
1225
+ )
1226
+ if (option.choices or option.autocomplete) and option.kind not in _OPTIONS_WITH_CHOICES:
1227
+ raise ModuleContractError(
1228
+ f"option {option.name!r} cannot use choices or autocomplete on a "
1229
+ f"{option.kind} option"
1230
+ )
1231
+ if len(option.choices) > 25:
1232
+ raise ModuleContractError(f"option {option.name!r} cannot declare more than 25 choices")
1233
+ values: set[str | int] = set()
1234
+ for name, value in option.choices:
1235
+ if not isinstance(name, str) or not 1 <= len(name) <= 100:
1236
+ raise ModuleContractError(f"invalid choice name {name!r} on option {option.name!r}")
1237
+ if isinstance(value, bool) or not isinstance(value, str | int):
1238
+ raise ModuleContractError(
1239
+ f"invalid choice value {value!r} on option {option.name!r}"
1240
+ )
1241
+ # A choice whose value type disagrees with its option is a 400 on the
1242
+ # whole payload, and nothing downstream cross-checks the two.
1243
+ if not isinstance(value, str if option.kind == "string" else int):
1244
+ raise ModuleContractError(
1245
+ f"choice values on {option.kind} option {option.name!r} must be "
1246
+ f"{'strings' if option.kind == 'string' else 'integers'}"
1247
+ )
1248
+ if (
1249
+ option.kind == "integer"
1250
+ and isinstance(value, int)
1251
+ and not _DISCORD_INTEGER_MIN <= value <= _DISCORD_INTEGER_MAX
1252
+ ):
1253
+ raise ModuleContractError(
1254
+ f"an integer choice value on option {option.name!r} must be between "
1255
+ f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
1256
+ )
1257
+ if isinstance(value, str) and not 1 <= len(value) <= 100:
1258
+ raise ModuleContractError(
1259
+ f"a choice value on option {option.name!r} must contain 1 to 100 characters"
1260
+ )
1261
+ if value in values:
1262
+ raise ModuleContractError(f"choice values on option {option.name!r} must be unique")
1263
+ values.add(value)
1264
+ for bound in (option.min_value, option.max_value):
1265
+ if bound is None:
1266
+ continue
1267
+ if isinstance(bound, bool) or not isinstance(bound, int):
1268
+ raise ModuleContractError(f"option {option.name!r} bounds must be integers")
1269
+ if option.kind != "integer":
1270
+ # Only integer options carry the bounds through to Discord, so a
1271
+ # bound anywhere else would be silently dropped.
1272
+ raise ModuleContractError(
1273
+ f"option {option.name!r} cannot set min_value or max_value on a "
1274
+ f"{option.kind} option"
1275
+ )
1276
+ if not _DISCORD_INTEGER_MIN <= bound <= _DISCORD_INTEGER_MAX:
1277
+ raise ModuleContractError(
1278
+ f"option {option.name!r} bounds must be between "
1279
+ f"{_DISCORD_INTEGER_MIN} and {_DISCORD_INTEGER_MAX}"
1280
+ )
1281
+ if (
1282
+ option.min_value is not None
1283
+ and option.max_value is not None
1284
+ and option.min_value > option.max_value
1285
+ ):
1286
+ raise ModuleContractError(f"option {option.name!r} min_value cannot exceed max_value")
1287
+
1288
+
1289
+ def validate_button_spec(button: ButtonSpec) -> None:
1290
+ """Validate Discord's hard button limits."""
1291
+
1292
+ if not isinstance(button.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(button.key):
1293
+ raise ModuleContractError(f"invalid button key {button.key!r}")
1294
+ if any(not isinstance(part, str) or ":" in part for part in button.parts):
1295
+ raise ModuleContractError("button custom_id parts must be strings without ':'")
1296
+ if not isinstance(button.label, str) or len(button.label) > 80:
1297
+ raise ModuleContractError("a button label cannot exceed 80 characters")
1298
+ if button.style not in _BUTTON_STYLES:
1299
+ raise ModuleContractError(f"invalid button style {button.style!r}")
1300
+ if button.emoji is not None and (not isinstance(button.emoji, str) or not button.emoji):
1301
+ raise ModuleContractError("a button emoji must be a non-empty string")
1302
+ if not button.label and button.emoji is None:
1303
+ raise ModuleContractError("a button must provide a label or emoji")
1304
+
1305
+
1306
+ def validate_component_spec(component: Any) -> None:
1307
+ """Validate one interactive component, whichever kind it is."""
1308
+
1309
+ if isinstance(component, ButtonSpec):
1310
+ validate_button_spec(component)
1311
+ elif isinstance(component, SelectSpec):
1312
+ validate_select_spec(component)
1313
+ else:
1314
+ raise ModuleContractError(f"unsupported component {component!r}")
1315
+
1316
+
1317
+ def validate_select_spec(select: SelectSpec) -> None:
1318
+ """Validate Discord's hard select-menu limits."""
1319
+
1320
+ if not isinstance(select.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(select.key):
1321
+ raise ModuleContractError(f"invalid select key {select.key!r}")
1322
+ if any(not isinstance(part, str) or ":" in part for part in select.parts):
1323
+ raise ModuleContractError("select custom_id parts must be strings without ':'")
1324
+ if select.placeholder is not None and (
1325
+ not isinstance(select.placeholder, str) or len(select.placeholder) > 150
1326
+ ):
1327
+ raise ModuleContractError("a select placeholder cannot exceed 150 characters")
1328
+ if not 1 <= len(select.options) <= 25:
1329
+ raise ModuleContractError("a select must contain between one and 25 options")
1330
+
1331
+ values: set[str] = set()
1332
+ for label, value, description in select.options:
1333
+ if not isinstance(label, str) or not 1 <= len(label) <= 100:
1334
+ raise ModuleContractError("a select option label must contain 1 to 100 characters")
1335
+ if not isinstance(value, str) or not 1 <= len(value) <= 100:
1336
+ raise ModuleContractError("a select option value must contain 1 to 100 characters")
1337
+ if value in values:
1338
+ raise ModuleContractError("select option values must be unique")
1339
+ values.add(value)
1340
+ if description is not None and (
1341
+ not isinstance(description, str) or not 1 <= len(description) <= 100
1342
+ ):
1343
+ raise ModuleContractError(
1344
+ "a select option description must contain 1 to 100 characters"
1345
+ )
1346
+
1347
+ for name, bound, low in (
1348
+ ("min_values", select.min_values, 0),
1349
+ ("max_values", select.max_values, 1),
1350
+ ):
1351
+ if isinstance(bound, bool) or not isinstance(bound, int) or not low <= bound <= 25:
1352
+ raise ModuleContractError(f"select {name} must be between {low} and 25")
1353
+ if select.min_values > select.max_values:
1354
+ raise ModuleContractError("select min_values cannot exceed max_values")
1355
+ # max_values may exceed the option count (Discord clamps it), but a
1356
+ # minimum above the count can never be satisfied, so the select is dead.
1357
+ if select.min_values > len(select.options):
1358
+ raise ModuleContractError("select min_values cannot exceed the number of options")
1359
+
1360
+
1150
1361
  def validate_modal_spec(modal: ModalSpec) -> None:
1151
1362
  """Validate Discord's hard modal and text-input limits."""
1152
1363
  if not isinstance(modal.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(modal.key):
@@ -1255,7 +1466,6 @@ class GuildSettingsSnapshot:
1255
1466
  valid: bool
1256
1467
  errors: tuple[str, ...]
1257
1468
  revision: str
1258
- legacy: bool = False
1259
1469
 
1260
1470
 
1261
1471
  class GuildSettings(Protocol):
@@ -69,11 +69,13 @@ class MessageDeleteEvent:
69
69
  author_id: int | None
70
70
  cached_content: str | None
71
71
  cached_attachments: tuple[AttachmentSnapshot, ...]
72
+ author_is_bot: bool | None = None
72
73
 
73
74
 
74
75
  @dataclass(frozen=True, slots=True)
75
76
  class MessageBulkDeleteEvent:
76
77
  refs: tuple[MessageRef, ...]
78
+ bot_message_ids: tuple[int, ...] = ()
77
79
 
78
80
 
79
81
  @dataclass(frozen=True, slots=True)