refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
"""Chat channels module with plugin architecture."""
"""Shared contracts for chat channels."""
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
__all__ = ["BaseChannel", "ChannelManager"]
__all__ = ["BaseChannel"]
+40
View File
@@ -0,0 +1,40 @@
"""Small constructors shared by declarative channel manifests."""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from nanobot.channels.contracts import ChannelFieldSpec, FieldKind, SetupRequirement
GROUP_POLICIES = frozenset({"mention", "open", "allowlist"})
DIRECT_GROUP_POLICIES = frozenset({"mention", "open"})
def field(
kind: FieldKind = "string",
*,
choices: Iterable[str] = (),
default: Any = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices),
default=default,
writable=writable,
snapshot=snapshot,
)
def required(name: str) -> SetupRequirement:
return SetupRequirement.field(name)
def required_fields(*names: str) -> tuple[SetupRequirement, ...]:
return tuple(required(name) for name in names)
def one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
return SetupRequirement.one_of(*alternatives)
+15 -335
View File
@@ -1,343 +1,23 @@
"""Shared channel setup contract for configuration, display, and validation."""
"""Resolve channel-owned setup contracts for settings consumers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from typing import TYPE_CHECKING
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
from nanobot.channels.contracts import ChannelSetupSpec
if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin
@dataclass(frozen=True)
class ChannelFieldSpec:
"""One channel field exposed through the settings contract."""
kind: FieldKind = "string"
choices: frozenset[str] = frozenset()
writable: bool = True
snapshot: bool = True
@property
def route_type(self) -> RouteFieldType:
if self.kind == "enum":
return ("enum", set(self.choices))
return self.kind
@dataclass(frozen=True)
class SetupRequirement:
"""A requirement satisfied by any one complete field group."""
alternatives: tuple[tuple[str, ...], ...]
def is_satisfied(self, values: Any) -> bool:
return any(
all(channel_value_present(channel_field_value(values, field)) for field in group)
for group in self.alternatives
)
@property
def simple_field(self) -> str | None:
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
return self.alternatives[0][0]
return None
@dataclass(frozen=True)
class ChannelSetupSpec:
"""Save, display, and validation contract for one channel."""
fields: dict[str, ChannelFieldSpec]
required: tuple[SetupRequirement, ...] = ()
official_url: str | None = None
@property
def secrets(self) -> frozenset[str]:
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
@property
def snapshot_fields(self) -> tuple[str, ...]:
return tuple(name for name, field in self.fields.items() if field.snapshot)
@property
def route_field_types(self) -> dict[str, RouteFieldType]:
return {
name: field.route_type
for name, field in self.fields.items()
if field.writable
}
@property
def simple_required_fields(self) -> tuple[str, ...]:
return tuple(
field
for requirement in self.required
if (field := requirement.simple_field) is not None
)
def is_configured(self, values: Any) -> bool:
return bool(self.required) and all(
requirement.is_satisfied(values) for requirement in self.required
)
def _field(
kind: FieldKind = "string",
def channel_setup_spec(
name: str,
*,
choices: set[str] | None = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices or ()),
writable=writable,
snapshot=snapshot,
)
plugin: ChannelPlugin | None = None,
) -> ChannelSetupSpec | None:
"""Return the setup contract declared by one channel descriptor."""
if plugin is None:
from nanobot.channels.registry import load_channel_plugin
def _required(field: str) -> SetupRequirement:
return SetupRequirement(((field,),))
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
return SetupRequirement(alternatives)
_GROUP_POLICIES = {"mention", "open", "allowlist"}
_DIRECT_GROUP_POLICIES = {"mention", "open"}
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
"websocket": ChannelSetupSpec(
fields={},
official_url="http://127.0.0.1:8765",
),
"telegram": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://t.me/BotFather",
),
"slack": ChannelSetupSpec(
fields={
"appToken": _field("secret"),
"botToken": _field("secret"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("appToken"), _required("botToken")),
official_url="https://api.slack.com/apps",
),
"discord": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list", snapshot=False),
"allowChannels": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://discord.com/developers/applications",
),
"email": ChannelSetupSpec(
fields={
"consentGranted": _field("bool"),
"imapHost": _field(),
"imapPort": _field("int"),
"imapUsername": _field(),
"imapPassword": _field("secret"),
"smtpHost": _field(),
"smtpPort": _field("int"),
"smtpUsername": _field(),
"smtpPassword": _field("secret"),
"fromAddress": _field(),
"pollIntervalSeconds": _field("int"),
"allowFrom": _field("list"),
"verifyDkim": _field("bool"),
"verifySpf": _field("bool"),
},
required=tuple(
_required(field)
for field in (
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
)
),
official_url="https://support.google.com/accounts/answer/185833",
),
"matrix": ChannelSetupSpec(
fields={
"homeserver": _field(),
"userId": _field(),
"password": _field("secret"),
"accessToken": _field("secret"),
"deviceId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list", writable=False),
},
required=(
_required("homeserver"),
_required("userId"),
_one_of(("password",), ("accessToken", "deviceId")),
),
official_url="https://matrix.org/ecosystem/clients/",
),
"mattermost": ChannelSetupSpec(
fields={
"serverUrl": _field(),
"token": _field("secret"),
"teamId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list"),
},
required=(_required("serverUrl"), _required("token")),
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
),
"whatsapp": ChannelSetupSpec(
fields={
"allowFrom": _field("list", snapshot=False),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
"databasePath": _field(writable=False, snapshot=False),
},
official_url="https://faq.whatsapp.com/",
),
"dingtalk": ChannelSetupSpec(
fields={
"clientId": _field(),
"clientSecret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("clientId"), _required("clientSecret")),
official_url="https://open.dingtalk.com/",
),
"wecom": ChannelSetupSpec(
fields={
"botId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("botId"), _required("secret")),
official_url="https://developer.work.weixin.qq.com/",
),
"weixin": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("token"),),
official_url="https://weixin.qq.com/",
),
"qq": ChannelSetupSpec(
fields={
"appId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
"msgFormat": _field("enum", choices={"plain", "markdown"}),
},
required=(_required("appId"), _required("secret")),
official_url="https://q.qq.com/",
),
"signal": ChannelSetupSpec(
fields={
"phoneNumber": _field(),
"daemonHost": _field(),
"daemonPort": _field("int"),
"allowFrom": _field("list", snapshot=False),
"dm.allowFrom": _field("list"),
"group.allowFrom": _field("list"),
},
required=(_required("phoneNumber"),),
official_url="https://github.com/bbernhard/signal-cli-rest-api",
),
"msteams": ChannelSetupSpec(
fields={
"appId": _field(),
"appPassword": _field("secret"),
"tenantId": _field(),
"path": _field(),
"allowFrom": _field("list"),
},
required=(_required("appId"), _required("appPassword")),
official_url="https://dev.teams.microsoft.com/apps",
),
"napcat": ChannelSetupSpec(
fields={
"wsUrl": _field(),
"accessToken": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("wsUrl"),),
official_url="https://napneko.github.io/",
),
"feishu": ChannelSetupSpec(
fields={
"appId": _field(snapshot=False),
"appSecret": _field("secret", snapshot=False),
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
"groupPolicy": _field(
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
),
"allowFrom": _field("list", snapshot=False),
"topicIsolation": _field("bool", snapshot=False),
},
required=(_required("appId"), _required("appSecret")),
official_url="https://open.feishu.cn/app",
),
}
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
return CHANNEL_SETUP_SPECS.get(name)
def channel_field_value(values: Any, field_path: str) -> Any:
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
return current
def channel_value_present(value: Any) -> bool:
return value not in (None, "", [], {})
def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _camel_to_snake(value: str) -> str:
chars: list[str] = []
for char in value:
if char.isupper():
if chars:
chars.append("_")
chars.append(char.lower())
else:
chars.append(char)
return "".join(chars)
plugin = load_channel_plugin(name)
return plugin.setup
+10
View File
@@ -278,6 +278,16 @@ class BaseChannel(ABC):
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
return {"enabled": False}
@classmethod
def refresh_feature_metadata(
cls,
config_path: Path,
*,
instance_id: str = "default",
) -> bool:
"""Refresh persisted display metadata after an explicit settings action."""
return False
@property
def is_running(self) -> bool:
"""Check if the channel is running."""
+24
View File
@@ -0,0 +1,24 @@
"""Small contract shared by channel-owned interactive connection flows."""
from __future__ import annotations
from collections.abc import Mapping
QueryParams = Mapping[str, list[str]]
class ChannelConnectError(Exception):
"""User-facing channel connection failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
__all__ = ["ChannelConnectError", "QueryParams", "query_first"]
+602
View File
@@ -0,0 +1,602 @@
"""Stable contracts shared by channel runtimes and management surfaces."""
from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
@dataclass(frozen=True, slots=True)
class ChannelValidationContext:
"""Host policy passed to package-owned setup validators."""
allow_local_service_access: bool = False
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
DefaultConfigFactory = Callable[[], dict[str, Any]]
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
InstanceConfigUpdater = Callable[..., dict[str, Any]]
RuntimeNameFactory = Callable[[str, str], str]
FeatureInstancesFactory = Callable[..., list[dict[str, Any]] | None]
LocalStatePresent = Callable[[Any], bool]
__all__ = [
"ChannelActivation",
"ChannelFieldSpec",
"ChannelInstanceSpec",
"ChannelManagementSpec",
"ChannelSetupSpec",
"ChannelValidationContext",
"SetupRequirement",
"channel_feature_instances",
"channel_default_config",
"channel_field_value",
"channel_instance_config",
"channel_instance_specs",
"channel_local_state_present",
"channel_runtime_name",
"resolve_channel_action_target",
"channel_set_config_enabled",
"channel_update_instance_config",
"channel_value_present",
"refresh_channel_feature_metadata",
"stringify_channel_value",
]
_MISSING = object()
@dataclass(frozen=True)
class ChannelActivation:
"""Normalized enablement state used before a channel runtime is imported.
Channel configuration may be a Pydantic model or persisted JSON, and a
channel may expose independently enabled instances. Instance envelopes are
opt-in so a channel can keep using an ``instances``
field as ordinary channel-owned configuration.
"""
enabled: bool | None = None
instances: tuple["ChannelActivation", ...] | None = None
@classmethod
def from_config(
cls,
section: Any,
*,
include_instances: bool = False,
) -> "ChannelActivation":
values = _config_mapping(section)
if values is None:
raw_enabled = getattr(section, "enabled", _MISSING)
return cls(enabled=None if raw_enabled is _MISSING else bool(raw_enabled))
raw_enabled = values.get("enabled", _MISSING)
raw_instances = values.get("instances", _MISSING) if include_instances else _MISSING
instances = (
tuple(
cls.from_config(item, include_instances=True)
for item in raw_instances
if _config_mapping(item) is not None
)
if isinstance(raw_instances, list)
else None
)
return cls(
enabled=None if raw_enabled is _MISSING else bool(raw_enabled),
instances=instances,
)
def resolve(self, *, default: bool = False) -> bool:
"""Return whether the section contains at least one enabled runtime."""
inherited = default if self.enabled is None else self.enabled
if self.instances is None:
return inherited
return any(instance.resolve(default=inherited) for instance in self.instances)
@dataclass(frozen=True)
class ChannelFieldSpec:
"""One channel field exposed through the settings contract."""
kind: FieldKind = "string"
choices: frozenset[str] = frozenset()
default: Any = None
writable: bool = True
snapshot: bool = True
@property
def route_type(self) -> RouteFieldType:
if self.kind == "enum":
return ("enum", set(self.choices))
return self.kind
@dataclass(frozen=True)
class SetupRequirement:
"""A requirement satisfied by any one complete field group."""
alternatives: tuple[tuple[str, ...], ...]
@classmethod
def field(cls, name: str) -> "SetupRequirement":
"""Require one field."""
return cls(((name,),))
@classmethod
def one_of(cls, *alternatives: tuple[str, ...]) -> "SetupRequirement":
"""Require one complete alternative field group."""
return cls(alternatives)
def is_satisfied(self, values: Any) -> bool:
return any(
all(channel_value_present(channel_field_value(values, field)) for field in group)
for group in self.alternatives
)
@property
def simple_field(self) -> str | None:
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
return self.alternatives[0][0]
return None
@dataclass(frozen=True)
class ChannelSetupSpec:
"""Writable setup fields, requirements, and optional validation."""
fields: dict[str, ChannelFieldSpec]
required: tuple[SetupRequirement, ...] = ()
official_url: str | None = None
validator: SetupValidator | None = None
@property
def secrets(self) -> frozenset[str]:
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
@property
def snapshot_fields(self) -> tuple[str, ...]:
return tuple(name for name, field in self.fields.items() if field.snapshot)
@property
def route_field_types(self) -> dict[str, RouteFieldType]:
return {
name: field.route_type
for name, field in self.fields.items()
if field.writable
}
@property
def simple_required_fields(self) -> tuple[str, ...]:
return tuple(
field
for requirement in self.required
if (field := requirement.simple_field) is not None
)
def is_configured(self, values: Any) -> bool:
return bool(self.required) and all(
requirement.is_satisfied(values) for requirement in self.required
)
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
"""Serialize the writable setup contract for generic WebUI consumers."""
simple_required = set(self.simple_required_fields)
fields = []
for name, field in self.fields.items():
if not field.writable:
continue
public_field = {
"key": f"channels.{channel_name}.{name}",
"field": name,
"kind": field.kind,
"choices": sorted(field.choices),
"required": name in simple_required,
}
if field.default is not None:
public_field["default_value"] = stringify_channel_value(field.default)
fields.append(public_field)
payload: dict[str, Any] = {
"fields": fields,
}
if self.official_url:
payload["official_url"] = self.official_url
return payload
@dataclass(frozen=True)
class ChannelInstanceSpec:
"""One independently managed runtime instance."""
instance_id: str
config: Any
@dataclass(frozen=True)
class ChannelManagementSpec:
"""Dependency-free adapter for persisted channel state.
Runtime classes own network and message lifecycle only. A multi-instance
channel supplies these callbacks from a module that can be imported without
its optional platform SDK.
"""
multi_instance: bool = False
default_config: DefaultConfigFactory | None = None
instance_specs: InstanceSpecsFactory | None = None
update_instance_config: InstanceConfigUpdater | None = None
runtime_name: RuntimeNameFactory | None = None
feature_instances: FeatureInstancesFactory | None = None
local_state_present: LocalStatePresent | None = None
def __post_init__(self) -> None:
multi_instance_callbacks = {
"instance_specs": self.instance_specs,
"update_instance_config": self.update_instance_config,
"runtime_name": self.runtime_name,
"feature_instances": self.feature_instances,
}
if not self.multi_instance:
unexpected = [
name for name, callback in multi_instance_callbacks.items() if callback is not None
]
if unexpected:
raise ValueError(
"single-instance channel management cannot define "
+ ", ".join(unexpected)
)
if self.multi_instance and self.instance_specs is None:
raise ValueError("multi-instance channel management requires instance_specs")
if self.multi_instance and self.update_instance_config is None:
raise ValueError("multi-instance channel management requires update_instance_config")
def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
from nanobot.config.loader import merge_missing_defaults
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
if plugin.setup is not None:
for name, field in plugin.setup.fields.items():
value = field.default
if value is None:
value = {
"string": "",
"secret": "",
"list": [],
"bool": False,
}.get(field.kind, _MISSING)
if value is not _MISSING:
_assign_channel_field(defaults, name, deepcopy(value))
factory = plugin.management.default_config
if factory is None:
return defaults
values = factory()
if not isinstance(values, dict):
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
return merge_missing_defaults(values, defaults)
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
target = values
parts = field.split(".")
for part in parts[:-1]:
nested = target.get(part)
if not isinstance(nested, dict):
nested = {}
target[part] = nested
target = nested
target[parts[-1]] = value
def channel_local_state_present(plugin: ChannelPlugin, section: Any) -> bool:
checker = plugin.management.local_state_present
return bool(checker and checker(section))
def channel_runtime_name(plugin: ChannelPlugin, instance_id: str = "default") -> str:
factory = plugin.management.runtime_name
if factory is None:
if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances")
runtime_name = plugin.name
else:
runtime_name = str(factory(plugin.name, instance_id))
_validate_runtime_name(plugin, runtime_name)
return runtime_name
def channel_instance_specs(
plugin: ChannelPlugin,
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
"""Expand persisted config through the dependency-free management adapter."""
factory = plugin.management.instance_specs
if factory is None:
activation = ChannelActivation.from_config(section)
raw_specs: Iterable[ChannelInstanceSpec] = (
[]
if enabled_only and not activation.resolve(default=plugin.default_enabled)
else [ChannelInstanceSpec(instance_id="default", config=section)]
)
else:
raw_specs = factory(section, enabled_only=enabled_only)
if not isinstance(raw_specs, Iterable):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
)
specs = list(raw_specs)
instance_ids: set[str] = set()
runtime_names: set[str] = set()
for spec in specs:
if not isinstance(spec, ChannelInstanceSpec):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
)
if spec.instance_id in instance_ids:
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate instance id "
f"'{spec.instance_id}'"
)
runtime_name = channel_runtime_name(plugin, spec.instance_id)
if runtime_name in runtime_names:
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate runtime name "
f"'{runtime_name}'"
)
instance_ids.add(spec.instance_id)
runtime_names.add(runtime_name)
return specs
def resolve_channel_action_target(
requested_instance_id: str | None,
) -> str:
"""Resolve a feature action to an explicit or default instance."""
return (requested_instance_id or "").strip() or "default"
def channel_instance_config(
plugin: ChannelPlugin,
section: Any,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Return editable config for one instance."""
selected = next(
(
spec
for spec in channel_instance_specs(plugin, section, enabled_only=False)
if spec.instance_id == instance_id
),
None,
)
if selected is None:
return {}
config = selected.config
if hasattr(config, "model_dump"):
return dict(config.model_dump(mode="json", by_alias=True))
return dict(config) if isinstance(config, dict) else {}
def channel_update_instance_config(
plugin: ChannelPlugin,
section: Any,
values: dict[str, Any],
*,
instance_id: str = "default",
) -> dict[str, Any]:
updater = plugin.management.update_instance_config
if updater is None:
if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances")
return values
return updater(section, values, instance_id=instance_id)
def channel_set_config_enabled(
plugin: ChannelPlugin,
section: Any,
enabled: bool,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Toggle one instance while preserving channel-owned config shape."""
from nanobot.config.loader import merge_missing_defaults
values = channel_instance_config(plugin, section, instance_id=instance_id)
values = merge_missing_defaults(values, channel_default_config(plugin))
values["enabled"] = enabled
return channel_update_instance_config(
plugin,
section,
values,
instance_id=instance_id,
)
def channel_feature_instances(
plugin: ChannelPlugin,
section: Any,
*,
setup_spec: ChannelSetupSpec | None = None,
) -> list[dict[str, Any]] | None:
factory = plugin.management.feature_instances
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
if overrides is None and not plugin.management.multi_instance:
return None
if overrides is not None and (
not isinstance(overrides, list)
or any(not isinstance(instance, dict) for instance in overrides)
):
raise TypeError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"must return a list of dicts or None"
)
enabled_ids = {
spec.instance_id for spec in channel_instance_specs(plugin, section, enabled_only=True)
}
instances = [
_channel_feature_instance(
plugin.name,
spec,
setup_spec,
enabled=spec.instance_id in enabled_ids,
)
for spec in channel_instance_specs(plugin, section, enabled_only=False)
]
if overrides is None:
return instances
by_id = {instance["id"]: instance for instance in instances}
seen: set[str] = set()
for override in overrides:
instance_id = override.get("id")
if not isinstance(instance_id, str) or instance_id not in by_id:
raise ValueError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"returned unknown instance id "
f"'{instance_id}'"
)
if instance_id in seen:
raise ValueError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"returned duplicate instance id "
f"'{instance_id}'"
)
seen.add(instance_id)
for field in ("name", "display_name", "avatar_url"):
if field in override:
by_id[instance_id][field] = str(override[field] or "")
return instances
def refresh_channel_feature_metadata(
channel_cls: type[Any],
config_path: Path,
*,
instance_id: str = "default",
) -> bool:
return bool(channel_cls.refresh_feature_metadata(config_path, instance_id=instance_id))
def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
channel_name = str(plugin.name).strip()
if not channel_name:
raise ValueError("ChannelPlugin.name must not be empty")
if not isinstance(runtime_name, str) or not runtime_name.strip():
raise ValueError(f"ChannelPlugin.management for '{plugin.name}' returned an empty runtime name")
if runtime_name != channel_name and not runtime_name.startswith(f"{channel_name}."):
raise ValueError(
f"ChannelPlugin.management runtime name '{runtime_name}' must be scoped under "
f"'{channel_name}'"
)
def channel_field_value(values: Any, field_path: str) -> Any:
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
return current
def channel_value_present(value: Any) -> bool:
return value not in (None, "", [], {})
def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _channel_feature_instance(
channel_name: str,
instance: ChannelInstanceSpec,
setup_spec: ChannelSetupSpec | None,
*,
enabled: bool,
) -> dict[str, Any]:
config = instance.config
name = str(channel_field_value(config, "name") or instance.instance_id).strip()
display_name = str(channel_field_value(config, "displayName") or name).strip()
avatar_url = str(channel_field_value(config, "avatarUrl") or "").strip()
config_values: dict[str, str] = {}
configured_fields: list[str] = []
setup_fields = setup_spec.fields.items() if setup_spec else ()
for field_name, field_spec in setup_fields:
if not field_spec.writable:
continue
value = channel_field_value(config, field_name)
if not channel_value_present(value):
continue
key = f"channels.{channel_name}.{field_name}"
configured_fields.append(key)
if field_spec.kind != "secret":
config_values[key] = stringify_channel_value(value)
return {
"id": instance.instance_id,
"name": name,
"display_name": display_name,
"avatar_url": avatar_url,
"enabled": enabled,
"configured": bool(setup_spec and setup_spec.is_configured(config)),
"config_values": config_values,
"configured_fields": configured_fields,
}
def _config_mapping(value: Any) -> dict[str, Any] | None:
if hasattr(value, "model_dump"):
dumped = value.model_dump(mode="json", by_alias=True)
return dumped if isinstance(dumped, dict) else None
return value if isinstance(value, dict) else None
def _camel_to_snake(value: str) -> str:
chars: list[str] = []
for char in value:
if char.isupper():
if chars:
chars.append("_")
chars.append(char.lower())
else:
chars.append(char)
return "".join(chars)
+1
View File
@@ -0,0 +1 @@
"""DingTalk channel package."""
+24
View File
@@ -0,0 +1,24 @@
"""DingTalk management contract."""
from nanobot.channels._manifest import field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"clientId": field(),
"clientSecret": field("secret"),
"allowFrom": field("list"),
},
required=required_fields("clientId", "clientSecret"),
official_url="https://open.dingtalk.com/",
)
PLUGIN = ChannelPlugin(
name="dingtalk",
display_name="DingTalk",
runtime=f"{__package__}.runtime:DingTalkChannel",
setup=SETUP_SPEC,
dependencies=("dingtalk-stream>=0.24.0,<1.0.0",),
webui="webui/index.ts",
)
@@ -0,0 +1 @@
"""Tests for the DingTalk channel package."""
@@ -0,0 +1,986 @@
import asyncio
import zipfile
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
# Check optional dingtalk dependencies before running tests
try:
from nanobot.channels import dingtalk
DINGTALK_AVAILABLE = getattr(dingtalk, "DINGTALK_AVAILABLE", False)
except ImportError:
DINGTALK_AVAILABLE = False
if not DINGTALK_AVAILABLE:
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
import nanobot.channels.dingtalk.runtime as dingtalk_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk.runtime import (
DingTalkChannel,
DingTalkConfig,
NanobotDingTalkHandler,
)
class _FakeResponse:
def __init__(
self,
status_code: int = 200,
json_body: dict | None = None,
*,
content: bytes = b"",
headers: dict[str, str] | None = None,
url: str = "https://example.com/file",
) -> None:
self.status_code = status_code
self._json_body = json_body or {}
self.text = content.decode("utf-8", errors="replace") if content else "{}"
self.content = content
self.headers = headers or {"content-type": "application/json"}
self.url = httpx.URL(url)
def json(self) -> dict:
return self._json_body
class _FakeHttp:
def __init__(self, responses: list[_FakeResponse] | None = None) -> None:
self.calls: list[dict] = []
self._responses = list(responses) if responses else []
def _next_response(self) -> _FakeResponse:
if self._responses:
return self._responses.pop(0)
return _FakeResponse()
async def post(self, url: str, json=None, headers=None, **kwargs):
self.calls.append(
{"method": "POST", "url": url, "json": json, "headers": headers, "kwargs": kwargs}
)
return self._next_response()
async def get(self, url: str, **kwargs):
self.calls.append({"method": "GET", "url": url, "kwargs": kwargs})
return self._next_response()
class _NetworkErrorHttp:
"""HTTP client stub that raises httpx.TransportError on every request."""
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url: str, json=None, headers=None, **kwargs):
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
raise httpx.ConnectError("Connection refused")
async def get(self, url: str, **kwargs):
self.calls.append({"method": "GET", "url": url})
raise httpx.ConnectError("Connection refused")
@pytest.mark.asyncio
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
bus = MessageBus()
channel = DingTalkChannel(config, bus)
await channel._on_message(
"hello",
sender_id="user1",
sender_name="Alice",
conversation_type="2",
conversation_id="conv123",
)
msg = await bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "group:conv123"
assert msg.metadata["conversation_type"] == "2"
@pytest.mark.asyncio
async def test_group_user_isolation_false_uses_shared_session() -> None:
"""By default group messages share the same session_key."""
config = DingTalkConfig(
client_id="app", client_secret="secret", allow_from=["*"], group_user_isolation=False
)
bus = MessageBus()
channel = DingTalkChannel(config, bus)
for user_id in ("user1", "user2"):
await channel._on_message(
"hello",
sender_id=user_id,
sender_name=user_id,
conversation_type="2",
conversation_id="conv123",
)
msg1 = await bus.consume_inbound()
msg2 = await bus.consume_inbound()
assert msg1.session_key == msg2.session_key == "dingtalk:group:conv123"
assert msg1.chat_id == msg2.chat_id == "group:conv123"
@pytest.mark.asyncio
async def test_group_user_isolation_true_separates_sessions() -> None:
"""When group_user_isolation is True, each user gets their own session_key."""
config = DingTalkConfig(
client_id="app", client_secret="secret", allow_from=["*"], group_user_isolation=True
)
bus = MessageBus()
channel = DingTalkChannel(config, bus)
for user_id in ("user1", "user2"):
await channel._on_message(
"hello",
sender_id=user_id,
sender_name=user_id,
conversation_type="2",
conversation_id="conv123",
)
msg1 = await bus.consume_inbound()
msg2 = await bus.consume_inbound()
assert msg1.session_key == "dingtalk:group:conv123:user1"
assert msg2.session_key == "dingtalk:group:conv123:user2"
assert msg1.chat_id == msg2.chat_id == "group:conv123"
@pytest.mark.asyncio
async def test_group_send_uses_group_messages_api() -> None:
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _FakeHttp()
ok = await channel._send_batch_message(
"token",
"group:conv123",
"sampleMarkdown",
{"text": "hello", "title": "Nanobot Reply"},
)
assert ok is True
call = channel._http.calls[0]
assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
assert call["json"]["openConversationId"] == "conv123"
assert call["json"]["msgKey"] == "sampleMarkdown"
@pytest.mark.asyncio
async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None:
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
class _FakeChatbotMessage:
text = None
extensions = {"content": {"recognition": "voice transcript"}}
sender_staff_id = "user1"
sender_id = "fallback-user"
sender_nick = "Alice"
message_type = "audio"
@staticmethod
def from_dict(_data):
return _FakeChatbotMessage()
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
status, body = await handler.process(
SimpleNamespace(
data={
"conversationType": "2",
"conversationId": "conv123",
"text": {"content": ""},
}
)
)
await asyncio.gather(*list(channel._background_tasks))
msg = await bus.consume_inbound()
assert (status, body) == ("OK", "OK")
assert msg.content == "voice transcript"
assert msg.sender_id == "user1"
assert msg.chat_id == "group:conv123"
@pytest.mark.asyncio
async def test_handler_processes_file_message(monkeypatch) -> None:
"""Test that file messages are handled and forwarded with downloaded path."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
class _FakeFileChatbotMessage:
text = None
extensions = {}
image_content = None
rich_text_content = None
sender_staff_id = "user1"
sender_id = "fallback-user"
sender_nick = "Alice"
message_type = "file"
@staticmethod
def from_dict(_data):
return _FakeFileChatbotMessage()
async def fake_download(download_code, filename, sender_id):
return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeFileChatbotMessage)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)
status, body = await handler.process(
SimpleNamespace(
data={
"conversationType": "1",
"content": {"downloadCode": "abc123", "fileName": "report.xlsx"},
"text": {"content": ""},
}
)
)
await asyncio.gather(*list(channel._background_tasks))
msg = await bus.consume_inbound()
assert (status, body) == ("OK", "OK")
assert "[File]" in msg.content
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
def _rich_text_message(rich_text_list):
class _FakeRichTextChatbotMessage:
text = None
extensions = {}
image_content = None
rich_text_content = SimpleNamespace(rich_text_list=rich_text_list)
sender_staff_id = "user1"
sender_id = "fallback-user"
sender_nick = "Alice"
message_type = "richText"
@staticmethod
def from_dict(_data):
return _FakeRichTextChatbotMessage()
return _FakeRichTextChatbotMessage
@pytest.mark.asyncio
async def test_handler_richtext_keeps_formatted_segments(monkeypatch) -> None:
"""richText segments with non-'text' types (bold/italic/code/pre) must be kept
and mapped to Markdown, not dropped (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([
{"type": "bold", "text": "Title"},
{"type": "text", "text": "plain"},
{"type": "italic", "text": "em"},
{"type": "inlineCode", "text": "x = 1"},
{"type": "pre", "text": "block"},
])
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert msg.content == "**Title** plain *em* `x = 1` ```\nblock\n```"
@pytest.mark.asyncio
async def test_handler_richtext_all_formatted_not_dropped(monkeypatch) -> None:
"""A richText message made only of formatted segments must not end up with empty
content and fall through to the 'unsupported message type' path (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([{"type": "bold", "text": "Important"}])
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
# Before the fix this message produced empty content and never reached the bus,
# so consume_inbound would block here.
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert msg.content == "**Important**"
@pytest.mark.asyncio
async def test_handler_richtext_item_with_text_and_download(monkeypatch) -> None:
"""A rich-text item carrying both text and a downloadCode must yield both the
text and the downloaded file, not drop the attachment (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([
{"text": "see attached", "downloadCode": "abc123", "fileName": "report.xlsx"},
])
async def fake_download(download_code, filename, sender_id):
return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
await asyncio.gather(*list(channel._background_tasks))
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert "see attached" in msg.content
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
@pytest.mark.asyncio
async def test_start_configures_http_timeout(monkeypatch) -> None:
"""The shared httpx client must be created with an explicit timeout so file/image
downloads don't hit httpx's 5s default and ConnectTimeout (issue #4497)."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
class _FakeStreamClient:
def __init__(self, _credential):
pass
def register_callback_handler(self, _topic, _handler):
pass
async def start(self):
# Exit the reconnect loop after one iteration.
channel._running = False
monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _FakeStreamClient)
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))
await channel.start()
assert channel._http is not None
timeout = channel._http.timeout
assert timeout.connect == 10.0
assert timeout.read == 30.0
assert timeout.write == 30.0
assert timeout.pool == 10.0
await channel.stop()
@pytest.mark.asyncio
async def test_stop_cancels_stream_client_after_sdk_swallows_first_cancel(monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
created: dict[str, object] = {}
class _FakeWebsocket:
def __init__(self) -> None:
self.closed = False
async def close(self) -> None:
self.closed = True
class _CancelSwallowingStreamClient:
def __init__(self, _credential):
self.websocket = _FakeWebsocket()
self.started = asyncio.Event()
self.cancelled_once = asyncio.Event()
created["client"] = self
def register_callback_handler(self, _topic, _handler):
pass
async def start(self):
self.started.set()
while True:
try:
await asyncio.Future()
except asyncio.CancelledError:
self.cancelled_once.set()
await asyncio.sleep(3600)
monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _CancelSwallowingStreamClient)
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))
start_task = asyncio.create_task(channel.start())
while "client" not in created:
await asyncio.sleep(0)
client = created["client"]
await asyncio.wait_for(client.started.wait(), timeout=0.5)
start_task.cancel()
await asyncio.wait_for(client.cancelled_once.wait(), timeout=0.5)
assert not start_task.done()
await asyncio.wait_for(channel.stop(), timeout=0.5)
assert client.websocket.closed is True
assert start_task.cancelled()
@pytest.mark.asyncio
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
"""Test the two-step file download flow (get URL then download content)."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
# Mock access token
async def fake_get_token():
return "test-token"
monkeypatch.setattr(channel, "_get_access_token", fake_get_token)
# Mock HTTP: first POST returns downloadUrl, then GET returns file bytes
file_content = b"fake file content"
channel._http = _FakeHttp(responses=[
_FakeResponse(200, {"downloadUrl": "https://example.com/tmpfile"}),
_FakeResponse(200),
])
channel._http._responses[1].content = file_content
# Redirect media dir to tmp_path
monkeypatch.setattr(
"nanobot.config.paths.get_media_dir",
lambda channel_name=None: tmp_path / channel_name if channel_name else tmp_path,
)
result = await channel._download_dingtalk_file("code123", "test.xlsx", "user1")
assert result is not None
assert result.endswith("test.xlsx")
assert (tmp_path / "dingtalk" / "user1" / "test.xlsx").read_bytes() == file_content
# Verify API calls
assert channel._http.calls[0]["method"] == "POST"
assert "messageFiles/download" in channel._http.calls[0]["url"]
assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
assert channel._http.calls[1]["method"] == "GET"
@pytest.mark.asyncio
async def test_read_media_bytes_rejects_private_http_target_before_fetch() -> None:
"""Remote media fetches must not reach loopback/private addresses."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
200,
content=b"internal secret",
headers={"content-type": "text/plain"},
url="http://127.0.0.1/admin.txt",
)
]
)
data, filename, content_type = await channel._read_media_bytes("http://127.0.0.1/admin.txt")
assert (data, filename, content_type) == (None, None, None)
assert channel._http.calls == []
@pytest.mark.asyncio
async def test_read_media_bytes_rejects_private_redirect_result() -> None:
"""A public-looking media URL must not be accepted after redirecting private."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
200,
content=b"metadata bytes",
headers={"content-type": "text/plain"},
url="http://127.0.0.1/metadata",
)
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/safe.txt")
assert (data, filename, content_type) == (None, None, None)
assert len(channel._http.calls) == 1
@pytest.mark.asyncio
async def test_read_media_bytes_rejects_oversized_remote_response(monkeypatch) -> None:
"""DingTalk media downloads should enforce a byte cap before upload."""
monkeypatch.setattr(dingtalk_module, "DINGTALK_MAX_REMOTE_MEDIA_BYTES", 8, raising=False)
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
200,
content=b"123456789",
headers={"content-type": "text/plain"},
url="https://example.com/large.txt",
)
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/large.txt")
assert (data, filename, content_type) == (None, None, None)
@pytest.mark.asyncio
async def test_read_media_bytes_does_not_follow_remote_redirects_by_default() -> None:
"""Redirects are refused by default instead of followed into internal networks."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
302,
headers={"location": "http://127.0.0.1/metadata"},
url="https://example.com/redirect.txt",
)
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
assert (data, filename, content_type) == (None, None, None)
assert channel._http.calls[0]["kwargs"]["follow_redirects"] is False
@pytest.mark.asyncio
async def test_read_media_bytes_follows_safe_redirect_when_explicitly_enabled() -> None:
"""Operators can opt in to public redirects without enabling private redirects."""
channel = DingTalkChannel(
DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"],
allow_remote_media_redirects=True,
),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
302,
headers={"location": "https://example.com/final.txt"},
url="https://example.com/redirect.txt",
),
_FakeResponse(
200,
content=b"redirected media",
headers={"content-type": "text/plain"},
url="https://example.com/final.txt",
),
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
assert (data, filename, content_type) == (b"redirected media", "redirect.txt", "text/plain")
assert [call["url"] for call in channel._http.calls] == [
"https://example.com/redirect.txt",
"https://example.com/final.txt",
]
assert all(call["kwargs"]["follow_redirects"] is False for call in channel._http.calls)
@pytest.mark.asyncio
async def test_read_media_bytes_blocks_cross_host_redirect_without_allowlist() -> None:
"""Redirect opt-in should not allow arbitrary cross-host redirects by default."""
channel = DingTalkChannel(
DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"],
allow_remote_media_redirects=True,
),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
302,
headers={"location": "https://example.org/final.txt"},
url="https://example.com/redirect.txt",
),
_FakeResponse(
200,
content=b"cross-host media",
headers={"content-type": "text/plain"},
url="https://example.org/final.txt",
),
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
assert (data, filename, content_type) == (None, None, None)
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
@pytest.mark.asyncio
async def test_read_media_bytes_allows_cross_host_redirect_when_allowlisted() -> None:
"""Operators can explicitly allow a known CDN/download host for redirects."""
channel = DingTalkChannel(
DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"],
allow_remote_media_redirects=True,
remote_media_redirect_allowed_hosts=["example.org"],
),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
302,
headers={"location": "https://example.org/final.txt"},
url="https://example.com/redirect.txt",
),
_FakeResponse(
200,
content=b"cross-host media",
headers={"content-type": "text/plain"},
url="https://example.org/final.txt",
),
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
assert (data, filename, content_type) == (b"cross-host media", "redirect.txt", "text/plain")
assert [call["url"] for call in channel._http.calls] == [
"https://example.com/redirect.txt",
"https://example.org/final.txt",
]
@pytest.mark.asyncio
async def test_read_media_bytes_blocks_private_redirect_even_when_redirects_enabled() -> None:
"""Redirect opt-in must still validate each hop before fetching it."""
channel = DingTalkChannel(
DingTalkConfig(
client_id="app",
client_secret="secret",
allow_from=["*"],
allow_remote_media_redirects=True,
),
MessageBus(),
)
channel._http = _FakeHttp(
responses=[
_FakeResponse(
302,
headers={"location": "http://127.0.0.1/metadata"},
url="https://example.com/redirect.txt",
),
_FakeResponse(
200,
content=b"internal secret",
headers={"content-type": "text/plain"},
url="http://127.0.0.1/metadata",
),
]
)
data, filename, content_type = await channel._read_media_bytes("https://example.com/redirect.txt")
assert (data, filename, content_type) == (None, None, None)
assert [call["url"] for call in channel._http.calls] == ["https://example.com/redirect.txt"]
def test_normalize_upload_payload_zips_html_attachment() -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
data, filename, content_type = channel._normalize_upload_payload(
"report.html",
b"<html><body>Hello</body></html>",
"text/html",
)
assert filename == "report.zip"
assert content_type == "application/zip"
archive = zipfile.ZipFile(BytesIO(data))
assert archive.namelist() == ["report.html"]
assert archive.read("report.html") == b"<html><body>Hello</body></html>"
@pytest.mark.asyncio
async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
html_path = tmp_path / "report.html"
html_path.write_text("<html><body>Hello</body></html>", encoding="utf-8")
captured: dict[str, object] = {}
async def fake_upload_media(*, token, data, media_type, filename, content_type):
captured.update(
{
"token": token,
"data": data,
"media_type": media_type,
"filename": filename,
"content_type": content_type,
}
)
return "media-123"
async def fake_send_batch_message(token, chat_id, msg_key, msg_param):
captured.update(
{
"sent_token": token,
"chat_id": chat_id,
"msg_key": msg_key,
"msg_param": msg_param,
}
)
return True
monkeypatch.setattr(channel, "_upload_media", fake_upload_media)
monkeypatch.setattr(channel, "_send_batch_message", fake_send_batch_message)
ok = await channel._send_media_ref("token-123", "user-1", str(html_path))
assert ok is True
assert captured["media_type"] == "file"
assert captured["filename"] == "report.zip"
assert captured["content_type"] == "application/zip"
assert captured["msg_key"] == "sampleFile"
assert captured["msg_param"] == {
"mediaId": "media-123",
"fileName": "report.zip",
"fileType": "zip",
}
archive = zipfile.ZipFile(BytesIO(captured["data"]))
assert archive.namelist() == ["report.html"]
# ── Exception handling tests ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_send_batch_message_propagates_transport_error() -> None:
"""Network/transport errors must re-raise so callers can retry."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _NetworkErrorHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_batch_message(
"token",
"user123",
"sampleMarkdown",
{"text": "hello", "title": "Nanobot Reply"},
)
# The POST was attempted exactly once
assert len(channel._http.calls) == 1
assert channel._http.calls[0]["method"] == "POST"
@pytest.mark.asyncio
async def test_send_batch_message_returns_false_on_api_error() -> None:
"""DingTalk API-level errors (non-200 status, errcode != 0) should return False."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
# Non-200 status code → API error → return False
channel._http = _FakeHttp(responses=[_FakeResponse(400, {"errcode": 400})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is False
# 200 with non-zero errcode → API error → return False
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 100})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is False
# 200 with errcode=0 → success → return True
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 0})])
result = await channel._send_batch_message(
"token", "user123", "sampleMarkdown", {"text": "hello"}
)
assert result is True
@pytest.mark.asyncio
async def test_send_raises_when_access_token_is_unavailable(monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value=None))
with pytest.raises(RuntimeError, match="access token unavailable"):
await channel.send(
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
)
@pytest.mark.asyncio
async def test_send_raises_when_text_is_not_delivered(monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value="token"))
monkeypatch.setattr(channel, "_send_markdown_text", AsyncMock(return_value=False))
with pytest.raises(RuntimeError, match="text message was not delivered"):
await channel.send(
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
)
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
"""When the first send fails with a transport error, _send_media_ref must
re-raise immediately instead of trying download+upload+fallback."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
channel._http = _NetworkErrorHttp()
# An image URL triggers the sampleImageMsg path first
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# Only one POST should have been attempted — no download/upload/fallback
assert len(channel._http.calls) == 1
assert channel._http.calls[0]["method"] == "POST"
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_download_transport_error() -> None:
"""When the image URL send returns an API error (False) but the download
for the fallback hits a transport error, it must re-raise rather than
silently returning False."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
# First POST (sampleImageMsg) returns API error → False, then GET (download) raises transport error
class _MixedHttp:
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url, json=None, headers=None, **kwargs):
self.calls.append({"method": "POST", "url": url})
# API-level failure: 200 with errcode != 0
return _FakeResponse(200, {"errcode": 100})
async def get(self, url, **kwargs):
self.calls.append({"method": "GET", "url": url})
raise httpx.ConnectError("Connection refused")
channel._http = _MixedHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# Should have attempted POST (image URL) and GET (download), but NOT upload
assert len(channel._http.calls) == 2
assert channel._http.calls[0]["method"] == "POST"
assert channel._http.calls[1]["method"] == "GET"
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_upload_transport_error() -> None:
"""When download succeeds but upload hits a transport error, must re-raise."""
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
channel = DingTalkChannel(config, MessageBus())
image_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG-ish data
class _UploadFailsHttp:
def __init__(self) -> None:
self.calls: list[dict] = []
async def post(self, url, json=None, headers=None, files=None, **kwargs):
self.calls.append({"method": "POST", "url": url})
# If it's the upload endpoint, raise transport error
if "media/upload" in url:
raise httpx.ConnectError("Connection refused")
# Otherwise (sampleImageMsg), return API error to trigger fallback
return _FakeResponse(200, {"errcode": 100})
async def get(self, url, **kwargs):
self.calls.append({"method": "GET", "url": url})
resp = _FakeResponse(200)
resp.content = image_bytes
resp.headers = {"content-type": "image/jpeg"}
return resp
channel._http = _UploadFailsHttp()
with pytest.raises(httpx.ConnectError, match="Connection refused"):
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
# POST (image URL), GET (download), POST (upload) attempted — no further sends
methods = [c["method"] for c in channel._http.calls]
assert methods == ["POST", "GET", "POST"]
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{
"channels": {
"dingtalk": {
"clientId": "ding-client",
"clientSecret": "ding-secret",
}
}
}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
result = validate_channel_config("dingtalk", {})
assert result["status"] == "configured"
assert result["can_enable"] is True
assert any(check["status"] == "skipped" for check in result["checks"])
+21
View File
@@ -0,0 +1,21 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
export default {
presentation: {
displayName: "DingTalk",
initials: "DT",
color: "#1677FF",
logoUrl:
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("dingtalk"),
fields: [
{ key: "channels.dingtalk.clientId" },
{ key: "channels.dingtalk.clientSecret" },
{ key: "channels.dingtalk.allowFrom" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,31 @@
{
"description": "Use nanobot from DingTalk groups.",
"requirements": "DingTalk app credentials and gateway",
"setup": {
"docsLabel": "Open DingTalk setup",
"officialLabel": "Open DingTalk console",
"tryIt": "Send a test message from the DingTalk group where the app is installed.",
"summary": "DingTalk needs app credentials from Stream mode.",
"steps": [
"Create or choose a DingTalk app with Stream mode enabled.",
"Add Client ID and Client Secret.",
"Save and enable DingTalk, then send a test message."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk client ID",
"help": "Copy it from DingTalk app credentials."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copy it from the same DingTalk app credentials page."
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "User IDs, comma separated"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Usa nanobot desde grupos de DingTalk.",
"requirements": "Credenciales de la app de DingTalk y gateway",
"setup": {
"docsLabel": "Abrir guía de DingTalk",
"officialLabel": "Abrir consola de DingTalk",
"tryIt": "Envía un mensaje de prueba desde el grupo de DingTalk donde está instalada la app.",
"summary": "DingTalk necesita credenciales de una app en modo Stream.",
"steps": [
"Crea o elige una app de DingTalk con el modo Stream activado.",
"Añade el Client ID y el Client Secret.",
"Guarda y activa DingTalk; después envía un mensaje de prueba."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID de DingTalk",
"help": "Cópialo de las credenciales de la app de DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Cópialo de la misma página de credenciales."
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "ID de usuario separados por comas"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Utilisez nanobot depuis les groupes DingTalk.",
"requirements": "Identifiants dapplication DingTalk et passerelle",
"setup": {
"docsLabel": "Ouvrir le guide DingTalk",
"officialLabel": "Ouvrir la console DingTalk",
"tryIt": "Envoyez un message test dans le groupe DingTalk où lapplication est installée.",
"summary": "DingTalk nécessite les identifiants dune application en mode Stream.",
"steps": [
"Créez ou choisissez une application DingTalk avec le mode Stream activé.",
"Ajoutez le Client ID et le Client Secret.",
"Enregistrez et activez DingTalk, puis envoyez un message test."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID DingTalk",
"help": "Copiez-le depuis les identifiants de lapplication DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copiez-le depuis la même page didentifiants DingTalk."
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "ID utilisateur séparés par des virgules"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Gunakan nanobot dari grup DingTalk.",
"requirements": "Kredensial aplikasi DingTalk dan gateway",
"setup": {
"docsLabel": "Buka panduan DingTalk",
"officialLabel": "Buka konsol DingTalk",
"tryIt": "Kirim pesan uji dari grup DingTalk tempat aplikasi dipasang.",
"summary": "DingTalk memerlukan kredensial aplikasi dari mode Stream.",
"steps": [
"Buat atau pilih aplikasi DingTalk dengan mode Stream aktif.",
"Tambahkan Client ID dan Client Secret.",
"Simpan dan aktifkan DingTalk, lalu kirim pesan uji."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID DingTalk",
"help": "Salin dari kredensial aplikasi DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Salin dari halaman kredensial yang sama."
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "ID pengguna, dipisahkan koma"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "DingTalk グループから nanobot を利用します。",
"requirements": "DingTalk アプリの認証情報とゲートウェイ",
"setup": {
"docsLabel": "DingTalk 設定ガイドを開く",
"officialLabel": "DingTalk コンソールを開く",
"tryIt": "アプリをインストールした DingTalk グループからテストメッセージを送信します。",
"summary": "DingTalk には Stream モードのアプリ認証情報が必要です。",
"steps": [
"Stream モードを有効にした DingTalk アプリを作成または選択します。",
"Client ID と Client Secret を追加します。",
"保存して DingTalk を有効にし、テストメッセージを送信します。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk Client ID",
"help": "DingTalk アプリの認証情報からコピーします。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "同じ認証情報ページからコピーします。"
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "ユーザー ID(カンマ区切り)"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "DingTalk 그룹에서 nanobot을 사용합니다.",
"requirements": "DingTalk 앱 자격 증명 및 게이트웨이",
"setup": {
"docsLabel": "DingTalk 설정 가이드 열기",
"officialLabel": "DingTalk 콘솔 열기",
"tryIt": "앱이 설치된 DingTalk 그룹에서 테스트 메시지를 보내세요.",
"summary": "DingTalk에는 Stream 모드 앱 자격 증명이 필요합니다.",
"steps": [
"Stream 모드가 활성화된 DingTalk 앱을 만들거나 선택하세요.",
"Client ID와 Client Secret을 추가하세요.",
"저장하고 DingTalk을 활성화한 다음 테스트 메시지를 보내세요."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk Client ID",
"help": "DingTalk 앱 자격 증명에서 복사하세요."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "같은 자격 증명 페이지에서 복사하세요."
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "사용자 ID, 쉼표로 구분"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Use o nanobot em grupos do DingTalk.",
"requirements": "Credenciais do app DingTalk e gateway",
"setup": {
"docsLabel": "Abrir guia do DingTalk",
"officialLabel": "Abrir console do DingTalk",
"tryIt": "Envie uma mensagem de teste no grupo do DingTalk onde o app está instalado.",
"summary": "O DingTalk precisa das credenciais de um app no modo Stream.",
"steps": [
"Crie ou escolha um app do DingTalk com o modo Stream ativado.",
"Adicione o Client ID e o Client Secret.",
"Salve e ative o DingTalk; depois, envie uma mensagem de teste."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID do DingTalk",
"help": "Copie das credenciais do app DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copie da mesma página de credenciais."
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "IDs de usuário separados por vírgulas"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Sử dụng nanobot trong các nhóm DingTalk.",
"requirements": "Thông tin xác thực ứng dụng DingTalk và gateway",
"setup": {
"docsLabel": "Mở hướng dẫn DingTalk",
"officialLabel": "Mở bảng điều khiển DingTalk",
"tryIt": "Gửi tin nhắn thử từ nhóm DingTalk đã cài ứng dụng.",
"summary": "DingTalk cần thông tin xác thực ứng dụng ở chế độ Stream.",
"steps": [
"Tạo hoặc chọn ứng dụng DingTalk đã bật chế độ Stream.",
"Thêm Client ID và Client Secret.",
"Lưu và bật DingTalk, sau đó gửi tin nhắn thử."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID DingTalk",
"help": "Sao chép từ thông tin xác thực ứng dụng DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Sao chép từ cùng trang thông tin xác thực."
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
}
}
}
}
@@ -0,0 +1,32 @@
{
"displayName": "钉钉",
"description": "在钉钉群中使用 nanobot。",
"requirements": "钉钉应用凭据和网关",
"setup": {
"docsLabel": "打开钉钉配置指南",
"officialLabel": "打开钉钉开发者后台",
"tryIt": "在已安装应用的钉钉群中发送一条测试消息。",
"summary": "钉钉需要 Stream 模式的应用凭据。",
"steps": [
"创建或选择一个已启用 Stream 模式的钉钉应用。",
"填写 Client ID 和 Client Secret。",
"保存并启用钉钉,然后发送一条测试消息。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "钉钉 Client ID",
"help": "从钉钉应用凭据页面复制。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "从同一个钉钉应用凭据页面复制。"
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "用户 ID,用逗号分隔"
}
}
}
}
@@ -0,0 +1,32 @@
{
"displayName": "釘釘",
"description": "在釘釘群組中使用 nanobot。",
"requirements": "釘釘應用程式憑證和閘道",
"setup": {
"docsLabel": "開啟釘釘設定指南",
"officialLabel": "開啟釘釘開發者後台",
"tryIt": "在已安裝應用程式的釘釘群組中傳送一則測試訊息。",
"summary": "釘釘需要 Stream 模式的應用程式憑證。",
"steps": [
"建立或選擇一個已啟用 Stream 模式的釘釘應用程式。",
"填入 Client ID 和 Client Secret。",
"儲存並啟用釘釘,然後傳送一則測試訊息。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "釘釘 Client ID",
"help": "從釘釘應用程式憑證頁面複製。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "從同一個釘釘應用程式憑證頁面複製。"
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "使用者 ID,以逗號分隔"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Discord channel package."""
+27
View File
@@ -0,0 +1,27 @@
"""Discord management contract."""
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.discord.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"token": field("secret"),
"allowFrom": field("list", snapshot=False),
"allowChannels": field("list"),
"groupPolicy": field("enum", choices=DIRECT_GROUP_POLICIES, default="mention"),
},
required=(required("token"),),
official_url="https://discord.com/developers/applications",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="discord",
display_name="Discord",
runtime=f"{__package__}.runtime:DiscordChannel",
setup=SETUP_SPEC,
dependencies=("discord.py>=2.5.2,<3.0.0",),
webui="webui/index.ts",
)
@@ -0,0 +1 @@
"""Tests for the Discord channel package."""
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
"""Discord setup validation owned by the channel package."""
from typing import Any
import httpx
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
http_get,
payload,
required_checks,
status_from_checks,
string_value,
)
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("discord", values)
token = string_value(values.get("token"))
if token:
try:
data = http_get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": f"Bot {token}"},
)
bot_id = str(data.get("id") or "")
checks.append(check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
identity = {
"name": data.get("global_name") or data.get("username"),
"account": bot_id,
}
if bot_id:
checks.append(
check(
"invite",
"Server invite",
"pass",
"Use this generated OAuth URL to invite the bot.",
action_url=(
"https://discord.com/oauth2/authorize"
f"?client_id={bot_id}&scope=bot%20applications.commands"
),
)
)
return payload(
"discord",
"connected",
checks,
identity=identity,
missing_fields=missing,
)
except httpx.HTTPStatusError as exc:
checks.append(
check(
"bot_token",
"Bot token",
"fail",
f"Discord rejected the token: HTTP {exc.response.status_code}",
)
)
except Exception as exc:
checks.append(
check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}")
)
return status_from_checks("discord", checks, missing)
__all__ = ["validate"]
+20
View File
@@ -0,0 +1,20 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
export default {
presentation: {
displayName: "Discord",
initials: "DC",
color: "#5865F2",
logoUrl: "https://discord.com/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("discord"),
fields: [
{ key: "channels.discord.token" },
{ key: "channels.discord.allowChannels" },
{ key: "channels.discord.groupPolicy" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,39 @@
{
"description": "Use nanobot from Discord servers and DMs.",
"requirements": "Discord bot token, permissions, gateway",
"setup": {
"docsLabel": "Open Discord setup",
"officialLabel": "Open Discord portal",
"tryIt": "Mention the bot in a server or send it a direct message.",
"summary": "Enable turns on Discord support. Discord still needs a bot token and server permissions.",
"steps": [
"Create a bot in Discord Developer Portal and copy its token.",
"Invite the bot to your server with message read/send and slash command permissions.",
"Save and enable Discord, then mention the bot or send a direct message."
],
"fields": {
"token": {
"label": "Bot token",
"placeholder": "Discord bot token",
"help": "Create it from the Bot page in Discord Developer Portal."
},
"allowChannels": {
"label": "Allowed channels",
"placeholder": "Channel IDs, comma separated",
"help": "Leave empty to allow any channel the bot can read."
},
"groupPolicy": {
"label": "Group behavior",
"choices": {
"mention": "Mention only",
"open": "All messages",
"allowlist": "Allowlist"
}
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "User IDs, comma separated"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Usa nanobot en servidores y mensajes directos de Discord.",
"requirements": "Token del bot de Discord, permisos y gateway",
"setup": {
"docsLabel": "Abrir guía de Discord",
"officialLabel": "Abrir portal de Discord",
"tryIt": "Menciona al bot en un servidor o envíale un mensaje directo.",
"summary": "Activar habilita Discord. Aún necesitas el token del bot y permisos del servidor.",
"steps": [
"Crea un bot en Discord Developer Portal y copia su token.",
"Invítalo al servidor con permisos para leer/enviar mensajes y usar comandos slash.",
"Guarda y activa Discord; después menciona al bot o envíale un mensaje directo."
],
"fields": {
"token": {
"label": "Token del bot",
"placeholder": "Token del bot de Discord",
"help": "Créalo desde la página Bot de Discord Developer Portal."
},
"allowChannels": {
"label": "Canales permitidos",
"placeholder": "ID de canal separados por comas",
"help": "Déjalo vacío para permitir cualquier canal que el bot pueda leer."
},
"groupPolicy": {
"label": "Comportamiento en grupos",
"choices": {
"mention": "Solo menciones",
"open": "Todos los mensajes",
"allowlist": "Lista permitida"
}
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "ID de usuario separados por comas"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Utilisez nanobot sur les serveurs Discord et en messages privés.",
"requirements": "Jeton du bot Discord, permissions et passerelle",
"setup": {
"docsLabel": "Ouvrir le guide Discord",
"officialLabel": "Ouvrir le portail Discord",
"tryIt": "Mentionnez le bot sur un serveur ou envoyez-lui un message privé.",
"summary": "Lactivation ouvre la prise en charge de Discord. Un jeton de bot et des permissions serveur restent nécessaires.",
"steps": [
"Créez un bot dans le portail développeur Discord et copiez son jeton.",
"Invitez-le sur votre serveur avec les permissions de lecture, denvoi et de commandes slash.",
"Enregistrez et activez Discord, puis mentionnez le bot ou envoyez-lui un message privé."
],
"fields": {
"token": {
"label": "Jeton du bot",
"placeholder": "Jeton du bot Discord",
"help": "Créez-le depuis la page Bot du portail développeur Discord."
},
"allowChannels": {
"label": "Salons autorisés",
"placeholder": "ID de salon séparés par des virgules",
"help": "Laissez vide pour autoriser tous les salons lisibles par le bot."
},
"groupPolicy": {
"label": "Comportement en groupe",
"choices": {
"mention": "Mentions uniquement",
"open": "Tous les messages",
"allowlist": "Liste dautorisation"
}
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "ID utilisateur séparés par des virgules"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Gunakan nanobot dari server dan DM Discord.",
"requirements": "Token bot Discord, izin, dan gateway",
"setup": {
"docsLabel": "Buka panduan Discord",
"officialLabel": "Buka portal Discord",
"tryIt": "Sebut bot di server atau kirim pesan langsung.",
"summary": "Mengaktifkan akan menyalakan dukungan Discord. Token bot dan izin server tetap diperlukan.",
"steps": [
"Buat bot di Discord Developer Portal dan salin tokennya.",
"Undang bot ke server dengan izin baca/kirim pesan dan perintah slash.",
"Simpan dan aktifkan Discord, lalu sebut bot atau kirim DM."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "Token bot Discord",
"help": "Buat dari halaman Bot di Discord Developer Portal."
},
"allowChannels": {
"label": "Channel yang diizinkan",
"placeholder": "ID channel, dipisahkan koma",
"help": "Kosongkan untuk mengizinkan semua channel yang dapat dibaca bot."
},
"groupPolicy": {
"label": "Perilaku grup",
"choices": {
"mention": "Hanya sebutan",
"open": "Semua pesan",
"allowlist": "Daftar izin"
}
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "ID pengguna, dipisahkan koma"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Discord サーバーと DM から nanobot を利用します。",
"requirements": "Discord ボットトークン、権限、ゲートウェイ",
"setup": {
"docsLabel": "Discord 設定ガイドを開く",
"officialLabel": "Discord ポータルを開く",
"tryIt": "サーバーでボットをメンションするか、DM を送信します。",
"summary": "有効化すると Discord 対応がオンになります。ボットトークンとサーバー権限が必要です。",
"steps": [
"Discord Developer Portal でボットを作成し、トークンをコピーします。",
"メッセージの読み書きとスラッシュコマンド権限を付けてサーバーに招待します。",
"保存して Discord を有効にし、メンションまたは DM を送信します。"
],
"fields": {
"token": {
"label": "ボットトークン",
"placeholder": "Discord ボットトークン",
"help": "Discord Developer Portal の Bot ページで作成します。"
},
"allowChannels": {
"label": "許可するチャンネル",
"placeholder": "チャンネル ID(カンマ区切り)",
"help": "空欄の場合、ボットが読めるすべてのチャンネルを許可します。"
},
"groupPolicy": {
"label": "グループでの動作",
"choices": {
"mention": "メンションのみ",
"open": "すべてのメッセージ",
"allowlist": "許可リスト"
}
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "ユーザー ID(カンマ区切り)"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Discord 서버와 DM에서 nanobot을 사용합니다.",
"requirements": "Discord 봇 토큰, 권한 및 게이트웨이",
"setup": {
"docsLabel": "Discord 설정 가이드 열기",
"officialLabel": "Discord 포털 열기",
"tryIt": "서버에서 봇을 멘션하거나 DM을 보내세요.",
"summary": "활성화하면 Discord 지원이 켜집니다. 봇 토큰과 서버 권한이 필요합니다.",
"steps": [
"Discord Developer Portal에서 봇을 만들고 토큰을 복사하세요.",
"메시지 읽기/보내기 및 슬래시 명령 권한으로 서버에 초대하세요.",
"저장하고 Discord를 활성화한 다음 봇을 멘션하거나 DM을 보내세요."
],
"fields": {
"token": {
"label": "봇 토큰",
"placeholder": "Discord 봇 토큰",
"help": "Discord Developer Portal의 Bot 페이지에서 생성하세요."
},
"allowChannels": {
"label": "허용된 채널",
"placeholder": "채널 ID, 쉼표로 구분",
"help": "비워 두면 봇이 읽을 수 있는 모든 채널을 허용합니다."
},
"groupPolicy": {
"label": "그룹 동작",
"choices": {
"mention": "멘션만",
"open": "모든 메시지",
"allowlist": "허용 목록"
}
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "사용자 ID, 쉼표로 구분"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Use o nanobot em servidores e DMs do Discord.",
"requirements": "Token do bot Discord, permissões e gateway",
"setup": {
"docsLabel": "Abrir guia do Discord",
"officialLabel": "Abrir portal do Discord",
"tryIt": "Mencione o bot em um servidor ou envie uma mensagem direta.",
"summary": "Ativar liga o suporte ao Discord. O token do bot e as permissões do servidor ainda são necessários.",
"steps": [
"Crie um bot no Discord Developer Portal e copie o token.",
"Convide-o para o servidor com permissões de leitura/envio e comandos slash.",
"Salve e ative o Discord; depois, mencione o bot ou envie uma DM."
],
"fields": {
"token": {
"label": "Token do bot",
"placeholder": "Token do bot Discord",
"help": "Crie-o na página Bot do Discord Developer Portal."
},
"allowChannels": {
"label": "Canais permitidos",
"placeholder": "IDs de canal separados por vírgulas",
"help": "Deixe vazio para permitir qualquer canal que o bot consiga ler."
},
"groupPolicy": {
"label": "Comportamento em grupos",
"choices": {
"mention": "Somente menções",
"open": "Todas as mensagens",
"allowlist": "Lista de permissão"
}
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "IDs de usuário separados por vírgulas"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Sử dụng nanobot trong máy chủ và tin nhắn riêng Discord.",
"requirements": "Token bot Discord, quyền và gateway",
"setup": {
"docsLabel": "Mở hướng dẫn Discord",
"officialLabel": "Mở cổng Discord",
"tryIt": "Nhắc bot trong máy chủ hoặc gửi tin nhắn riêng.",
"summary": "Bật sẽ kích hoạt hỗ trợ Discord. Bạn vẫn cần token bot và quyền trên máy chủ.",
"steps": [
"Tạo bot trong Discord Developer Portal và sao chép token.",
"Mời bot vào máy chủ với quyền đọc/gửi tin nhắn và lệnh slash.",
"Lưu và bật Discord, sau đó nhắc bot hoặc gửi tin nhắn riêng."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "Token bot Discord",
"help": "Tạo từ trang Bot trong Discord Developer Portal."
},
"allowChannels": {
"label": "Kênh được phép",
"placeholder": "ID kênh, phân tách bằng dấu phẩy",
"help": "Để trống để cho phép mọi kênh bot có thể đọc."
},
"groupPolicy": {
"label": "Hành vi trong nhóm",
"choices": {
"mention": "Chỉ khi được nhắc",
"open": "Mọi tin nhắn",
"allowlist": "Danh sách cho phép"
}
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "在 Discord 服务器和私信中使用 nanobot。",
"requirements": "Discord 机器人令牌、权限和网关",
"setup": {
"docsLabel": "打开 Discord 配置指南",
"officialLabel": "打开 Discord 开发者后台",
"tryIt": "在服务器中提及机器人,或向它发送私信。",
"summary": "启用只会打开 Discord 支持;还需要机器人令牌和服务器权限。",
"steps": [
"在 Discord Developer Portal 中创建机器人并复制令牌。",
"将机器人邀请到服务器,并授予读取/发送消息及斜杠命令权限。",
"保存并启用 Discord,然后提及机器人或发送私信。"
],
"fields": {
"token": {
"label": "机器人令牌",
"placeholder": "Discord 机器人令牌",
"help": "从 Discord Developer Portal 的 Bot 页面创建。"
},
"allowChannels": {
"label": "允许的频道",
"placeholder": "频道 ID,用逗号分隔",
"help": "留空则允许机器人可读取的所有频道。"
},
"groupPolicy": {
"label": "群组行为",
"choices": {
"mention": "仅提及时",
"open": "所有消息",
"allowlist": "白名单"
}
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "用户 ID,用逗号分隔"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "在 Discord 伺服器和私訊中使用 nanobot。",
"requirements": "Discord 機器人權杖、權限和閘道",
"setup": {
"docsLabel": "開啟 Discord 設定指南",
"officialLabel": "開啟 Discord 開發者後台",
"tryIt": "在伺服器中提及機器人,或向它傳送私訊。",
"summary": "啟用只會開啟 Discord 支援;還需要機器人權杖和伺服器權限。",
"steps": [
"在 Discord Developer Portal 中建立機器人並複製權杖。",
"將機器人邀請到伺服器,並授予讀取/傳送訊息及斜線指令權限。",
"儲存並啟用 Discord,然後提及機器人或傳送私訊。"
],
"fields": {
"token": {
"label": "機器人權杖",
"placeholder": "Discord 機器人權杖",
"help": "從 Discord Developer Portal 的 Bot 頁面建立。"
},
"allowChannels": {
"label": "允許的頻道",
"placeholder": "頻道 ID,以逗號分隔",
"help": "留空則允許機器人可讀取的所有頻道。"
},
"groupPolicy": {
"label": "群組行為",
"choices": {
"mention": "僅提及時",
"open": "所有訊息",
"allowlist": "允許清單"
}
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "使用者 ID,以逗號分隔"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Email channel package."""
+44
View File
@@ -0,0 +1,44 @@
"""Email management contract."""
from nanobot.channels._manifest import field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.email.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"consentGranted": field("bool", default=False),
"imapHost": field(),
"imapPort": field("int", default=993),
"imapUsername": field(),
"imapPassword": field("secret"),
"smtpHost": field(),
"smtpPort": field("int", default=587),
"smtpUsername": field(),
"smtpPassword": field("secret"),
"fromAddress": field(),
"pollIntervalSeconds": field("int", default=30),
"allowFrom": field("list"),
"verifyDkim": field("bool", default=True),
"verifySpf": field("bool", default=True),
},
required=required_fields(
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
),
official_url="https://support.google.com/accounts/answer/185833",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="email",
display_name="Email",
runtime=f"{__package__}.runtime:EmailChannel",
setup=SETUP_SPEC,
webui="webui/index.ts",
)
+1
View File
@@ -0,0 +1 @@
"""Tests for the email channel package."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
from __future__ import annotations
import pytest
from nanobot.channels.email import validation as email_validation
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(email_validation, "probe_tcp", lambda *_args, **_kwargs: None)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert result["status"] == "connected"
assert result["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.channels.validation.socket.create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in result["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
+91
View File
@@ -0,0 +1,91 @@
"""Email setup validation owned by the channel package."""
from typing import Any
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
int_value,
probe_tcp,
required_checks,
status_from_checks,
string_value,
truthy,
)
def validate(
values: dict[str, Any],
context: ChannelValidationContext,
) -> dict[str, Any]:
checks, missing = required_checks("email", values)
if truthy(values.get("consentGranted")):
checks.append(check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
else:
checks.append(
check(
"consent",
"Mailbox consent",
"fail",
"Grant consent before nanobot reads this mailbox.",
)
)
for prefix, default_port in (("imap", 993), ("smtp", 587)):
host = string_value(values.get(f"{prefix}Host"))
port = int_value(values.get(f"{prefix}Port")) or default_port
if not host:
continue
if port <= 0 or port > 65535:
checks.append(
check(
f"{prefix}_port",
f"{prefix.upper()} port",
"fail",
"Port must be between 1 and 65535.",
)
)
continue
checks.append(
check(
f"{prefix}_settings",
f"{prefix.upper()} settings",
"pass",
f"{host}:{port} is set.",
)
)
try:
probe_tcp(
host,
port,
allow_loopback=context.allow_local_service_access,
)
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"pass",
"The server accepted a TCP connection.",
)
)
except Exception as exc:
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"warn",
f"Could not verify network reachability now: {exc}",
)
)
identity = {
"account": string_value(
values.get("fromAddress")
or values.get("imapUsername")
or values.get("smtpUsername")
)
}
return status_from_checks("email", checks, missing, identity=identity)
__all__ = ["validate"]
+66
View File
@@ -0,0 +1,66 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import {
type ChannelProviderPresetDefinition,
chatAppGuideUrl,
} from "@/components/settings/channels/catalog";
const EMAIL_PROVIDER_PRESETS: ChannelProviderPresetDefinition[] = [
{
id: "gmail",
values: {
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpPort": "587",
},
},
{
id: "outlook",
values: {
"channels.email.imapHost": "outlook.office365.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.office365.com",
"channels.email.smtpPort": "587",
},
},
{
id: "icloud",
values: {
"channels.email.imapHost": "imap.mail.me.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.mail.me.com",
"channels.email.smtpPort": "587",
},
},
{ id: "custom", values: {} },
];
export default {
presentation: {
displayName: "Email",
initials: "EM",
color: "#64748B",
logoUrl: "https://gmail.com/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("email"),
presets: EMAIL_PROVIDER_PRESETS,
fields: [
{ key: "channels.email.consentGranted" },
{ key: "channels.email.imapHost" },
{ key: "channels.email.imapUsername" },
{ key: "channels.email.imapPassword" },
{ key: "channels.email.smtpHost" },
{ key: "channels.email.smtpUsername" },
{ key: "channels.email.smtpPassword" },
{ key: "channels.email.imapPort" },
{ key: "channels.email.smtpPort" },
{ key: "channels.email.fromAddress" },
{ key: "channels.email.pollIntervalSeconds" },
{ key: "channels.email.allowFrom" },
{ key: "channels.email.verifyDkim" },
{ key: "channels.email.verifySpf" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,92 @@
{
"description": "Let nanobot receive and answer email messages.",
"requirements": "IMAP inbox, SMTP sender, app password, explicit consent",
"setup": {
"docsLabel": "Open Email setup",
"officialLabel": "Open app password guide",
"tryIt": "Send a test email to the connected mailbox.",
"summary": "Email reads messages over IMAP and replies over SMTP. Use a dedicated mailbox and grant consent before enabling it.",
"steps": [
"Create a dedicated mailbox and, when required, an app password.",
"Choose a provider preset or enter the IMAP and SMTP settings manually.",
"Grant consent, save and enable Email, then send a test message to the mailbox."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Custom"
},
"fields": {
"consentGranted": {
"label": "Consent granted",
"help": "Required safety switch. Leave false until this bot mailbox is intentionally connected.",
"choices": {
"true": "Granted",
"false": "Not granted"
}
},
"imapHost": {
"label": "IMAP host",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP username",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP password",
"placeholder": "App password",
"help": "Use an app password when your mail provider requires one."
},
"smtpHost": {
"label": "SMTP host",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP username",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP password",
"placeholder": "App password",
"help": "Usually the same app password used for IMAP."
},
"imapPort": {
"label": "IMAP port",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP port",
"placeholder": "587"
},
"fromAddress": {
"label": "From address",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Poll interval",
"placeholder": "30"
},
"allowFrom": {
"label": "Allowed senders",
"placeholder": "Email addresses, comma separated",
"help": "Leave empty to require pairing before a sender can use email."
},
"verifyDkim": {
"label": "Verify DKIM",
"choices": {
"true": "On",
"false": "Off"
}
},
"verifySpf": {
"label": "Verify SPF",
"choices": {
"true": "On",
"false": "Off"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permite que nanobot reciba y responda correos.",
"requirements": "Bandeja IMAP, envío SMTP, contraseña de app y consentimiento explícito",
"setup": {
"docsLabel": "Abrir guía de Email",
"officialLabel": "Abrir guía de contraseñas de app",
"tryIt": "Envía un correo de prueba al buzón conectado.",
"summary": "Email lee mensajes por IMAP y responde por SMTP. Usa un buzón dedicado y da tu consentimiento antes de activarlo.",
"steps": [
"Crea un buzón dedicado y, si hace falta, una contraseña de app.",
"Elige un proveedor o introduce manualmente IMAP y SMTP.",
"Da tu consentimiento, guarda y activa Email; después envía un mensaje de prueba."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimiento concedido",
"help": "Control de seguridad obligatorio. Déjalo desactivado hasta decidir conectar este buzón al bot.",
"choices": {
"true": "Concedido",
"false": "No concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuario IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Contraseña IMAP",
"placeholder": "Contraseña de app",
"help": "Usa una contraseña de app si el proveedor la exige."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuario SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Contraseña SMTP",
"placeholder": "Contraseña de app",
"help": "Normalmente es la misma que para IMAP."
},
"imapPort": {
"label": "Puerto IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Puerto SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Dirección remitente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remitentes permitidos",
"placeholder": "Correos separados por comas",
"help": "Déjalo vacío para exigir vinculación previa."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permettez à nanobot de recevoir et répondre aux e-mails.",
"requirements": "Boîte IMAP, envoi SMTP, mot de passe dapplication et consentement explicite",
"setup": {
"docsLabel": "Ouvrir le guide Email",
"officialLabel": "Ouvrir le guide des mots de passe dapplication",
"tryIt": "Envoyez un e-mail test à la boîte connectée.",
"summary": "Email lit les messages via IMAP et répond via SMTP. Utilisez une boîte dédiée et accordez votre consentement avant lactivation.",
"steps": [
"Créez une boîte dédiée et, si nécessaire, un mot de passe dapplication.",
"Choisissez un fournisseur ou saisissez les paramètres IMAP et SMTP.",
"Accordez le consentement, enregistrez et activez Email, puis envoyez un message test."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personnalisé"
},
"fields": {
"consentGranted": {
"label": "Consentement accordé",
"help": "Sécurité obligatoire. Nactivez quaprès avoir choisi de connecter cette boîte au bot.",
"choices": {
"true": "Accordé",
"false": "Non accordé"
}
},
"imapHost": {
"label": "Hôte IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Nom dutilisateur IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mot de passe IMAP",
"placeholder": "Mot de passe dapplication",
"help": "Utilisez un mot de passe dapplication si le fournisseur lexige."
},
"smtpHost": {
"label": "Hôte SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nom dutilisateur SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mot de passe SMTP",
"placeholder": "Mot de passe dapplication",
"help": "Généralement identique à celui dIMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Adresse denvoi",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalle de relève",
"placeholder": "30"
},
"allowFrom": {
"label": "Expéditeurs autorisés",
"placeholder": "Adresses séparées par des virgules",
"help": "Laissez vide pour imposer lassociation avant utilisation."
},
"verifyDkim": {
"label": "Vérifier DKIM",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
},
"verifySpf": {
"label": "Vérifier SPF",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Izinkan nanobot menerima dan membalas email.",
"requirements": "Kotak masuk IMAP, pengirim SMTP, kata sandi aplikasi, dan persetujuan eksplisit",
"setup": {
"docsLabel": "Buka panduan Email",
"officialLabel": "Buka panduan kata sandi aplikasi",
"tryIt": "Kirim email uji ke kotak surat yang terhubung.",
"summary": "Email membaca pesan melalui IMAP dan membalas melalui SMTP. Gunakan kotak surat khusus dan berikan persetujuan sebelum mengaktifkan.",
"steps": [
"Buat kotak surat khusus dan kata sandi aplikasi bila diperlukan.",
"Pilih preset penyedia atau masukkan IMAP dan SMTP secara manual.",
"Berikan persetujuan, simpan dan aktifkan Email, lalu kirim pesan uji."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Kustom"
},
"fields": {
"consentGranted": {
"label": "Persetujuan diberikan",
"help": "Sakelar keamanan wajib. Aktifkan hanya setelah sengaja menghubungkan kotak surat ini ke bot.",
"choices": {
"true": "Diberikan",
"false": "Belum diberikan"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Nama pengguna IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Kata sandi IMAP",
"placeholder": "Kata sandi aplikasi",
"help": "Gunakan kata sandi aplikasi jika diwajibkan penyedia."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nama pengguna SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Kata sandi SMTP",
"placeholder": "Kata sandi aplikasi",
"help": "Biasanya sama dengan kata sandi aplikasi IMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Alamat pengirim",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Interval pemeriksaan",
"placeholder": "30"
},
"allowFrom": {
"label": "Pengirim yang diizinkan",
"placeholder": "Alamat email, dipisahkan koma",
"help": "Kosongkan untuk mewajibkan pairing terlebih dahulu."
},
"verifyDkim": {
"label": "Verifikasi DKIM",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
},
"verifySpf": {
"label": "Verifikasi SPF",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot でメールを受信し、返信します。",
"requirements": "IMAP 受信箱、SMTP 送信、アプリパスワード、明示的な同意",
"setup": {
"docsLabel": "メール設定ガイドを開く",
"officialLabel": "アプリパスワードガイドを開く",
"tryIt": "接続したメールボックスにテストメールを送信します。",
"summary": "メールは IMAP で受信し SMTP で返信します。専用メールボックスを使い、有効化前に同意してください。",
"steps": [
"専用メールボックスを作成し、必要ならアプリパスワードを発行します。",
"プロバイダープリセットを選ぶか、IMAP と SMTP を手動入力します。",
"同意して保存し、メールを有効にしてテストメールを送信します。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "カスタム"
},
"fields": {
"consentGranted": {
"label": "同意済み",
"help": "必須の安全設定です。このボット用メールボックスを接続すると決めるまでオフにしてください。",
"choices": {
"true": "同意済み",
"false": "未同意"
}
},
"imapHost": {
"label": "IMAP ホスト",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP ユーザー名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP パスワード",
"placeholder": "アプリパスワード",
"help": "プロバイダーが求める場合はアプリパスワードを使います。"
},
"smtpHost": {
"label": "SMTP ホスト",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP ユーザー名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP パスワード",
"placeholder": "アプリパスワード",
"help": "通常は IMAP と同じアプリパスワードです。"
},
"imapPort": {
"label": "IMAP ポート",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP ポート",
"placeholder": "587"
},
"fromAddress": {
"label": "送信元アドレス",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "確認間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "許可する送信者",
"placeholder": "メールアドレス(カンマ区切り)",
"help": "空欄の場合、送信者は先にペアリングが必要です。"
},
"verifyDkim": {
"label": "DKIM を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
},
"verifySpf": {
"label": "SPF を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot이 이메일을 받고 답장하도록 합니다.",
"requirements": "IMAP 받은편지함, SMTP 발신, 앱 비밀번호 및 명시적 동의",
"setup": {
"docsLabel": "이메일 설정 가이드 열기",
"officialLabel": "앱 비밀번호 가이드 열기",
"tryIt": "연결된 사서함으로 테스트 이메일을 보내세요.",
"summary": "이메일은 IMAP으로 읽고 SMTP로 답장합니다. 전용 사서함을 사용하고 활성화 전에 동의하세요.",
"steps": [
"전용 사서함을 만들고 필요하면 앱 비밀번호를 생성하세요.",
"제공자 프리셋을 선택하거나 IMAP 및 SMTP 설정을 직접 입력하세요.",
"동의하고 저장한 뒤 이메일을 활성화하고 테스트 메시지를 보내세요."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "사용자 지정"
},
"fields": {
"consentGranted": {
"label": "동의함",
"help": "필수 안전 스위치입니다. 이 봇 사서함을 연결하기로 결정하기 전에는 끄세요.",
"choices": {
"true": "동의함",
"false": "동의하지 않음"
}
},
"imapHost": {
"label": "IMAP 호스트",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 사용자 이름",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "메일 제공자가 요구하면 앱 비밀번호를 사용하세요."
},
"smtpHost": {
"label": "SMTP 호스트",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 사용자 이름",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "보통 IMAP과 같은 앱 비밀번호를 사용합니다."
},
"imapPort": {
"label": "IMAP 포트",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 포트",
"placeholder": "587"
},
"fromAddress": {
"label": "보내는 주소",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "확인 간격",
"placeholder": "30"
},
"allowFrom": {
"label": "허용된 발신자",
"placeholder": "이메일 주소, 쉼표로 구분",
"help": "비워 두면 발신자가 먼저 페어링해야 합니다."
},
"verifyDkim": {
"label": "DKIM 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
},
"verifySpf": {
"label": "SPF 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permita que o nanobot receba e responda e-mails.",
"requirements": "Caixa IMAP, envio SMTP, senha de app e consentimento explícito",
"setup": {
"docsLabel": "Abrir guia de Email",
"officialLabel": "Abrir guia de senhas de app",
"tryIt": "Envie um e-mail de teste para a caixa conectada.",
"summary": "Email lê mensagens por IMAP e responde por SMTP. Use uma caixa dedicada e dê consentimento antes de ativar.",
"steps": [
"Crie uma caixa dedicada e, quando necessário, uma senha de app.",
"Escolha um provedor ou informe IMAP e SMTP manualmente.",
"Dê consentimento, salve e ative Email; depois, envie uma mensagem de teste."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimento concedido",
"help": "Controle de segurança obrigatório. Deixe desativado até decidir conectar esta caixa ao bot.",
"choices": {
"true": "Concedido",
"false": "Não concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuário IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Senha IMAP",
"placeholder": "Senha de app",
"help": "Use uma senha de app quando o provedor exigir."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuário SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Senha SMTP",
"placeholder": "Senha de app",
"help": "Normalmente é a mesma senha usada no IMAP."
},
"imapPort": {
"label": "Porta IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Porta SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Endereço remetente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remetentes permitidos",
"placeholder": "E-mails separados por vírgulas",
"help": "Deixe vazio para exigir pareamento prévio."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Cho phép nanobot nhận và trả lời email.",
"requirements": "Hộp thư IMAP, gửi SMTP, mật khẩu ứng dụng và sự đồng ý rõ ràng",
"setup": {
"docsLabel": "Mở hướng dẫn Email",
"officialLabel": "Mở hướng dẫn mật khẩu ứng dụng",
"tryIt": "Gửi email thử đến hộp thư đã kết nối.",
"summary": "Email đọc thư qua IMAP và trả lời qua SMTP. Dùng hộp thư riêng và cấp quyền trước khi bật.",
"steps": [
"Tạo hộp thư riêng và mật khẩu ứng dụng nếu cần.",
"Chọn nhà cung cấp hoặc nhập thủ công cài đặt IMAP và SMTP.",
"Cấp quyền, lưu và bật Email, sau đó gửi tin nhắn thử."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Tùy chỉnh"
},
"fields": {
"consentGranted": {
"label": "Đã đồng ý",
"help": "Công tắc an toàn bắt buộc. Chỉ bật sau khi chủ động kết nối hộp thư này với bot.",
"choices": {
"true": "Đã đồng ý",
"false": "Chưa đồng ý"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Tên người dùng IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mật khẩu IMAP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Dùng mật khẩu ứng dụng khi nhà cung cấp yêu cầu."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Tên người dùng SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mật khẩu SMTP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Thường giống mật khẩu ứng dụng dùng cho IMAP."
},
"imapPort": {
"label": "Cổng IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Cổng SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Địa chỉ gửi",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Chu kỳ kiểm tra",
"placeholder": "30"
},
"allowFrom": {
"label": "Người gửi được phép",
"placeholder": "Địa chỉ email, phân tách bằng dấu phẩy",
"help": "Để trống để yêu cầu ghép nối trước."
},
"verifyDkim": {
"label": "Xác minh DKIM",
"choices": {
"true": "Bật",
"false": "Tắt"
}
},
"verifySpf": {
"label": "Xác minh SPF",
"choices": {
"true": "Bật",
"false": "Tắt"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "让 nanobot 接收并回复电子邮件。",
"requirements": "IMAP 收件箱、SMTP 发件服务、应用专用密码和明确授权",
"setup": {
"docsLabel": "打开邮件配置指南",
"officialLabel": "打开应用专用密码指南",
"tryIt": "向已连接的邮箱发送一封测试邮件。",
"summary": "邮件渠道通过 IMAP 读取邮件并通过 SMTP 回复。请使用专用邮箱,并在启用前明确授权。",
"steps": [
"创建专用邮箱,并在服务商要求时创建应用专用密码。",
"选择服务商预设,或手动填写 IMAP 和 SMTP 设置。",
"授予授权,保存并启用邮件渠道,然后向邮箱发送一封测试邮件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自定义"
},
"fields": {
"consentGranted": {
"label": "已授权",
"help": "必需的安全开关。仅在确定要连接此机器人邮箱后才开启。",
"choices": {
"true": "已授权",
"false": "未授权"
}
},
"imapHost": {
"label": "IMAP 主机",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 用户名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密码",
"placeholder": "应用专用密码",
"help": "如果邮件服务商要求,请使用应用专用密码。"
},
"smtpHost": {
"label": "SMTP 主机",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 用户名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密码",
"placeholder": "应用专用密码",
"help": "通常与 IMAP 使用同一个应用专用密码。"
},
"imapPort": {
"label": "IMAP 端口",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 端口",
"placeholder": "587"
},
"fromAddress": {
"label": "发件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "轮询间隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允许的发件人",
"placeholder": "邮箱地址,用逗号分隔",
"help": "留空则要求发件人先完成配对。"
},
"verifyDkim": {
"label": "验证 DKIM",
"choices": {
"true": "开启",
"false": "关闭"
}
},
"verifySpf": {
"label": "验证 SPF",
"choices": {
"true": "开启",
"false": "关闭"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "讓 nanobot 接收並回覆電子郵件。",
"requirements": "IMAP 收件匣、SMTP 寄件服務、應用程式密碼和明確授權",
"setup": {
"docsLabel": "開啟郵件設定指南",
"officialLabel": "開啟應用程式密碼指南",
"tryIt": "向已連接的信箱傳送一封測試郵件。",
"summary": "郵件渠道透過 IMAP 讀取郵件並透過 SMTP 回覆。請使用專用信箱,並在啟用前明確授權。",
"steps": [
"建立專用信箱,並在服務商要求時建立應用程式密碼。",
"選擇服務商預設,或手動填入 IMAP 和 SMTP 設定。",
"授予權限,儲存並啟用郵件渠道,然後向信箱傳送一封測試郵件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自訂"
},
"fields": {
"consentGranted": {
"label": "已授權",
"help": "必要的安全開關。僅在確定要連接此機器人信箱後才開啟。",
"choices": {
"true": "已授權",
"false": "未授權"
}
},
"imapHost": {
"label": "IMAP 主機",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 使用者名稱",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密碼",
"placeholder": "應用程式密碼",
"help": "若郵件服務商要求,請使用應用程式密碼。"
},
"smtpHost": {
"label": "SMTP 主機",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 使用者名稱",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密碼",
"placeholder": "應用程式密碼",
"help": "通常與 IMAP 使用同一個應用程式密碼。"
},
"imapPort": {
"label": "IMAP 連接埠",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 連接埠",
"placeholder": "587"
},
"fromAddress": {
"label": "寄件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "輪詢間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允許的寄件者",
"placeholder": "電子郵件地址,以逗號分隔",
"help": "留空則要求寄件者先完成配對。"
},
"verifyDkim": {
"label": "驗證 DKIM",
"choices": {
"true": "開啟",
"false": "關閉"
}
},
"verifySpf": {
"label": "驗證 SPF",
"choices": {
"true": "開啟",
"false": "關閉"
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Feishu/Lark channel package."""
+36
View File
@@ -0,0 +1,36 @@
"""Dependency-free Feishu configuration model shared by management and runtime."""
from typing import Literal
from pydantic import Field
from nanobot.config.schema import Base
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
instance_id: str = "default"
name: str = "nanobot"
identity_key: str = ""
enabled: bool = False
app_id: str = ""
app_secret: str = ""
encrypt_key: str = ""
verification_token: str = ""
allow_from: list[str] = Field(default_factory=list)
react_emoji: str = "THUMBSUP"
done_emoji: str | None = None
tool_hint_prefix: str = "\U0001f527"
group_policy: Literal["open", "mention"] = "mention"
reply_to_message: bool = False
streaming: bool = True
domain: Literal["feishu", "lark"] = "feishu"
topic_isolation: bool = True
def feishu_default_config() -> dict[str, object]:
return FeishuConfig().model_dump(by_alias=True)
__all__ = ["FeishuConfig", "feishu_default_config"]
+216
View File
@@ -0,0 +1,216 @@
"""Short-lived WebUI channel connection sessions."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any
import httpx
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
from nanobot.channels.feishu import runtime as feishu
from nanobot.channels.feishu.instances import DEFAULT_INSTANCE_ID, validate_instance_id
@dataclass(slots=True)
class FeishuConnectSession:
id: str
instance_id: str
instance_name: str
device_code: str
qr_url: str
domain: str
interval: int
expire_in: int
created_wall: float
deadline: float
last_error: str | None = None
class FeishuConnectStore:
"""In-memory Feishu/Lark QR connection state.
Sessions intentionally live only in the gateway process and expire quickly.
The app secret is never returned to the browser; it is saved directly to
config when Feishu/Lark completes authorization.
"""
def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {}
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
"""Handle one generic settings connection action."""
if action == "start":
return await asyncio.to_thread(
self.start,
domain=(query_first(query, "domain") or "feishu").strip(),
instance_id=(query_first(query, "instance_id") or "default").strip(),
mode=(query_first(query, "mode") or "replace").strip(),
)
session_id = (query_first(query, "session_id") or "").strip()
if not session_id:
raise ChannelConnectError("missing Feishu connect session")
if action == "poll":
return await asyncio.to_thread(self.poll, session_id)
if action == "cancel":
return self.cancel(session_id)
raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
def start(
self,
*,
domain: str = "feishu",
instance_id: str = DEFAULT_INSTANCE_ID,
mode: str = "replace",
) -> dict[str, Any]:
domain = _normalize_domain(domain)
instance_id = _resolve_instance_id(instance_id, mode)
self._cleanup()
try:
feishu._init_registration(domain)
begin = feishu._begin_registration(domain)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
raise ChannelConnectError(
f"Unable to start Feishu/Lark connection: {exc}",
status=502,
) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
now = time.monotonic()
expire_in = int(begin["expire_in"])
interval = max(2, int(begin["interval"]))
session = FeishuConnectSession(
id=session_id,
instance_id=instance_id,
instance_name=_default_instance_name(instance_id),
device_code=str(begin["device_code"]),
qr_url=str(begin["qr_url"]),
domain=domain,
interval=interval,
expire_in=expire_in,
created_wall=now_wall,
deadline=now + expire_in,
)
self._sessions[session_id] = session
return _start_payload(session)
def poll(self, session_id: str) -> dict[str, Any]:
self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
if time.monotonic() >= session.deadline:
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
try:
result = feishu.poll_registration_once(
device_code=session.device_code,
domain=session.domain,
)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
session.last_error = str(exc)
return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status")
if status == "succeeded":
session.instance_id = feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
if status == "failed":
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "failed",
"message": "Authorization was cancelled or expired.",
"domain": session.domain,
}
return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
"status": "cancelled",
"message": "Feishu connection cancelled.",
}
def _cleanup(self) -> None:
now = time.monotonic()
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
for session_id in expired:
self._sessions.pop(session_id, None)
def _normalize_domain(domain: str) -> str:
normalized = domain.strip().lower()
return normalized if normalized in {"feishu", "lark"} else "feishu"
def _resolve_instance_id(instance_id: str, mode: str) -> str:
if mode == "create":
return f"assistant-{secrets.token_hex(3)}"
try:
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
except ValueError as exc:
raise ChannelConnectError(str(exc), status=400) from exc
def _default_instance_name(instance_id: str) -> str:
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"qr_url": session.qr_url,
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Scan with Feishu or Lark to connect.",
}
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Waiting for authorization.",
}
@@ -1,34 +1,20 @@
"""Helpers for channel instance configuration.
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
ChannelManager can support Feishu assistant instances without turning every
channel into a multi-instance abstraction.
"""
"""Feishu-owned helpers for its persisted multi-instance configuration."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from loguru import logger
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
from nanobot.channels.feishu.config import feishu_default_config
from nanobot.config.loader import merge_missing_defaults
DEFAULT_INSTANCE_ID = "default"
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@dataclass(frozen=True)
class ChannelInstanceSpec:
"""Runtime description for one channel instance."""
base_name: str
instance_id: str
runtime_name: str
config: dict[str, Any]
def validate_instance_id(value: str) -> str:
"""Return a normalized instance id or raise ValueError."""
instance_id = value.strip()
@@ -42,6 +28,33 @@ def runtime_channel_name(base_name: str, instance_id: str) -> str:
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
def managed_feishu_instance_specs(
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
return feishu_instance_specs(
section,
feishu_default_config(),
enabled_only=enabled_only,
)
def update_managed_feishu_instance(
section: Any,
values: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
existing = section if isinstance(section, dict) else {}
return upsert_feishu_instance(
existing,
feishu_default_config(),
instance_id,
values,
)
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
config = dict(defaults)
config["instanceId"] = DEFAULT_INSTANCE_ID
@@ -67,6 +80,31 @@ def _normalize_feishu_instance(
return config
def feishu_app_identity_key(app_id: Any, domain: Any = "feishu") -> str:
"""Return the stable identity shared by persisted and runtime instances."""
app_id = str(app_id or "").strip()
if not app_id:
return ""
normalized_domain = "lark" if str(domain or "feishu").strip().lower() == "lark" else "feishu"
return f"{normalized_domain}:{app_id}"
def _feishu_instance_inputs(
section: Any,
defaults: dict[str, Any],
) -> tuple[list[Any], dict[str, Any] | None]:
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
section = {}
instances = section.get("instances")
if isinstance(instances, list):
inherited = {key: value for key, value in section.items() if key != "instances"}
return list(instances), inherited
return ([section] if section else [_base_feishu_instance_config(defaults)]), None
def feishu_instance_specs(
section: Any,
defaults: dict[str, Any],
@@ -74,22 +112,15 @@ def feishu_instance_specs(
enabled_only: bool = False,
) -> list[ChannelInstanceSpec]:
"""Expand legacy or canonical Feishu config into runtime instance specs."""
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
section = {}
instances = section.get("instances")
raw_specs: list[dict[str, Any]]
inherited: dict[str, Any] | None = None
if isinstance(instances, list):
inherited = {key: value for key, value in section.items() if key != "instances"}
raw_specs = [item for item in instances if isinstance(item, dict)]
else:
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
specs: list[ChannelInstanceSpec] = []
instance_ids: set[str] = set()
identity_owners: dict[str, str] = {}
for index, raw in enumerate(raw_specs):
if not isinstance(raw, dict):
logger.warning("Skipping invalid Feishu instance at index {}: expected an object", index)
continue
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
@@ -102,16 +133,33 @@ def feishu_instance_specs(
logger.warning("Skipping invalid Feishu instance config: {}", exc)
continue
instance_id = str(config["instanceId"])
if instance_id in instance_ids:
logger.warning("Skipping duplicate Feishu instance id '{}'", instance_id)
continue
instance_ids.add(instance_id)
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
if enabled_only and not enabled:
continue
instance_id = str(config["instanceId"])
identity = feishu_app_identity_key(
config.get("appId") or config.get("app_id"),
config.get("domain"),
)
if enabled_only and identity:
if identity in identity_owners:
logger.warning(
"Skipping Feishu instance '{}' because it uses the same app as instance '{}'",
instance_id,
identity_owners[identity],
)
continue
identity_owners[identity] = instance_id
specs.append(
ChannelInstanceSpec(
base_name="feishu",
instance_id=instance_id,
runtime_name=runtime_channel_name("feishu", instance_id),
config=config,
)
)
@@ -120,9 +168,32 @@ def feishu_instance_specs(
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
"""Return Feishu config in the canonical ``instances`` shape."""
specs = feishu_instance_specs(section, defaults)
return {"instances": [dict(spec.config) for spec in specs]}
"""Return a canonical section, rejecting input that cannot be preserved safely."""
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
instances: list[dict[str, Any]] = []
instance_ids: set[str] = set()
for index, raw in enumerate(raw_specs):
if not isinstance(raw, dict):
raise ValueError(f"Feishu instance at index {index} must be an object")
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
raw,
defaults,
inherited=inherited,
fallback_id=fallback_id,
)
except ValueError as exc:
raise ValueError(f"Invalid Feishu instance at index {index}: {exc}") from exc
instance_id = str(config["instanceId"])
if instance_id in instance_ids:
raise ValueError(f"duplicate Feishu instance id '{instance_id}'")
instance_ids.add(instance_id)
instances.append(config)
return {"instances": instances}
def upsert_feishu_instance(
@@ -174,11 +245,23 @@ def update_feishu_instance_preserving_shape(
return upsert_feishu_instance(section, defaults, instance_id, values)
def set_feishu_instance_enabled(
section: Any,
defaults: dict[str, Any],
instance_id: str,
enabled: bool,
) -> dict[str, Any]:
"""Return canonical Feishu section with one instance's enabled flag updated."""
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
FEISHU_MANAGEMENT = ChannelManagementSpec(
multi_instance=True,
default_config=feishu_default_config,
instance_specs=managed_feishu_instance_specs,
update_instance_config=update_managed_feishu_instance,
runtime_name=runtime_channel_name,
)
__all__ = [
"DEFAULT_INSTANCE_ID",
"FEISHU_MANAGEMENT",
"canonical_feishu_section",
"feishu_app_identity_key",
"feishu_instance_specs",
"runtime_channel_name",
"update_feishu_instance_preserving_shape",
"upsert_feishu_instance",
"validate_instance_id",
]
+42
View File
@@ -0,0 +1,42 @@
"""Dependency-free Feishu/Lark management contract."""
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.feishu.instances import FEISHU_MANAGEMENT
from nanobot.channels.feishu.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"appId": field(snapshot=False),
"appSecret": field("secret", snapshot=False),
"domain": field(
"enum",
choices={"feishu", "lark"},
default="feishu",
snapshot=False,
),
"groupPolicy": field(
"enum",
choices=DIRECT_GROUP_POLICIES,
default="mention",
snapshot=False,
),
"allowFrom": field("list", snapshot=False),
"topicIsolation": field("bool", default=True, snapshot=False),
},
required=required_fields("appId", "appSecret"),
official_url="https://open.feishu.cn/app",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="feishu",
display_name="Feishu",
runtime=f"{__package__}.runtime:FeishuChannel",
connector=f"{__package__}.connect:FeishuConnectStore",
setup=SETUP_SPEC,
management=FEISHU_MANAGEMENT,
dependencies=("lark-oapi>=1.5.0,<2.0.0",),
webui="webui/index.tsx",
)
@@ -14,9 +14,9 @@ from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import Field
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
@@ -25,18 +25,20 @@ from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import (
from nanobot.channels.base import BaseChannel
from nanobot.channels.contracts import ChannelInstanceSpec
from nanobot.channels.feishu.config import FeishuConfig, feishu_default_config
from nanobot.channels.feishu.instances import (
DEFAULT_INSTANCE_ID,
feishu_app_identity_key,
feishu_instance_specs,
runtime_channel_name,
update_feishu_instance_preserving_shape,
upsert_feishu_instance,
)
from nanobot.channels._feishu_ws import get_feishu_ws_runner
from nanobot.channels.base import BaseChannel
from nanobot.channels.feishu.websocket import get_feishu_ws_runner
from nanobot.command.router import normalize_command_text
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.pairing import clear_channel
from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
@@ -46,6 +48,7 @@ if TYPE_CHECKING:
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
_LOGIN_CONSOLE = Console()
_LARK_RUNTIME_LOCK = threading.Lock()
def _identity_timestamp() -> str:
@@ -60,27 +63,32 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
"""
import sys
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
import lark_oapi as lark
import lark_oapi.ws.client as lark_ws_client
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
# The SDK creates a module-global event loop while importing its WebSocket
# client. Multiple Feishu instances start concurrently, so serialize this
# one-time import and cleanup rather than allowing two worker threads to
# close the same loop.
with _LARK_RUNTIME_LOCK:
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
import lark_oapi as lark
import lark_oapi.ws.client as lark_ws_client
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
if (
not ws_client_already_imported
and threading.current_thread() is not threading.main_thread()
):
import_loop = getattr(lark_ws_client, "loop", None)
if (
import_loop is not None
and not import_loop.is_running()
and not import_loop.is_closed()
not ws_client_already_imported
and threading.current_thread() is not threading.main_thread()
):
import_loop.close()
lark_ws_client.loop = None
with suppress(Exception):
asyncio.set_event_loop(None)
import_loop = getattr(lark_ws_client, "loop", None)
if (
import_loop is not None
and not import_loop.is_running()
and not import_loop.is_closed()
):
import_loop.close()
lark_ws_client.loop = None
with suppress(Exception):
asyncio.set_event_loop(None)
return lark, FEISHU_DOMAIN, LARK_DOMAIN
return lark, FEISHU_DOMAIN, LARK_DOMAIN
def fetch_feishu_app_identity(
@@ -406,28 +414,6 @@ def _extract_post_text(content_json: dict) -> str:
return text
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
instance_id: str = DEFAULT_INSTANCE_ID
name: str = "nanobot"
identity_key: str = ""
enabled: bool = False
app_id: str = ""
app_secret: str = ""
encrypt_key: str = ""
verification_token: str = ""
allow_from: list[str] = Field(default_factory=list)
react_emoji: str = "THUMBSUP"
done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK")
tool_hint_prefix: str = "\U0001f527" # Prefix for inline tool hints (default: 🔧)
group_policy: Literal["open", "mention"] = "mention"
reply_to_message: bool = False # If True, bot replies quote the user's original message
streaming: bool = True
domain: Literal["feishu", "lark"] = "feishu" # Set to "lark" for international Lark
topic_isolation: bool = True # If True, each topic in group chat gets its own session (isolation)
# =============================================================================
# QR scan-to-create onboarding
#
@@ -590,12 +576,6 @@ def poll_registration_once(
}
def _feishu_app_identity_key(app_id: str, domain: str) -> str:
normalized_app_id = app_id.strip()
normalized_domain = "lark" if domain.strip().lower() == "lark" else "feishu"
return f"{normalized_domain}:{normalized_app_id}" if normalized_app_id else ""
def _saved_feishu_instance_identity_key(
feishu_cfg: Any,
defaults: dict[str, Any],
@@ -603,13 +583,32 @@ def _saved_feishu_instance_identity_key(
) -> str:
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
return _feishu_app_identity_key(
return feishu_app_identity_key(
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
str(spec.config.get("domain") or "feishu"),
)
return ""
def _saved_feishu_instance_for_identity(
feishu_cfg: Any,
defaults: dict[str, Any],
app_id: str,
domain: str,
) -> ChannelInstanceSpec | None:
identity_key = feishu_app_identity_key(app_id, domain)
if not identity_key:
return None
for spec in feishu_instance_specs(feishu_cfg, defaults):
saved_identity = feishu_app_identity_key(
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
str(spec.config.get("domain") or "feishu"),
)
if saved_identity == identity_key:
return spec
return None
def sync_saved_feishu_identity_boundary(
*,
instance_id: str,
@@ -622,7 +621,7 @@ def sync_saved_feishu_identity_boundary(
manual config edits so approved users do not accidentally carry over to a
different Feishu/Lark app in the same local instance slot.
"""
current_identity_key = _feishu_app_identity_key(app_id, domain)
current_identity_key = feishu_app_identity_key(app_id, domain)
if not current_identity_key:
return False
@@ -633,7 +632,7 @@ def sync_saved_feishu_identity_boundary(
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
defaults = feishu_default_config()
previous_identity_key = ""
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
@@ -667,7 +666,7 @@ def save_registration_result(
*,
instance_id: str = DEFAULT_INSTANCE_ID,
name: str | None = None,
) -> None:
) -> str:
"""Persist a successful Feishu/Lark registration result to config.json."""
from nanobot.config.loader import load_config, save_config
@@ -675,12 +674,18 @@ def save_registration_result(
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
defaults = feishu_default_config()
app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
domain = "lark" if domain == "lark" else "feishu"
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
next_identity_key = _feishu_app_identity_key(app_id, domain)
existing = _saved_feishu_instance_for_identity(feishu_cfg, defaults, app_id, domain)
effective_instance_id = existing.instance_id if existing is not None else instance_id
previous_identity_key = _saved_feishu_instance_identity_key(
feishu_cfg,
defaults,
effective_instance_id,
)
next_identity_key = feishu_app_identity_key(app_id, domain)
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
identity: dict[str, str] = {}
with suppress(Exception):
@@ -689,8 +694,19 @@ def save_registration_result(
str(result["app_secret"]),
domain,
)
default_name = (
"nanobot"
if effective_instance_id == DEFAULT_INSTANCE_ID
else f"nanobot {effective_instance_id}"
)
existing_name = existing.config.get("name") if existing is not None else None
saved_name = (
existing_name
if existing is not None and existing.instance_id != instance_id
else name
)
values = {
"name": name or ("nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"),
"name": str(saved_name or default_name),
"appId": app_id,
"appSecret": result["app_secret"],
"domain": domain,
@@ -701,18 +717,24 @@ def save_registration_result(
if identity_changed:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id))
clear_channel(runtime_channel_name("feishu", effective_instance_id))
feishu_cfg = upsert_feishu_instance(
feishu_cfg,
defaults,
instance_id,
effective_instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
return effective_instance_id
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
def refresh_saved_feishu_identities(
config: Any | None = None,
*,
config_path: Path | None = None,
instance_id: str | None = None,
) -> bool:
"""Backfill missing Feishu assistant display identity in saved config.
Existing users may already have working App ID/Secret credentials from
@@ -727,8 +749,10 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
full_config = config or load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None)
defaults = FeishuChannel.default_config()
defaults = feishu_default_config()
specs = feishu_instance_specs(feishu_cfg, defaults)
if instance_id:
specs = [spec for spec in specs if spec.instance_id == instance_id]
updated = False
for spec in specs:
@@ -765,7 +789,7 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
return False
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
save_config(full_config, config_path)
return True
@@ -869,7 +893,22 @@ class FeishuChannel(BaseChannel):
@classmethod
def default_config(cls) -> dict[str, Any]:
return FeishuConfig().model_dump(by_alias=True)
return feishu_default_config()
@classmethod
def refresh_feature_metadata(
cls,
config_path: Path,
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> bool:
from nanobot.config.loader import load_config
return refresh_saved_feishu_identities(
load_config(config_path),
config_path=config_path,
instance_id=instance_id,
)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
@@ -963,7 +1002,7 @@ class FeishuChannel(BaseChannel):
app_id=self.config.app_id,
domain=self.config.domain,
):
self.config.identity_key = _feishu_app_identity_key(self.config.app_id, self.config.domain)
self.config.identity_key = feishu_app_identity_key(self.config.app_id, self.config.domain)
self.config.allow_from = []
self.logger.info(
"Feishu app identity changed for {}; cleared paired users for this assistant",
@@ -0,0 +1 @@
"""Tests for the Feishu channel package."""
@@ -0,0 +1,39 @@
import json
from nanobot.channels.feishu.runtime import _extract_share_card_content
def test_extract_interactive_card_reads_user_dsl_body_elements() -> None:
content = {
"user_dsl": json.dumps(
{
"schema": "2.0",
"body": {"elements": [{"tag": "markdown", "content": "**hello**"}]},
}
)
}
assert _extract_share_card_content(content, "interactive") == "**hello**"
def test_extract_interactive_card_reads_nested_text_elements() -> None:
content = {"elements": [[{"tag": "text", "text": "hello"}]]}
assert _extract_share_card_content(content, "interactive") == "hello"
def test_extract_interactive_card_reads_table_rows() -> None:
content = {
"elements": [
{
"tag": "table",
"columns": [
{"name": "c0", "display_name": "Name"},
{"name": "c1", "display_name": "Score"},
],
"rows": [{"c0": "Alice", "c1": 98}],
}
]
}
assert _extract_share_card_content(content, "interactive") == "Name | Score\nAlice | 98"
@@ -0,0 +1,46 @@
"""Tests for Feishu/Lark domain configuration."""
from unittest.mock import MagicMock
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig
def _make_channel(domain: str = "feishu") -> FeishuChannel:
config = FeishuConfig(
enabled=True,
app_id="cli_test",
app_secret="secret",
allow_from=["*"],
domain=domain,
)
ch = FeishuChannel(config, MessageBus())
ch._client = MagicMock()
ch._loop = None
return ch
class TestFeishuConfigDomain:
def test_domain_default_is_feishu(self):
config = FeishuConfig()
assert config.domain == "feishu"
def test_domain_accepts_lark(self):
config = FeishuConfig(domain="lark")
assert config.domain == "lark"
def test_domain_accepts_feishu(self):
config = FeishuConfig(domain="feishu")
assert config.domain == "feishu"
def test_default_config_includes_domain(self):
default_cfg = FeishuChannel.default_config()
assert "domain" in default_cfg
assert default_cfg["domain"] == "feishu"
def test_channel_persists_domain_from_config(self):
ch = _make_channel(domain="lark")
assert ch.config.domain == "lark"
def test_channel_persists_feishu_domain_from_config(self):
ch = _make_channel(domain="feishu")
assert ch.config.domain == "feishu"
@@ -0,0 +1,99 @@
import subprocess
import sys
def _run_import_probe(source: str) -> str:
proc = subprocess.run(
[sys.executable, "-c", source],
check=True,
capture_output=True,
text=True,
)
return proc.stdout.strip()
def test_feishu_module_import_does_not_import_lark_oapi():
out = _run_import_probe(
"import sys; import nanobot.channels.feishu; print('lark_oapi' in sys.modules)"
)
assert out == "False"
def test_feishu_channel_constructor_does_not_import_lark_oapi():
out = _run_import_probe(
"import sys; "
"from nanobot.bus.queue import MessageBus; "
"from nanobot.channels.feishu.runtime import FeishuChannel; "
"FeishuChannel({'enabled': True}, MessageBus()); "
"print('lark_oapi' in sys.modules)"
)
assert out == "False"
def test_lark_runtime_thread_import_clears_sdk_import_loop():
out = _run_import_probe(
"import asyncio\n"
"import sys\n"
"import tempfile\n"
"from pathlib import Path\n"
"from nanobot.channels.feishu.runtime import _load_lark_runtime\n"
"root = Path(tempfile.mkdtemp())\n"
"pkg = root / 'lark_oapi'\n"
"(pkg / 'ws').mkdir(parents=True)\n"
"(pkg / 'core').mkdir(parents=True)\n"
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
"(pkg / 'ws' / '__init__.py').write_text('')\n"
"(pkg / 'ws' / 'client.py').write_text('import asyncio\\nloop = asyncio.new_event_loop()\\n')\n"
"(pkg / 'core' / '__init__.py').write_text('')\n"
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
"sys.path.insert(0, str(root))\n"
"async def main():\n"
" await asyncio.to_thread(_load_lark_runtime)\n"
" import lark_oapi.ws.client as ws\n"
" print(getattr(ws, 'loop', 'sentinel') is None)\n"
"asyncio.run(main())"
)
assert out == "True"
def test_lark_runtime_thread_import_is_serialized_for_multiple_instances():
out = _run_import_probe(
"import asyncio\n"
"import sys\n"
"import tempfile\n"
"from pathlib import Path\n"
"from nanobot.channels.feishu.runtime import _load_lark_runtime\n"
"root = Path(tempfile.mkdtemp())\n"
"pkg = root / 'lark_oapi'\n"
"(pkg / 'ws').mkdir(parents=True)\n"
"(pkg / 'core').mkdir(parents=True)\n"
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
"(pkg / 'ws' / '__init__.py').write_text('')\n"
"(pkg / 'ws' / 'client.py').write_text(\n"
" 'import time\\n'\n"
" 'class ImportLoop:\\n'\n"
" ' closed = False\\n'\n"
" ' close_calls = 0\\n'\n"
" ' def is_running(self): return False\\n'\n"
" ' def is_closed(self): return self.closed\\n'\n"
" ' def close(self):\\n'\n"
" ' self.close_calls += 1\\n'\n"
" ' time.sleep(0.05)\\n'\n"
" ' if self.close_calls > 1: raise AttributeError(\"closed twice\")\\n'\n"
" ' self.closed = True\\n'\n"
" 'loop = ImportLoop()\\n'\n"
")\n"
"(pkg / 'core' / '__init__.py').write_text('')\n"
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
"sys.path.insert(0, str(root))\n"
"async def main():\n"
" await asyncio.gather(*[asyncio.to_thread(_load_lark_runtime) for _ in range(8)])\n"
" import lark_oapi.ws.client as ws\n"
" print(ws.loop is None)\n"
"asyncio.run(main())"
)
assert out == "True"
@@ -0,0 +1,448 @@
import json
import httpx
import pytest
from nanobot.channels.feishu import runtime as feishu_module
from nanobot.channels.feishu.runtime import FeishuChannel
from nanobot.config import loader
from nanobot.config.schema import Config
from nanobot.pairing import store as pairing_store
def _default_feishu_instance(data: dict) -> dict:
return data["channels"]["feishu"]["instances"][0]
@pytest.mark.asyncio
async def test_feishu_login_writes_credentials_to_active_config(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {"enabled": False, "domain": "feishu"}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(
feishu_module,
"qr_register",
lambda initial_domain="feishu": {
"app_id": "cli_app",
"app_secret": "secret",
"domain": "lark",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/avatar.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
channel = FeishuChannel({"enabled": False, "domain": "feishu"}, None)
assert await channel.login() is True
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["domain"] == "lark"
assert instance["identityKey"] == "lark:cli_app"
assert instance["enabled"] is True
assert instance["displayName"] == "Voraflare Bot"
assert instance["avatarUrl"] == "https://example.com/avatar.png"
assert instance["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_begin_registration_requires_login_url(monkeypatch):
monkeypatch.setattr(
feishu_module,
"_post_registration",
lambda _base_url, _body: {"device_code": "device"},
)
with pytest.raises(RuntimeError, match="login URL"):
feishu_module._begin_registration()
def test_begin_registration_preserves_login_url(monkeypatch):
login_url = "https://accounts.feishu.cn/login?device_code=device"
monkeypatch.setattr(
feishu_module,
"_post_registration",
lambda _base_url, _body: {
"device_code": "device",
"verification_uri_complete": login_url,
},
)
assert feishu_module._begin_registration()["qr_url"] == login_url
def test_qr_register_returns_none_on_network_error(monkeypatch):
def raise_connect_error(_base_url, _body):
raise httpx.ConnectError("network down")
monkeypatch.setattr(feishu_module, "_post_registration", raise_connect_error)
assert feishu_module.qr_register() is None
def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fail_identity(_app_id, _app_secret, _domain):
raise RuntimeError("metadata unavailable")
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", fail_identity)
feishu_module.save_registration_result({
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
})
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["identityKey"] == "feishu:cli_app"
assert "displayName" not in instance
assert "avatarUrl" not in instance
def test_save_registration_result_reuses_existing_app_instance(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "default",
"instanceId": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "feishu",
"identityKey": "feishu:cli_same",
"allowFrom": ["approved-user"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
effective_id = feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "rotated-secret",
"domain": "feishu",
},
instance_id="assistant-new",
name="nanobot assistant-new",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instances = data["channels"]["feishu"]["instances"]
assert effective_id == "default"
assert len(instances) == 1
assert instances[0]["id"] == "default"
assert instances[0]["name"] == "nanobot"
assert instances[0]["appSecret"] == "rotated-secret"
assert instances[0]["allowFrom"] == ["approved-user"]
def test_save_registration_result_resets_access_when_instance_app_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "old assistant",
"enabled": True,
"appId": "cli_old",
"appSecret": "old-secret",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="new assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appId"] == "cli_new"
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_save_registration_result_keeps_access_when_only_secret_rotates(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "same assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "feishu",
"identityKey": "feishu:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="same assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
assert pairing_store.approve_code(pending_code) == (
"feishu.assistant-test",
"pending-user",
)
def test_save_registration_result_resets_access_when_domain_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "lark assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "lark",
"identityKey": "lark:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="feishu assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["domain"] == "feishu"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
def test_sync_saved_identity_boundary_resets_access_after_manual_app_change(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "manual assistant",
"enabled": True,
"appId": "cli_new",
"appSecret": "secret",
"domain": "feishu",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_new",
domain="feishu",
) is True
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_sync_saved_identity_boundary_backfills_marker_without_resetting_access(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "existing assistant",
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_existing",
domain="feishu",
) is False
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_existing"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
def test_sync_saved_identity_boundary_preserves_legacy_flat_config(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="default",
app_id="cli_existing",
domain="feishu",
) is False
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_existing"
assert saved["appSecret"] == "secret"
assert saved["identityKey"] == "feishu:cli_existing"
assert saved["allowFrom"] == ["old-open-id"]
assert "instances" not in saved
@pytest.mark.asyncio
async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path):
missing_config = tmp_path / "missing.json"
monkeypatch.setattr(loader, "_current_config_path", missing_config)
monkeypatch.setattr(
feishu_module,
"qr_register",
lambda initial_domain="feishu": {
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
},
)
channel = FeishuChannel({}, None)
assert await channel.login() is True
assert missing_config.exists()
data = json.loads(missing_config.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
@@ -0,0 +1,68 @@
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu.runtime import FeishuChannel
def test_parse_md_table_strips_markdown_formatting_in_headers_and_cells() -> None:
table = FeishuChannel._parse_md_table(
"""
| **Name** | __Status__ | *Notes* | ~~State~~ |
| --- | --- | --- | --- |
| **Alice** | __Ready__ | *Fast* | ~~Old~~ |
"""
)
assert table is not None
assert [col["display_name"] for col in table["columns"]] == [
"Name",
"Status",
"Notes",
"State",
]
assert table["rows"] == [
{"c0": "Alice", "c1": "Ready", "c2": "Fast", "c3": "Old"}
]
def test_split_headings_strips_embedded_markdown_before_bolding() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
elements = channel._split_headings("# **Important** *status* ~~update~~")
assert elements == [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**Important status update**",
},
}
]
def test_split_headings_keeps_markdown_body_and_code_blocks_intact() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
elements = channel._split_headings(
"# **Heading**\n\nBody with **bold** text.\n\n```python\nprint('hi')\n```"
)
assert elements[0] == {
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**Heading**",
},
}
assert elements[1]["tag"] == "markdown"
assert "Body with **bold** text." in elements[1]["content"]
assert "```python\nprint('hi')\n```" in elements[1]["content"]
@@ -0,0 +1,38 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.channels.feishu import runtime as feishu_module
from nanobot.channels.feishu.runtime import FeishuChannel
@pytest.mark.asyncio
async def test_feishu_downloaded_media_filename_cannot_escape_media_dir(monkeypatch, tmp_path):
media_dir = tmp_path / "media"
media_dir.mkdir()
outside = tmp_path / "escaped.txt"
monkeypatch.setattr(feishu_module, "get_media_dir", lambda _channel: media_dir)
channel = FeishuChannel.__new__(FeishuChannel)
channel.logger = SimpleNamespace(
debug=lambda *args, **kwargs: None,
warning=lambda *args, **kwargs: None,
)
def fake_download(_message_id, _file_key, _resource_type):
return b"owned", "../escaped.txt"
channel._download_file_sync = fake_download
path_str, content = await channel._download_and_save_media(
"file", {"file_key": "fk_123"}, "msg_123"
)
saved_path = Path(path_str)
assert not outside.exists()
assert saved_path.parent == media_dir
assert saved_path.name == "escaped.txt"
assert saved_path.read_bytes() == b"owned"
assert content == f"[file: {saved_path}]"
@@ -0,0 +1,60 @@
"""Tests for Feishu _is_bot_mentioned logic."""
from types import SimpleNamespace
from nanobot.channels.feishu.runtime import FeishuChannel
def _make_channel(bot_open_id: str | None = None) -> FeishuChannel:
config = SimpleNamespace(
app_id="test_id",
app_secret="test_secret",
verification_token="",
event_encrypt_key="",
group_policy="mention",
)
ch = FeishuChannel.__new__(FeishuChannel)
ch.config = config
ch._bot_open_id = bot_open_id
return ch
def _make_message(mentions=None, content="hello"):
return SimpleNamespace(content=content, mentions=mentions)
def _make_mention(open_id: str, user_id: str | None = None):
mid = SimpleNamespace(open_id=open_id, user_id=user_id)
return SimpleNamespace(id=mid)
class TestIsBotMentioned:
def test_exact_match_with_bot_open_id(self):
ch = _make_channel(bot_open_id="ou_bot123")
msg = _make_message(mentions=[_make_mention("ou_bot123")])
assert ch._is_bot_mentioned(msg) is True
def test_no_match_different_bot(self):
ch = _make_channel(bot_open_id="ou_bot123")
msg = _make_message(mentions=[_make_mention("ou_other_bot")])
assert ch._is_bot_mentioned(msg) is False
def test_at_all_always_matches(self):
ch = _make_channel(bot_open_id="ou_bot123")
msg = _make_message(content="@_all hello")
assert ch._is_bot_mentioned(msg) is True
def test_fallback_heuristic_when_no_bot_open_id(self):
ch = _make_channel(bot_open_id=None)
msg = _make_message(mentions=[_make_mention("ou_some_bot", user_id=None)])
assert ch._is_bot_mentioned(msg) is True
def test_fallback_ignores_user_mentions(self):
ch = _make_channel(bot_open_id=None)
msg = _make_message(mentions=[_make_mention("ou_user", user_id="u_12345")])
assert ch._is_bot_mentioned(msg) is False
def test_no_mentions_returns_false(self):
ch = _make_channel(bot_open_id="ou_bot123")
msg = _make_message(mentions=None)
assert ch._is_bot_mentioned(msg) is False
@@ -0,0 +1,65 @@
"""Tests for FeishuChannel._resolve_mentions."""
from types import SimpleNamespace
from nanobot.channels.feishu.runtime import FeishuChannel
def _mention(key: str, name: str, open_id: str = "", user_id: str = ""):
"""Build a mock MentionEvent-like object."""
id_obj = SimpleNamespace(open_id=open_id, user_id=user_id) if (open_id or user_id) else None
return SimpleNamespace(key=key, name=name, id=id_obj)
class TestResolveMentions:
def test_single_mention_replaced(self):
text = "hello @_user_1 how are you"
mentions = [_mention("@_user_1", "Alice", open_id="ou_abc123")]
result = FeishuChannel._resolve_mentions(text, mentions)
assert "@Alice (ou_abc123)" in result
assert "@_user_1" not in result
def test_mention_with_both_ids(self):
text = "@_user_1 said hi"
mentions = [_mention("@_user_1", "Bob", open_id="ou_abc", user_id="uid_456")]
result = FeishuChannel._resolve_mentions(text, mentions)
assert "@Bob (ou_abc, user id: uid_456)" in result
def test_mention_no_id_skipped(self):
"""When mention has no id object, the placeholder is left unchanged."""
text = "@_user_1 said hi"
mentions = [SimpleNamespace(key="@_user_1", name="Charlie", id=None)]
result = FeishuChannel._resolve_mentions(text, mentions)
assert result == "@_user_1 said hi"
def test_multiple_mentions(self):
text = "@_user_1 and @_user_2 are here"
mentions = [
_mention("@_user_1", "Alice", open_id="ou_a"),
_mention("@_user_2", "Bob", open_id="ou_b"),
]
result = FeishuChannel._resolve_mentions(text, mentions)
assert "@Alice (ou_a)" in result
assert "@Bob (ou_b)" in result
assert "@_user_1" not in result
assert "@_user_2" not in result
def test_mention_before_punctuation_replaced(self):
text = "hello @_user_1, are you there?"
mentions = [_mention("@_user_1", "Alice", open_id="ou_a")]
result = FeishuChannel._resolve_mentions(text, mentions)
assert result == "hello @Alice (ou_a), are you there?"
def test_no_mentions_returns_text(self):
assert FeishuChannel._resolve_mentions("hello world", None) == "hello world"
assert FeishuChannel._resolve_mentions("hello world", []) == "hello world"
def test_empty_text_returns_empty(self):
mentions = [_mention("@_user_1", "Alice", open_id="ou_a")]
assert FeishuChannel._resolve_mentions("", mentions) == ""
def test_mention_key_not_in_text_skipped(self):
text = "hello world"
mentions = [_mention("@_user_99", "Ghost", open_id="ou_ghost")]
result = FeishuChannel._resolve_mentions(text, mentions)
assert result == "hello world"
@@ -0,0 +1,76 @@
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels.feishu import runtime as feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu.runtime import FeishuChannel, _extract_post_content
def test_extract_post_content_supports_post_wrapper_shape() -> None:
payload = {
"post": {
"zh_cn": {
"title": "日报",
"content": [
[
{"tag": "text", "text": "完成"},
{"tag": "img", "image_key": "img_1"},
]
],
}
}
}
text, image_keys = _extract_post_content(payload)
assert text == "日报 完成"
assert image_keys == ["img_1"]
def test_extract_post_content_keeps_direct_shape_behavior() -> None:
payload = {
"title": "Daily",
"content": [
[
{"tag": "text", "text": "report"},
{"tag": "img", "image_key": "img_a"},
{"tag": "img", "image_key": "img_b"},
]
],
}
text, image_keys = _extract_post_content(payload)
assert text == "Daily report"
assert image_keys == ["img_a", "img_b"]
def test_register_optional_event_keeps_builder_when_method_missing() -> None:
class Builder:
pass
builder = Builder()
same = FeishuChannel._register_optional_event(builder, "missing", object())
assert same is builder
def test_register_optional_event_calls_supported_method() -> None:
called = []
class Builder:
def register_event(self, handler):
called.append(handler)
return self
builder = Builder()
handler = object()
same = FeishuChannel._register_optional_event(builder, "register_event", handler)
assert same is builder
assert called == [handler]
@@ -0,0 +1,329 @@
# ruff: noqa: E402
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
pytest.importorskip("lark_oapi")
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig, _FeishuStreamBuf
def _make_channel() -> FeishuChannel:
config = FeishuConfig(
enabled=True,
app_id="cli_test",
app_secret="secret",
allow_from=["*"],
)
ch = FeishuChannel(config, MessageBus())
ch._client = MagicMock()
ch._loop = None
return ch
def _mock_reaction_create_response(reaction_id: str = "reaction_001", success: bool = True):
resp = MagicMock()
resp.success.return_value = success
resp.code = 0 if success else 99999
resp.msg = "ok" if success else "error"
if success:
resp.data = SimpleNamespace(reaction_id=reaction_id)
else:
resp.data = None
return resp
# ── _add_reaction_sync ──────────────────────────────────────────────────────
class TestAddReactionSync:
def test_returns_reaction_id_on_success(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response("rx_42")
result = ch._add_reaction_sync("om_001", "THUMBSUP")
assert result == "rx_42"
def test_returns_none_when_response_fails(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.return_value = _mock_reaction_create_response(success=False)
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
def test_returns_none_when_response_data_is_none(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = True
resp.data = None
ch._client.im.v1.message_reaction.create.return_value = resp
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
def test_returns_none_on_exception(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.create.side_effect = RuntimeError("network error")
assert ch._add_reaction_sync("om_001", "THUMBSUP") is None
# ── _add_reaction (async) ───────────────────────────────────────────────────
class TestAddReactionAsync:
@pytest.mark.asyncio
async def test_returns_reaction_id(self):
ch = _make_channel()
ch._add_reaction_sync = MagicMock(return_value="rx_99")
result = await ch._add_reaction("om_001", "EYES")
assert result == "rx_99"
@pytest.mark.asyncio
async def test_returns_none_when_no_client(self):
ch = _make_channel()
ch._client = None
result = await ch._add_reaction("om_001", "THUMBSUP")
assert result is None
# ── _remove_reaction_sync ───────────────────────────────────────────────────
class TestRemoveReactionSync:
def test_calls_delete_on_success(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = True
ch._client.im.v1.message_reaction.delete.return_value = resp
ch._remove_reaction_sync("om_001", "rx_42")
ch._client.im.v1.message_reaction.delete.assert_called_once()
def test_handles_failure_gracefully(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 99999
resp.msg = "not found"
ch._client.im.v1.message_reaction.delete.return_value = resp
# Should not raise
ch._remove_reaction_sync("om_001", "rx_42")
ch._client.im.v1.message_reaction.delete.assert_called_once()
def test_handles_exception_gracefully(self):
ch = _make_channel()
ch._client.im.v1.message_reaction.delete.side_effect = RuntimeError("network error")
# Should not raise
ch._remove_reaction_sync("om_001", "rx_42")
ch._client.im.v1.message_reaction.delete.assert_called_once()
# ── _remove_reaction (async) ────────────────────────────────────────────────
class TestRemoveReactionAsync:
@pytest.mark.asyncio
async def test_calls_sync_helper(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "rx_42")
ch._remove_reaction_sync.assert_called_once_with("om_001", "rx_42")
@pytest.mark.asyncio
async def test_noop_when_no_client(self):
ch = _make_channel()
ch._client = None
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "rx_42")
ch._remove_reaction_sync.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_reaction_id_is_empty(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", "")
ch._remove_reaction_sync.assert_not_called()
@pytest.mark.asyncio
async def test_noop_when_reaction_id_is_none(self):
ch = _make_channel()
ch._remove_reaction_sync = MagicMock()
await ch._remove_reaction("om_001", None)
ch._remove_reaction_sync.assert_not_called()
# ── send_delta stream end: reaction auto-cleanup ────────────────────────────
class TestStreamEndReactionCleanup:
@pytest.mark.asyncio
async def test_stream_buffers_are_scoped_by_message_id(self):
ch = _make_channel()
ch._create_streaming_card_sync = MagicMock(return_value=None)
await ch.send_delta(
"oc_chat1", "first",
metadata={"message_id": "om_first"},
)
await ch.send_delta(
"oc_chat1", "second",
metadata={"message_id": "om_second"},
)
assert ch._stream_bufs["om_first"].text == "first"
assert ch._stream_bufs["om_second"].text == "second"
assert "oc_chat1" not in ch._stream_bufs
@pytest.mark.asyncio
async def test_removes_reaction_on_stream_end(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._reaction_ids["om_001"] = "rx_42"
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@pytest.mark.asyncio
async def test_no_removal_when_message_id_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
stream_end=True,
)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_reaction_id_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_both_ids_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_not_stream_end(self):
ch = _make_channel()
ch._remove_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "more text",
metadata={"message_id": "om_001", "reaction_id": "rx_42"},
)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_resuming(self):
"""resuming=True means more tool-call rounds follow; reaction must persist."""
ch = _make_channel()
ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="partial", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._reaction_ids["om_001"] = "rx_42"
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
ch._add_reaction = AsyncMock()
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
)
ch._remove_reaction.assert_not_called()
ch._add_reaction.assert_not_called()
# OnIt reaction id is still tracked for the eventual final stream end
assert ch._reaction_ids.get("om_001") == "rx_42"
@pytest.mark.asyncio
async def test_done_emoji_only_on_final_stream_end(self):
"""Across resuming rounds, done_emoji is added only on the final round."""
ch = _make_channel()
ch.config.done_emoji = "DONE"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._reaction_ids["om_001"] = "rx_42"
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
ch._add_reaction = AsyncMock()
# Intermediate stream end (more tool calls coming).
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=True,
)
ch._remove_reaction.assert_not_called()
ch._add_reaction.assert_not_called()
# Re-prime the stream buffer for the final round (the previous stream end popped it).
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="t", card_id="card_1", sequence=5, last_edit=0.0,
)
# Final stream end (resuming=False): OnIt removed, done_emoji added.
await ch.send_delta(
"oc_chat1", "",
metadata={"message_id": "om_001"},
stream_end=True,
resuming=False,
)
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
ch._add_reaction.assert_called_once_with("om_001", "DONE")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
# ruff: noqa: E402
"""Tests for Feishu streaming (send_delta) via CardKit streaming API."""
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
pytest.importorskip("lark_oapi")
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu.runtime import FeishuChannel, FeishuConfig, _FeishuStreamBuf
def _make_channel(streaming: bool = True, reply_to_message: bool = False) -> FeishuChannel:
config = FeishuConfig(
enabled=True,
app_id="cli_test",
app_secret="secret",
allow_from=["*"],
streaming=streaming,
reply_to_message=reply_to_message,
)
ch = FeishuChannel(config, MessageBus())
ch._client = MagicMock()
ch._loop = None
return ch
def _mock_create_card_response(card_id: str = "card_stream_001"):
resp = MagicMock()
resp.success.return_value = True
resp.data = SimpleNamespace(card_id=card_id)
return resp
def _mock_send_response(message_id: str = "om_stream_001"):
resp = MagicMock()
resp.success.return_value = True
resp.data = SimpleNamespace(message_id=message_id)
return resp
def _mock_content_response(success: bool = True):
resp = MagicMock()
resp.success.return_value = success
resp.code = 0 if success else 99999
resp.msg = "ok" if success else "error"
return resp
class TestFeishuStreamingConfig:
def test_streaming_default_true(self):
assert FeishuConfig().streaming is True
def test_supports_streaming_when_enabled(self):
ch = _make_channel(streaming=True)
assert ch.supports_streaming is True
def test_supports_streaming_disabled(self):
ch = _make_channel(streaming=False)
assert ch.supports_streaming is False
class TestCreateStreamingCard:
def test_returns_card_id_on_success(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_123")
ch._client.im.v1.message.create.return_value = _mock_send_response()
result = ch._create_streaming_card_sync("chat_id", "oc_chat1")
assert result == "card_123"
ch._client.cardkit.v1.card.create.assert_called_once()
ch._client.im.v1.message.create.assert_called_once()
def test_returns_none_on_failure(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 99999
resp.msg = "error"
ch._client.cardkit.v1.card.create.return_value = resp
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
def test_returns_none_on_exception(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.side_effect = RuntimeError("network")
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
def test_returns_none_when_card_send_fails(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_123")
resp = MagicMock()
resp.success.return_value = False
resp.code = 99999
resp.msg = "error"
resp.get_log_id.return_value = "log1"
ch._client.im.v1.message.create.return_value = resp
assert ch._create_streaming_card_sync("chat_id", "oc_chat1") is None
class TestCloseStreamingMode:
def test_returns_true_on_success(self):
ch = _make_channel()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
assert ch._close_streaming_mode_sync("card_1", 10) is True
def test_returns_false_on_failure(self):
ch = _make_channel()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(False)
assert ch._close_streaming_mode_sync("card_1", 10) is False
def test_returns_false_on_exception(self):
ch = _make_channel()
ch._client.cardkit.v1.card.settings.side_effect = RuntimeError("err")
assert ch._close_streaming_mode_sync("card_1", 10) is False
class TestStreamUpdateWithReopen:
def test_reopens_streaming_mode_and_retries_update(self):
ch = _make_channel()
ch._client.cardkit.v1.card_element.content.side_effect = [
_mock_content_response(False),
_mock_content_response(True),
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
assert ch._stream_update_text_with_reopen_sync("card_1", "hello", 4) == (True, 6)
assert ch._client.cardkit.v1.card_element.content.call_count == 2
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
assert settings_call.body.sequence == 5
assert '"streaming_mode": true' in settings_call.body.settings
class TestStreamUpdateText:
def test_returns_true_on_success(self):
ch = _make_channel()
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(True)
assert ch._stream_update_text_sync("card_1", "hello", 1) is True
def test_returns_false_on_failure(self):
ch = _make_channel()
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(False)
assert ch._stream_update_text_sync("card_1", "hello", 1) is False
def test_returns_false_on_exception(self):
ch = _make_channel()
ch._client.cardkit.v1.card_element.content.side_effect = RuntimeError("err")
assert ch._stream_update_text_sync("card_1", "hello", 1) is False
class TestSendDelta:
@pytest.mark.asyncio
async def test_first_delta_creates_card_and_sends(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "Hello ")
assert "oc_chat1" in ch._stream_bufs
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Hello "
assert buf.card_id == "card_new"
assert buf.sequence == 1
ch._client.cardkit.v1.card.create.assert_called_once()
ch._client.im.v1.message.create.assert_called_once()
ch._client.cardkit.v1.card_element.content.assert_called_once()
@pytest.mark.asyncio
async def test_first_delta_closes_blank_card_when_initial_update_fails(self):
ch = _make_channel()
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(False)
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "Hello ")
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Hello "
assert buf.card_id is None
assert ch._client.cardkit.v1.card_element.content.call_count == 2
assert ch._client.cardkit.v1.card.settings.call_count == 2
close_call = ch._client.cardkit.v1.card.settings.call_args_list[-1][0][0]
assert '"streaming_mode": false' in close_call.body.settings
@pytest.mark.asyncio
async def test_group_delta_uses_create_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
ch._client.im.v1.message.create.return_value = _mock_send_response("om_new")
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta(
"oc_chat1",
"Hello ",
metadata={"message_id": "om_001", "chat_type": "group"},
)
ch._client.im.v1.message.create.assert_called_once()
ch._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_group_delta_keeps_existing_topic_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta(
"oc_chat1",
"Hello ",
metadata={"message_id": "om_001", "chat_type": "group", "thread_id": "ot_001"},
)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is not True
@pytest.mark.asyncio
async def test_group_delta_replies_in_thread_when_reply_enabled(self):
ch = _make_channel(reply_to_message=True)
ch._client.cardkit.v1.card.create.return_value = _mock_create_card_response("card_new")
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta(
"oc_chat1",
"Hello ",
metadata={"message_id": "om_001", "chat_type": "group"},
)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is True
@pytest.mark.asyncio
async def test_second_delta_within_interval_skips_update(self):
ch = _make_channel()
buf = _FeishuStreamBuf(text="Hello ", card_id="card_1", sequence=1, last_edit=time.monotonic())
ch._stream_bufs["oc_chat1"] = buf
await ch.send_delta("oc_chat1", "world")
assert buf.text == "Hello world"
ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio
async def test_delta_after_interval_updates_text(self):
ch = _make_channel()
buf = _FeishuStreamBuf(text="Hello ", card_id="card_1", sequence=1, last_edit=time.monotonic() - 1.0)
ch._stream_bufs["oc_chat1"] = buf
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "world")
assert buf.text == "Hello world"
assert buf.sequence == 2
ch._client.cardkit.v1.card_element.content.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_sends_final_update(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_called_once()
ch._client.cardkit.v1.card.settings.assert_called_once()
settings_call = ch._client.cardkit.v1.card.settings.call_args[0][0]
assert settings_call.body.sequence == 5 # after final content seq 4
@pytest.mark.asyncio
async def test_stream_end_fallback_when_no_card_id(self):
"""If card creation failed, stream_end falls back to a plain card message."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
ch._client.cardkit.v1.card_element.content.assert_not_called()
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_fallback_group_uses_create_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta(
"oc_chat1",
"",
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
)
ch._client.im.v1.message.create.assert_called_once()
ch._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_stream_end_fallback_keeps_existing_topic_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
)
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
await ch.send_delta(
"oc_chat1",
"",
metadata={
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
},
stream_end=True,
)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is not True
@pytest.mark.asyncio
async def test_stream_end_fallback_group_replies_when_reply_enabled(self):
ch = _make_channel(reply_to_message=True)
ch._stream_bufs["om_001"] = _FeishuStreamBuf(
text="Fallback content", card_id=None, sequence=0, last_edit=0.0,
)
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
await ch.send_delta(
"oc_chat1",
"",
metadata={"message_id": "om_001", "chat_type": "group"},
stream_end=True,
)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is True
@pytest.mark.asyncio
async def test_stream_end_fallback_when_final_update_fails(self):
"""If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Lost content", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card.settings.call_count == 2
# Should fall back to sending a regular interactive card
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_stream_end_reopens_streaming_card_before_fallback(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Recovered content", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.side_effect = [
_mock_content_response(False),
_mock_content_response(True),
]
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
assert ch._client.cardkit.v1.card_element.content.call_count == 2
assert ch._client.cardkit.v1.card.settings.call_count == 2
ch._client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
async def test_stream_end_without_buf_is_noop(self):
ch = _make_channel()
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._client.cardkit.v1.card_element.content.assert_not_called()
@pytest.mark.asyncio
async def test_empty_delta_skips_send(self):
ch = _make_channel()
await ch.send_delta("oc_chat1", " ")
assert "oc_chat1" in ch._stream_bufs
ch._client.cardkit.v1.card.create.assert_not_called()
@pytest.mark.asyncio
async def test_no_client_returns_early(self):
ch = _make_channel()
ch._client = None
await ch.send_delta("oc_chat1", "text")
assert "oc_chat1" not in ch._stream_bufs
@pytest.mark.asyncio
async def test_sequence_increments_correctly(self):
ch = _make_channel()
buf = _FeishuStreamBuf(text="a", card_id="card_1", sequence=5, last_edit=0.0)
ch._stream_bufs["oc_chat1"] = buf
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "b")
assert buf.sequence == 6
buf.last_edit = 0.0 # reset to bypass throttle
await ch.send_delta("oc_chat1", "c")
assert buf.sequence == 7
class TestToolHintInlineStreaming:
"""Tool hint messages should be inlined into active streaming cards."""
@pytest.mark.asyncio
async def test_tool_hint_inlined_when_stream_active(self):
"""With an active streaming buffer, tool hint appends to the card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='web_fetch("https://example.com")',
event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
)
await ch.send(msg)
buf = ch._stream_bufs["oc_chat1"]
assert '🔧 web_fetch("https://example.com")' in buf.text
assert buf.sequence == 3
ch._client.cardkit.v1.card_element.content.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_next_delta(self):
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer\n\n🔧 web_fetch(\"url\")\n\n",
card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", " continued")
buf = ch._stream_bufs["oc_chat1"]
assert "Partial answer" in buf.text
assert "🔧 web_fetch" in buf.text
assert buf.text.endswith(" continued")
@pytest.mark.asyncio
async def test_tool_hint_fallback_when_no_stream(self):
"""Without an active buffer, tool hint falls back to a standalone card."""
ch = _make_channel()
ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint")
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
)
await ch.send(msg)
assert "oc_chat1" not in ch._stream_bufs
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_tool_hint_group_uses_create_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
ch._client.im.v1.message.create.return_value = _mock_send_response("om_hint")
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
ch._client.im.v1.message.create.assert_called_once()
ch._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_tool_hint_keeps_existing_topic_when_reply_disabled(self):
ch = _make_channel(reply_to_message=False)
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={
"message_id": "om_001",
"chat_type": "group",
"thread_id": "ot_001",
},
)
await ch.send(msg)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is not True
@pytest.mark.asyncio
async def test_tool_hint_group_replies_when_reply_enabled(self):
ch = _make_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
ch._client.im.v1.message.reply.return_value = reply_resp
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='read_file("path")',
event=ProgressEvent(content='read_file("path")', tool_hint=True),
metadata={"message_id": "om_001", "chat_type": "group"},
)
await ch.send(msg)
ch._client.im.v1.message.reply.assert_called_once()
ch._client.im.v1.message.create.assert_not_called()
request = ch._client.im.v1.message.reply.call_args[0][0]
assert request.request_body.reply_in_thread is True
@pytest.mark.asyncio
async def test_consecutive_tool_hints_append(self):
"""When multiple tool hints arrive consecutively, each appends to the card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ cd /project',
event=ProgressEvent(content='$ cd /project', tool_hint=True),
)
await ch.send(msg1)
msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ git status',
event=ProgressEvent(content='$ git status', tool_hint=True),
)
await ch.send(msg2)
buf = ch._stream_bufs["oc_chat1"]
assert "$ cd /project" in buf.text
assert "$ git status" in buf.text
assert buf.text.startswith("Partial answer")
assert "🔧 $ cd /project" in buf.text
assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self):
"""When stream end closes the card, tool hint is kept in the final text."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", stream_end=True)
assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
assert "🔧" in update_call.body.content
@pytest.mark.asyncio
async def test_empty_tool_hint_is_noop(self):
"""Empty or whitespace-only tool hint content is silently ignored."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
)
for content in ("", " ", "\t\n"):
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content=content,
event=ProgressEvent(content=content, tool_hint=True),
)
await ch.send(msg)
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Partial answer"
assert buf.sequence == 2
ch._client.cardkit.v1.card_element.content.assert_not_called()
class TestSendMessageReturnsId:
def test_returns_message_id_on_success(self):
ch = _make_channel()
ch._client.im.v1.message.create.return_value = _mock_send_response("om_abc")
result = ch._send_message_sync("chat_id", "oc_chat1", "text", '{"text":"hi"}')
assert result == "om_abc"
def test_returns_none_on_failure(self):
ch = _make_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 99999
resp.msg = "error"
resp.get_log_id.return_value = "log1"
ch._client.im.v1.message.create.return_value = resp
result = ch._send_message_sync("chat_id", "oc_chat1", "text", '{"text":"hi"}')
assert result is None
@@ -0,0 +1,115 @@
"""Tests for FeishuChannel._split_elements_by_table_limit.
Feishu cards reject messages that contain more than one table element
(API error 11310: card table number over limit). The helper splits a flat
list of card elements into groups so that each group contains at most one
table, allowing nanobot to send multiple cards instead of failing.
"""
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
import pytest
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.channels.feishu.runtime import FeishuChannel
def _md(text: str) -> dict:
return {"tag": "markdown", "content": text}
def _table() -> dict:
return {
"tag": "table",
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
"rows": [{"c0": "v"}],
"page_size": 2,
}
split = FeishuChannel._split_elements_by_table_limit
def test_empty_list_returns_single_empty_group() -> None:
assert split([]) == [[]]
def test_no_tables_returns_single_group() -> None:
els = [_md("hello"), _md("world")]
result = split(els)
assert result == [els]
def test_single_table_stays_in_one_group() -> None:
els = [_md("intro"), _table(), _md("outro")]
result = split(els)
assert len(result) == 1
assert result[0] == els
def test_two_tables_split_into_two_groups() -> None:
# Use different row values so the two tables are not equal
t1 = {
"tag": "table",
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
"rows": [{"c0": "table-one"}],
"page_size": 2,
}
t2 = {
"tag": "table",
"columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}],
"rows": [{"c0": "table-two"}],
"page_size": 2,
}
els = [_md("before"), t1, _md("between"), t2, _md("after")]
result = split(els)
assert len(result) == 2
# First group: text before table-1 + table-1
assert t1 in result[0]
assert t2 not in result[0]
# Second group: text between tables + table-2 + text after
assert t2 in result[1]
assert t1 not in result[1]
def test_three_tables_split_into_three_groups() -> None:
tables = [
{"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1}
for i in range(3)
]
els = tables[:]
result = split(els)
assert len(result) == 3
for i, group in enumerate(result):
assert tables[i] in group
def test_leading_markdown_stays_with_first_table() -> None:
intro = _md("intro")
t = _table()
result = split([intro, t])
assert len(result) == 1
assert result[0] == [intro, t]
def test_trailing_markdown_after_second_table() -> None:
t1, t2 = _table(), _table()
tail = _md("end")
result = split([t1, t2, tail])
assert len(result) == 2
assert result[1] == [t2, tail]
def test_non_table_elements_before_first_table_kept_in_first_group() -> None:
head = _md("head")
t1, t2 = _table(), _table()
result = split([head, t1, t2])
# head + t1 in group 0; t2 in group 1
assert result[0] == [head, t1]
assert result[1] == [t2]
@@ -0,0 +1,214 @@
"""Tests for FeishuChannel tool hint formatting."""
import json
from unittest.mock import MagicMock, patch
import pytest
from pytest import mark
# Check optional Feishu dependencies before running tests
try:
from nanobot.channels import feishu
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
except ImportError:
FEISHU_AVAILABLE = False
if not FEISHU_AVAILABLE:
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.feishu.runtime import FeishuChannel
@pytest.fixture
def mock_feishu_channel():
"""Create a FeishuChannel with mocked client."""
config = MagicMock()
config.app_id = "test_app_id"
config.app_secret = "test_app_secret"
config.encrypt_key = None
config.verification_token = None
config.tool_hint_prefix = "\U0001f527" # 🔧
bus = MagicMock()
channel = FeishuChannel(config, bus)
channel._client = MagicMock()
return channel
def _get_tool_hint_card(mock_send):
"""Extract the interactive card from _send_message_sync calls."""
call_args = mock_send.call_args[0]
_, _, msg_type, content = call_args
assert msg_type == "interactive"
return json.loads(content)
@mark.asyncio
async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
"""Tool hint without active buffer sends an interactive card with 🔧 style."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("test query")',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
assert mock_send.call_count == 1
card = _get_tool_hint_card(mock_send)
assert card["config"]["wide_screen_mode"] is True
md = card["elements"][0]["content"]
assert "\U0001f527" in md
assert "web_search" in md
@mark.asyncio
async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
"""Empty tool hint messages should not be sent."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content=" ", # whitespace only
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
mock_send.assert_not_called()
@mark.asyncio
async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
"""Regular messages without _tool_hint should use normal formatting."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content="Hello, world!",
metadata={}
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
assert mock_send.call_count == 1
call_args = mock_send.call_args[0]
_, _, msg_type, content = call_args
assert msg_type == "text"
assert json.loads(content) == {"text": "Hello, world!"}
@mark.asyncio
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
"""Multiple tool calls should each get the 🔧 prefix."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("query"), read_file("/path/to/file")',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "web_search" in md
assert "read_file" in md
assert "\U0001f527" in md
@mark.asyncio
async def test_tool_hint_new_format_basic(mock_feishu_channel):
"""New format hints (read path, grep "pattern") should parse correctly."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='read src/main.py, grep "TODO"',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "read src/main.py" in md
assert 'grep "TODO"' in md
@mark.asyncio
async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
"""Commas inside quoted arguments must not cause incorrect line splits."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='grep "hello, world", $ echo test',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert 'grep "hello, world"' in md
assert "$ echo test" in md
@mark.asyncio
async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
"""Folded calls (× N) should display correctly."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='read path × 3, grep "pattern"',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "\u00d7 3" in md
assert 'grep "pattern"' in md
@mark.asyncio
async def test_tool_hint_new_format_mcp(mock_feishu_channel):
"""MCP tool format (server::tool) should parse correctly."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='4_5v::analyze_image("photo.jpg")',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert "4_5v::analyze_image" in md
@mark.asyncio
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
"""Commas inside a single tool argument must not be split onto a new line."""
msg = OutboundMessage(
channel="feishu",
chat_id="oc_123456",
content='web_search("foo, bar"), read_file("/path/to/file")',
event=ProgressEvent(tool_hint=True),
)
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
await mock_feishu_channel.send(msg)
card = _get_tool_hint_card(mock_send)
md = card["elements"][0]["content"]
assert 'web_search("foo, bar")' in md
assert 'read_file("/path/to/file")' in md
@@ -0,0 +1,116 @@
from __future__ import annotations
import asyncio
import threading
from typing import Any
from nanobot.channels.feishu.websocket import FeishuWsRunner
class _CleanCloseError(Exception):
pass
class _SdkLikeClient:
"""Model the lark SDK's detached receive task and reconnect behavior."""
def __init__(self) -> None:
self._auto_reconnect = True
self.connected = asyncio.Event()
self.reconnected = asyncio.Event()
self.receive_errors = 0
self.reconnects = 0
self.disconnects = 0
self._receiving = False
self._receive_events: asyncio.Queue[Exception] = asyncio.Queue()
async def _connect(self) -> None:
self.connected.set()
asyncio.create_task(self._receive_message_loop())
async def _receive_message_loop(self) -> None:
try:
self._receiving = True
error = await self._receive_events.get()
self._receiving = False
raise error
except asyncio.CancelledError:
self._receiving = False
raise
except Exception:
self.receive_errors += 1
await self._disconnect()
if self._auto_reconnect:
self.reconnects += 1
await self._connect()
self.reconnected.set()
async def _disconnect(self) -> None:
self.disconnects += 1
if self._receiving:
await self._receive_events.put(_CleanCloseError("1000 OK"))
async def _ping_loop(self) -> None:
await asyncio.Event().wait()
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
runner = FeishuWsRunner()
created_loops: list[asyncio.AbstractEventLoop] = []
release_start = threading.Event()
def fake_run_loop() -> None:
loop = asyncio.new_event_loop()
created_loops.append(loop)
assert release_start.wait(timeout=2)
runner._loop = loop
runner._ready.set()
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
loops: list[asyncio.AbstractEventLoop] = []
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
for thread in threads:
thread.start()
release_start.set()
for thread in threads:
thread.join(timeout=2)
assert len(created_loops) == 1
assert loops == [created_loops[0], created_loops[0]]
created_loops[0].close()
async def test_stop_cancels_sdk_receive_loop_without_reconnecting() -> None:
runner = FeishuWsRunner()
client = _SdkLikeClient()
original_receive_loop: Any = client._receive_message_loop
await runner._start_client("default", client)
await asyncio.wait_for(client.connected.wait(), timeout=1)
await runner._stop_client("default")
await asyncio.sleep(0)
assert client.receive_errors == 0
assert client.reconnects == 0
assert client._auto_reconnect is True
assert client._receive_message_loop == original_receive_loop
async def test_network_failure_keeps_sdk_auto_reconnect_behavior() -> None:
runner = FeishuWsRunner()
client = _SdkLikeClient()
await runner._start_client("default", client)
await asyncio.wait_for(client.connected.wait(), timeout=1)
await client._receive_events.put(RuntimeError("network dropped"))
await asyncio.wait_for(client.reconnected.wait(), timeout=1)
assert client.receive_errors == 1
assert client.reconnects == 1
assert client._auto_reconnect is True
await runner._stop_client("default")
await asyncio.sleep(0)
assert client.receive_errors == 1
assert client.reconnects == 1
+34
View File
@@ -0,0 +1,34 @@
"""Feishu/Lark setup validation owned by the channel package."""
from typing import Any
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import check, payload, required_checks, string_value
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("feishu", values)
display_name = string_value(values.get("displayName") or values.get("name"))
avatar_url = string_value(values.get("avatarUrl"))
app_id = string_value(values.get("appId"))
if app_id.startswith(("cli_", "oapi_")):
checks.append(check("app_id", "App ID", "pass", "A Feishu/Lark App ID is saved."))
elif app_id:
checks.append(
check(
"app_id",
"App ID",
"warn",
"App ID is saved, but it does not look like a standard Feishu App ID.",
)
)
status = "connected" if not missing else "needs_setup"
identity = {
"name": display_name or "Feishu assistant",
"avatar_url": avatar_url or None,
"account": app_id,
}
return payload("feishu", status, checks, identity=identity, missing_fields=missing)
__all__ = ["validate"]
@@ -10,18 +10,35 @@ from __future__ import annotations
import asyncio
import threading
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from dataclasses import dataclass, field
from typing import Any, Protocol
from loguru import logger
class _LarkWsClient(Protocol):
"""Private SDK surface isolated behind the Feishu runtime adapter."""
_auto_reconnect: bool
_receive_message_loop: Callable[[], Awaitable[None]]
async def _connect(self) -> None: ...
async def _disconnect(self) -> None: ...
async def _ping_loop(self) -> None: ...
@dataclass
class _ClientRuntime:
client: Any
client: _LarkWsClient
stop_event: asyncio.Event
task: asyncio.Task
task: asyncio.Task[Any] | None
receive_loop: Callable[[], Awaitable[None]]
auto_reconnect: bool
receive_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
class FeishuWsRunner:
@@ -34,7 +51,7 @@ class FeishuWsRunner:
self._lock = threading.Lock()
self._clients: dict[str, _ClientRuntime] = {}
async def start_client(self, key: str, client: Any) -> None:
async def start_client(self, key: str, client: _LarkWsClient) -> None:
"""Start or replace one client runtime."""
loop = self._ensure_loop()
await asyncio.wrap_future(
@@ -74,24 +91,63 @@ class FeishuWsRunner:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
async def _start_client(self, key: str, client: Any) -> None:
async def _start_client(self, key: str, client: _LarkWsClient) -> None:
await self._stop_client(key)
stop_event = asyncio.Event()
task = asyncio.create_task(self._client_main(key, client, stop_event))
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
receive_loop = client._receive_message_loop
runtime = _ClientRuntime(
client=client,
stop_event=stop_event,
task=None,
receive_loop=receive_loop,
auto_reconnect=client._auto_reconnect,
)
# The SDK discards this task handle. Track it at the adapter boundary so
# an intentional stop can cancel recv() before closing the socket; otherwise
# the SDK logs close code 1000 as an error and starts an unwanted reconnect.
async def tracked_receive_loop() -> None:
if stop_event.is_set():
return
task = asyncio.current_task()
if task is not None:
runtime.receive_tasks.add(task)
try:
await receive_loop()
finally:
if task is not None:
runtime.receive_tasks.discard(task)
client._receive_message_loop = tracked_receive_loop
runtime.task = asyncio.create_task(self._client_main(key, client, stop_event))
self._clients[key] = runtime
async def _stop_client(self, key: str) -> None:
runtime = self._clients.pop(key, None)
if runtime is None:
return
runtime.stop_event.set()
with suppress(Exception):
await runtime.client._disconnect()
runtime.task.cancel()
with suppress(asyncio.CancelledError):
await runtime.task
runtime.client._auto_reconnect = False
try:
receive_tasks = tuple(runtime.receive_tasks)
for task in receive_tasks:
task.cancel()
if receive_tasks:
await asyncio.gather(*receive_tasks, return_exceptions=True)
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
if runtime.task is not None:
runtime.task.cancel()
with suppress(asyncio.CancelledError):
await runtime.task
with suppress(Exception):
await runtime.client._disconnect()
finally:
runtime.client._receive_message_loop = runtime.receive_loop
runtime.client._auto_reconnect = runtime.auto_reconnect
async def _client_main(
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
) -> None:
ping_task: asyncio.Task | None = None
while not stop_event.is_set():
try:
@@ -0,0 +1,182 @@
import { useState } from "react";
import { Loader2, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
channelTranslator,
type ChannelTranslator,
} from "@/channel-plugins/i18n";
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel";
import { Button } from "@/components/ui/button";
import { enableNanobotFeature } from "@/lib/api";
import type {
NanobotChannelInstanceInfo,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { FeishuConnectFlow } from "./FeishuConnectFlow";
export function FeishuAssistantsPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const instances = feature.instances?.length
? feature.instances
: [defaultFeishuInstance(feature)];
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
instances={instances}
onFeaturesUpdate={onFeaturesUpdate}
customization={{
countLabel: (count) => feishuAssistantCountLabel(count, tx),
toggleAriaLabel: (instance) => tx("custom.toggleAssistant", "{{name}} assistant", {
name: instanceDisplayName(instance),
}),
configuredLabel: tx("custom.configured", "Connected"),
needsSetupLabel: tx("custom.needsSetup", "Needs authorization"),
renderInstanceSummary: (instance) => (
maskFeishuAppId(instance.config_values?.["channels.feishu.appId"])
|| tx("custom.noAppId", "No App ID")
),
renderInstanceAction: (instance) => (
<FeishuInstanceAction
key={instance.id}
token={token}
instance={instance}
onFeaturesUpdate={onFeaturesUpdate}
/>
),
footer: (
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("custom.createAnother", "Create another assistant")}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{tx(
"custom.createHint",
"Create a separate Feishu bot for another team, space, or workflow.",
)}
</p>
<FeishuConnectFlow
token={token}
instanceId="default"
mode="create"
idleLabel={tx("custom.createAssistant", "Create assistant")}
onFeaturesUpdate={onFeaturesUpdate}
/>
</div>
),
}}
/>
);
}
function FeishuInstanceAction({
token,
instance,
onFeaturesUpdate,
}: {
token: string;
instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!instance.configured) {
return (
<FeishuConnectFlow
token={token}
instanceId={instance.id}
mode="replace"
idleLabel={t("settings.channels.connect", { defaultValue: "Connect" })}
onFeaturesUpdate={onFeaturesUpdate}
/>
);
}
const reconnect = async () => {
setBusy(true);
setError(null);
try {
onFeaturesUpdate(
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void reconnect()}
disabled={busy || !instance.enabled}
>
{busy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{tx("custom.reconnect", "Reconnect")}
</Button>
</div>
{error ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
</>
);
}
function defaultFeishuInstance(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo {
return {
id: "default",
name: "nanobot",
enabled: feature.enabled,
configured: Boolean(feature.configured),
config_values: feature.config_values ?? {},
configured_fields: feature.configured_fields ?? [],
};
}
function feishuAssistantCountLabel(
count: number,
tx: ChannelTranslator,
): string {
if (count === 0) return tx("custom.countNone", "No assistant connected");
if (count === 1) return tx("custom.countOne", "1 assistant connected");
return tx("custom.countMany", "{{count}} assistants connected", { count });
}
function instanceDisplayName(instance: NanobotChannelInstanceInfo): string {
return instance.display_name?.trim() || instance.name.trim() || instance.id;
}
function maskFeishuAppId(appId: string | undefined): string {
if (!appId) return "";
if (appId.length <= 10) return appId;
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
}
@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import { channelTranslator } from "@/channel-plugins/i18n";
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
import type { NanobotFeaturesPayload } from "@/lib/types";
export function FeishuConnectFlow({
token,
instanceId = "default",
mode = "replace",
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
instanceId?: string;
mode?: "replace" | "create";
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
return (
<ChannelQrConnectFlow
token={token}
channelName="feishu"
startOptions={{ domain: "feishu", instanceId, mode }}
idleLabel={idleLabel}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
labels={{
qrAlt: tx("custom.qrAlt", "Feishu connection QR code"),
scanTitle: tx("custom.scanTitle", "Scan with Feishu"),
scanDescription: tx(
"custom.scanDescription",
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
),
waiting: tx("custom.waiting", "Waiting for authorization..."),
connected: tx("custom.connected", "Feishu is connected."),
stopped: tx("custom.stopped", "Connection stopped."),
connecting: tx("custom.connecting", "Connecting..."),
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
}}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel";
export default {
Panel: FeishuAssistantsPanel,
aliases: {
lark: {
displayName: "Lark",
initials: "LK",
logoUrl: "https://www.larksuite.com/favicon.ico",
},
},
presentation: {
displayName: "Feishu",
initials: "FS",
color: "#3370FF",
logoUrl: "https://www.feishu.cn/favicon.ico",
setup: {
mode: "connect",
command: "nanobot channels login feishu",
docsUrl: chatAppGuideUrl("feishu"),
manualFields: [
{ key: "channels.feishu.appId" },
{ key: "channels.feishu.appSecret" },
{ key: "channels.feishu.domain" },
{ key: "channels.feishu.groupPolicy" },
{ key: "channels.feishu.allowFrom" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,73 @@
{
"description": "Use nanobot from Feishu chats and groups.",
"requirements": "Feishu app credentials, event subscription, gateway",
"setup": {
"primaryAction": "Connect with Feishu",
"docsLabel": "Open Feishu setup",
"officialLabel": "Open Feishu console",
"tryIt": "Send a DM or mention the Feishu assistant in a group.",
"summary": "Connect creates or links a Feishu app by QR code, then saves the app credentials for nanobot.",
"steps": [
"Click Connect and scan the QR code with Feishu or Lark on your phone.",
"Approve the app connection. nanobot saves the App ID and Secret automatically.",
"Send the bot a direct message or mention it in a Feishu group to test it."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Leave blank to keep current secret",
"help": "Paste a new App Secret only when rotating credentials."
},
"domain": {
"label": "Region",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Group behavior",
"choices": {
"mention": "Mention only",
"open": "All messages",
"allowlist": "Allowlist"
}
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "User IDs, comma separated"
},
"topicIsolation": {
"label": "Topic isolation",
"choices": {
"true": "Separate session for each topic",
"false": "One shared session for the group"
}
}
}
},
"custom": {
"toggleAssistant": "{{name}} assistant",
"configured": "Connected",
"needsSetup": "Needs authorization",
"noAppId": "No App ID",
"createAnother": "Create another assistant",
"createHint": "Create a separate Feishu bot for another team, space, or workflow.",
"createAssistant": "Create assistant",
"reconnect": "Reconnect",
"countNone": "No assistant connected",
"countOne": "1 assistant connected",
"countMany": "{{count}} assistants connected",
"qrAlt": "Feishu connection QR code",
"scanTitle": "Scan with Feishu",
"scanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
"waiting": "Waiting for authorization...",
"connected": "Feishu is connected.",
"stopped": "Connection stopped.",
"connecting": "Connecting..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Usa nanobot en chats y grupos de Feishu.",
"requirements": "Credenciales de Feishu, suscripción a eventos y gateway",
"setup": {
"primaryAction": "Conectar Feishu",
"docsLabel": "Abrir guía de Feishu",
"officialLabel": "Abrir consola de Feishu",
"tryIt": "Envía un DM o menciona al asistente en un grupo.",
"summary": "La conexión crea o vincula una app de Feishu por QR y guarda sus credenciales.",
"steps": [
"Haz clic en Conectar y escanea el QR con Feishu o Lark.",
"Aprueba la conexión. nanobot guarda el App ID y el Secret automáticamente.",
"Envía un DM al bot o menciónalo en un grupo de Feishu."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Déjalo vacío para conservar el secreto",
"help": "Pega uno nuevo solo al rotar credenciales."
},
"domain": {
"label": "Región",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Comportamiento en grupos",
"choices": {
"mention": "Solo menciones",
"open": "Todos los mensajes",
"allowlist": "Lista permitida"
}
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "ID de usuario separados por comas"
},
"topicIsolation": {
"label": "Aislamiento por tema",
"choices": {
"true": "Una sesión separada por tema",
"false": "Una sesión compartida para el grupo"
}
}
}
},
"custom": {
"toggleAssistant": "Asistente {{name}}",
"configured": "Conectado",
"needsSetup": "Necesita autorización",
"noAppId": "Sin App ID",
"createAnother": "Crear otro asistente",
"createHint": "Crea un bot Feishu independiente para otro equipo o flujo.",
"createAssistant": "Crear asistente",
"reconnect": "Reconectar",
"countNone": "Ningún asistente conectado",
"countOne": "1 asistente conectado",
"countMany": "{{count}} asistentes conectados",
"qrAlt": "Código QR de conexión de Feishu",
"scanTitle": "Escanea con Feishu",
"scanDescription": "Escanea con Feishu o Lark en tu teléfono. nanobot completará la configuración tras la autorización.",
"waiting": "Esperando autorización...",
"connected": "Feishu está conectado.",
"stopped": "Conexión detenida.",
"connecting": "Conectando..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Utilisez nanobot dans les conversations et groupes Feishu.",
"requirements": "Identifiants Feishu, abonnement aux événements et passerelle",
"setup": {
"primaryAction": "Connecter Feishu",
"docsLabel": "Ouvrir le guide Feishu",
"officialLabel": "Ouvrir la console Feishu",
"tryIt": "Envoyez un message privé ou mentionnez lassistant dans un groupe.",
"summary": "La connexion crée ou associe une application Feishu par QR code et enregistre ses identifiants.",
"steps": [
"Cliquez sur Connecter et scannez le QR code avec Feishu ou Lark.",
"Approuvez la connexion. nanobot enregistre automatiquement lApp ID et le Secret.",
"Envoyez un message privé au bot ou mentionnez-le dans un groupe Feishu."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Laisser vide pour conserver le secret",
"help": "Collez un nouveau secret uniquement lors dune rotation."
},
"domain": {
"label": "Région",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Comportement en groupe",
"choices": {
"mention": "Mentions uniquement",
"open": "Tous les messages",
"allowlist": "Liste dautorisation"
}
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "ID utilisateur séparés par des virgules"
},
"topicIsolation": {
"label": "Isolation par sujet",
"choices": {
"true": "Une session séparée par sujet",
"false": "Une session partagée pour le groupe"
}
}
}
},
"custom": {
"toggleAssistant": "Assistant {{name}}",
"configured": "Connecté",
"needsSetup": "Autorisation requise",
"noAppId": "Aucun App ID",
"createAnother": "Créer un autre assistant",
"createHint": "Créez un bot Feishu distinct pour une autre équipe ou un autre flux.",
"createAssistant": "Créer lassistant",
"reconnect": "Reconnecter",
"countNone": "Aucun assistant connecté",
"countOne": "1 assistant connecté",
"countMany": "{{count}} assistants connectés",
"qrAlt": "QR code de connexion Feishu",
"scanTitle": "Scanner avec Feishu",
"scanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration après autorisation.",
"waiting": "En attente dautorisation...",
"connected": "Feishu est connecté.",
"stopped": "Connexion arrêtée.",
"connecting": "Connexion..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Gunakan nanobot dari chat dan grup Feishu.",
"requirements": "Kredensial Feishu, langganan event, dan gateway",
"setup": {
"primaryAction": "Hubungkan Feishu",
"docsLabel": "Buka panduan Feishu",
"officialLabel": "Buka konsol Feishu",
"tryIt": "Kirim DM atau sebut asisten di grup.",
"summary": "Koneksi membuat atau menautkan aplikasi Feishu lewat QR dan menyimpan kredensialnya.",
"steps": [
"Klik Hubungkan dan pindai QR dengan Feishu atau Lark.",
"Setujui koneksi. nanobot menyimpan App ID dan Secret otomatis.",
"Kirim DM ke bot atau sebut di grup Feishu."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Kosongkan untuk mempertahankan secret",
"help": "Tempel secret baru hanya saat rotasi kredensial."
},
"domain": {
"label": "Wilayah",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Perilaku grup",
"choices": {
"mention": "Hanya sebutan",
"open": "Semua pesan",
"allowlist": "Daftar izin"
}
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "ID pengguna, dipisahkan koma"
},
"topicIsolation": {
"label": "Isolasi topik",
"choices": {
"true": "Sesi terpisah untuk setiap topik",
"false": "Satu sesi bersama untuk grup"
}
}
}
},
"custom": {
"toggleAssistant": "Asisten {{name}}",
"configured": "Terhubung",
"needsSetup": "Perlu otorisasi",
"noAppId": "Tidak ada App ID",
"createAnother": "Buat asisten lain",
"createHint": "Buat bot Feishu terpisah untuk tim atau alur kerja lain.",
"createAssistant": "Buat asisten",
"reconnect": "Hubungkan ulang",
"countNone": "Belum ada asisten terhubung",
"countOne": "1 asisten terhubung",
"countMany": "{{count}} asisten terhubung",
"qrAlt": "Kode QR koneksi Feishu",
"scanTitle": "Pindai dengan Feishu",
"scanDescription": "Pindai dengan Feishu atau Lark di ponsel. nanobot akan menyelesaikan setup setelah otorisasi.",
"waiting": "Menunggu otorisasi...",
"connected": "Feishu sudah terhubung.",
"stopped": "Koneksi dihentikan.",
"connecting": "Menghubungkan..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Feishu のチャットとグループから nanobot を利用します。",
"requirements": "Feishu アプリ認証情報、イベント購読、ゲートウェイ",
"setup": {
"primaryAction": "Feishu に接続",
"docsLabel": "Feishu 設定ガイドを開く",
"officialLabel": "Feishu コンソールを開く",
"tryIt": "DM を送るか、グループで Feishu アシスタントをメンションします。",
"summary": "QR コードで Feishu アプリを作成または連携し、認証情報を自動保存します。",
"steps": [
"接続をクリックし、スマートフォンの Feishu または Lark で QR コードを読み取ります。",
"アプリ接続を承認すると、nanobot が App ID と Secret を保存します。",
"ボットに DM を送るか Feishu グループでメンションします。"
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "現在のシークレットを保持するには空欄",
"help": "認証情報を更新するときだけ新しい App Secret を貼り付けます。"
},
"domain": {
"label": "地域",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "グループでの動作",
"choices": {
"mention": "メンションのみ",
"open": "すべてのメッセージ",
"allowlist": "許可リスト"
}
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "ユーザー ID(カンマ区切り)"
},
"topicIsolation": {
"label": "トピック分離",
"choices": {
"true": "トピックごとにセッションを分離",
"false": "グループでセッションを共有"
}
}
}
},
"custom": {
"toggleAssistant": "{{name}} アシスタント",
"configured": "接続済み",
"needsSetup": "認可が必要",
"noAppId": "App ID なし",
"createAnother": "別のアシスタントを作成",
"createHint": "別のチームやワークフロー用に独立した Feishu ボットを作成します。",
"createAssistant": "アシスタントを作成",
"reconnect": "再接続",
"countNone": "接続済みアシスタントなし",
"countOne": "1 個のアシスタントを接続中",
"countMany": "{{count}} 個のアシスタントを接続中",
"qrAlt": "Feishu 接続 QR コード",
"scanTitle": "Feishu でスキャン",
"scanDescription": "スマートフォンの Feishu または Lark でスキャンしてください。認可後に nanobot が設定を完了します。",
"waiting": "認可を待っています...",
"connected": "Feishu に接続しました。",
"stopped": "接続を停止しました。",
"connecting": "接続中..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Feishu 채팅과 그룹에서 nanobot을 사용합니다.",
"requirements": "Feishu 앱 자격 증명, 이벤트 구독 및 게이트웨이",
"setup": {
"primaryAction": "Feishu 연결",
"docsLabel": "Feishu 설정 가이드 열기",
"officialLabel": "Feishu 콘솔 열기",
"tryIt": "DM을 보내거나 그룹에서 Feishu 어시스턴트를 멘션하세요.",
"summary": "QR 코드로 Feishu 앱을 만들거나 연결하고 자격 증명을 자동 저장합니다.",
"steps": [
"연결을 클릭하고 휴대폰의 Feishu 또는 Lark로 QR 코드를 스캔하세요.",
"앱 연결을 승인하면 nanobot이 App ID와 Secret을 자동 저장합니다.",
"봇에 DM을 보내거나 Feishu 그룹에서 멘션하세요."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "현재 Secret을 유지하려면 비워 두세요",
"help": "자격 증명을 교체할 때만 새 App Secret을 붙여 넣으세요."
},
"domain": {
"label": "지역",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "그룹 동작",
"choices": {
"mention": "멘션만",
"open": "모든 메시지",
"allowlist": "허용 목록"
}
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "사용자 ID, 쉼표로 구분"
},
"topicIsolation": {
"label": "주제 격리",
"choices": {
"true": "주제별로 세션 분리",
"false": "그룹에서 하나의 세션 공유"
}
}
}
},
"custom": {
"toggleAssistant": "{{name}} 어시스턴트",
"configured": "연결됨",
"needsSetup": "인증 필요",
"noAppId": "App ID 없음",
"createAnother": "다른 어시스턴트 만들기",
"createHint": "다른 팀이나 워크플로를 위한 별도 Feishu 봇을 만드세요.",
"createAssistant": "어시스턴트 만들기",
"reconnect": "다시 연결",
"countNone": "연결된 어시스턴트 없음",
"countOne": "어시스턴트 1개 연결됨",
"countMany": "어시스턴트 {{count}}개 연결됨",
"qrAlt": "Feishu 연결 QR 코드",
"scanTitle": "Feishu로 스캔",
"scanDescription": "휴대폰의 Feishu 또는 Lark로 스캔하세요. 승인 후 nanobot이 설정을 완료합니다.",
"waiting": "승인을 기다리는 중...",
"connected": "Feishu가 연결되었습니다.",
"stopped": "연결이 중지되었습니다.",
"connecting": "연결 중..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Use o nanobot em conversas e grupos do Feishu.",
"requirements": "Credenciais do Feishu, assinatura de eventos e gateway",
"setup": {
"primaryAction": "Conectar Feishu",
"docsLabel": "Abrir guia do Feishu",
"officialLabel": "Abrir console do Feishu",
"tryIt": "Envie uma DM ou mencione o assistente em um grupo.",
"summary": "A conexão cria ou vincula um app Feishu por QR e salva as credenciais.",
"steps": [
"Clique em Conectar e escaneie o QR com Feishu ou Lark.",
"Aprove a conexão. O nanobot salva App ID e Secret automaticamente.",
"Envie uma DM ao bot ou mencione-o em um grupo Feishu."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Deixe vazio para manter o segredo",
"help": "Cole um novo apenas ao trocar credenciais."
},
"domain": {
"label": "Região",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Comportamento em grupos",
"choices": {
"mention": "Somente menções",
"open": "Todas as mensagens",
"allowlist": "Lista de permissão"
}
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "IDs de usuário separados por vírgulas"
},
"topicIsolation": {
"label": "Isolamento por tópico",
"choices": {
"true": "Uma sessão separada por tópico",
"false": "Uma sessão compartilhada para o grupo"
}
}
}
},
"custom": {
"toggleAssistant": "Assistente {{name}}",
"configured": "Conectado",
"needsSetup": "Precisa de autorização",
"noAppId": "Sem App ID",
"createAnother": "Criar outro assistente",
"createHint": "Crie um bot Feishu separado para outra equipe ou fluxo.",
"createAssistant": "Criar assistente",
"reconnect": "Reconectar",
"countNone": "Nenhum assistente conectado",
"countOne": "1 assistente conectado",
"countMany": "{{count}} assistentes conectados",
"qrAlt": "QR code de conexão do Feishu",
"scanTitle": "Escaneie com o Feishu",
"scanDescription": "Escaneie com Feishu ou Lark no celular. O nanobot concluirá a configuração após a autorização.",
"waiting": "Aguardando autorização...",
"connected": "Feishu está conectado.",
"stopped": "Conexão interrompida.",
"connecting": "Conectando..."
}
}
@@ -0,0 +1,73 @@
{
"description": "Sử dụng nanobot trong cuộc trò chuyện và nhóm Feishu.",
"requirements": "Thông tin xác thực Feishu, đăng ký sự kiện và gateway",
"setup": {
"primaryAction": "Kết nối Feishu",
"docsLabel": "Mở hướng dẫn Feishu",
"officialLabel": "Mở bảng điều khiển Feishu",
"tryIt": "Gửi tin nhắn riêng hoặc nhắc trợ lý trong nhóm.",
"summary": "Kết nối tạo hoặc liên kết ứng dụng Feishu bằng QR và lưu thông tin xác thực.",
"steps": [
"Nhấn Kết nối và quét QR bằng Feishu hoặc Lark.",
"Phê duyệt kết nối. nanobot tự lưu App ID và Secret.",
"Gửi tin nhắn riêng cho bot hoặc nhắc bot trong nhóm Feishu."
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Để trống để giữ secret hiện tại",
"help": "Chỉ dán secret mới khi xoay vòng thông tin xác thực."
},
"domain": {
"label": "Khu vực",
"choices": {
"feishu": "Feishu",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "Hành vi trong nhóm",
"choices": {
"mention": "Chỉ khi được nhắc",
"open": "Mọi tin nhắn",
"allowlist": "Danh sách cho phép"
}
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
},
"topicIsolation": {
"label": "Tách biệt chủ đề",
"choices": {
"true": "Phiên riêng cho từng chủ đề",
"false": "Dùng chung một phiên cho nhóm"
}
}
}
},
"custom": {
"toggleAssistant": "Trợ lý {{name}}",
"configured": "Đã kết nối",
"needsSetup": "Cần cấp quyền",
"noAppId": "Không có App ID",
"createAnother": "Tạo trợ lý khác",
"createHint": "Tạo bot Feishu riêng cho nhóm hoặc quy trình khác.",
"createAssistant": "Tạo trợ lý",
"reconnect": "Kết nối lại",
"countNone": "Chưa kết nối trợ lý",
"countOne": "Đã kết nối 1 trợ lý",
"countMany": "Đã kết nối {{count}} trợ lý",
"qrAlt": "Mã QR kết nối Feishu",
"scanTitle": "Quét bằng Feishu",
"scanDescription": "Quét bằng Feishu hoặc Lark trên điện thoại. nanobot sẽ hoàn tất cấu hình sau khi cấp quyền.",
"waiting": "Đang chờ cấp quyền...",
"connected": "Feishu đã kết nối.",
"stopped": "Kết nối đã dừng.",
"connecting": "Đang kết nối..."
}
}
@@ -0,0 +1,74 @@
{
"displayName": "飞书",
"description": "在飞书会话和群组中使用 nanobot。",
"requirements": "飞书应用凭据、事件订阅和网关",
"setup": {
"primaryAction": "连接飞书",
"docsLabel": "打开飞书配置指南",
"officialLabel": "打开飞书开发者后台",
"tryIt": "向飞书助手发送私信,或在群组中提及它。",
"summary": "连接流程会通过二维码创建或关联飞书应用,并自动为 nanobot 保存应用凭据。",
"steps": [
"点击连接,用手机飞书或 Lark 扫描二维码。",
"批准应用连接,nanobot 会自动保存 App ID 和 Secret。",
"向机器人发送私信,或在飞书群中提及它以完成测试。"
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "留空以保留现有密钥",
"help": "仅在轮换凭据时粘贴新的 App Secret。"
},
"domain": {
"label": "区域",
"choices": {
"feishu": "飞书",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "群组行为",
"choices": {
"mention": "仅提及时",
"open": "所有消息",
"allowlist": "白名单"
}
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "用户 ID,用逗号分隔"
},
"topicIsolation": {
"label": "话题隔离",
"choices": {
"true": "每个话题使用独立会话",
"false": "群聊共用一个会话"
}
}
}
},
"custom": {
"toggleAssistant": "{{name}} 助手",
"configured": "已连接",
"needsSetup": "需要授权",
"noAppId": "没有 App ID",
"createAnother": "创建另一个助手",
"createHint": "为其他团队、空间或工作流创建独立的飞书机器人。",
"createAssistant": "创建助手",
"reconnect": "重新连接",
"countNone": "尚未连接助手",
"countOne": "已连接 1 个助手",
"countMany": "已连接 {{count}} 个助手",
"qrAlt": "飞书连接二维码",
"scanTitle": "使用飞书扫码",
"scanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。",
"waiting": "正在等待授权...",
"connected": "飞书已连接。",
"stopped": "连接已停止。",
"connecting": "正在连接..."
}
}
@@ -0,0 +1,74 @@
{
"displayName": "飛書",
"description": "在飛書對話和群組中使用 nanobot。",
"requirements": "飛書應用程式憑證、事件訂閱和閘道",
"setup": {
"primaryAction": "連接飛書",
"docsLabel": "開啟飛書設定指南",
"officialLabel": "開啟飛書開發者後台",
"tryIt": "向飛書助手傳送私訊,或在群組中提及它。",
"summary": "連接流程會透過二維碼建立或關聯飛書應用程式,並自動為 nanobot 儲存應用程式憑證。",
"steps": [
"點擊連接,用手機飛書或 Lark 掃描二維碼。",
"批准應用程式連接,nanobot 會自動儲存 App ID 和 Secret。",
"向機器人傳送私訊,或在飛書群組中提及它以完成測試。"
],
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "留空以保留現有密鑰",
"help": "僅在輪換憑證時貼上新的 App Secret。"
},
"domain": {
"label": "區域",
"choices": {
"feishu": "飛書",
"lark": "Lark"
}
},
"groupPolicy": {
"label": "群組行為",
"choices": {
"mention": "僅提及時",
"open": "所有訊息",
"allowlist": "允許清單"
}
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "使用者 ID,以逗號分隔"
},
"topicIsolation": {
"label": "主題隔離",
"choices": {
"true": "每個主題使用獨立工作階段",
"false": "群組共用一個工作階段"
}
}
}
},
"custom": {
"toggleAssistant": "{{name}} 助手",
"configured": "已連接",
"needsSetup": "需要授權",
"noAppId": "沒有 App ID",
"createAnother": "建立另一個助手",
"createHint": "為其他團隊、空間或工作流程建立獨立的飛書機器人。",
"createAssistant": "建立助手",
"reconnect": "重新連線",
"countNone": "尚未連接助手",
"countOne": "已連接 1 個助手",
"countMany": "已連接 {{count}} 個助手",
"qrAlt": "飛書連線 QR Code",
"scanTitle": "使用飛書掃描",
"scanDescription": "請使用手機上的飛書或 Lark 掃描此 QR Code。完成授權後,nanobot 會自動完成設定。",
"waiting": "正在等待授權…",
"connected": "飛書已連線。",
"stopped": "連線已停止。",
"connecting": "正在連線…"
}
}
+277 -213
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable
from collections.abc import Callable, Iterable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -24,9 +23,15 @@ from nanobot.bus.outbound_events import (
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_instance_specs
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.channels.contracts import (
channel_default_config,
channel_instance_specs,
channel_runtime_name,
resolve_channel_action_target,
)
from nanobot.channels.registry import channel_default_enabled
from nanobot.config.schema import Config
from nanobot.utils.restart import (
RestartNotice,
@@ -60,22 +65,12 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
}
def _default_channel_config(name: str) -> dict[str, Any] | None:
if name != "websocket":
from nanobot.channels.registry import load_channel_plugin
plugin = load_channel_plugin(name)
if not plugin.default_enabled:
return None
from nanobot.channels.websocket import WebSocketChannel
return WebSocketChannel.default_config()
def _channel_config_enabled(name: str, section: Any) -> bool:
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
return bool(getattr(section, "enabled", default_enabled))
return channel_default_config(plugin)
class ChannelManager:
@@ -115,6 +110,9 @@ class ChannelManager:
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {}
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
self._channel_errors: dict[str, str] = {}
self._channel_tasks: dict[str, asyncio.Task] = {}
self._dispatch_task: asyncio.Task | None = None
self._started = False
@@ -122,20 +120,19 @@ class ChannelManager:
self._init_channels()
def _config_extra_channel_names(self, config: Config | None = None) -> set[str]:
extra = getattr((config or self.config).channels, "__pydantic_extra__", None) or {}
return set(extra.keys())
def _channel_section(
self,
name: str,
*,
config: Config | None = None,
default_sections: dict[str, Any] | None = None,
default_enabled: bool | None = None,
) -> Any:
config = config or self.config
section = getattr(config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
if default_enabled is None:
default_enabled = channel_default_enabled(name)
if section is not None or not default_enabled:
return section
if default_sections is None:
return _default_channel_config(name)
@@ -145,29 +142,6 @@ class ChannelManager:
default_sections[name] = default
return default_sections.get(name)
def _channel_instance_specs(
self,
name: str,
cls: type[BaseChannel],
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
if name == "feishu":
return feishu_instance_specs(
section,
cls.default_config(),
enabled_only=enabled_only,
)
return [
ChannelInstanceSpec(
base_name=name,
instance_id="default",
runtime_name=name,
config=section,
)
]
def _build_channel(
self,
name: str,
@@ -178,7 +152,7 @@ class ChannelManager:
) -> BaseChannel:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.channels.websocket.runtime import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
@@ -200,6 +174,7 @@ class ChannelManager:
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status,
logger=logger,
)
kwargs["gateway"] = gateway
@@ -218,47 +193,99 @@ class ChannelManager:
return channel
def _init_channels(self) -> None:
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
from nanobot.channels.registry import discover_channel_names, discover_enabled
"""Initialize enabled runtimes from dependency-free channel descriptors."""
from nanobot.channels.registry import discover_plugins
from nanobot.optional_features import ensure_enabled_channel_dependencies
# Collect enabled module names first, then only import those.
# Channel configs live in ChannelsConfig's extra fields (via
# extra="allow"), so we enumerate candidates from pkgutil scan
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
names = discover_channel_names()
candidate_names = set(names) | self._config_extra_channel_names()
plugins = discover_plugins()
default_sections: dict[str, Any] = {}
activations: dict[str, tuple[Any, list[tuple[str, Any]]]] = {}
enabled_names: set[str] = set()
for name in candidate_names:
section = self._channel_section(name, default_sections=default_sections)
if section is None:
continue
if _channel_config_enabled(name, section):
enabled_names.add(name)
for name, cls in discover_enabled(
enabled_names,
_names=names,
warn_import_errors=True,
).items():
section = self._channel_section(name, default_sections=default_sections)
for name, plugin in plugins.items():
section = self._channel_section(
name,
default_sections=default_sections,
default_enabled=plugin.default_enabled,
)
if section is None:
continue
try:
for spec in self._channel_instance_specs(name, cls, section):
self.channels[spec.runtime_name] = self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
channel_setup_spec(name, plugin=plugin)
specs = channel_instance_specs(plugin, section)
runtime_specs = [
(channel_runtime_name(plugin, spec.instance_id), spec)
for spec in specs
]
except Exception as exc:
logger.warning("Could not inspect {} channel activation: {}", name, exc)
continue
if not runtime_specs:
continue
collisions = sorted(
set(self._channel_runtime_specs)
& {runtime_name for runtime_name, _spec in runtime_specs}
)
if collisions:
logger.warning(
"{} channel runtime name(s) are already claimed: {}",
name,
", ".join(collisions),
)
continue
for runtime_name, spec in runtime_specs:
self._channel_runtime_specs[runtime_name] = (name, spec.instance_id)
activations[name] = (plugin, runtime_specs)
enabled_names.add(name)
dependency_errors = ensure_enabled_channel_dependencies(enabled_names, plugins)
for name, error in dependency_errors.items():
self._mark_channel_error(name, error)
for name, (plugin, runtime_specs) in activations.items():
if name in dependency_errors:
continue
try:
cls = plugin.load_channel_class()
built = [
(
runtime_name,
self._build_channel(
name,
cls,
spec.config,
runtime_name=runtime_name,
),
)
logger.info("{} channel enabled as {}", cls.display_name, spec.runtime_name)
except Exception as e:
logger.warning("{} channel not available: {}", name, e)
for runtime_name, spec in runtime_specs
]
for runtime_name, channel in built:
self.channels[runtime_name] = channel
self._channel_owners[runtime_name] = name
logger.info("{} channel enabled as {}", cls.display_name, runtime_name)
except Exception as exc:
self._mark_channel_error(
name,
"Channel runtime could not be loaded. Check gateway logs.",
)
logger.warning("{} channel not available: {}", name, exc)
self._validate_allow_from()
def _mark_channel_error(self, owner: str, message: str) -> None:
self._mark_runtime_error(
(
runtime_name
for runtime_name, (runtime_owner, _instance_id)
in self._channel_runtime_specs.items()
if runtime_owner == owner
),
message,
)
def _mark_runtime_error(self, runtime_names: Iterable[str], message: str) -> None:
for runtime_name in runtime_names:
self._channel_errors[runtime_name] = message
def _validate_allow_from(self) -> None:
for name, ch in self.channels.items():
cfg = ch.config
@@ -304,9 +331,16 @@ class ChannelManager:
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
errors = getattr(self, "_channel_errors", None)
if errors is None:
errors = self._channel_errors = {}
errors.pop(name, None)
try:
await channel.start()
except asyncio.CancelledError:
raise
except Exception:
errors[name] = "Channel failed to start. Check gateway logs."
logger.exception("Failed to start channel {}", name)
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task:
@@ -338,18 +372,12 @@ class ChannelManager:
await task
return True
def _is_known_channel_name(self, name: str) -> bool:
from nanobot.channels.registry import discover_channel_names, discover_plugins
return name in set(discover_channel_names()) or name in discover_plugins()
def _load_channel_class(self, name: str) -> type[BaseChannel] | None:
from nanobot.channels.registry import discover_channel_names, discover_enabled
names = discover_channel_names()
return discover_enabled({name}, _names=names, warn_import_errors=True).get(name)
async def apply_channel_feature_action(self, action: str, name: str) -> dict[str, Any]:
async def apply_channel_feature_action(
self,
action: str,
name: str,
instance_id: str | None = None,
) -> dict[str, Any]:
"""Apply a WebUI channel enable/disable action without restarting the gateway.
Returns a small transport-neutral result. ``handled=False`` means the
@@ -357,35 +385,44 @@ class ChannelManager:
response semantics.
"""
name = name.strip()
instance_id = ""
if "." in name:
name, instance_id = name.split(".", 1)
if not name or not self._is_known_channel_name(name):
instance_id = (instance_id or "").strip() or None
if not name:
return {"handled": False}
if name == "websocket":
from nanobot.channels.registry import discover_plugins
plugin = discover_plugins({name}).get(name)
if plugin is None:
return {"handled": False}
if "always_enabled" in plugin.capabilities:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": "WebSocket hosts the WebUI and is applied on restart.",
"message": f"{plugin.display_name} is always enabled and is applied on restart.",
}
from nanobot.config.loader import load_config
self.config = load_config()
section = self._channel_section(name)
section = self._channel_section(name, default_enabled=plugin.default_enabled)
channel_setup_spec(name, plugin=plugin)
instance_id = resolve_channel_action_target(instance_id)
if action == "disable":
runtime_names = [name if not instance_id else f"{name}.{instance_id}"]
if name == "feishu" and not instance_id:
runtime_names = [
runtime_name
for runtime_name in self.channels
if runtime_name == "feishu" or runtime_name.startswith("feishu.")
]
runtime_name = channel_runtime_name(plugin, instance_id)
runtime_names = (
[runtime_name]
if self._channel_owners.get(runtime_name) == name
else []
)
stopped = False
for runtime_name in runtime_names:
stopped = await self._stop_channel(runtime_name) or stopped
self.channels.pop(runtime_name, None)
self._channel_owners.pop(runtime_name, None)
self._channel_runtime_specs.pop(runtime_name, None)
self._channel_errors.pop(runtime_name, None)
return {
"handled": True,
"ok": True,
@@ -396,26 +433,8 @@ class ChannelManager:
if action != "enable":
return {"handled": True, "ok": False, "requires_restart": True}
if section is None or not _channel_config_enabled(name, section):
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel config was not enabled.",
}
cls = self._load_channel_class(name)
if cls is None:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be loaded.",
}
specs = self._channel_instance_specs(name, cls, section)
if instance_id:
specs = [spec for spec in specs if spec.instance_id == instance_id]
specs = channel_instance_specs(plugin, section) if section is not None else []
specs = [spec for spec in specs if spec.instance_id == instance_id]
if not specs:
return {
"handled": True,
@@ -424,42 +443,102 @@ class ChannelManager:
"message": f"{name} channel config was not enabled.",
}
try:
built = [
(
spec.runtime_name,
self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
),
)
for spec in specs
]
except Exception as exc:
logger.exception("Failed to build {} channel after settings change", name)
runtime_specs = [
(channel_runtime_name(plugin, spec.instance_id), spec)
for spec in specs
]
collisions = [
runtime_name
for runtime_name, _spec in runtime_specs
if (
runtime_name in self.channels
and self._channel_owners.get(runtime_name) != name
)
]
if collisions:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be started: {exc}",
"message": (
"Channel runtime name(s) already owned by another channel: "
+ ", ".join(sorted(collisions))
),
}
for runtime_name, spec in runtime_specs:
self._channel_runtime_specs[runtime_name] = (name, spec.instance_id)
try:
cls = plugin.load_channel_class()
except Exception:
self._mark_runtime_error(
(runtime_name for runtime_name, _spec in runtime_specs),
"Channel runtime could not be loaded. Check gateway logs.",
)
return {
"handled": True,
"ok": False,
"requires_restart": False,
"message": f"{name} channel could not be loaded. Check gateway logs.",
}
for runtime_name, _channel in built:
if runtime_name in self.channels:
await self._stop_channel(runtime_name)
try:
built = [
(
runtime_name,
self._build_channel(
name,
cls,
spec.config,
runtime_name=runtime_name,
),
)
for runtime_name, spec in runtime_specs
]
except Exception:
self._mark_runtime_error(
(runtime_name for runtime_name, _spec in runtime_specs),
"Channel runtime could not be built. Check gateway logs.",
)
logger.exception("Failed to build {} channel after settings change", name)
return {
"handled": True,
"ok": False,
"requires_restart": False,
"message": f"{name} channel could not be started. Check gateway logs.",
}
runtime_names_to_replace = {runtime_name for runtime_name, _channel in built}
for runtime_name in sorted(runtime_names_to_replace):
if runtime_name not in self.channels:
continue
await self._stop_channel(runtime_name)
self.channels.pop(runtime_name, None)
self._channel_owners.pop(runtime_name, None)
for runtime_name, channel in built:
self.channels[runtime_name] = channel
self._channel_owners[runtime_name] = name
self._channel_errors.pop(runtime_name, None)
if self._started:
self._start_channel_task(runtime_name, channel)
logger.info("{} channel applied without restart", runtime_name)
if self._started:
await asyncio.sleep(0)
failed = [
runtime_name
for runtime_name, _channel in built
if runtime_name in self._channel_errors
]
return {
"handled": True,
"ok": True,
"ok": not failed,
"requires_restart": False,
"message": f"{cls.display_name} channel applied without restart.",
"message": (
f"{cls.display_name} channel failed to start. Check gateway logs."
if failed
else f"{cls.display_name} channel applied without restart."
),
}
async def start_all(self) -> None:
@@ -654,84 +733,43 @@ class ChannelManager:
break
@staticmethod
def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool:
try:
signature = inspect.signature(callable_obj)
except (TypeError, ValueError):
return True
return any(
parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name
for parameter in signature.parameters.values()
)
@classmethod
async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_delta"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
async def _send_reasoning_delta(
channel: BaseChannel,
msg: OutboundMessage,
event: ProgressEvent,
) -> None:
await channel.send_reasoning_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
msg.metadata,
stream_id=event.stream_id,
)
@classmethod
async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
metadata["_reasoning_end"] = True
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
@staticmethod
async def _send_reasoning_end(
channel: BaseChannel,
msg: OutboundMessage,
event: ProgressEvent,
) -> None:
await channel.send_reasoning_end(
msg.chat_id,
metadata,
**kwargs,
msg.metadata,
stream_id=event.stream_id,
)
@classmethod
@staticmethod
async def _send_stream_event(
cls,
channel: BaseChannel,
msg: OutboundMessage,
event: StreamDeltaEvent | StreamEndEvent,
) -> None:
metadata = msg.metadata
kwargs: dict[str, Any] = {}
if cls._accepts_keyword(channel.send_delta, "stream_id"):
kwargs["stream_id"] = event.stream_id
else:
metadata = dict(metadata or {})
if event.stream_id is not None:
metadata["_stream_id"] = event.stream_id
if isinstance(event, StreamEndEvent):
if cls._accepts_keyword(channel.send_delta, "stream_end"):
kwargs["stream_end"] = True
else:
metadata = dict(metadata or {})
metadata["_stream_end"] = True
if cls._accepts_keyword(channel.send_delta, "resuming"):
kwargs["resuming"] = event.resuming
elif not kwargs:
metadata = dict(metadata or {})
metadata["_stream_delta"] = True
await channel.send_delta(
msg.chat_id,
msg.content,
metadata,
**kwargs,
msg.metadata,
stream_id=event.stream_id,
stream_end=isinstance(event, StreamEndEvent),
resuming=event.resuming if isinstance(event, StreamEndEvent) else False,
)
@staticmethod
@@ -880,14 +918,40 @@ class ChannelManager:
return self.channels.get(name)
def get_status(self) -> dict[str, Any]:
"""Get status of all channels."""
return {
name: {
"""Return actual runtime state, including enabled runtimes that failed."""
owners = getattr(self, "_channel_owners", {})
runtime_specs = dict(getattr(self, "_channel_runtime_specs", {}))
for runtime_name in self.channels:
runtime_specs.setdefault(
runtime_name,
(owners.get(runtime_name, runtime_name), "default"),
)
tasks = getattr(self, "_channel_tasks", {})
errors = getattr(self, "_channel_errors", {})
status: dict[str, Any] = {}
for runtime_name, (owner, instance_id) in runtime_specs.items():
channel = self.channels.get(runtime_name)
task = tasks.get(runtime_name)
error = errors.get(runtime_name)
running = bool(channel and channel.is_running)
if error:
state = "failed"
elif running:
state = "running"
elif task is not None and not task.done():
state = "starting"
else:
state = "stopped"
status[runtime_name] = {
"enabled": True,
"running": channel.is_running
"running": running,
"state": state,
"owner": owner,
"instance_id": instance_id,
}
for name, channel in self.channels.items()
}
if error:
status[runtime_name]["error"] = error
return status
@property
def enabled_channels(self) -> list[str]:
+1
View File
@@ -0,0 +1 @@
"""Matrix channel package."""
+39
View File
@@ -0,0 +1,39 @@
"""Matrix management contract."""
from nanobot.channels._manifest import GROUP_POLICIES, field, one_of, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.matrix.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"homeserver": field(default="https://matrix.org"),
"userId": field(),
"password": field("secret"),
"accessToken": field("secret"),
"deviceId": field(),
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="open"),
"allowFrom": field("list", writable=False),
},
required=(
*required_fields("homeserver", "userId"),
one_of(("password",), ("accessToken", "deviceId")),
),
official_url="https://matrix.org/ecosystem/clients/",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="matrix",
display_name="Matrix",
runtime=f"{__package__}.runtime:MatrixChannel",
setup=SETUP_SPEC,
dependencies=(
"matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'",
"matrix-nio>=0.25.2; sys_platform == 'win32'",
"aiohttp>=3.9.0,<4.0.0",
"mistune>=3.0.0,<4.0.0",
"nh3>=0.2.17,<1.0.0",
),
webui="webui/index.ts",
)
@@ -894,7 +894,7 @@ class MatrixChannel(BaseChannel):
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
info = self._event_source_content(event).get("info")
size = info.get("size") if isinstance(info, dict) else None
return size if type(size) is int and size >= 0 else None
return size if type(size) is int and size >= 0 else None # noqa: E721
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
info = self._event_source_content(event).get("info")
@@ -0,0 +1 @@
"""Tests for the Matrix channel package."""

Some files were not shown because too many files have changed in this diff Show More