kimi-agent-module-api 1.1.0__tar.gz → 1.3.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.1.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-1.3.0}/PKG-INFO +18 -1
  2. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/README.md +17 -0
  3. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/pyproject.toml +8 -1
  4. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py +20 -0
  5. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py +398 -1
  6. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py +2 -0
  7. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/testing.py +259 -5
  8. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/tools.py +4 -0
  9. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +18 -1
  10. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +4 -1
  11. kimi_agent_module_api-1.3.0/tests/test_contracts.py +187 -0
  12. kimi_agent_module_api-1.3.0/tests/test_public_api.py +43 -0
  13. kimi_agent_module_api-1.3.0/tests/test_testing.py +114 -0
  14. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/LICENSE +0 -0
  15. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/setup.cfg +0 -0
  16. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/images.py +0 -0
  17. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/py.typed +0 -0
  18. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/settings.py +0 -0
  19. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/trust.py +0 -0
  20. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  21. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  22. {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.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.1.0
3
+ Version: 1.3.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -54,3 +54,20 @@ Guild-scoped live command replacement was added in 1.1. Modules using
54
54
  `InteractionRouter.replace_guild_commands()` should depend on
55
55
  `kimi-agent-module-api>=1.1,<2` and require the host capability
56
56
  `discord.guild_commands.v1`.
57
+
58
+ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
59
+ 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
61
+ `discord.modals.v1` and/or `discord.components_v2.v1`.
62
+
63
+ Version 1.3 adds cached author classification to message-deletion events:
64
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
65
+ The values remain unknown for messages that were absent from Discord's cache.
66
+
67
+ ## Testing the SDK
68
+
69
+ From this package directory, run its tests without installing the Kimi application:
70
+
71
+ ```console
72
+ uv run --isolated --group test python -m pytest -q
73
+ ```
@@ -37,3 +37,20 @@ Guild-scoped live command replacement was added in 1.1. Modules using
37
37
  `InteractionRouter.replace_guild_commands()` should depend on
38
38
  `kimi-agent-module-api>=1.1,<2` and require the host capability
39
39
  `discord.guild_commands.v1`.
40
+
41
+ Version 1.2 adds typed modal forms and a narrow Components V2 layout model. Once a response uses
42
+ 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
44
+ `discord.modals.v1` and/or `discord.components_v2.v1`.
45
+
46
+ Version 1.3 adds cached author classification to message-deletion events:
47
+ `MessageDeleteEvent.author_is_bot` and `MessageBulkDeleteEvent.bot_message_ids`.
48
+ The values remain unknown for messages that were absent from Discord's cache.
49
+
50
+ ## Testing the SDK
51
+
52
+ From this package directory, run its tests without installing the Kimi application:
53
+
54
+ ```console
55
+ uv run --isolated --group test python -m pytest -q
56
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "1.1.0"
3
+ version = "1.3.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"
@@ -20,9 +20,17 @@ from kimi_agent_module_api.contracts import (
20
20
  HealthReporter,
21
21
  InviteSnapshot,
22
22
  InteractionRouter,
23
+ LayoutGallery,
24
+ LayoutItem,
25
+ LayoutSection,
26
+ LayoutSeparator,
27
+ LayoutSeparatorSpacing,
28
+ LayoutText,
29
+ ModalSpec,
23
30
  ModuleHttp,
24
31
  ModulePermissions,
25
32
  ModuleStorage,
33
+ OutgoingLayout,
26
34
  ScopedModuleMigration,
27
35
  ProposalActor,
28
36
  ProposalError,
@@ -34,6 +42,8 @@ from kimi_agent_module_api.contracts import (
34
42
  ServiceRegistry,
35
43
  ServiceRequirement,
36
44
  TrustLookup,
45
+ TextInputSpec,
46
+ TextInputStyle,
37
47
  )
38
48
  from kimi_agent_module_api.settings import ModuleSetting, ModuleSettingsDefinition
39
49
  from kimi_agent_module_api.tools import (
@@ -151,6 +161,13 @@ __all__ = [
151
161
  "ConfigSnapshot",
152
162
  "GuildSettingsSchema",
153
163
  "InviteSnapshot",
164
+ "LayoutGallery",
165
+ "LayoutItem",
166
+ "LayoutSection",
167
+ "LayoutSeparator",
168
+ "LayoutSeparatorSpacing",
169
+ "LayoutText",
170
+ "ModalSpec",
154
171
  "ModuleCapabilities",
155
172
  "ModuleLoadContext",
156
173
  "ModulePermissions",
@@ -161,6 +178,7 @@ __all__ = [
161
178
  "ModuleToolContext",
162
179
  "ModuleToolHandler",
163
180
  "ModuleToolRegistry",
181
+ "OutgoingLayout",
164
182
  "ProposalActor",
165
183
  "ProposalError",
166
184
  "ProposalRef",
@@ -170,6 +188,8 @@ __all__ = [
170
188
  "ScopedModuleMigration",
171
189
  "ServiceDeclaration",
172
190
  "ServiceRequirement",
191
+ "TextInputSpec",
192
+ "TextInputStyle",
173
193
  "TrustTier",
174
194
  "render_guild_settings",
175
195
  ]
@@ -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:
@@ -819,6 +827,82 @@ class OutgoingEmbed:
819
827
  timestamp: bool = False
820
828
 
821
829
 
830
+ @dataclass(frozen=True, slots=True)
831
+ class LayoutText:
832
+ content: str
833
+
834
+
835
+ type LayoutSeparatorSpacing = Literal["small", "large"]
836
+
837
+
838
+ @dataclass(frozen=True, slots=True)
839
+ class LayoutSeparator:
840
+ visible: bool = True
841
+ spacing: LayoutSeparatorSpacing = "small"
842
+
843
+
844
+ @dataclass(frozen=True, slots=True)
845
+ class LayoutGallery:
846
+ urls: tuple[str, ...]
847
+
848
+
849
+ @dataclass(frozen=True, slots=True)
850
+ class LayoutSection:
851
+ texts: tuple[str, ...]
852
+ thumbnail_url: str
853
+
854
+
855
+ type LayoutItem = LayoutText | LayoutSeparator | LayoutGallery | LayoutSection
856
+
857
+
858
+ @dataclass(frozen=True, slots=True)
859
+ class OutgoingLayout:
860
+ """One Components V2 container, optionally followed by interactive controls."""
861
+
862
+ items: tuple[LayoutItem, ...]
863
+ accent_color: int | None = None
864
+
865
+
866
+ def validate_outgoing_layout(layout: OutgoingLayout) -> None:
867
+ """Validate the Discord hard limits represented by ``OutgoingLayout``."""
868
+ if not 1 <= len(layout.items) <= 40:
869
+ raise ModuleContractError("a layout must contain between one and 40 items")
870
+ if layout.accent_color is not None and (
871
+ isinstance(layout.accent_color, bool) or not 0 <= layout.accent_color <= 0xFFFFFF
872
+ ):
873
+ raise ModuleContractError("layout accent_color must be between 0 and 0xFFFFFF")
874
+
875
+ def validate_text(text: Any, label: str) -> None:
876
+ if not isinstance(text, str) or not 1 <= len(text) <= 4_000:
877
+ raise ModuleContractError(f"{label} must contain between one and 4000 characters")
878
+
879
+ text_length = 0
880
+ for item in layout.items:
881
+ if isinstance(item, LayoutText):
882
+ validate_text(item.content, "layout text")
883
+ text_length += len(item.content)
884
+ elif isinstance(item, LayoutSeparator):
885
+ if item.spacing not in ("small", "large"):
886
+ raise ModuleContractError(f"invalid layout separator spacing {item.spacing!r}")
887
+ elif isinstance(item, LayoutGallery):
888
+ if not 1 <= len(item.urls) <= 10:
889
+ raise ModuleContractError("a layout gallery must contain between one and 10 URLs")
890
+ if any(not isinstance(url, str) or not url for url in item.urls):
891
+ raise ModuleContractError("layout gallery URLs must be non-empty strings")
892
+ elif isinstance(item, LayoutSection):
893
+ if not 1 <= len(item.texts) <= 3:
894
+ raise ModuleContractError("a layout section must contain between one and 3 texts")
895
+ for text in item.texts:
896
+ validate_text(text, "layout section text")
897
+ text_length += len(text)
898
+ if not isinstance(item.thumbnail_url, str) or not item.thumbnail_url:
899
+ raise ModuleContractError("a layout section thumbnail URL must be non-empty")
900
+ else:
901
+ raise ModuleContractError(f"unsupported layout item {item!r}")
902
+ if text_length > 4_000:
903
+ raise ModuleContractError("layout text cannot exceed 4000 characters in total")
904
+
905
+
822
906
  class DiscordActions(Protocol):
823
907
  """Declared Discord operations on stable IDs.
824
908
 
@@ -911,6 +995,7 @@ class TrustLookup(Protocol):
911
995
 
912
996
 
913
997
  type CommandOptionKind = Literal["string", "integer", "boolean", "user", "channel", "role"]
998
+ _OPTION_KINDS = frozenset({"string", "integer", "boolean", "user", "channel", "role"})
914
999
 
915
1000
 
916
1001
  @dataclass(frozen=True, slots=True)
@@ -957,6 +1042,9 @@ class ModuleInteraction(Protocol):
957
1042
  @property
958
1043
  def values(self) -> tuple[str, ...]: ...
959
1044
 
1045
+ @property
1046
+ def text_values(self) -> Mapping[str, str]: ...
1047
+
960
1048
  @property
961
1049
  def message(self) -> MessageRef | None:
962
1050
  """The message a button or select lives on; ``None`` for slash commands."""
@@ -967,17 +1055,21 @@ class ModuleInteraction(Protocol):
967
1055
  content: str | None = None,
968
1056
  *,
969
1057
  embed: OutgoingEmbed | None = None,
1058
+ layout: OutgoingLayout | None = None,
970
1059
  ephemeral: bool = False,
971
1060
  components: Sequence[Any] = (),
972
1061
  ) -> None: ...
973
1062
 
974
1063
  async def defer(self, *, ephemeral: bool = False) -> None: ...
975
1064
 
1065
+ async def show_modal(self, modal: ModalSpec) -> None: ...
1066
+
976
1067
  async def edit_original(
977
1068
  self,
978
1069
  content: str | None = None,
979
1070
  *,
980
1071
  embed: OutgoingEmbed | None = None,
1072
+ layout: OutgoingLayout | None = None,
981
1073
  components: Sequence[Any] = (),
982
1074
  ) -> None: ...
983
1075
 
@@ -1013,11 +1105,316 @@ class SelectSpec:
1013
1105
  max_values: int = 1
1014
1106
 
1015
1107
 
1108
+ def validate_layout_components(
1109
+ components: Sequence[Any], *, layout: OutgoingLayout | None = None
1110
+ ) -> None:
1111
+ """Validate control rows and the full Components V2 descendant limit."""
1112
+ rows = 0
1113
+ buttons_in_row = 0
1114
+ for component in components:
1115
+ if isinstance(component, ButtonSpec):
1116
+ if buttons_in_row == 0:
1117
+ rows += 1
1118
+ buttons_in_row = (buttons_in_row + 1) % 5
1119
+ elif isinstance(component, SelectSpec):
1120
+ buttons_in_row = 0
1121
+ rows += 1
1122
+ else:
1123
+ raise ModuleContractError(f"unsupported component {component!r}")
1124
+ if rows > 5:
1125
+ raise ModuleContractError("layout controls cannot require more than five action rows")
1126
+ if layout is not None:
1127
+ descendants = 1 + rows + len(components)
1128
+ for item in layout.items:
1129
+ descendants += len(item.texts) + 2 if isinstance(item, LayoutSection) else 1
1130
+ if descendants > 40:
1131
+ raise ModuleContractError("a layout cannot exceed 40 components in total")
1132
+
1133
+
1134
+ type TextInputStyle = Literal["short", "paragraph"]
1135
+
1136
+
1137
+ @dataclass(frozen=True, slots=True)
1138
+ class TextInputSpec:
1139
+ key: str
1140
+ label: str
1141
+ style: TextInputStyle = "short"
1142
+ default: str | None = None
1143
+ placeholder: str | None = None
1144
+ required: bool = True
1145
+ min_length: int | None = None
1146
+ max_length: int | None = None
1147
+
1148
+
1149
+ @dataclass(frozen=True, slots=True)
1150
+ class ModalSpec:
1151
+ """A modal whose submit handler is registered under ``key``."""
1152
+
1153
+ key: str
1154
+ title: str
1155
+ inputs: tuple[TextInputSpec, ...]
1156
+ parts: tuple[str, ...] = ()
1157
+
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
+
1361
+ def validate_modal_spec(modal: ModalSpec) -> None:
1362
+ """Validate Discord's hard modal and text-input limits."""
1363
+ if not isinstance(modal.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(modal.key):
1364
+ raise ModuleContractError(f"invalid modal key {modal.key!r}")
1365
+ if any(not isinstance(part, str) or ":" in part for part in modal.parts):
1366
+ raise ModuleContractError("modal custom_id parts must be strings without ':'")
1367
+ if not isinstance(modal.title, str) or not 1 <= len(modal.title) <= 45:
1368
+ raise ModuleContractError("a modal title must contain between one and 45 characters")
1369
+ if not 1 <= len(modal.inputs) <= 5:
1370
+ raise ModuleContractError("a modal must contain between one and five text inputs")
1371
+
1372
+ keys: set[str] = set()
1373
+ for input_spec in modal.inputs:
1374
+ if not isinstance(input_spec.key, str) or not _TOPIC_SEGMENT_RE.fullmatch(input_spec.key):
1375
+ raise ModuleContractError(f"invalid modal text input key {input_spec.key!r}")
1376
+ if input_spec.key in keys:
1377
+ raise ModuleContractError("modal text input keys must be unique")
1378
+ keys.add(input_spec.key)
1379
+ if not isinstance(input_spec.label, str) or not 1 <= len(input_spec.label) <= 45:
1380
+ raise ModuleContractError(
1381
+ "a modal text input label must contain between one and 45 characters"
1382
+ )
1383
+ if input_spec.style not in ("short", "paragraph"):
1384
+ raise ModuleContractError(f"invalid modal text input style {input_spec.style!r}")
1385
+ if input_spec.placeholder is not None and (
1386
+ not isinstance(input_spec.placeholder, str) or len(input_spec.placeholder) > 100
1387
+ ):
1388
+ raise ModuleContractError("a modal text input placeholder cannot exceed 100 characters")
1389
+ if input_spec.default is not None and (
1390
+ not isinstance(input_spec.default, str) or len(input_spec.default) > 4_000
1391
+ ):
1392
+ raise ModuleContractError("a modal text input default cannot exceed 4000 characters")
1393
+ if input_spec.min_length is not None and (
1394
+ isinstance(input_spec.min_length, bool)
1395
+ or not isinstance(input_spec.min_length, int)
1396
+ or not 0 <= input_spec.min_length <= 4_000
1397
+ ):
1398
+ raise ModuleContractError("modal text input min_length must be between 0 and 4000")
1399
+ if input_spec.max_length is not None and (
1400
+ isinstance(input_spec.max_length, bool)
1401
+ or not isinstance(input_spec.max_length, int)
1402
+ or not 1 <= input_spec.max_length <= 4_000
1403
+ ):
1404
+ raise ModuleContractError("modal text input max_length must be between 1 and 4000")
1405
+ if (
1406
+ input_spec.min_length is not None
1407
+ and input_spec.max_length is not None
1408
+ and input_spec.min_length > input_spec.max_length
1409
+ ):
1410
+ raise ModuleContractError("modal text input min_length cannot exceed max_length")
1411
+
1412
+
1016
1413
  type CommandHandler = Callable[[ModuleInteraction], Awaitable[None]]
1017
1414
  type AutocompleteHandler = Callable[
1018
1415
  [ModuleInteraction, str, str], Awaitable[Sequence[tuple[str, str | int]]]
1019
1416
  ]
1020
- type ComponentKind = Literal["button", "select"]
1417
+ type ComponentKind = Literal["button", "select", "modal"]
1021
1418
 
1022
1419
 
1023
1420
  @dataclass(frozen=True, slots=True)
@@ -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)