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