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:
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""DingTalk channel package."""
|
||||
@@ -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"])
|
||||
@@ -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 d’application DingTalk et passerelle",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide DingTalk",
|
||||
"officialLabel": "Ouvrir la console DingTalk",
|
||||
"tryIt": "Envoyez un message test dans le groupe DingTalk où l’application est installée.",
|
||||
"summary": "DingTalk nécessite les identifiants d’une 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 l’application DingTalk."
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Client Secret",
|
||||
"placeholder": "••••••",
|
||||
"help": "Copiez-le depuis la même page d’identifiants 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,以逗號分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Discord channel package."""
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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": "L’activation 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, d’envoi 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 d’autorisation"
|
||||
}
|
||||
},
|
||||
"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,以逗號分隔"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Email channel package."""
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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 d’application et consentement explicite",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide Email",
|
||||
"officialLabel": "Ouvrir le guide des mots de passe d’application",
|
||||
"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 l’activation.",
|
||||
"steps": [
|
||||
"Créez une boîte dédiée et, si nécessaire, un mot de passe d’application.",
|
||||
"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. N’activez qu’aprè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 d’utilisateur IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Mot de passe IMAP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Utilisez un mot de passe d’application si le fournisseur l’exige."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Hôte SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Nom d’utilisateur SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Mot de passe SMTP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Généralement identique à celui d’IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Port IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Port SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Adresse d’envoi",
|
||||
"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 l’association 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": "關閉"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Feishu/Lark channel package."""
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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" }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 l’assistant 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 l’App 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 d’une 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 d’autorisation"
|
||||
}
|
||||
},
|
||||
"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 l’assistant",
|
||||
"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 d’autorisation...",
|
||||
"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
@@ -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]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Matrix channel package."""
|
||||
@@ -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
Reference in New Issue
Block a user