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.
- {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
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/README.md +17 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/pyproject.toml +8 -1
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py +20 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py +398 -1
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py +2 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/testing.py +259 -5
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/tools.py +4 -0
- {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
- {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
- kimi_agent_module_api-1.3.0/tests/test_contracts.py +187 -0
- kimi_agent_module_api-1.3.0/tests/test_public_api.py +43 -0
- kimi_agent_module_api-1.3.0/tests/test_testing.py +114 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/LICENSE +0 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/setup.cfg +0 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/images.py +0 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/py.typed +0 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/settings.py +0 -0
- {kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/trust.py +0 -0
- {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
- {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
- {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.
|
|
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.
|
|
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"
|
{kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/__init__.py
RENAMED
|
@@ -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
|
]
|
{kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/contracts.py
RENAMED
|
@@ -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)
|
{kimi_agent_module_api-1.1.0 → kimi_agent_module_api-1.3.0}/src/kimi_agent_module_api/events.py
RENAMED
|
@@ -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)
|