From 0ea532bd91e9566f041a1bde87151cf52eb1cb9d Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 14 Mar 2026 22:32:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E4=BF=A1=E6=81=AF=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=BB=84=E4=BB=B6=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=B3=A8=E5=85=A5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-new/astrbot_sdk/errors.py | 18 +++- .../astrbot_sdk/runtime/capability_router.py | 91 +++++++++++++++--- .../astrbot_sdk/runtime/handler_dispatcher.py | 95 +++++++++++++++++-- src-new/astrbot_sdk/runtime/loader.py | 90 ++++++++++++++---- src-new/astrbot_sdk/testing.py | 2 + tests_v4/test_capability_router.py | 10 +- tests_v4/test_handler_dispatcher.py | 17 +++- tests_v4/test_testing_module.py | 44 +++++++++ 8 files changed, 319 insertions(+), 48 deletions(-) diff --git a/src-new/astrbot_sdk/errors.py b/src-new/astrbot_sdk/errors.py index 50f53ca44..f188e15ef 100644 --- a/src-new/astrbot_sdk/errors.py +++ b/src-new/astrbot_sdk/errors.py @@ -56,11 +56,16 @@ class AstrBotError(Exception): ) @classmethod - def invalid_input(cls, message: str) -> "AstrBotError": + def invalid_input( + cls, + message: str, + *, + hint: str = "请检查调用参数", + ) -> "AstrBotError": return cls( code=ErrorCodes.INVALID_INPUT, message=message, - hint="请检查调用参数", + hint=hint, retryable=False, ) @@ -83,11 +88,16 @@ class AstrBotError(Exception): ) @classmethod - def internal_error(cls, message: str) -> "AstrBotError": + def internal_error( + cls, + message: str, + *, + hint: str = "请联系插件作者", + ) -> "AstrBotError": return cls( code=ErrorCodes.INTERNAL_ERROR, message=message, - hint="请联系插件作者", + hint=hint, retryable=False, ) diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py index 9746dd80c..7c0a974c0 100644 --- a/src-new/astrbot_sdk/runtime/capability_router.py +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -260,7 +260,12 @@ class CapabilityRouter: if registration is None: raise AstrBotError.capability_not_found(capability) - self._validate_schema(registration.descriptor.input_schema, payload) + self._validate_schema_with_context( + capability=capability, + phase="输入", + schema=registration.descriptor.input_schema, + payload=payload, + ) if stream: if registration.stream_handler is None: raise AstrBotError.invalid_input(f"{capability} 不支持 stream=true") @@ -286,7 +291,12 @@ class CapabilityRouter: if registration.call_handler is None: raise AstrBotError.invalid_input(f"{capability} 只能以 stream=true 调用") output = await registration.call_handler(request_id, payload, cancel_token) - self._validate_schema(registration.descriptor.output_schema, output) + self._validate_schema_with_context( + capability=capability, + phase="输出", + schema=registration.descriptor.output_schema, + payload=output, + ) return output def _wrap_stream_execution( @@ -296,7 +306,12 @@ class CapabilityRouter: ) -> StreamExecution: def validated_finalize(chunks: list[dict[str, Any]]) -> dict[str, Any]: output = execution.finalize(chunks) - self._validate_schema(descriptor.output_schema, output) + self._validate_schema_with_context( + capability=descriptor.name, + phase="输出", + schema=descriptor.output_schema, + payload=output, + ) return output return StreamExecution( @@ -911,6 +926,26 @@ class CapabilityRouter: return self._validate_value(schema, payload, path="") + def _validate_schema_with_context( + self, + *, + capability: str, + phase: str, + schema: dict[str, Any] | None, + payload: Any, + ) -> None: + try: + self._validate_schema(schema, payload) + except AstrBotError as exc: + if exc.code != "invalid_input": + raise + raise AstrBotError.invalid_input( + f"capability '{capability}' 的{phase}校验失败:{exc.message}", + hint=( + f"请检查 capability '{capability}' 的{phase.lower()}是否符合声明的 schema" + ), + ) from exc + def _validate_value( self, schema: dict[str, Any], @@ -929,20 +964,26 @@ class CapabilityRouter: except AstrBotError: continue raise AstrBotError.invalid_input( - f"{self._field_label(path)} 不符合允许的 schema 约束" + f"{self._field_label(path)} 不符合允许的 schema 约束," + f"实际收到 {self._value_type_name(value)}" ) enum = schema.get("enum") if isinstance(enum, list) and value not in enum: - raise AstrBotError.invalid_input(f"{self._field_label(path)} 必须是 {enum}") + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 {enum},实际收到 {value!r}" + ) schema_type = schema.get("type") if schema_type == "object": if not isinstance(value, dict): if not path: - raise AstrBotError.invalid_input("输入必须是 object") + raise AstrBotError.invalid_input( + f"输入必须是 object,实际收到 {self._value_type_name(value)}" + ) raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 object" + f"{self._field_label(path)} 必须是 object," + f"实际收到 {self._value_type_name(value)}" ) properties = schema.get("properties", {}) required_fields = schema.get("required", []) @@ -973,7 +1014,8 @@ class CapabilityRouter: if schema_type == "array": if not isinstance(value, list): raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 array" + f"{self._field_label(path)} 必须是 array," + f"实际收到 {self._value_type_name(value)}" ) item_schema = schema.get("items") if isinstance(item_schema, dict): @@ -988,35 +1030,40 @@ class CapabilityRouter: if schema_type == "string": if not isinstance(value, str): raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 string" + f"{self._field_label(path)} 必须是 string," + f"实际收到 {self._value_type_name(value)}" ) return if schema_type == "integer": if not isinstance(value, int) or isinstance(value, bool): raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 integer" + f"{self._field_label(path)} 必须是 integer," + f"实际收到 {self._value_type_name(value)}" ) return if schema_type == "number": if not isinstance(value, (int, float)) or isinstance(value, bool): raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 number" + f"{self._field_label(path)} 必须是 number," + f"实际收到 {self._value_type_name(value)}" ) return if schema_type == "boolean": if not isinstance(value, bool): raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 boolean" + f"{self._field_label(path)} 必须是 boolean," + f"实际收到 {self._value_type_name(value)}" ) return if schema_type == "null": if value is not None: raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 null" + f"{self._field_label(path)} 必须是 null," + f"实际收到 {self._value_type_name(value)}" ) return @@ -1061,3 +1108,21 @@ class CapabilityRouter: isinstance(candidate, dict) and candidate.get("type") == "null" for candidate in any_of ) + + @staticmethod + def _value_type_name(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return type(value).__name__ diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index efa4c7e64..8f8ec0eb3 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -106,7 +106,14 @@ class HandlerDispatcher: ) -> None: try: result = loaded.callable( - *self._build_args(loaded.callable, event, ctx, args) + *self._build_args( + loaded.callable, + event, + ctx, + args, + plugin_id=self._resolve_plugin_id(loaded), + handler_ref=loaded.descriptor.id, + ) ) if inspect.isasyncgen(result): async for item in result: @@ -133,6 +140,9 @@ class HandlerDispatcher: event: MessageEvent, ctx: Context, args: dict[str, Any] | None = None, + *, + plugin_id: str | None = None, + handler_ref: str | None = None, ) -> list[Any]: """构建 handler 参数列表。""" from loguru import logger @@ -180,8 +190,13 @@ class HandlerDispatcher: parameter.name, ) raise TypeError( - f"handler '{handler.__name__}' 的必填参数 " - f"'{parameter.name}' 无法注入" + self._format_handler_injection_error( + handler=handler, + parameter_name=parameter.name, + plugin_id=plugin_id, + handler_ref=handler_ref, + args=args, + ) ) else: injected_args.append(injected) @@ -219,6 +234,36 @@ class HandlerDispatcher: return None + def _format_handler_injection_error( + self, + *, + handler, + parameter_name: str, + plugin_id: str | None, + handler_ref: str | None, + args: dict[str, Any], + ) -> str: + plugin_text = plugin_id or self._plugin_id + target = handler_ref or getattr(handler, "__name__", "") + arg_keys = sorted(str(key) for key in args.keys()) + arg_keys_text = ", ".join(arg_keys) if arg_keys else "" + return ( + f"插件 '{plugin_text}' 的 handler '{target}' 参数注入失败:" + f"必填参数 '{parameter_name}' 无法注入。" + f"签名: {getattr(handler, '__name__', '')}" + f"{self._callable_signature(handler)}。" + "当前支持按类型注入 MessageEvent / Context," + "按参数名注入 event / ctx / context," + f"以及 args 中现有键:{arg_keys_text}。" + ) + + @staticmethod + def _callable_signature(handler) -> str: + try: + return str(inspect.signature(handler)) + except (TypeError, ValueError): + return "(...)" + async def _send_result( self, item: Any, @@ -325,7 +370,14 @@ class CapabilityDispatcher: stream: bool, ) -> dict[str, Any] | StreamExecution: result = loaded.callable( - *self._build_args(loaded.callable, payload, ctx, cancel_token) + *self._build_args( + loaded.callable, + payload, + ctx, + cancel_token, + plugin_id=self._resolve_plugin_id(loaded), + capability_name=loaded.descriptor.name, + ) ) if stream: if inspect.isasyncgen(result): @@ -355,6 +407,9 @@ class CapabilityDispatcher: payload: dict[str, Any], ctx: Context, cancel_token: CancelToken, + *, + plugin_id: str | None = None, + capability_name: str | None = None, ) -> list[Any]: signature = inspect.signature(handler) args: list[Any] = [] @@ -389,8 +444,13 @@ class CapabilityDispatcher: if parameter.default is not parameter.empty: continue raise TypeError( - f"capability '{handler.__name__}' 的必填参数 " - f"'{parameter.name}' 无法注入" + self._format_capability_injection_error( + handler=handler, + parameter_name=parameter.name, + plugin_id=plugin_id, + capability_name=capability_name, + payload=payload, + ) ) args.append(injected) @@ -423,6 +483,29 @@ class CapabilityDispatcher: return payload return None + def _format_capability_injection_error( + self, + *, + handler, + parameter_name: str, + plugin_id: str | None, + capability_name: str | None, + payload: dict[str, Any], + ) -> str: + plugin_text = plugin_id or self._plugin_id + target = capability_name or getattr(handler, "__name__", "") + payload_keys = sorted(str(key) for key in payload.keys()) + payload_keys_text = ", ".join(payload_keys) if payload_keys else "" + return ( + f"插件 '{plugin_text}' 的 capability '{target}' 参数注入失败:" + f"必填参数 '{parameter_name}' 无法注入。" + f"签名: {getattr(handler, '__name__', '')}" + f"{HandlerDispatcher._callable_signature(handler)}。" + "当前支持按类型注入 Context / CancelToken / dict," + "按参数名注入 ctx / context / payload / input / data / cancel_token / token," + f"以及 payload 中现有键:{payload_keys_text}。" + ) + async def _iterate_generator( self, generator: AsyncIterator[Any], diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index 0669578fa..b966f557e 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -131,6 +131,13 @@ class LoadedPlugin: instances: list[Any] = field(default_factory=list) +@dataclass(slots=True) +class _ResolvedComponent: + cls: type[Any] + class_path: str + index: int + + def _iter_handler_names(instance: Any) -> list[str]: handler_names = getattr(instance.__class__, "__handlers__", ()) if handler_names: @@ -145,6 +152,14 @@ def _iter_discoverable_names(instance: Any) -> list[str]: return [*handler_names, *extra_names] +def _plugin_context(plugin: PluginSpec) -> str: + return f"插件 '{plugin.name}'({plugin.manifest_path})" + + +def _component_context(plugin: PluginSpec, *, class_path: str, index: int) -> str: + return f"{_plugin_context(plugin)} 的 components[{index}].class='{class_path}'" + + def _resolve_handler_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: """解析 handler 名称,避免在扫描阶段触发无关 descriptor 副作用。""" try: @@ -309,25 +324,48 @@ def _is_new_star_component(cls: type[Any]) -> bool: return bool(getattr(cls, "__astrbot_is_new_star__", False)) -def _plugin_component_classes(plugin: PluginSpec) -> list[type[Any]]: +def _plugin_component_classes(plugin: PluginSpec) -> list[_ResolvedComponent]: """解析插件组件类列表。""" components = plugin.manifest_data.get("components") or [] if not isinstance(components, list): return [] - classes: list[type[Any]] = [] - for component in components: + classes: list[_ResolvedComponent] = [] + for index, component in enumerate(components): if not isinstance(component, dict): - continue + raise ValueError( + f"{_plugin_context(plugin)} 的 components[{index}] 必须是 object。" + ) class_path = component.get("class") if not isinstance(class_path, str) or ":" not in class_path: - continue + raise ValueError( + f"{_plugin_context(plugin)} 的 components[{index}].class " + "必须是 ':'。" + ) try: cls = import_string(class_path, plugin.plugin_dir) - if isinstance(cls, type): - classes.append(cls) - except Exception: - continue + except Exception as exc: + raise ValueError( + f"{_component_context(plugin, class_path=class_path, index=index)} " + f"加载失败:{exc}" + ) from exc + if not isinstance(cls, type): + raise ValueError( + f"{_component_context(plugin, class_path=class_path, index=index)} " + "解析结果不是类,请检查导出名称。" + ) + classes.append( + _ResolvedComponent( + cls=cls, + class_path=class_path, + index=index, + ) + ) + if not classes: + raise ValueError( + f"{_plugin_context(plugin)} 未声明任何可加载组件。" + "请检查 plugin.yaml 中的 components 配置。" + ) return classes @@ -338,7 +376,7 @@ def load_plugin_spec(plugin_dir: Path) -> PluginSpec: requirements_path = plugin_dir / "requirements.txt" if not manifest_path.exists(): - raise ValueError(f"missing {PLUGIN_MANIFEST_FILE}") + raise ValueError(f"插件目录 '{plugin_dir}' 缺少 {PLUGIN_MANIFEST_FILE}。") manifest_data = _read_yaml(manifest_path) runtime = manifest_data.get("runtime") or {} @@ -357,29 +395,33 @@ def load_plugin_spec(plugin_dir: Path) -> PluginSpec: def validate_plugin_spec(plugin: PluginSpec) -> None: """校验单个插件规范,供 CLI 和发现流程复用。""" manifest_data = plugin.manifest_data + manifest_label = f"插件 '{plugin.name}'({plugin.manifest_path})" if not plugin.requirements_path.exists(): - raise ValueError("missing requirements.txt") + raise ValueError(f"{manifest_label} 缺少 requirements.txt。") raw_name = manifest_data.get("name") if not isinstance(raw_name, str) or not raw_name: - raise ValueError("plugin name is required") + raise ValueError(f"{manifest_label} 缺少 name。") raw_runtime = manifest_data.get("runtime") or {} raw_python = raw_runtime.get("python") if not isinstance(raw_python, str) or not raw_python: - raise ValueError("runtime.python is required") + raise ValueError(f"{manifest_label} 缺少 runtime.python。") components = manifest_data.get("components") if not isinstance(components, list): - raise ValueError("components must be a list") + raise ValueError(f"{manifest_label} 的 components 必须是数组。") for index, component in enumerate(components): if not isinstance(component, dict): - raise ValueError(f"components[{index}] must be an object") + raise ValueError(f"{manifest_label} 的 components[{index}] 必须是 object。") class_path = component.get("class") if not isinstance(class_path, str) or ":" not in class_path: - raise ValueError(f"components[{index}].class must be ':'") + raise ValueError( + f"{manifest_label} 的 components[{index}].class " + "必须是 ':'。" + ) def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult: @@ -548,13 +590,21 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin: handlers: list[LoadedHandler] = [] capabilities: list[LoadedCapability] = [] - for component_cls in _plugin_component_classes(plugin): + for resolved_component in _plugin_component_classes(plugin): + component_cls = resolved_component.cls if not _is_new_star_component(component_cls): raise ValueError( - f"组件 {component_cls.__name__} 不是 v4 Star 组件。" - "旧版插件请使用 AstrBot 主程序运行。" + f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} " + f"解析到的类 {component_cls.__module__}.{component_cls.__qualname__} " + "不是 v4 Star 组件。请继承 astrbot_sdk.Star。" ) - instance = component_cls() + try: + instance = component_cls() + except Exception as exc: + raise ValueError( + f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} " + f"实例化失败:{exc}" + ) from exc instances.append(instance) for name in _iter_discoverable_names(instance): diff --git a/src-new/astrbot_sdk/testing.py b/src-new/astrbot_sdk/testing.py index c9f1631ce..a2ed8c589 100644 --- a/src-new/astrbot_sdk/testing.py +++ b/src-new/astrbot_sdk/testing.py @@ -41,6 +41,7 @@ from .runtime.loader import ( load_plugin, load_plugin_config, load_plugin_spec, + validate_plugin_spec, ) from .star import Star @@ -563,6 +564,7 @@ class PluginHarness: return try: self.plugin = load_plugin_spec(self.config.plugin_dir) + validate_plugin_spec(self.plugin) self.loaded_plugin = load_plugin(self.plugin) except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 raise _PluginLoadError(str(exc)) from exc diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py index ca55470ab..13af46bf8 100644 --- a/tests_v4/test_capability_router.py +++ b/tests_v4/test_capability_router.py @@ -335,7 +335,7 @@ class TestCapabilityRouterExecute: token = CancelToken() # Missing required field - with pytest.raises(AstrBotError, match="缺少必填字段"): + with pytest.raises(AstrBotError) as raised: await router.execute( "test.cap", {}, @@ -343,6 +343,9 @@ class TestCapabilityRouterExecute: cancel_token=token, request_id="req-1", ) + message = str(raised.value) + assert "capability 'test.cap' 的输入校验失败" in message + assert "缺少必填字段:name" in message @pytest.mark.asyncio async def test_execute_validates_output_schema(self): @@ -363,7 +366,7 @@ class TestCapabilityRouterExecute: token = CancelToken() - with pytest.raises(AstrBotError, match="缺少必填字段"): + with pytest.raises(AstrBotError) as raised: await router.execute( "test.cap", {}, @@ -371,6 +374,9 @@ class TestCapabilityRouterExecute: cancel_token=token, request_id="req-1", ) + message = str(raised.value) + assert "capability 'test.cap' 的输出校验失败" in message + assert "缺少必填字段:result" in message class TestCapabilityRouterDBWatch: diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py index 4ae99e04b..cf1449e33 100644 --- a/tests_v4/test_handler_dispatcher.py +++ b/tests_v4/test_handler_dispatcher.py @@ -32,8 +32,12 @@ class TestHandlerDispatcherArgumentValidation: async def bad_handler(event: MessageEvent, missing: str) -> None: return None - with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + with pytest.raises(TypeError) as raised: dispatcher._build_args(bad_handler, event, ctx, args={}) + message = str(raised.value) + assert "插件 'demo' 的 handler" in message + assert "必填参数 'missing' 无法注入" in message + assert "MessageEvent / Context" in message def test_capability_dispatcher_raises_for_uninjectable_required_param(self): peer = _peer() @@ -50,13 +54,17 @@ class TestHandlerDispatcherArgumentValidation: ) ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken()) - with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + with pytest.raises(TypeError) as raised: dispatcher._build_args( capability.callable, payload={}, ctx=ctx, cancel_token=CancelToken(), ) + message = str(raised.value) + assert "插件 'demo' 的 capability" in message + assert "必填参数 'missing' 无法注入" in message + assert "Context / CancelToken / dict" in message class TestHandlerDispatcherInvoke: @@ -85,8 +93,11 @@ class TestHandlerDispatcherInvoke: "event": {"text": "hello", "session_id": "s1"}, } - with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + with pytest.raises(TypeError) as raised: await dispatcher.invoke(Message(), CancelToken()) + message = str(raised.value) + assert "demo:plugin.bad_handler" in message + assert "必填参数 'missing' 无法注入" in message @pytest.mark.asyncio async def test_invoke_binds_runtime_caller_plugin_id_for_raw_peer_calls(self): diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py index e6101613f..af8757279 100644 --- a/tests_v4/test_testing_module.py +++ b/tests_v4/test_testing_module.py @@ -85,6 +85,50 @@ async def test_plugin_harness_supports_metadata_and_http_commands() -> None: ) +@pytest.mark.asyncio +async def test_plugin_harness_reports_component_load_errors(tmp_path: Path) -> None: + from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness, _PluginLoadError + + plugin_dir = tmp_path / "broken-plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") + (plugin_dir / "plugin.yaml").write_text( + "\n".join( + [ + "name: broken_demo", + "display_name: Broken Demo", + "author: test", + "version: 0.1.0", + "runtime:", + ' python: "3.13"', + "components:", + " - class: main:MissingComponent", + ] + ), + encoding="utf-8", + ) + (plugin_dir / "main.py").write_text( + "\n".join( + [ + "from astrbot_sdk import Star", + "", + "class PresentComponent(Star):", + " pass", + "", + ] + ), + encoding="utf-8", + ) + + harness = PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) + with pytest.raises(_PluginLoadError) as raised: + await harness.start() + message = str(raised.value) + assert "插件 'broken_demo'" in message + assert "components[0].class='main:MissingComponent'" in message + assert "加载失败" in message + + def _write_watch_plugin(plugin_dir: Path, *, reply_text: str) -> None: plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "requirements.txt").write_text("", encoding="utf-8")