tg-bot-plugin-sdk 0.2.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.
- tg_bot_plugin_sdk/__init__.py +88 -0
- tg_bot_plugin_sdk/admin_ops.py +134 -0
- tg_bot_plugin_sdk/capability.py +320 -0
- tg_bot_plugin_sdk/content.py +704 -0
- tg_bot_plugin_sdk/context.py +1511 -0
- tg_bot_plugin_sdk/exceptions.py +43 -0
- tg_bot_plugin_sdk/externalize.py +156 -0
- tg_bot_plugin_sdk/governance.py +635 -0
- tg_bot_plugin_sdk/plugin.py +31 -0
- tg_bot_plugin_sdk/providers.py +354 -0
- tg_bot_plugin_sdk/runtime.py +500 -0
- tg_bot_plugin_sdk-0.2.0.dist-info/METADATA +40 -0
- tg_bot_plugin_sdk-0.2.0.dist-info/RECORD +14 -0
- tg_bot_plugin_sdk-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""TG-BOT 插件作者侧 SDK。"""
|
|
2
|
+
|
|
3
|
+
from .capability import (
|
|
4
|
+
CAPABILITY_CONCURRENCY_ASYNC_SIDECAR,
|
|
5
|
+
CAPABILITY_CONCURRENCY_ASYNC_TERMINAL,
|
|
6
|
+
CAPABILITY_CONCURRENCY_SYNC_SESSION,
|
|
7
|
+
CAPABILITY_KIND_EVENT,
|
|
8
|
+
CAPABILITY_KIND_INVOKE,
|
|
9
|
+
PluginCapabilitySpec,
|
|
10
|
+
event_capability,
|
|
11
|
+
invoke_capability,
|
|
12
|
+
project_plugin_capability_contract,
|
|
13
|
+
resolve_plugin_capability_specs,
|
|
14
|
+
)
|
|
15
|
+
from .content import PluginContent
|
|
16
|
+
from .context import (
|
|
17
|
+
BackgroundTaskHandle,
|
|
18
|
+
BackgroundTaskObserver,
|
|
19
|
+
EventCapabilityContext,
|
|
20
|
+
PluginContext,
|
|
21
|
+
PluginAdminOpsSink,
|
|
22
|
+
PluginDurableTaskHandler,
|
|
23
|
+
PluginScheduler,
|
|
24
|
+
PluginTaskQueueProtocol,
|
|
25
|
+
ScheduledTaskHandle,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import CapabilityDeclarationError, PluginError, SdkContractError
|
|
28
|
+
from .externalize import (
|
|
29
|
+
DEFAULT_EXTERNALIZE_RELATIVE_PATH,
|
|
30
|
+
externalize_manifest_capability_contract,
|
|
31
|
+
project_manifest_capability_contract,
|
|
32
|
+
)
|
|
33
|
+
from .admin_ops import PluginAdminOps
|
|
34
|
+
from .plugin import BasePlugin
|
|
35
|
+
from .governance import GovernanceActionResult, PluginGovernance
|
|
36
|
+
from .providers import PluginProviderHandler, PluginProviders, ProviderCallError
|
|
37
|
+
from .runtime import (
|
|
38
|
+
CapabilityResult,
|
|
39
|
+
ManagedResourceAccessError,
|
|
40
|
+
ManagedDataDomainRoute,
|
|
41
|
+
ManagedResourceContext,
|
|
42
|
+
ManagedResourceHandle,
|
|
43
|
+
ManagedStorageProfileSummary,
|
|
44
|
+
RuntimeCapabilityInvocation,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BasePlugin",
|
|
49
|
+
"BackgroundTaskHandle",
|
|
50
|
+
"BackgroundTaskObserver",
|
|
51
|
+
"CAPABILITY_CONCURRENCY_ASYNC_SIDECAR",
|
|
52
|
+
"CAPABILITY_CONCURRENCY_ASYNC_TERMINAL",
|
|
53
|
+
"CAPABILITY_CONCURRENCY_SYNC_SESSION",
|
|
54
|
+
"CAPABILITY_KIND_EVENT",
|
|
55
|
+
"CAPABILITY_KIND_INVOKE",
|
|
56
|
+
"CapabilityDeclarationError",
|
|
57
|
+
"CapabilityResult",
|
|
58
|
+
"DEFAULT_EXTERNALIZE_RELATIVE_PATH",
|
|
59
|
+
"EventCapabilityContext",
|
|
60
|
+
"GovernanceActionResult",
|
|
61
|
+
"ManagedDataDomainRoute",
|
|
62
|
+
"ManagedResourceAccessError",
|
|
63
|
+
"ManagedResourceContext",
|
|
64
|
+
"ManagedResourceHandle",
|
|
65
|
+
"ManagedStorageProfileSummary",
|
|
66
|
+
"PluginAdminOps",
|
|
67
|
+
"PluginAdminOpsSink",
|
|
68
|
+
"PluginCapabilitySpec",
|
|
69
|
+
"PluginContent",
|
|
70
|
+
"PluginContext",
|
|
71
|
+
"PluginDurableTaskHandler",
|
|
72
|
+
"PluginError",
|
|
73
|
+
"PluginGovernance",
|
|
74
|
+
"PluginProviderHandler",
|
|
75
|
+
"PluginProviders",
|
|
76
|
+
"PluginScheduler",
|
|
77
|
+
"PluginTaskQueueProtocol",
|
|
78
|
+
"ProviderCallError",
|
|
79
|
+
"RuntimeCapabilityInvocation",
|
|
80
|
+
"ScheduledTaskHandle",
|
|
81
|
+
"SdkContractError",
|
|
82
|
+
"event_capability",
|
|
83
|
+
"externalize_manifest_capability_contract",
|
|
84
|
+
"invoke_capability",
|
|
85
|
+
"project_manifest_capability_contract",
|
|
86
|
+
"project_plugin_capability_contract",
|
|
87
|
+
"resolve_plugin_capability_specs",
|
|
88
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Author-facing admin_ops facade with binding-scoped runtime helper semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _clone_json(payload: Any) -> Any:
|
|
15
|
+
return json.loads(json.dumps(payload, ensure_ascii=True, default=str))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clone_mapping(payload: Any) -> dict[str, Any]:
|
|
19
|
+
if not isinstance(payload, Mapping):
|
|
20
|
+
return {}
|
|
21
|
+
cloned = _clone_json(dict(payload))
|
|
22
|
+
if isinstance(cloned, dict):
|
|
23
|
+
return cloned
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PluginAdminOps:
|
|
28
|
+
"""Expose minimal admin_ops helper surfaces without freezing storage internals."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, ctx: Any) -> None:
|
|
31
|
+
self._ctx = ctx
|
|
32
|
+
|
|
33
|
+
def is_available(self) -> bool:
|
|
34
|
+
return callable(getattr(self._ctx, "_admin_ops_sink", None))
|
|
35
|
+
|
|
36
|
+
def _sink(self) -> Any:
|
|
37
|
+
sink = getattr(self._ctx, "_admin_ops_sink", None)
|
|
38
|
+
if callable(sink):
|
|
39
|
+
return sink
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def _resource_scope(self) -> str:
|
|
43
|
+
resource_context = getattr(self._ctx, "resource_context", None)
|
|
44
|
+
return str(getattr(resource_context, "scope", "") or "").strip()
|
|
45
|
+
|
|
46
|
+
def _business_space(self) -> dict[str, Any]:
|
|
47
|
+
resource_context = getattr(self._ctx, "resource_context", None)
|
|
48
|
+
return _clone_mapping(getattr(resource_context, "business_space", {}))
|
|
49
|
+
|
|
50
|
+
def _resolve_dimension(self, payload: Mapping[str, Any], field_name: str, fallback: str = "") -> str:
|
|
51
|
+
normalized_value = str(payload.get(field_name) or fallback or "").strip()
|
|
52
|
+
if not normalized_value:
|
|
53
|
+
raise ValueError(f"{field_name} is required")
|
|
54
|
+
return normalized_value
|
|
55
|
+
|
|
56
|
+
def _resolve_bot_id(self, payload: Mapping[str, Any]) -> str:
|
|
57
|
+
return self._resolve_dimension(
|
|
58
|
+
payload,
|
|
59
|
+
"bot_id",
|
|
60
|
+
fallback=str(getattr(self._ctx, "bot_id", "") or "").strip(),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _resolve_resource_id(self, payload: Mapping[str, Any]) -> str:
|
|
64
|
+
resource_context = getattr(self._ctx, "resource_context", None)
|
|
65
|
+
fallback = str(getattr(resource_context, "resource_id", "") or "").strip()
|
|
66
|
+
return self._resolve_dimension(payload, "resource_id", fallback=fallback)
|
|
67
|
+
|
|
68
|
+
def _resolve_binding_id(self, payload: Mapping[str, Any]) -> str:
|
|
69
|
+
resource_context = getattr(self._ctx, "resource_context", None)
|
|
70
|
+
fallback = str(getattr(resource_context, "binding_id", "") or "").strip()
|
|
71
|
+
return self._resolve_dimension(payload, "binding_id", fallback=fallback)
|
|
72
|
+
|
|
73
|
+
def _build_entry(self, event_kind: str, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
74
|
+
normalized_payload = _clone_mapping(payload or {})
|
|
75
|
+
entry = {
|
|
76
|
+
"event_kind": str(event_kind or "").strip(),
|
|
77
|
+
"plugin_name": str(getattr(self._ctx, "plugin_name", "") or "").strip(),
|
|
78
|
+
"plugin_id": str(getattr(self._ctx, "plugin_id", "") or "").strip(),
|
|
79
|
+
"bot_id": self._resolve_bot_id(normalized_payload),
|
|
80
|
+
"resource_id": self._resolve_resource_id(normalized_payload),
|
|
81
|
+
"binding_id": self._resolve_binding_id(normalized_payload),
|
|
82
|
+
}
|
|
83
|
+
scope = self._resource_scope()
|
|
84
|
+
if scope:
|
|
85
|
+
entry["scope"] = scope
|
|
86
|
+
business_space = self._business_space()
|
|
87
|
+
if business_space:
|
|
88
|
+
entry["business_space"] = business_space
|
|
89
|
+
normalized_payload.setdefault("bot_id", entry["bot_id"])
|
|
90
|
+
normalized_payload.setdefault("resource_id", entry["resource_id"])
|
|
91
|
+
normalized_payload.setdefault("binding_id", entry["binding_id"])
|
|
92
|
+
entry["payload"] = normalized_payload
|
|
93
|
+
return entry
|
|
94
|
+
|
|
95
|
+
async def _persist_entry(self, entry: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
sink = self._sink()
|
|
97
|
+
if sink is None:
|
|
98
|
+
raise RuntimeError("admin_ops sink is unavailable")
|
|
99
|
+
try:
|
|
100
|
+
result = sink(_clone_mapping(entry))
|
|
101
|
+
if inspect.isawaitable(result):
|
|
102
|
+
await result
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception(
|
|
105
|
+
"Failed to persist admin_ops event=%s plugin=%s",
|
|
106
|
+
str(entry.get("event_kind") or "").strip(),
|
|
107
|
+
str(getattr(self._ctx, "plugin_name", "") or "").strip(),
|
|
108
|
+
)
|
|
109
|
+
raise
|
|
110
|
+
return entry
|
|
111
|
+
|
|
112
|
+
def build_operation_log_entry(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
113
|
+
return self._build_entry("operation_log", payload)
|
|
114
|
+
|
|
115
|
+
def build_bot_log_entry(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
116
|
+
return self._build_entry("bot_log", payload)
|
|
117
|
+
|
|
118
|
+
def build_name_change_event_entry(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
119
|
+
return self._build_entry("name_change_event", payload)
|
|
120
|
+
|
|
121
|
+
def build_permission_event_entry(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
122
|
+
return self._build_entry("permission_event", payload)
|
|
123
|
+
|
|
124
|
+
async def write_operation_log(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
125
|
+
return await self._persist_entry(self.build_operation_log_entry(payload))
|
|
126
|
+
|
|
127
|
+
async def write_bot_log(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
128
|
+
return await self._persist_entry(self.build_bot_log_entry(payload))
|
|
129
|
+
|
|
130
|
+
async def write_name_change_event(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
131
|
+
return await self._persist_entry(self.build_name_change_event_entry(payload))
|
|
132
|
+
|
|
133
|
+
async def write_permission_event(self, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
134
|
+
return await self._persist_entry(self.build_permission_event_entry(payload))
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""作者侧 capability 真源模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .exceptions import CapabilityDeclarationError, SdkContractError
|
|
11
|
+
|
|
12
|
+
CAPABILITY_KIND_INVOKE = "invoke"
|
|
13
|
+
CAPABILITY_KIND_EVENT = "event"
|
|
14
|
+
CAPABILITY_CONCURRENCY_SYNC_SESSION = "sync_session"
|
|
15
|
+
CAPABILITY_CONCURRENCY_ASYNC_SIDECAR = "async_sidecar"
|
|
16
|
+
CAPABILITY_CONCURRENCY_ASYNC_TERMINAL = "async_terminal"
|
|
17
|
+
DEFAULT_CAPABILITY_CONCURRENCY_CLASS = CAPABILITY_CONCURRENCY_SYNC_SESSION
|
|
18
|
+
DEFAULT_SUPPORTED_CHAT_TYPES = ("private", "group", "supergroup", "channel")
|
|
19
|
+
DEFAULT_FOLLOW_UP_ACTOR_MODES = ("inherit_root_actor",)
|
|
20
|
+
VALID_CONCURRENCY_CLASSES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
CAPABILITY_CONCURRENCY_SYNC_SESSION,
|
|
23
|
+
CAPABILITY_CONCURRENCY_ASYNC_SIDECAR,
|
|
24
|
+
CAPABILITY_CONCURRENCY_ASYNC_TERMINAL,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
VALID_ASYNC_IDEMPOTENCY_STRATEGIES = frozenset({"update_id", "message_revision"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _clone_json_value(value: Any) -> Any:
|
|
31
|
+
if value in (None, ""):
|
|
32
|
+
return None
|
|
33
|
+
return json.loads(json.dumps(value, ensure_ascii=True, default=str))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clone_json_object(value: Any) -> dict[str, Any] | None:
|
|
37
|
+
if value in (None, ""):
|
|
38
|
+
return None
|
|
39
|
+
cloned = _clone_json_value(value)
|
|
40
|
+
if isinstance(cloned, dict):
|
|
41
|
+
return cloned
|
|
42
|
+
raise SdkContractError("capability_json_invalid", "capability json object fields must be objects")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_json_tuple(values: Any) -> tuple[dict[str, Any], ...]:
|
|
46
|
+
if values in (None, ""):
|
|
47
|
+
return ()
|
|
48
|
+
if not isinstance(values, (list, tuple)):
|
|
49
|
+
raise SdkContractError("capability_json_invalid", "capability json tuple fields must be arrays")
|
|
50
|
+
result: list[dict[str, Any]] = []
|
|
51
|
+
for item in values:
|
|
52
|
+
cloned = _clone_json_object(item)
|
|
53
|
+
if cloned is not None:
|
|
54
|
+
result.append(cloned)
|
|
55
|
+
result.sort(key=lambda item: json.dumps(item, ensure_ascii=True, sort_keys=True))
|
|
56
|
+
return tuple(result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_string_tuple(values: Any) -> tuple[str, ...]:
|
|
60
|
+
if values in (None, ""):
|
|
61
|
+
return ()
|
|
62
|
+
if isinstance(values, str):
|
|
63
|
+
values = [values]
|
|
64
|
+
result: list[str] = []
|
|
65
|
+
seen: set[str] = set()
|
|
66
|
+
for item in values:
|
|
67
|
+
normalized = str(item or "").strip()
|
|
68
|
+
if not normalized or normalized in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(normalized)
|
|
71
|
+
result.append(normalized)
|
|
72
|
+
result.sort()
|
|
73
|
+
return tuple(result)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_concurrency_class(value: Any) -> str:
|
|
77
|
+
normalized = str(value or "").strip().lower() or DEFAULT_CAPABILITY_CONCURRENCY_CLASS
|
|
78
|
+
if normalized not in VALID_CONCURRENCY_CLASSES:
|
|
79
|
+
raise SdkContractError(
|
|
80
|
+
"capability_concurrency_class_invalid",
|
|
81
|
+
f"unsupported concurrency_class: {normalized}",
|
|
82
|
+
)
|
|
83
|
+
return normalized
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class PluginCapabilitySpec:
|
|
88
|
+
capability_id: str
|
|
89
|
+
description: str
|
|
90
|
+
title: str = ""
|
|
91
|
+
label: str = ""
|
|
92
|
+
kind: str = CAPABILITY_KIND_INVOKE
|
|
93
|
+
allowed_surfaces: tuple[str, ...] = ()
|
|
94
|
+
supported_chat_types: tuple[str, ...] = DEFAULT_SUPPORTED_CHAT_TYPES
|
|
95
|
+
input_schema: dict[str, Any] | None = None
|
|
96
|
+
output_schema: dict[str, Any] | None = None
|
|
97
|
+
result_schema: dict[str, Any] | None = None
|
|
98
|
+
filter_schema: dict[str, Any] | None = None
|
|
99
|
+
trigger_types: tuple[str, ...] = ()
|
|
100
|
+
concurrency_class: str = DEFAULT_CAPABILITY_CONCURRENCY_CLASS
|
|
101
|
+
delivery_mode: str = ""
|
|
102
|
+
idempotency_strategy: str = ""
|
|
103
|
+
presentations: tuple[dict[str, Any], ...] = ()
|
|
104
|
+
required_bot_admin_rights: tuple[str, ...] = ()
|
|
105
|
+
required_chat_traits: dict[str, Any] | None = None
|
|
106
|
+
privacy_mode_requirement: str = "any"
|
|
107
|
+
resource_override_slots: tuple[str, ...] = ()
|
|
108
|
+
follow_up_actor_modes: tuple[str, ...] = DEFAULT_FOLLOW_UP_ACTOR_MODES
|
|
109
|
+
requires_managed_webapp_auth_context: bool = False
|
|
110
|
+
allow_resource_override: bool = False
|
|
111
|
+
variables: tuple[dict[str, Any], ...] = ()
|
|
112
|
+
default_template: dict[str, Any] | None = None
|
|
113
|
+
|
|
114
|
+
def __post_init__(self) -> None:
|
|
115
|
+
object.__setattr__(self, "capability_id", str(self.capability_id or "").strip())
|
|
116
|
+
object.__setattr__(self, "description", str(self.description or "").strip())
|
|
117
|
+
object.__setattr__(self, "title", str(self.title or "").strip())
|
|
118
|
+
object.__setattr__(self, "label", str(self.label or "").strip())
|
|
119
|
+
if self.title and not self.label:
|
|
120
|
+
object.__setattr__(self, "label", self.title)
|
|
121
|
+
if self.label and not self.title:
|
|
122
|
+
object.__setattr__(self, "title", self.label)
|
|
123
|
+
object.__setattr__(self, "kind", CAPABILITY_KIND_EVENT if self.kind == CAPABILITY_KIND_EVENT else CAPABILITY_KIND_INVOKE)
|
|
124
|
+
object.__setattr__(self, "allowed_surfaces", _normalize_string_tuple(self.allowed_surfaces))
|
|
125
|
+
object.__setattr__(
|
|
126
|
+
self,
|
|
127
|
+
"supported_chat_types",
|
|
128
|
+
_normalize_string_tuple(self.supported_chat_types or DEFAULT_SUPPORTED_CHAT_TYPES),
|
|
129
|
+
)
|
|
130
|
+
object.__setattr__(self, "input_schema", _clone_json_object(self.input_schema))
|
|
131
|
+
object.__setattr__(self, "output_schema", _clone_json_object(self.output_schema))
|
|
132
|
+
object.__setattr__(self, "result_schema", _clone_json_object(self.result_schema))
|
|
133
|
+
object.__setattr__(self, "filter_schema", _clone_json_object(self.filter_schema))
|
|
134
|
+
object.__setattr__(self, "trigger_types", _normalize_string_tuple(self.trigger_types))
|
|
135
|
+
object.__setattr__(self, "concurrency_class", _normalize_concurrency_class(self.concurrency_class))
|
|
136
|
+
object.__setattr__(self, "delivery_mode", str(self.delivery_mode or "").strip().lower())
|
|
137
|
+
object.__setattr__(self, "idempotency_strategy", str(self.idempotency_strategy or "").strip().lower())
|
|
138
|
+
object.__setattr__(self, "presentations", _normalize_json_tuple(self.presentations))
|
|
139
|
+
object.__setattr__(self, "required_bot_admin_rights", _normalize_string_tuple(self.required_bot_admin_rights))
|
|
140
|
+
object.__setattr__(self, "required_chat_traits", _clone_json_object(self.required_chat_traits))
|
|
141
|
+
object.__setattr__(self, "privacy_mode_requirement", str(self.privacy_mode_requirement or "any").strip() or "any")
|
|
142
|
+
object.__setattr__(self, "resource_override_slots", _normalize_string_tuple(self.resource_override_slots))
|
|
143
|
+
object.__setattr__(self, "follow_up_actor_modes", _normalize_string_tuple(self.follow_up_actor_modes or DEFAULT_FOLLOW_UP_ACTOR_MODES))
|
|
144
|
+
object.__setattr__(self, "requires_managed_webapp_auth_context", bool(self.requires_managed_webapp_auth_context))
|
|
145
|
+
object.__setattr__(self, "allow_resource_override", bool(self.allow_resource_override or self.resource_override_slots))
|
|
146
|
+
object.__setattr__(self, "variables", _normalize_json_tuple(self.variables))
|
|
147
|
+
object.__setattr__(self, "default_template", _clone_json_object(self.default_template))
|
|
148
|
+
|
|
149
|
+
def to_contract_dict(self) -> dict[str, Any]:
|
|
150
|
+
payload: dict[str, Any] = {
|
|
151
|
+
"capability_id": self.capability_id,
|
|
152
|
+
"kind": self.kind,
|
|
153
|
+
"description": self.description,
|
|
154
|
+
"supported_chat_types": list(self.supported_chat_types),
|
|
155
|
+
"allow_resource_override": bool(self.allow_resource_override),
|
|
156
|
+
"variables": [dict(item) for item in self.variables],
|
|
157
|
+
}
|
|
158
|
+
if self.title:
|
|
159
|
+
payload["title"] = self.title
|
|
160
|
+
if self.label:
|
|
161
|
+
payload["label"] = self.label
|
|
162
|
+
if self.allowed_surfaces:
|
|
163
|
+
payload["allowed_surfaces"] = list(self.allowed_surfaces)
|
|
164
|
+
if self.input_schema:
|
|
165
|
+
payload["input_schema"] = dict(self.input_schema)
|
|
166
|
+
if self.output_schema:
|
|
167
|
+
payload["output_schema"] = dict(self.output_schema)
|
|
168
|
+
if self.result_schema:
|
|
169
|
+
payload["result_schema"] = dict(self.result_schema)
|
|
170
|
+
if self.filter_schema:
|
|
171
|
+
payload["filter_schema"] = dict(self.filter_schema)
|
|
172
|
+
if self.trigger_types:
|
|
173
|
+
payload["trigger_types"] = list(self.trigger_types)
|
|
174
|
+
payload["concurrency_class"] = self.concurrency_class
|
|
175
|
+
if self.delivery_mode:
|
|
176
|
+
payload["delivery_mode"] = self.delivery_mode
|
|
177
|
+
if self.idempotency_strategy:
|
|
178
|
+
payload["idempotency_strategy"] = self.idempotency_strategy
|
|
179
|
+
if self.presentations:
|
|
180
|
+
payload["presentations"] = [dict(item) for item in self.presentations]
|
|
181
|
+
if self.required_bot_admin_rights:
|
|
182
|
+
payload["required_bot_admin_rights"] = list(self.required_bot_admin_rights)
|
|
183
|
+
if self.required_chat_traits:
|
|
184
|
+
payload["required_chat_traits"] = dict(self.required_chat_traits)
|
|
185
|
+
if self.privacy_mode_requirement:
|
|
186
|
+
payload["privacy_mode_requirement"] = self.privacy_mode_requirement
|
|
187
|
+
if self.resource_override_slots:
|
|
188
|
+
payload["resource_override_slots"] = list(self.resource_override_slots)
|
|
189
|
+
if self.follow_up_actor_modes:
|
|
190
|
+
payload["follow_up_actor_modes"] = list(self.follow_up_actor_modes)
|
|
191
|
+
if self.requires_managed_webapp_auth_context:
|
|
192
|
+
payload["requires_managed_webapp_auth_context"] = True
|
|
193
|
+
if self.default_template:
|
|
194
|
+
payload["default_template"] = dict(self.default_template)
|
|
195
|
+
return payload
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class PluginCapabilityBinding:
|
|
200
|
+
spec: PluginCapabilitySpec
|
|
201
|
+
method_name: str
|
|
202
|
+
handler: Callable[..., Any]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_plugin_capability_specs(specs: list[PluginCapabilitySpec]) -> None:
|
|
206
|
+
for spec in specs:
|
|
207
|
+
if not spec.capability_id:
|
|
208
|
+
raise SdkContractError("capability_id_required", "capability_id is required")
|
|
209
|
+
if not spec.description:
|
|
210
|
+
raise SdkContractError("capability_description_required", f"{spec.capability_id}: description is required")
|
|
211
|
+
delivery_mode = spec.delivery_mode or "inline"
|
|
212
|
+
if spec.concurrency_class == CAPABILITY_CONCURRENCY_SYNC_SESSION:
|
|
213
|
+
if delivery_mode == "durable_queue":
|
|
214
|
+
raise SdkContractError(
|
|
215
|
+
"capability_sync_session_delivery_mode_invalid",
|
|
216
|
+
f"{spec.capability_id}: sync_session capabilities must not declare durable_queue delivery_mode",
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
if delivery_mode != "durable_queue":
|
|
220
|
+
raise SdkContractError(
|
|
221
|
+
"capability_async_delivery_mode_invalid",
|
|
222
|
+
f"{spec.capability_id}: async capabilities must declare delivery_mode=durable_queue",
|
|
223
|
+
)
|
|
224
|
+
if spec.idempotency_strategy not in VALID_ASYNC_IDEMPOTENCY_STRATEGIES:
|
|
225
|
+
raise SdkContractError(
|
|
226
|
+
"capability_async_idempotency_strategy_invalid",
|
|
227
|
+
f"{spec.capability_id}: async capabilities must declare idempotency_strategy=update_id|message_revision",
|
|
228
|
+
)
|
|
229
|
+
if spec.kind == CAPABILITY_KIND_EVENT:
|
|
230
|
+
if spec.allowed_surfaces:
|
|
231
|
+
raise SdkContractError(
|
|
232
|
+
"event_capability_allowed_surfaces_forbidden",
|
|
233
|
+
f"{spec.capability_id}: event capabilities must not declare allowed_surfaces",
|
|
234
|
+
)
|
|
235
|
+
continue
|
|
236
|
+
if spec.input_schema is None:
|
|
237
|
+
raise SdkContractError(
|
|
238
|
+
"invoke_capability_input_schema_required",
|
|
239
|
+
f"{spec.capability_id}: invoke capabilities must declare input_schema",
|
|
240
|
+
)
|
|
241
|
+
if spec.output_schema is None:
|
|
242
|
+
raise SdkContractError(
|
|
243
|
+
"invoke_capability_output_schema_required",
|
|
244
|
+
f"{spec.capability_id}: invoke capabilities must declare output_schema",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _record_capability_spec(
|
|
249
|
+
specs_by_id: dict[str, PluginCapabilitySpec],
|
|
250
|
+
spec: PluginCapabilitySpec,
|
|
251
|
+
*,
|
|
252
|
+
origin: str,
|
|
253
|
+
) -> None:
|
|
254
|
+
if spec.capability_id in specs_by_id:
|
|
255
|
+
raise CapabilityDeclarationError(
|
|
256
|
+
"duplicate_capability_id",
|
|
257
|
+
f"duplicate capability_id detected: {spec.capability_id}",
|
|
258
|
+
details={"origin": origin},
|
|
259
|
+
)
|
|
260
|
+
specs_by_id[spec.capability_id] = spec
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def invoke_capability(**kwargs: Any):
|
|
264
|
+
spec = PluginCapabilitySpec(kind=CAPABILITY_KIND_INVOKE, **kwargs)
|
|
265
|
+
|
|
266
|
+
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
267
|
+
setattr(func, "__tg_bot_capability_spec__", spec)
|
|
268
|
+
return func
|
|
269
|
+
|
|
270
|
+
return _decorator
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def event_capability(**kwargs: Any):
|
|
274
|
+
spec = PluginCapabilitySpec(kind=CAPABILITY_KIND_EVENT, **kwargs)
|
|
275
|
+
|
|
276
|
+
def _decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
277
|
+
setattr(func, "__tg_bot_capability_spec__", spec)
|
|
278
|
+
return func
|
|
279
|
+
|
|
280
|
+
return _decorator
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _collect_decorated_capability_bindings(plugin: Any) -> list[PluginCapabilityBinding]:
|
|
284
|
+
bindings: list[PluginCapabilityBinding] = []
|
|
285
|
+
seen_methods: set[str] = set()
|
|
286
|
+
for cls in reversed(type(plugin).mro()):
|
|
287
|
+
if cls is object:
|
|
288
|
+
continue
|
|
289
|
+
for method_name, candidate in cls.__dict__.items():
|
|
290
|
+
if method_name in seen_methods:
|
|
291
|
+
continue
|
|
292
|
+
spec = getattr(candidate, "__tg_bot_capability_spec__", None)
|
|
293
|
+
if not isinstance(spec, PluginCapabilitySpec):
|
|
294
|
+
continue
|
|
295
|
+
bound_handler = getattr(plugin, method_name, None)
|
|
296
|
+
if not callable(bound_handler):
|
|
297
|
+
continue
|
|
298
|
+
bindings.append(PluginCapabilityBinding(spec=spec, method_name=method_name, handler=bound_handler))
|
|
299
|
+
seen_methods.add(method_name)
|
|
300
|
+
bindings.sort(key=lambda item: (item.spec.capability_id, item.method_name))
|
|
301
|
+
return bindings
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def resolve_plugin_capability_specs(plugin: Any) -> list[PluginCapabilitySpec]:
|
|
305
|
+
specs_by_id: dict[str, PluginCapabilitySpec] = {}
|
|
306
|
+
if "get_capability_specs" in type(plugin).__dict__:
|
|
307
|
+
for spec in plugin.get_capability_specs():
|
|
308
|
+
if not isinstance(spec, PluginCapabilitySpec):
|
|
309
|
+
raise TypeError("plugin capability specs must be PluginCapabilitySpec")
|
|
310
|
+
_record_capability_spec(specs_by_id, spec, origin="get_capability_specs")
|
|
311
|
+
for binding in _collect_decorated_capability_bindings(plugin):
|
|
312
|
+
_record_capability_spec(specs_by_id, binding.spec, origin=f"decorated:{binding.method_name}")
|
|
313
|
+
return [specs_by_id[key] for key in sorted(specs_by_id.keys())]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def project_plugin_capability_contract(plugin: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
317
|
+
specs = resolve_plugin_capability_specs(plugin)
|
|
318
|
+
_validate_plugin_capability_specs(specs)
|
|
319
|
+
capability_payload = [spec.to_contract_dict() for spec in specs]
|
|
320
|
+
return {"capabilities": capability_payload}, capability_payload
|