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

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

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

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

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

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

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

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

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

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

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

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+588
View File
@@ -0,0 +1,588 @@
"""Shared contract tests for self-contained channel packages."""
from __future__ import annotations
import subprocess
import sys
from typing import Any
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.base import BaseChannel
from nanobot.channels.contracts import (
ChannelActivation,
ChannelFieldSpec,
ChannelInstanceSpec,
ChannelManagementSpec,
ChannelSetupSpec,
ChannelValidationContext,
SetupRequirement,
channel_feature_instances,
channel_instance_config,
channel_instance_specs,
channel_runtime_name,
channel_set_config_enabled,
channel_update_instance_config,
resolve_channel_action_target,
)
from nanobot.channels.plugin import ChannelPlugin
from nanobot.channels.registry import discover_plugins, load_channel_plugin
class _SingleChannel(BaseChannel):
name = "single"
display_name = "Single"
@classmethod
def default_config(cls) -> dict[str, Any]:
return {"enabled": False, "token": ""}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
class _SetupChannel(_SingleChannel):
name = "setup_contract"
@staticmethod
def _validate(
values: dict[str, Any],
_context: ChannelValidationContext,
) -> dict[str, Any]:
return {
"status": "connected" if values.get("token") else "invalid",
"checks": [],
}
_SETUP_PLUGIN = ChannelPlugin(
name=_SetupChannel.name,
display_name=_SetupChannel.display_name,
runtime=f"{__name__}:_SetupChannel",
setup=ChannelSetupSpec(
fields={"token": ChannelFieldSpec(kind="secret")},
required=(SetupRequirement((("token",),)),),
validator=_SetupChannel._validate,
),
)
_SINGLE_PLUGIN = ChannelPlugin(
name=_SingleChannel.name,
display_name=_SingleChannel.display_name,
runtime=f"{__name__}:_SingleChannel",
management=ChannelManagementSpec(default_config=_SingleChannel.default_config),
)
def test_management_contract_is_not_declared_on_runtime_base_class() -> None:
management_hooks = {
"feature_instances",
"instance_specs",
"runtime_name",
"supports_multiple_instances",
"update_instance_config",
}
assert management_hooks.isdisjoint(BaseChannel.__dict__.keys())
assert "refresh_feature_metadata" in BaseChannel.__dict__
def test_multi_instance_support_is_declared_by_management_spec() -> None:
assert _SINGLE_PLUGIN.management.multi_instance is False
assert load_channel_plugin("feishu").management.multi_instance is True
@pytest.mark.parametrize(
"callback",
[
"instance_specs",
"update_instance_config",
"runtime_name",
"feature_instances",
],
)
def test_single_instance_management_rejects_multi_instance_callbacks(callback: str) -> None:
with pytest.raises(ValueError, match=callback):
ChannelManagementSpec(**{callback: lambda *args, **kwargs: None})
@pytest.mark.parametrize(
("requested", "expected"),
[
pytest.param(None, "default", id="default-instance"),
pytest.param("product", "product", id="explicit-instance"),
],
)
def test_channel_action_target_contract(
requested,
expected,
) -> None:
assert resolve_channel_action_target(requested) == expected
def test_contract_module_is_not_discovered_as_a_channel() -> None:
assert "contracts" not in discover_plugins()
assert "manifests" not in discover_plugins()
def test_settings_contract_import_does_not_eagerly_load_runtime_graph() -> None:
code = """
import sys
import nanobot.channels.validation
unexpected = {
"nanobot.channels.manager",
"nanobot.channels.websocket",
"nanobot.webui.gateway_services",
} & sys.modules.keys()
assert not unexpected, sorted(unexpected)
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize(
("section", "default", "include_instances", "expected"),
[
pytest.param({"enabled": True}, False, False, True, id="flat-enabled"),
pytest.param({}, True, False, True, id="flat-inherits-default"),
pytest.param(
{"enabled": True, "instances": ["plugin-owned-value"]},
False,
False,
True,
id="single-instance-plugin-owns-instances-field",
),
pytest.param(
{"enabled": False, "instances": [{"enabled": True}]},
False,
True,
True,
id="instance-overrides-parent",
),
pytest.param(
{"enabled": True, "instances": [{}, {"enabled": False}]},
False,
True,
True,
id="instance-inherits-parent",
),
pytest.param(
{"enabled": True, "instances": []},
False,
True,
False,
id="empty-instance-list",
),
],
)
def test_channel_activation_normalizes_persisted_config(
section: dict[str, Any],
default: bool,
include_instances: bool,
expected: bool,
) -> None:
activation = ChannelActivation.from_config(
section,
include_instances=include_instances,
)
assert activation.resolve(default=default) is expected
def _instance_contract_cases():
return [
pytest.param(
_SINGLE_PLUGIN,
{"enabled": True, "token": "saved"},
"default",
{"default"},
id="single-instance-default",
),
pytest.param(
load_channel_plugin("feishu"),
{
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"enabled": True,
"appId": "cli_product",
"appSecret": "secret",
},
]
},
"product",
{"default", "product"},
id="feishu-multi-instance",
),
]
@pytest.mark.parametrize(
("plugin", "section", "target_id", "expected_ids"),
_instance_contract_cases(),
)
def test_channel_instance_contract_round_trip(
plugin,
section,
target_id,
expected_ids,
) -> None:
all_specs = channel_instance_specs(plugin, section, enabled_only=False)
enabled_specs = channel_instance_specs(plugin, section)
assert {spec.instance_id for spec in all_specs} == expected_ids
assert {spec.instance_id for spec in enabled_specs} == expected_ids
runtime_names = {channel_runtime_name(plugin, spec.instance_id) for spec in all_specs}
assert len(runtime_names) == len(all_specs)
disabled = channel_set_config_enabled(
plugin,
section,
False,
instance_id=target_id,
)
assert target_id not in {
spec.instance_id for spec in channel_instance_specs(plugin, disabled)
}
values = channel_instance_config(plugin, disabled, instance_id=target_id)
values["contractMarker"] = "preserved"
updated = channel_update_instance_config(
plugin,
disabled,
values,
instance_id=target_id,
)
assert channel_instance_config(
plugin,
updated,
instance_id=target_id,
)["contractMarker"] == "preserved"
def test_channel_feature_instances_use_generic_setup_snapshot() -> None:
setup_spec = ChannelSetupSpec(
fields={
"token": ChannelFieldSpec(kind="secret"),
"region": ChannelFieldSpec(kind="enum", choices=frozenset({"eu", "us"})),
"topicIsolation": ChannelFieldSpec(kind="bool"),
},
required=(SetupRequirement.field("token"),),
)
plugin = ChannelPlugin(
name="feature_multi",
display_name="Feature multi",
runtime=f"{__name__}:_SingleChannel",
setup=setup_spec,
management=ChannelManagementSpec(
multi_instance=True,
instance_specs=lambda section, *, enabled_only=True: [
ChannelInstanceSpec(item["id"], item)
for item in section["instances"]
if not enabled_only or item["enabled"]
],
update_instance_config=lambda section, values, *, instance_id="default": section,
runtime_name=lambda name, instance_id: (
name if instance_id == "default" else f"{name}.{instance_id}"
),
feature_instances=lambda section, *, setup_spec=None: [{
"id": "product",
"display_name": "Catalog product helper",
"enabled": False,
"config_values": {"channels.feature_multi.token": "leaked"},
}],
),
)
section = {
"instances": [
{
"id": "product",
"name": "Product bot",
"displayName": "Product helper",
"avatarUrl": "https://example.com/product.png",
"enabled": True,
"token": "secret",
"region": "eu",
"topicIsolation": False,
}
]
}
instances = channel_feature_instances(
plugin,
section,
setup_spec=setup_spec,
)
assert instances == [
{
"id": "product",
"name": "Product bot",
"display_name": "Catalog product helper",
"avatar_url": "https://example.com/product.png",
"enabled": True,
"configured": True,
"config_values": {
"channels.feature_multi.region": "eu",
"channels.feature_multi.topicIsolation": "false",
},
"configured_fields": [
"channels.feature_multi.token",
"channels.feature_multi.region",
"channels.feature_multi.topicIsolation",
],
}
]
def test_feishu_instance_contract_skips_duplicate_app_identity() -> None:
section = {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret",
"domain": "feishu",
},
{
"id": "assistant-copy",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret",
"domain": "feishu",
},
]
}
specs = channel_instance_specs(load_channel_plugin("feishu"), section)
assert [spec.instance_id for spec in specs] == ["default"]
def test_feishu_feature_state_matches_runtime_duplicate_filter() -> None:
section = {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret",
"domain": "feishu",
},
{
"id": "assistant-copy",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret",
"domain": "feishu",
},
]
}
instances = channel_feature_instances(
load_channel_plugin("feishu"),
section,
setup_spec=channel_setup_spec("feishu"),
)
assert instances is not None
assert [(item["id"], item["enabled"]) for item in instances] == [
("default", True),
("assistant-copy", False),
]
def test_feishu_runtime_duplicate_ignores_disabled_identity_owner() -> None:
section = {
"instances": [
{
"id": "default",
"enabled": False,
"appId": "cli_same",
"appSecret": "secret-a",
"domain": "feishu",
},
{
"id": "assistant-copy",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret-b",
"domain": "feishu",
},
]
}
specs = channel_instance_specs(load_channel_plugin("feishu"), section)
assert [spec.instance_id for spec in specs] == ["assistant-copy"]
def test_feishu_instance_write_preserves_duplicate_app_identity() -> None:
section = {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret-a",
},
{
"id": "assistant-copy",
"enabled": True,
"appId": "cli_same",
"appSecret": "secret-b",
},
]
}
updated = channel_set_config_enabled(
load_channel_plugin("feishu"),
section,
False,
instance_id="assistant-copy",
)
assert [instance["id"] for instance in updated["instances"]] == [
"default",
"assistant-copy",
]
assert updated["instances"][0]["appSecret"] == "secret-a"
assert updated["instances"][1]["appId"] == "cli_same"
assert updated["instances"][1]["appSecret"] == "secret-b"
assert updated["instances"][1]["enabled"] is False
def test_channel_instance_contract_materializes_generators() -> None:
def generate_specs(section, *, enabled_only=True):
yield ChannelInstanceSpec("default", section)
yield ChannelInstanceSpec("product", section)
plugin = ChannelPlugin(
name="generated",
display_name="Generated",
runtime=f"{__name__}:_SingleChannel",
setup=ChannelSetupSpec(fields={}),
management=ChannelManagementSpec(
multi_instance=True,
instance_specs=generate_specs,
update_instance_config=lambda section, values, *, instance_id="default": values,
runtime_name=lambda name, instance_id: (
name if instance_id == "default" else f"{name}.{instance_id}"
),
),
)
specs = channel_instance_specs(plugin, {"enabled": True})
assert [spec.instance_id for spec in specs] == ["default", "product"]
def test_single_instance_contract_preserves_plugin_owned_instances_field() -> None:
section = {
"enabled": True,
"instances": ["plugin-owned-value"],
}
specs = channel_instance_specs(_SINGLE_PLUGIN, section)
assert specs == [ChannelInstanceSpec("default", section)]
@pytest.mark.parametrize(
("instance_ids", "message"),
[
pytest.param(
["default", "default"],
"duplicate instance id 'default'",
id="duplicate-instance-id",
),
pytest.param(
["default", "product"],
"duplicate runtime name 'invalid'",
id="duplicate-runtime-name",
),
],
)
def test_channel_instance_contract_rejects_invalid_specs(instance_ids, message) -> None:
plugin = ChannelPlugin(
name="invalid",
display_name="Invalid",
runtime=f"{__name__}:_SingleChannel",
setup=ChannelSetupSpec(fields={}),
management=ChannelManagementSpec(
multi_instance=True,
instance_specs=lambda section, *, enabled_only=True: [
ChannelInstanceSpec(instance_id, {}) for instance_id in instance_ids
],
update_instance_config=lambda section, values, *, instance_id="default": values,
runtime_name=lambda name, instance_id: name,
),
)
with pytest.raises(ValueError, match=message):
channel_instance_specs(plugin, {"enabled": True})
def test_channel_instance_contract_rejects_runtime_name_outside_namespace() -> None:
plugin = ChannelPlugin(
name="invalid",
display_name="Invalid",
runtime=f"{__name__}:_SingleChannel",
setup=ChannelSetupSpec(fields={}),
management=ChannelManagementSpec(
multi_instance=True,
instance_specs=lambda section, *, enabled_only=True: [
ChannelInstanceSpec("default", section)
],
update_instance_config=lambda section, values, *, instance_id="default": values,
runtime_name=lambda name, instance_id: "other",
),
)
with pytest.raises(ValueError, match="must be scoped under 'invalid'"):
channel_instance_specs(plugin, {"enabled": True})
def test_channel_setup_contract_owns_fields_and_validation() -> None:
spec = channel_setup_spec(
_SetupChannel.name,
plugin=_SETUP_PLUGIN,
)
assert spec is not None
assert spec.route_field_types == {"token": "secret"}
assert spec.is_configured({"token": "saved"}) is True
assert spec.validator is not None
assert spec.validator({"token": "saved"}, ChannelValidationContext())["status"] == "connected"
assert spec.to_public_dict(_SetupChannel.name) == {
"fields": [{
"key": "channels.setup_contract.token",
"field": "token",
"kind": "secret",
"choices": [],
"required": True,
}],
}
+172 -18
View File
@@ -6,7 +6,13 @@ import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.contracts import (
ChannelInstanceSpec,
ChannelManagementSpec,
ChannelSetupSpec,
)
from nanobot.channels.manager import ChannelManager
from nanobot.channels.plugin import ChannelPlugin
from nanobot.config.schema import Config
@@ -32,6 +38,77 @@ class _HotChannel(BaseChannel):
raise AssertionError("send should not be called")
class _MultiHotChannel(_HotChannel):
name = "multi"
display_name = "Multi"
class _AliasHotChannel(_HotChannel):
"""Package descriptor alias that claims another channel's runtime namespace."""
name = "hot"
display_name = "Alias"
def _multi_instance_specs(section, *, enabled_only=True):
instances = section.get("instances", []) if isinstance(section, dict) else []
return [
ChannelInstanceSpec(
instance_id=item["id"],
config=item,
)
for item in instances
if not enabled_only or item.get("enabled", False)
]
def _plugin(channel_cls: type[BaseChannel], *, multi_instance: bool = False) -> ChannelPlugin:
runtime_attr = f"_runtime_{channel_cls.display_name.lower()}"
globals()[runtime_attr] = channel_cls
setup = ChannelSetupSpec(fields={}) if multi_instance else None
management = (
ChannelManagementSpec(
multi_instance=True,
instance_specs=_multi_instance_specs,
update_instance_config=lambda section, values, *, instance_id="default": values,
runtime_name=lambda name, instance_id: (
name if instance_id == "default" else f"{name}.{instance_id}"
),
)
if multi_instance
else ChannelManagementSpec()
)
return ChannelPlugin(
name=channel_cls.name,
display_name=channel_cls.display_name,
runtime=f"{__name__}:{runtime_attr}",
setup=setup,
management=management,
)
def _stub_registry(monkeypatch, *plugins: ChannelPlugin) -> None:
by_name = {plugin.name: plugin for plugin in plugins}
monkeypatch.setattr(
"nanobot.channels.registry.discover_plugins",
lambda enabled_names=None: {
name: plugin
for name, plugin in by_name.items()
if enabled_names is None or name in enabled_names
},
)
def test_descriptor_rejects_runtime_class_owned_by_another_name():
plugin = ChannelPlugin(
name="alias",
display_name="Alias",
runtime=f"{__name__}:_AliasHotChannel",
)
with pytest.raises(ImportError, match="runtime declares name 'hot'"):
plugin.load_channel_class()
@pytest.mark.asyncio
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
disabled = Config.model_validate({
@@ -47,15 +124,8 @@ async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch
}
})
import nanobot.channels.registry as registry
def discover_enabled(enabled_names, **_kwargs):
return {"hot": _HotChannel} if "hot" in enabled_names else {}
configs = iter([enabled, disabled])
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
_stub_registry(monkeypatch, _plugin(_HotChannel))
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
manager = ChannelManager(disabled, MessageBus())
@@ -86,15 +156,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
}
})
import nanobot.channels.registry as registry
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(
registry,
"discover_enabled",
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
)
_stub_registry(monkeypatch, _plugin(_HotChannel))
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
manager = ChannelManager(enabled, MessageBus())
@@ -108,7 +170,99 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
result = await manager.apply_channel_feature_action("enable", "hot")
assert result["requires_restart"] is True
assert result["requires_restart"] is False
assert result["ok"] is False
assert manager.channels["hot"] is old_channel
assert old_channel.is_running is True
assert not old_channel.stopped.is_set()
@pytest.mark.asyncio
async def test_apply_channel_feature_action_uses_channel_runtime_name(monkeypatch):
config = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"multi": {
"enabled": True,
"instances": [
{"id": "default", "enabled": True},
{"id": "product", "enabled": True},
]
},
}
})
_stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True))
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
manager = ChannelManager(config, MessageBus())
product = manager.channels["multi.product"]
product._running = True
result = await manager.apply_channel_feature_action("disable", "multi", "product")
assert result["requires_restart"] is False
assert "multi" in manager.channels
assert "multi.product" not in manager.channels
assert product.is_running is False
@pytest.mark.asyncio
async def test_default_multi_channel_action_reconciles_only_default_runtime(monkeypatch):
initial = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"multi": {
"enabled": True,
"instances": [
{"id": "default", "enabled": True},
{"id": "product", "enabled": True},
],
},
}
})
disabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"multi": {
"enabled": True,
"instances": [
{"id": "default", "enabled": False},
{"id": "product", "enabled": True},
],
},
}
})
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"multi": {
"enabled": True,
"instances": [
{"id": "default", "enabled": True},
{"id": "product", "enabled": True},
],
},
}
})
_stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True))
configs = iter([disabled, enabled])
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
manager = ChannelManager(initial, MessageBus())
default = manager.channels["multi"]
product = manager.channels["multi.product"]
disabled_result = await manager.apply_channel_feature_action("disable", "multi")
assert disabled_result["requires_restart"] is False
assert set(manager.channels) == {"multi.product"}
assert default.stopped.is_set()
assert not product.stopped.is_set()
enabled_result = await manager.apply_channel_feature_action("enable", "multi")
assert enabled_result["requires_restart"] is False
assert set(manager.channels) == {"multi", "multi.product"}
assert manager.channels["multi.product"] is product
@@ -124,6 +124,7 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
)
await manager._send_once(channel, msg)
channel._end_mock.assert_awaited_once()
assert channel._end_mock.await_args.kwargs["stream_id"] == "r1"
channel._delta_mock.assert_not_awaited()
File diff suppressed because it is too large Load Diff
+229
View File
@@ -1,4 +1,37 @@
import ast
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
import nanobot.channels._setup as channel_setup_module
import nanobot.channels.registry as registry_module
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.plugin import ChannelPlugin, load_channel_package
from nanobot.channels.registry import channel_default_enabled, discover_plugins
EXPECTED_CHANNELS = {
"dingtalk",
"discord",
"email",
"feishu",
"matrix",
"mattermost",
"mochat",
"msteams",
"napcat",
"qq",
"signal",
"slack",
"telegram",
"websocket",
"wecom",
"weixin",
"whatsapp",
}
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
@@ -12,6 +45,13 @@ def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
}
assert slack.simple_required_fields == ("appToken", "botToken")
assert slack.fields["groupPolicy"].default == "mention"
group_policy = next(
field
for field in slack.to_public_dict("slack")["fields"]
if field["field"] == "groupPolicy"
)
assert group_policy["default_value"] == "mention"
def test_matrix_setup_requires_one_complete_login_method() -> None:
@@ -52,3 +92,192 @@ def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
"enum",
{"mention", "open"},
)
def test_every_channel_is_a_self_contained_package() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
package_names = {path.parent.name for path in channel_dir.glob("*/manifest.py")}
assert not hasattr(channel_setup_module, "CHANNEL_SETUP_SPECS")
assert package_names == EXPECTED_CHANNELS
assert set(discover_plugins()) == EXPECTED_CHANNELS
for name in EXPECTED_CHANNELS:
package_dir = channel_dir / name
assert (package_dir / "__init__.py").is_file()
assert (package_dir / "manifest.py").is_file()
assert (package_dir / "runtime.py").is_file()
assert not (channel_dir / f"{name}.py").exists()
plugin = load_channel_package(name)
assert plugin is not None
assert plugin.name == name
assert plugin.runtime.startswith(f"nanobot.channels.{name}.runtime:")
assert plugin.setup is channel_setup_spec(name)
if plugin.webui is not None:
assert (package_dir / plugin.webui).is_file()
def test_channel_locales_cover_authoritative_setup_contracts() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
for name in EXPECTED_CHANNELS:
plugin = load_channel_package(name)
assert plugin is not None
if plugin.webui is None or plugin.setup is None:
continue
english = json.loads(
(channel_dir / name / "webui" / "locales" / "en.json").read_text(encoding="utf-8")
)
setup_messages = english["setup"]
field_messages = setup_messages.get("fields", {})
for field_name, field in plugin.setup.fields.items():
if not field.writable:
continue
message_key = re.sub(r"[^A-Za-z0-9_-]+", "_", field_name)
assert message_key in field_messages, f"{name} field {field_name} has no locale copy"
if plugin.setup.official_url:
assert setup_messages.get("officialLabel"), f"{name} has no localized official label"
def test_channel_manifests_only_import_contract_modules() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
allowed_imports = {
"nanobot.channels._manifest",
"nanobot.channels.contracts",
"nanobot.channels.plugin",
}
for name in EXPECTED_CHANNELS:
manifest_path = channel_dir / name / "manifest.py"
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
imports: set[str] = set()
for node in tree.body:
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module)
allowed_channel_imports = {
module
for module in imports
if module.startswith(f"nanobot.channels.{name}.")
and not module.endswith(".runtime")
}
unexpected = imports - allowed_imports - allowed_channel_imports
assert not unexpected, f"{name} imports runtime dependencies: {unexpected}"
def test_runtime_classes_do_not_declare_persisted_management_hooks() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
management_hooks = {
"feature_instances",
"instance_specs",
"runtime_name",
"supports_multiple_instances",
"update_instance_config",
}
for name in EXPECTED_CHANNELS:
tree = ast.parse((channel_dir / name / "runtime.py").read_text(encoding="utf-8"))
declared = {
item.name
for node in tree.body
if isinstance(node, ast.ClassDef)
for item in node.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
}
assert declared.isdisjoint(management_hooks), f"{name} runtime owns {declared & management_hooks}"
def test_feishu_package_manifest_owns_runtime_and_webui_metadata() -> None:
plugin = load_channel_package("feishu")
assert plugin is not None
assert plugin.runtime == "nanobot.channels.feishu.runtime:FeishuChannel"
assert plugin.dependencies == ("lark-oapi>=1.5.0,<2.0.0",)
assert plugin.connector == "nanobot.channels.feishu.connect:FeishuConnectStore"
assert plugin.management.multi_instance is True
assert plugin.webui == "webui/index.tsx"
def test_weixin_package_manifest_owns_runtime_and_webui_metadata() -> None:
plugin = load_channel_package("weixin")
assert plugin is not None
assert plugin.runtime == "nanobot.channels.weixin.runtime:WeixinChannel"
assert plugin.dependencies == ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0")
assert plugin.connector == "nanobot.channels.weixin.connect:WeixinConnectStore"
assert plugin.webui == "webui/index.tsx"
def test_package_manifests_do_not_import_runtimes() -> None:
code = f"""
import sys
from nanobot.channels.plugin import load_channel_package
for name in {sorted(EXPECTED_CHANNELS)!r}:
plugin = load_channel_package(name)
assert plugin is not None
assert f"nanobot.channels.{{name}}.runtime" not in sys.modules
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_channel_plugin_normalizes_webui_entry() -> None:
plugin = ChannelPlugin(
name="demo",
display_name="Demo",
runtime="example.demo.runtime:DemoChannel",
webui="webui\\index.tsx",
)
assert plugin.webui == "webui/index.tsx"
def test_channel_plugin_name_must_match_package_identifier() -> None:
with pytest.raises(ValueError, match="letters, digits, or underscores"):
ChannelPlugin(
name="google-chat",
display_name="Google Chat",
runtime="example.google_chat.runtime:GoogleChatChannel",
)
def test_channel_plugin_rejects_invalid_runtime_import_path() -> None:
with pytest.raises(ValueError, match="absolute import path"):
ChannelPlugin(
name="demo",
display_name="Demo",
runtime="../runtime:DemoChannel",
)
def test_channel_default_enabled_uses_package_manifest(monkeypatch) -> None:
plugin = ChannelPlugin(
name="demo",
display_name="Demo",
runtime="example.demo.runtime:DemoChannel",
default_enabled=True,
)
monkeypatch.setattr(
registry_module,
"load_channel_plugin",
lambda name: plugin if name == "demo" else (_ for _ in ()).throw(ImportError()),
)
assert channel_default_enabled("demo") is True
assert channel_default_enabled("missing") is False
def test_websocket_manifest_declares_the_only_default_enabled_channel() -> None:
enabled = {
name
for name in EXPECTED_CHANNELS
if (plugin := load_channel_package(name)) is not None and plugin.default_enabled
}
assert enabled == {"websocket"}
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from nanobot.channels import validation
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
connected: list[tuple[str, int]] = []
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
monkeypatch.setattr(
validation,
"resolve_url_target",
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
)
monkeypatch.setattr(
validation.socket,
"create_connection",
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
)
validation.probe_tcp("mail.example.com", 2525)
assert connected == [("203.0.113.10", 2525)]
-982
View File
@@ -1,982 +0,0 @@
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 as dingtalk_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk 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"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,39 +0,0 @@
import json
from nanobot.channels.feishu 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"
-46
View File
@@ -1,46 +0,0 @@
"""Tests for Feishu/Lark domain configuration."""
from unittest.mock import MagicMock
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu 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"
-59
View File
@@ -1,59 +0,0 @@
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 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 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"
-406
View File
@@ -1,406 +0,0 @@
import json
import httpx
import pytest
from nanobot.channels import feishu as feishu_module
from nanobot.channels.feishu 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_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"
@@ -1,68 +0,0 @@
# 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 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"]
@@ -1,38 +0,0 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.channels import feishu as feishu_module
from nanobot.channels.feishu 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}]"
-60
View File
@@ -1,60 +0,0 @@
"""Tests for Feishu _is_bot_mentioned logic."""
from types import SimpleNamespace
from nanobot.channels.feishu 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
-65
View File
@@ -1,65 +0,0 @@
"""Tests for FeishuChannel._resolve_mentions."""
from types import SimpleNamespace
from nanobot.channels.feishu 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"
@@ -1,76 +0,0 @@
# 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 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]
-327
View File
@@ -1,327 +0,0 @@
"""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 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
-642
View File
@@ -1,642 +0,0 @@
"""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 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
-115
View File
@@ -1,115 +0,0 @@
"""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 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]
@@ -1,214 +0,0 @@
"""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 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
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
import asyncio
import threading
from nanobot.channels._feishu_ws import FeishuWsRunner
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()
File diff suppressed because it is too large Load Diff
-992
View File
@@ -1,992 +0,0 @@
"""Tests for the Mattermost channel implementation."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.mattermost import (
MATTERMOST_MAX_MESSAGE_LEN,
MattermostChannel,
MattermostConfig,
)
from nanobot.pairing import PAIRING_CODE_META_KEY
class _FakeHTTPClient:
"""Mock httpx.AsyncClient that records calls and returns canned responses."""
def __init__(self) -> None:
self.get_calls: list[dict[str, Any]] = []
self.post_calls: list[dict[str, Any]] = []
self.put_calls: list[dict[str, Any]] = []
self.delete_calls: list[dict[str, Any]] = []
self._get_responses: dict[str, Any] = {}
self._post_responses: dict[str, Any] = {}
self._put_responses: dict[str, Any] = {}
self._delete_status: int | None = None
def _req(self, method: str, path: str) -> httpx.Request:
return httpx.Request(method, f"https://chat.example.com{path}")
def _resp(self, status: int, json_data: Any, method: str = "GET", path: str = "/") -> httpx.Response:
return httpx.Response(status, json=json_data, request=self._req(method, path))
def set_get_response(self, path: str, data: Any) -> None:
self._get_responses[path] = data
def set_post_response(self, path: str, data: Any) -> None:
self._post_responses[path] = data
def set_put_response(self, path: str, data: Any) -> None:
self._put_responses[path] = data
def set_delete_status(self, status: int) -> None:
self._delete_status = status
async def get(self, path: str, **kwargs) -> httpx.Response:
self.get_calls.append({"path": path, **kwargs})
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
return self._resp(200, data, "GET", path)
async def post(self, path: str, *, json: dict[str, Any] | None = None, data: Any = None, files: Any = None, **kwargs) -> httpx.Response:
call: dict[str, Any] = {"path": path}
if json is not None:
call["json"] = json
if data is not None:
call["data"] = data
if files is not None:
call["files"] = files
self.post_calls.append(call)
data = self._post_responses.get(path, {"id": "new_id"})
return self._resp(201, data, "POST", path)
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
self.put_calls.append({"path": path, "json": json})
data = self._put_responses.get(path, {"id": path.split("/")[-1]})
return self._resp(200, data, "PUT", path)
async def delete(self, path: str, **kwargs) -> httpx.Response:
self.delete_calls.append({"path": path})
status = self._delete_status if self._delete_status is not None else 200
return self._resp(status, {}, "DELETE", path)
async def aclose(self) -> None:
pass
def _make_channel(
overrides: dict[str, Any] | None = None,
bus: MessageBus | None = None,
) -> tuple[MattermostChannel, _FakeHTTPClient]:
config_dict: dict[str, Any] = {
"enabled": True,
"serverUrl": "https://chat.example.com",
"token": "test_token",
**(overrides or {}),
}
config = MattermostConfig.model_validate(config_dict)
if bus is None:
bus = MessageBus()
channel = MattermostChannel(config, bus)
fake = _FakeHTTPClient()
fake.set_get_response("/api/v4/users/me", {
"id": "botuserid123",
"username": "nanobot",
"email": "bot@example.com",
})
fake.set_post_response("/api/v4/posts", {"id": "post_new_id"})
channel._http_client = fake
return channel, fake
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
def test_config_defaults():
config = MattermostConfig()
assert config.enabled is False
assert config.server_url == ""
assert config.token == ""
assert config.streaming is True
assert config.streaming_max_chars == 16000
assert config.dm.enabled is True
assert config.dm.policy == "open"
assert config.reply_in_thread is True
def test_config_camelcase_aliases():
raw = {
"serverUrl": "https://mm.example.com",
"token": "abc123",
"allowFromMatchMode": "username",
"streamingMaxChars": 8000,
"replyInThread": False,
}
config = MattermostConfig.model_validate(raw)
assert config.server_url == "https://mm.example.com"
assert config.token == "abc123"
assert config.allow_from_match_mode == "username"
assert config.streaming_max_chars == 8000
assert config.reply_in_thread is False
def test_config_default_config_classmethod():
d = MattermostChannel.default_config()
assert d["enabled"] is False
assert d["serverUrl"] == ""
assert d["token"] == ""
# ---------------------------------------------------------------------------
# Self-identification on start
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_identifies_bot():
channel, fake = _make_channel({"serverUrl": "https://chat.example.com", "token": "tok"})
calls_before = len(fake.get_calls)
async def fake_listen_loop():
while channel._running:
await asyncio.sleep(0.01)
with patch.object(channel, "_ws_listen_loop", fake_listen_loop):
start_task = asyncio.create_task(channel.start())
for _ in range(50):
if channel._self_id:
break
await asyncio.sleep(0.01)
assert channel._self_id == "botuserid123"
assert channel._self_username == "nanobot"
assert channel._self_email == "bot@example.com"
assert not start_task.done()
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
assert len(user_me_calls) == 1
await channel.stop()
try:
await start_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_start_missing_config():
channel, fake = _make_channel({"serverUrl": "", "token": ""})
await channel.start()
assert channel._self_id is None
# ---------------------------------------------------------------------------
# Server URL normalization
# ---------------------------------------------------------------------------
def test_server_url_normalization():
config = MattermostConfig.model_validate({
"serverUrl": "https://chat.example.com/",
"token": "tok",
})
channel = MattermostChannel(config, MessageBus())
assert channel._server_url == "https://chat.example.com"
assert "/api/v4/websocket" in channel._ws_url
assert channel._ws_url.startswith("wss://")
def test_server_url_no_trailing_slash():
config = MattermostConfig.model_validate({
"serverUrl": "https://chat.example.com",
"token": "tok",
})
channel = MattermostChannel(config, MessageBus())
assert channel._server_url == "https://chat.example.com"
# ---------------------------------------------------------------------------
# Inbound routing: posted event
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_posted_event_routes_to_handle_message():
channel, fake = _make_channel()
channel._self_id = "botuserid123"
channel._self_username = "nanobot"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "posted",
"data": {
"channel_type": "D",
"post": json.dumps({
"id": "post_abc",
"user_id": "user_42",
"channel_id": "chan_1",
"message": "hello",
"root_id": "",
}),
},
"broadcast": {"channel_id": "chan_1", "team_id": ""},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_awaited_once()
args, kwargs = mock_handle.call_args
assert kwargs["sender_id"] == "user_42"
assert kwargs["chat_id"] == "chan_1"
assert kwargs["content"] == "hello"
assert kwargs["is_dm"] is True
@pytest.mark.asyncio
async def test_posted_event_self_message_ignored():
channel, fake = _make_channel()
channel._self_id = "bot_id"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "posted",
"data": {
"channel_type": "D",
"post": json.dumps({
"id": "p1", "user_id": "bot_id",
"channel_id": "c1", "message": "ignore me", "root_id": "",
}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_not_awaited()
@pytest.mark.asyncio
async def test_posted_event_channel_type_detection():
channel, fake = _make_channel()
channel._self_id = "bot_id"
for code, expected_dm in [("D", True), ("O", False), ("P", False), ("G", False)]:
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
with patch.object(channel, "_should_respond_in_channel", return_value=True):
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
ws_msg = {
"event": "posted",
"data": {
"channel_type": code,
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "c1", "message": "hi", "root_id": "",
}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_called_once()
assert mock_handle.call_args[1]["is_dm"] == expected_dm
# ---------------------------------------------------------------------------
# Bot @mention stripping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_strip_bot_mention_from_incoming():
channel, fake = _make_channel()
channel._self_id = "bot_id"
channel._self_username = "nanobot"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
with patch.object(channel, "_should_respond_in_channel", return_value=True):
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "c1", "message": "@nanobot hello there", "root_id": "",
}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
assert mock_handle.call_args[1]["content"] == "hello there"
# ---------------------------------------------------------------------------
# DM policy: open / allowlist
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dm_policy_open():
channel, fake = _make_channel({"dm": {"policy": "open"}})
result = await channel._is_allowed("any_user", "dm_chan", "dm")
assert result is True
@pytest.mark.asyncio
async def test_dm_policy_allowlist_match():
channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["user_1", "user_2"]}})
assert await channel._is_allowed("user_1", "dm_chan", "dm") is True
assert await channel._is_allowed("user_3", "dm_chan", "dm") is False
@pytest.mark.asyncio
async def test_dm_disabled():
channel, fake = _make_channel({"dm": {"enabled": False}})
assert await channel._is_allowed("u1", "dm_chan", "dm") is False
# ---------------------------------------------------------------------------
# Group policy: mention / open / allowlist
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_group_policy_mention():
channel, fake = _make_channel({"groupPolicy": "mention"})
channel._self_username = "nanobot"
assert channel._should_respond_in_channel("hello", "c1") is False
assert channel._should_respond_in_channel("@nanobot hello", "c1") is True
@pytest.mark.asyncio
async def test_group_policy_open():
channel, fake = _make_channel({"groupPolicy": "open"})
assert channel._should_respond_in_channel("anything", "c1") is True
@pytest.mark.asyncio
async def test_group_policy_allowlist():
channel, fake = _make_channel({"groupPolicy": "allowlist", "groupAllowFrom": ["c1"]})
assert channel._should_respond_in_channel("msg", "c1") is True
assert channel._should_respond_in_channel("msg", "c2") is False
# ---------------------------------------------------------------------------
# Match mode: id / username / email
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_match_mode_id():
channel, fake = _make_channel({"allowFromMatchMode": "id", "allowFrom": ["u1", "u2"]})
assert await channel._match_sender("u1", ["u1", "u2"]) is True
assert await channel._match_sender("u3", ["u1", "u2"]) is False
@pytest.mark.asyncio
async def test_match_mode_username():
channel, fake = _make_channel({"allowFromMatchMode": "username", "allowFrom": ["alice"]})
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": "alice@x.com"})
assert await channel._match_sender("u1", ["alice"]) is True
assert await channel._match_sender("u2", ["alice"]) is False
@pytest.mark.asyncio
async def test_match_mode_email():
channel, fake = _make_channel({"allowFromMatchMode": "email", "allowFrom": ["alice@x.com"]})
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": "alice@x.com"})
assert await channel._match_sender("u1", ["alice@x.com"]) is True
assert await channel._match_sender("u2", ["alice@x.com"]) is False
# ---------------------------------------------------------------------------
# Identity cache
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_identity_cache_username():
channel, fake = _make_channel({"allowFromMatchMode": "username", "allowFrom": ["alice"]})
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": ""})
calls_before = len(fake.get_calls)
await channel._match_sender("u1", ["alice"])
assert len(fake.get_calls) == calls_before + 1
await channel._match_sender("u1", ["alice"])
assert len(fake.get_calls) == calls_before + 1
# ---------------------------------------------------------------------------
# Send
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_creates_post():
channel, fake = _make_channel()
channel._self_id = "bot_id"
msg = OutboundMessage(
channel="mattermost",
chat_id="chan_1",
content="hello world",
)
await channel.send(msg)
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["channel_id"] == "chan_1"
assert posts[0]["json"]["message"] == "hello world"
@pytest.mark.asyncio
async def test_send_with_file_upload():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/files", {
"file_infos": [{"id": "file_abc", "name": "test.txt"}],
})
with patch("nanobot.channels.mattermost.Path.exists", return_value=True):
with patch("nanobot.channels.mattermost.Path.read_bytes", return_value=b"data"):
msg = OutboundMessage(
channel="mattermost",
chat_id="chan_1",
content="with file",
media=["/tmp/test.txt"],
)
await channel.send(msg)
file_uploads = [c for c in fake.post_calls if c["path"] == "/api/v4/files"]
assert len(file_uploads) == 1
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["file_ids"] == ["file_abc"]
@pytest.mark.asyncio
async def test_send_with_thread_root_id():
channel, fake = _make_channel({"replyInThread": True})
channel._self_id = "bot_id"
msg = OutboundMessage(
channel="mattermost",
chat_id="chan_1",
content="reply in thread",
metadata={"mattermost": {"root_id": "root_42"}},
)
await channel.send(msg)
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["root_id"] == "root_42"
@pytest.mark.asyncio
async def test_send_reaction_on_completion():
channel, fake = _make_channel()
channel._self_id = "bot_id"
msg = OutboundMessage(
channel="mattermost",
chat_id="chan_1",
content="done",
metadata={"message_id": "orig_post_1"},
)
await channel.send(msg)
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions"]
assert len(reactions) == 1
assert reactions[0]["json"]["emoji_name"] == "white_check_mark"
# ---------------------------------------------------------------------------
# Streaming
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_first_delta_creates_post():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 0
assert channel._stream_buffers["s1"] == "Hello"
assert channel._stream_committed["s1"] == "Hello"
@pytest.mark.asyncio
async def test_stream_subsequent_delta_edits_post():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
assert channel._stream_buffers["s1"] == "Hello"
await channel.send_delta("chan_1", " world", {"_stream_id": "s1"})
edits = [c for c in fake.put_calls if c["path"] == "/api/v4/posts/stream_post_1"]
assert len(edits) == 0
assert channel._stream_buffers["s1"] == "Hello world"
@pytest.mark.asyncio
async def test_stream_end_adds_done_emoji():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
await channel.send_delta("chan_1", "", {"_stream_id": "s1", "_stream_end": True})
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions" and c["json"]["emoji_name"] == "white_check_mark"]
assert len(reactions) >= 1
assert channel._stream_posts.get("s1") is None
@pytest.mark.asyncio
async def test_stream_chunk_boundary_finalizes_and_creates_new():
channel, fake = _make_channel({"streamingMaxChars": 10})
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
await channel.send_delta("chan_1", "Hello ", {"_stream_id": "s1"})
await channel.send_delta("chan_1", "world", {"_stream_id": "s1"})
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 0
assert channel._stream_buffers["s1"] == "Hello world"
@pytest.mark.asyncio
async def test_stream_end_keyword_resuming_does_not_post_or_mark_done():
channel, fake = _make_channel()
channel._self_id = "bot_id"
await channel.send_delta("chan_1", "Working", stream_id="s1")
await channel.send_delta(
"chan_1",
"",
{"message_id": "orig_post_1"},
stream_id="s1",
stream_end=True,
resuming=True,
)
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions"]
assert posts == []
assert reactions == []
assert "s1" not in channel._stream_buffers
@pytest.mark.asyncio
async def test_stream_end_failure_keeps_buffer_for_retry():
channel, fake = _make_channel()
channel._self_id = "bot_id"
await channel.send_delta("chan_1", "final answer", stream_id="s1")
async def fail_create_post(*args, **kwargs):
raise RuntimeError("network down")
channel._create_post = fail_create_post
with pytest.raises(RuntimeError):
await channel.send_delta("chan_1", "", stream_id="s1", stream_end=True)
assert channel._stream_buffers["s1"] == "final answer"
assert channel._stream_committed["s1"] == "final answer"
@pytest.mark.asyncio
async def test_coalesced_stream_end_posts_inline_content():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
await channel.send_delta(
"chan_1",
"coalesced final",
{"mattermost": {"root_id": "root_1"}},
stream_id="s1",
stream_end=True,
)
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
assert len(posts) == 1
assert posts[0]["json"]["message"] == "coalesced final"
assert posts[0]["json"]["root_id"] == "root_1"
# ---------------------------------------------------------------------------
# Reactions
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reaction_add_on_receipt():
channel, fake = _make_channel()
channel._self_id = "bot_id"
await channel._add_reaction("chan_1", "post_1", "eyes")
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions"]
assert len(reactions) >= 1
assert reactions[-1]["json"]["emoji_name"] == "eyes"
@pytest.mark.asyncio
async def test_reaction_remove():
channel, fake = _make_channel()
channel._self_id = "bot_id"
await channel._remove_reaction("post_1", "eyes")
assert len(fake.delete_calls) >= 1
assert "post_1" in fake.delete_calls[-1]["path"]
assert "eyes" in fake.delete_calls[-1]["path"]
# ---------------------------------------------------------------------------
# Team filtering
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_team_filtering_rejects_wrong_team():
channel, fake = _make_channel({"teamId": "team_a"})
channel._self_id = "bot_id"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "c1", "message": "hi", "root_id": "",
}),
},
"broadcast": {"channel_id": "c1", "team_id": "team_b"},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_not_awaited()
@pytest.mark.asyncio
async def test_team_filtering_allows_correct_team():
channel, fake = _make_channel({"teamId": "team_a"})
channel._self_id = "bot_id"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
with patch.object(channel, "_should_respond_in_channel", return_value=True):
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "c1", "message": "hi", "root_id": "",
}),
},
"broadcast": {"channel_id": "c1", "team_id": "team_a"},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_awaited_once()
@pytest.mark.asyncio
async def test_team_filtering_dm_bypass():
channel, fake = _make_channel({"teamId": "team_a"})
channel._self_id = "bot_id"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "posted",
"data": {
"channel_type": "D",
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "dm_chan", "message": "hi", "root_id": "",
}),
},
"broadcast": {"channel_id": "dm_chan", "team_id": ""},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_awaited_once()
@pytest.mark.asyncio
async def test_team_filtering_resolves_missing_broadcast_team_and_rejects_wrong_team():
channel, fake = _make_channel({"teamId": "team_a"})
channel._self_id = "bot_id"
fake.set_get_response("/api/v4/channels/c1", {
"id": "c1",
"type": "O",
"team_id": "team_b",
})
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "p1", "user_id": "u1",
"channel_id": "c1", "message": "hi", "root_id": "",
}),
},
"broadcast": {"channel_id": "c1"},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_not_awaited()
# ---------------------------------------------------------------------------
# Thread session key
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_thread_session_key():
channel, fake = _make_channel()
channel._self_id = "bot_id"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
with patch.object(channel, "_should_respond_in_channel", return_value=True):
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "post_1", "user_id": "u1",
"channel_id": "c1", "message": "in thread",
"root_id": "root_99",
}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
kwargs = mock_handle.call_args[1]
assert kwargs["session_key"] == "mattermost:c1:root_99"
@pytest.mark.asyncio
async def test_top_level_mention_uses_thread_session_key():
channel, fake = _make_channel({"replyInThread": True})
channel._self_id = "bot_id"
channel._self_username = "nanobot"
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
ws_msg = {
"event": "posted",
"data": {
"channel_type": "O",
"post": json.dumps({
"id": "post_1", "user_id": "u1",
"channel_id": "c1", "message": "@nanobot start thread",
"root_id": "",
}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
kwargs = mock_handle.call_args[1]
assert kwargs["session_key"] == "mattermost:c1:post_1"
assert kwargs["metadata"]["mattermost"]["thread_ts"] == "post_1"
# ---------------------------------------------------------------------------
# Action event (interactive buttons)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_action_event():
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_get_response("/api/v4/channels/c1", {"id": "c1", "type": "O"})
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "action",
"data": {
"user_id": "u1",
"channel_id": "c1",
"context": {"selected_option": "Approve"},
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_awaited_once_with(
sender_id="u1",
chat_id="c1",
content="Approve",
metadata={"mattermost": {"channel_type": "public", "is_action": True}},
)
@pytest.mark.asyncio
async def test_action_event_denied_dm():
channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["u_other"]}})
channel._self_id = "bot_id"
fake.set_get_response("/api/v4/channels/c1", {"id": "c1", "type": "D"})
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "action",
"data": {
"user_id": "u1",
"channel_id": "c1",
"context": {"selected_option": "Approve"},
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_not_awaited()
@pytest.mark.asyncio
async def test_action_event_rejects_wrong_team():
channel, fake = _make_channel({"teamId": "team_a"})
channel._self_id = "bot_id"
fake.set_get_response("/api/v4/channels/c1", {
"id": "c1",
"type": "O",
"team_id": "team_b",
})
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
ws_msg = {
"event": "action",
"data": {
"user_id": "u1",
"channel_id": "c1",
"context": {"selected_option": "Approve"},
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
mock_handle.assert_not_awaited()
# ---------------------------------------------------------------------------
# Post deleted event
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_deleted_cleans_stream_state():
channel, fake = _make_channel()
channel._stream_posts["s1"] = "del_post_1"
channel._stream_posts["s2"] = "keep_post_2"
ws_msg = {
"event": "post_deleted",
"data": {
"channel_id": "c1",
"post": json.dumps({"id": "del_post_1", "delete_at": 123}),
},
"broadcast": {},
}
await channel._handle_ws_message(ws_msg)
assert "s1" not in channel._stream_posts
assert channel._stream_posts["s2"] == "keep_post_2"
# ---------------------------------------------------------------------------
# Auth failure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_auth_failure_prevents_start():
channel, fake = _make_channel()
fake.set_get_response("/api/v4/users/me", {"id": "", "username": ""})
with patch.object(fake, "get", side_effect=Exception("401 Unauthorized")):
await channel.start()
assert channel._self_id is None
# ---------------------------------------------------------------------------
# DM allowlist with match mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dm_allowlist_with_username_match():
channel, fake = _make_channel({
"allowFromMatchMode": "username",
"dm": {"policy": "allowlist", "allowFrom": ["alice"]},
})
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": ""})
assert await channel._is_allowed("u1", "dm_chan", "dm") is True
assert await channel._is_allowed("u2", "dm_chan", "dm") is False
@pytest.mark.asyncio
async def test_dm_allowlist_accepts_pairing_approval():
channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["u_allowed"]}})
with patch("nanobot.channels.mattermost.is_approved", return_value=True):
assert await channel._is_allowed("u_paired", "dm_chan", "dm") is True
# ---------------------------------------------------------------------------
# Denied DM sends pairing code (not empty message)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_denied_dm_sends_pairing_not_empty_inbound():
channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["u_allowed"]}})
channel._self_id = "botuserid123"
channel._self_username = "nanobot"
ws_msg = {
"event": "posted",
"data": {
"channel_type": "D",
"post": json.dumps({
"id": "p1", "user_id": "u_denied",
"channel_id": "dm_chan", "message": "hello", "root_id": "",
}),
},
"broadcast": {"channel_id": "dm_chan", "team_id": ""},
}
inbound_events = []
channel.bus.publish_inbound = AsyncMock(side_effect=lambda e: inbound_events.append(e))
with patch.object(channel, "send", AsyncMock()) as mock_send:
await channel._handle_ws_message(ws_msg)
mock_send.assert_awaited_once()
sent = mock_send.call_args[0][0]
assert sent.channel == "mattermost"
assert sent.chat_id == "dm_chan"
assert "pairing" in sent.content.lower() or "code" in sent.content.lower()
assert PAIRING_CODE_META_KEY in (sent.metadata or {})
assert len(inbound_events) == 0
# ---------------------------------------------------------------------------
# Bot mention boundary
# ---------------------------------------------------------------------------
def test_is_mentioned_exact():
channel, fake = _make_channel({"groupPolicy": "mention"})
channel._self_username = "nanobot"
assert channel._is_mentioned("hello @nanobot how are you") is True
assert channel._is_mentioned("hello @nanobotty") is False
assert channel._is_mentioned("@nanobot_extra") is False
assert channel._is_mentioned("plain text") is False
def test_is_mentioned_no_username():
channel, fake = _make_channel({"groupPolicy": "mention"})
channel._self_username = None
assert channel._is_mentioned("hello @nanobot") is False
# ---------------------------------------------------------------------------
# split_message helper
# ---------------------------------------------------------------------------
def test_message_splitting():
from nanobot.utils.helpers import split_message
short = "short message"
assert split_message(short, MATTERMOST_MAX_MESSAGE_LEN) == [short]
long_text = "A" * (MATTERMOST_MAX_MESSAGE_LEN + 100)
chunks = split_message(long_text, MATTERMOST_MAX_MESSAGE_LEN)
assert all(len(c) <= MATTERMOST_MAX_MESSAGE_LEN for c in chunks)
assert "".join(chunks) == long_text
-183
View File
@@ -1,183 +0,0 @@
import asyncio
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.napcat import NapcatChannel, NapcatConfig
class _FakeWs:
def __init__(self) -> None:
self.sent: list[str] = []
async def send(self, payload: str) -> None:
self.sent.append(payload)
async def close(self) -> None:
pass
class _FakeContent:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
async def iter_chunked(self, _size: int):
for chunk in self._chunks:
yield chunk
class _FakeResponse:
def __init__(self, status: int, chunks: list[bytes] | None = None) -> None:
self.status = status
self.content = _FakeContent(chunks or [])
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class _FakeHttp:
def __init__(self, response: _FakeResponse) -> None:
self.response = response
self.calls: list[dict] = []
def get(self, url: str, **kwargs):
self.calls.append({"url": url, "kwargs": kwargs})
return self.response
def _channel(config: NapcatConfig | None = None) -> NapcatChannel:
return NapcatChannel(config or NapcatConfig(allow_from=["*"]), MessageBus())
@pytest.mark.asyncio
async def test_send_raises_while_websocket_is_not_connected() -> None:
channel = _channel()
with pytest.raises(RuntimeError, match="not connected"):
await channel.send(
OutboundMessage(channel="napcat", chat_id="private:123", content="hello")
)
@pytest.mark.asyncio
async def test_group_message_requires_mention_by_default() -> None:
channel = _channel(NapcatConfig(allow_from=["user1"], group_policy="mention"))
channel._self_id = 42
await channel._on_message(
{
"message_id": 1,
"message_type": "group",
"group_id": 100,
"user_id": "user1",
"sender": {"nickname": "Alice"},
"message": [{"type": "text", "data": {"text": "hello"}}],
}
)
assert channel.bus.inbound_size == 0
@pytest.mark.asyncio
async def test_group_mention_routes_with_sender_label() -> None:
channel = _channel(NapcatConfig(allow_from=["user1"], group_policy="mention"))
channel._self_id = 42
await channel._on_message(
{
"message_id": 1,
"message_type": "group",
"group_id": 100,
"user_id": "user1",
"sender": {"card": "Alice"},
"message": [
{"type": "at", "data": {"qq": "42"}},
{"type": "text", "data": {"text": "hello"}},
],
}
)
msg = await channel.bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "group:100"
assert msg.content == "Alice: hello"
assert msg.metadata["message_id"] == 1
@pytest.mark.asyncio
async def test_call_action_raises_on_onebot_failure_and_clears_pending() -> None:
channel = _channel()
channel._ws = _FakeWs()
task = asyncio.create_task(channel._call_action("send_msg", {"message": []}))
while not channel._pending:
await asyncio.sleep(0)
fut = next(iter(channel._pending.values()))
fut.set_result({"status": "failed", "retcode": 1400, "wording": "bad request"})
with pytest.raises(RuntimeError, match="action send_msg failed"):
await task
assert channel._pending == {}
@pytest.mark.asyncio
async def test_notice_with_invalid_ids_is_ignored(monkeypatch) -> None:
channel = _channel()
async def fail_lookup(*_args, **_kwargs):
raise AssertionError("lookup should not be called for invalid ids")
monkeypatch.setattr(channel, "_lookup_member_name", fail_lookup)
await channel._on_notice(
{
"notice_type": "group_increase",
"group_id": "not-an-int",
"user_id": "user1",
}
)
assert channel.bus.inbound_size == 0
@pytest.mark.asyncio
async def test_download_image_rejects_redirects(tmp_path, monkeypatch) -> None:
channel = _channel()
channel._media_root = tmp_path
channel._http = _FakeHttp(_FakeResponse(status=302))
monkeypatch.setattr(
"nanobot.channels.napcat.validate_url_target",
lambda _url: (True, ""),
)
result = await channel._download_image({"url": "https://example.com/a.png", "file": "a.png"})
assert result is None
assert channel._http.calls == [
{"url": "https://example.com/a.png", "kwargs": {"allow_redirects": False}}
]
assert list(tmp_path.iterdir()) == []
@pytest.mark.asyncio
async def test_dispatch_tracks_and_discards_background_tasks() -> None:
channel = _channel()
seen = asyncio.Event()
async def fake_on_message(_payload):
seen.set()
channel._on_message = fake_on_message
await channel._dispatch_frame(
'{"post_type":"message","message_type":"private","user_id":"user1","message":"hi"}'
)
assert len(channel._background_tasks) == 1
await asyncio.wait_for(seen.wait(), timeout=1)
await asyncio.sleep(0)
assert channel._background_tasks == set()
-172
View File
@@ -1,172 +0,0 @@
"""Tests for QQ channel ack_message feature.
Covers the four verification points from the PR:
1. C2C message: ack appears instantly
2. Group message: ack appears instantly
3. ack_message set to "": no ack sent
4. Custom ack_message text: correct text delivered
Each test also verifies that normal message processing is not blocked.
"""
from types import SimpleNamespace
import pytest
try:
from nanobot.channels import qq
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
except ImportError:
QQ_AVAILABLE = False
if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
from nanobot.bus.queue import MessageBus
from nanobot.channels.qq import QQChannel, QQConfig
class _FakeApi:
def __init__(self) -> None:
self.c2c_calls: list[dict] = []
self.group_calls: list[dict] = []
async def post_c2c_message(self, **kwargs) -> None:
self.c2c_calls.append(kwargs)
async def post_group_message(self, **kwargs) -> None:
self.group_calls.append(kwargs)
class _FakeClient:
def __init__(self) -> None:
self.api = _FakeApi()
@pytest.mark.asyncio
async def test_ack_sent_on_c2c_message() -> None:
"""Ack is sent immediately for C2C messages, then normal processing continues."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="⏳ Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg1",
content="hello",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) >= 1
ack_call = channel._client.api.c2c_calls[0]
assert ack_call["content"] == "⏳ Processing..."
assert ack_call["openid"] == "user1"
assert ack_call["msg_id"] == "msg1"
assert ack_call["msg_type"] == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello"
assert msg.sender_id == "user1"
@pytest.mark.asyncio
async def test_ack_sent_on_group_message() -> None:
"""Ack is sent immediately for group messages, then normal processing continues."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="⏳ Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg2",
content="hello group",
group_openid="group123",
author=SimpleNamespace(member_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=True)
assert len(channel._client.api.group_calls) >= 1
ack_call = channel._client.api.group_calls[0]
assert ack_call["content"] == "⏳ Processing..."
assert ack_call["group_openid"] == "group123"
assert ack_call["msg_id"] == "msg2"
assert ack_call["msg_type"] == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello group"
assert msg.chat_id == "group123"
@pytest.mark.asyncio
async def test_no_ack_when_ack_message_empty() -> None:
"""Setting ack_message to empty string disables the ack entirely."""
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message="",
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg3",
content="hello",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) == 0
assert len(channel._client.api.group_calls) == 0
msg = await channel.bus.consume_inbound()
assert msg.content == "hello"
@pytest.mark.asyncio
async def test_custom_ack_message_text() -> None:
"""Custom Chinese ack_message text is delivered correctly."""
custom = "正在处理中,请稍候..."
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["*"],
ack_message=custom,
),
MessageBus(),
)
channel._client = _FakeClient()
data = SimpleNamespace(
id="msg4",
content="test input",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
assert len(channel._client.api.c2c_calls) >= 1
ack_call = channel._client.api.c2c_calls[0]
assert ack_call["content"] == custom
msg = await channel.bus.consume_inbound()
assert msg.content == "test input"
-438
View File
@@ -1,438 +0,0 @@
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
# Check optional QQ dependencies before running tests
try:
from nanobot.channels import qq
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
except ImportError:
QQ_AVAILABLE = False
if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
import aiohttp
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.qq import QQChannel, QQConfig
class _FakeApi:
def __init__(self) -> None:
self.c2c_calls: list[dict] = []
self.group_calls: list[dict] = []
async def post_c2c_message(self, **kwargs) -> None:
self.c2c_calls.append(kwargs)
async def post_group_message(self, **kwargs) -> None:
self.group_calls.append(kwargs)
class _FakeClient:
def __init__(self) -> None:
self.api = _FakeApi()
@pytest.mark.asyncio
async def test_on_group_message_routes_to_group_chat_id() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["user1"]), MessageBus())
data = SimpleNamespace(
id="msg1",
content="hello",
group_openid="group123",
author=SimpleNamespace(member_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=True)
msg = await channel.bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "group123"
@pytest.mark.asyncio
async def test_on_c2c_message_passes_is_dm_true_to_base_handler() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["user1"]), MessageBus())
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-c2c",
content="hello",
author=SimpleNamespace(user_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=False)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "user1"
assert kwargs["chat_id"] == "user1"
assert kwargs["content"] == "hello"
assert kwargs["is_dm"] is True
@pytest.mark.asyncio
async def test_on_group_message_passes_is_dm_false_to_base_handler() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["user1"]), MessageBus())
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-group",
content="hello",
group_openid="group123",
author=SimpleNamespace(member_openid="user1"),
attachments=[],
)
await channel._on_message(data, is_group=True)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "user1"
assert kwargs["chat_id"] == "group123"
assert kwargs["content"] == "hello"
assert kwargs["is_dm"] is False
@pytest.mark.asyncio
async def test_send_group_message_uses_plain_text_group_api_with_msg_seq() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
channel._chat_type_cache["group123"] = "group"
await channel.send(
OutboundMessage(
channel="qq",
chat_id="group123",
content="hello",
metadata={"message_id": "msg1"},
)
)
assert len(channel._client.api.group_calls) == 1
call = channel._client.api.group_calls[0]
assert call == {
"group_openid": "group123",
"msg_type": 0,
"content": "hello",
"msg_id": "msg1",
"msg_seq": 2,
}
assert not channel._client.api.c2c_calls
@pytest.mark.asyncio
async def test_send_c2c_message_uses_plain_text_c2c_api_with_msg_seq() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user123",
content="hello",
metadata={"message_id": "msg1"},
)
)
assert len(channel._client.api.c2c_calls) == 1
call = channel._client.api.c2c_calls[0]
assert call == {
"openid": "user123",
"msg_type": 0,
"content": "hello",
"msg_id": "msg1",
"msg_seq": 2,
}
assert not channel._client.api.group_calls
@pytest.mark.asyncio
async def test_send_group_message_uses_markdown_when_configured() -> None:
channel = QQChannel(
QQConfig(app_id="app", secret="secret", allow_from=["*"], msg_format="markdown"),
MessageBus(),
)
channel._client = _FakeClient()
channel._chat_type_cache["group123"] = "group"
await channel.send(
OutboundMessage(
channel="qq",
chat_id="group123",
content="**hello**",
metadata={"message_id": "msg1"},
)
)
assert len(channel._client.api.group_calls) == 1
call = channel._client.api.group_calls[0]
assert call == {
"group_openid": "group123",
"msg_type": 2,
"markdown": {"content": "**hello**"},
"msg_id": "msg1",
"msg_seq": 2,
}
@pytest.mark.asyncio
async def test_read_media_bytes_local_path() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp_path = f.name
data, filename = await channel._read_media_bytes(tmp_path)
assert data == b"\x89PNG\r\n"
assert filename == Path(tmp_path).name
@pytest.mark.asyncio
async def test_read_media_bytes_file_uri() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(b"JFIF")
tmp_path = f.name
data, filename = await channel._read_media_bytes(f"file://{tmp_path}")
assert data == b"JFIF"
assert filename == Path(tmp_path).name
@pytest.mark.asyncio
async def test_read_media_bytes_missing_file() -> None:
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
data, filename = await channel._read_media_bytes("/nonexistent/path/image.png")
assert data is None
assert filename is None
# -------------------------------------------------------
# Tests for _send_media exception handling
# -------------------------------------------------------
def _make_channel_with_local_file(suffix: str = ".png", content: bytes = b"\x89PNG\r\n"):
"""Create a QQChannel with a fake client and a temp file for media."""
channel = QQChannel(
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
MessageBus(),
)
channel._client = _FakeClient()
channel._chat_type_cache["user1"] = "c2c"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(content)
tmp.close()
return channel, tmp.name
@pytest.mark.asyncio
async def test_send_media_network_error_propagates() -> None:
"""aiohttp.ClientError (network/transport) should re-raise, not return False."""
channel, tmp_path = _make_channel_with_local_file()
# Make the base64 upload raise a network error
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
)
with pytest.raises(aiohttp.ServerDisconnectedError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_client_connector_error_propagates() -> None:
"""aiohttp.ClientConnectorError (DNS/connection refused) should re-raise."""
channel, tmp_path = _make_channel_with_local_file()
from aiohttp.client_reqrep import ConnectionKey
conn_key = ConnectionKey("api.qq.com", 443, True, None, None, None, None)
connector_error = aiohttp.ClientConnectorError(
connection_key=conn_key,
os_error=OSError("Connection refused"),
)
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=connector_error,
)
with pytest.raises(aiohttp.ClientConnectorError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_oserror_propagates() -> None:
"""OSError (low-level I/O) should re-raise for retry."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=OSError("Network is unreachable"),
)
with pytest.raises(OSError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_media_api_error_returns_false() -> None:
"""API-level errors (botpy RuntimeError subclasses) should return False, not raise."""
channel, tmp_path = _make_channel_with_local_file()
# Simulate a botpy API error (e.g. ServerError is a RuntimeError subclass)
from botpy.errors import ServerError
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ServerError("internal server error"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_generic_runtime_error_returns_false() -> None:
"""Generic RuntimeError (not network) should return False."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=RuntimeError("some API error"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_value_error_returns_false() -> None:
"""ValueError (bad API response data) should return False."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ValueError("bad response data"),
)
result = await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
assert result is False
@pytest.mark.asyncio
async def test_send_media_timeout_error_propagates() -> None:
"""asyncio.TimeoutError inherits from Exception but not ClientError/OSError.
However, aiohttp.ServerTimeoutError IS a ClientError subclass, so that propagates.
For a plain TimeoutError (which is also OSError in Python 3.11+), it should propagate."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerTimeoutError("request timed out"),
)
with pytest.raises(aiohttp.ServerTimeoutError):
await channel._send_media(
chat_id="user1",
media_ref=tmp_path,
msg_id="msg1",
is_group=False,
)
@pytest.mark.asyncio
async def test_send_fallback_text_on_api_error() -> None:
"""When _send_media returns False (API error), send() should emit fallback text."""
channel, tmp_path = _make_channel_with_local_file()
from botpy.errors import ServerError
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=ServerError("internal server error"),
)
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="",
media=[tmp_path],
metadata={"message_id": "msg1"},
)
)
# Should have sent a fallback text message
assert len(channel._client.api.c2c_calls) == 1
fallback_content = channel._client.api.c2c_calls[0]["content"]
assert "Attachment send failed" in fallback_content
@pytest.mark.asyncio
async def test_send_propagates_network_error_no_fallback() -> None:
"""When _send_media raises a network error, send() should NOT silently fallback."""
channel, tmp_path = _make_channel_with_local_file()
channel._client.api._http = SimpleNamespace()
channel._client.api._http.request = AsyncMock(
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
)
with pytest.raises(aiohttp.ServerDisconnectedError):
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="hello",
media=[tmp_path],
metadata={"message_id": "msg1"},
)
)
# No fallback text should have been sent
assert len(channel._client.api.c2c_calls) == 0
-374
View File
@@ -1,374 +0,0 @@
"""Tests for QQ channel media support: helpers, send, inbound, and upload."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
try:
from nanobot.channels import qq
QQ_AVAILABLE = getattr(qq, "QQ_AVAILABLE", False)
except ImportError:
QQ_AVAILABLE = False
if not QQ_AVAILABLE:
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.qq import (
QQ_FILE_TYPE_FILE,
QQ_FILE_TYPE_IMAGE,
QQChannel,
QQConfig,
_guess_send_file_type,
_is_image_name,
_sanitize_filename,
)
class _FakeApi:
def __init__(self) -> None:
self.c2c_calls: list[dict] = []
self.group_calls: list[dict] = []
async def post_c2c_message(self, **kwargs) -> None:
self.c2c_calls.append(kwargs)
async def post_group_message(self, **kwargs) -> None:
self.group_calls.append(kwargs)
class _FakeHttp:
"""Fake _http for _post_base64file tests."""
def __init__(self, return_value: dict | None = None) -> None:
self.return_value = return_value or {}
self.calls: list[tuple] = []
async def request(self, route, **kwargs):
self.calls.append((route, kwargs))
return self.return_value
class _FakeClient:
def __init__(self, http_return: dict | None = None) -> None:
self.api = _FakeApi()
self.api._http = _FakeHttp(http_return)
# ── Helper function tests (pure, no async) ──────────────────────────
def test_sanitize_filename_strips_path_traversal() -> None:
assert _sanitize_filename("../../etc/passwd") == "passwd"
def test_sanitize_filename_keeps_chinese_chars() -> None:
assert _sanitize_filename("文件(1.jpg") == "文件(1.jpg"
def test_sanitize_filename_strips_unsafe_chars() -> None:
result = _sanitize_filename('file<>:"|?*.txt')
# All unsafe chars replaced with "_", but * is replaced too
assert result.startswith("file")
assert result.endswith(".txt")
assert "<" not in result
assert ">" not in result
assert '"' not in result
assert "|" not in result
assert "?" not in result
def test_sanitize_filename_empty_input() -> None:
assert _sanitize_filename("") == ""
def test_is_image_name_with_known_extensions() -> None:
for ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff", ".ico", ".svg"):
assert _is_image_name(f"photo{ext}") is True
def test_is_image_name_with_unknown_extension() -> None:
for ext in (".pdf", ".txt", ".mp3", ".mp4"):
assert _is_image_name(f"doc{ext}") is False
def test_guess_send_file_type_image() -> None:
assert _guess_send_file_type("photo.png") == QQ_FILE_TYPE_IMAGE
assert _guess_send_file_type("pic.jpg") == QQ_FILE_TYPE_IMAGE
def test_guess_send_file_type_file() -> None:
assert _guess_send_file_type("doc.pdf") == QQ_FILE_TYPE_FILE
def test_guess_send_file_type_by_mime() -> None:
# A filename with no known extension but whose mime type is image/*
assert _guess_send_file_type("photo.xyz_image_test") == QQ_FILE_TYPE_FILE
# ── send() exception handling ───────────────────────────────────────
@pytest.mark.asyncio
async def test_send_exception_propagates_for_manager_retry() -> None:
"""Delivery failures must propagate to the channel manager."""
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
with patch.object(
channel, "_send_text_only", new_callable=AsyncMock, side_effect=RuntimeError("boom")
) as send_text:
with pytest.raises(RuntimeError, match="boom"):
await channel.send(
OutboundMessage(channel="qq", chat_id="user1", content="hello")
)
send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_media_then_text() -> None:
"""Media is sent before text when both are present."""
import tempfile
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp = f.name
try:
with patch.object(channel, "_post_base64file", new_callable=AsyncMock, return_value={"file_info": "1"}) as mock_upload:
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="text after image",
media=[tmp],
metadata={"message_id": "m1"},
)
)
assert mock_upload.called
# Text should have been sent via c2c (default chat type)
text_calls = [c for c in channel._client.api.c2c_calls if c.get("msg_type") == 0]
assert len(text_calls) >= 1
assert text_calls[-1]["content"] == "text after image"
finally:
import os
os.unlink(tmp)
@pytest.mark.asyncio
async def test_send_media_failure_falls_back_to_text() -> None:
"""When _send_media returns False, a failure notice is appended."""
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
with patch.object(channel, "_send_media", new_callable=AsyncMock, return_value=False):
await channel.send(
OutboundMessage(
channel="qq",
chat_id="user1",
content="hello",
media=["https://example.com/bad.png"],
metadata={"message_id": "m1"},
)
)
# Should have the failure text among the c2c calls
failure_calls = [c for c in channel._client.api.c2c_calls if "Attachment send failed" in c.get("content", "")]
assert len(failure_calls) == 1
assert "bad.png" in failure_calls[0]["content"]
@pytest.mark.asyncio
async def test_on_message_unauthorized_c2c_pairs_before_attachments_and_ack() -> None:
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["allowed-user"],
ack_message="Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], []))
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-blocked",
content="hello",
author=SimpleNamespace(user_openid="blocked-user"),
attachments=[SimpleNamespace(filename="a.png")],
)
await channel._on_message(data, is_group=False)
channel._handle_attachments.assert_not_awaited()
channel._handle_message.assert_awaited_once_with(
sender_id="blocked-user",
chat_id="blocked-user",
content="",
is_dm=True,
)
assert channel._client.api.c2c_calls == []
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_group_before_attachments_and_ack() -> None:
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["allowed-user"],
ack_message="Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], []))
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-blocked-group",
content="hello",
group_openid="group123",
author=SimpleNamespace(member_openid="blocked-user"),
attachments=[SimpleNamespace(filename="a.png")],
)
await channel._on_message(data, is_group=True)
channel._handle_attachments.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel._client.api.c2c_calls == []
assert channel._client.api.group_calls == []
# ── _on_message() exception handling ────────────────────────────────
@pytest.mark.asyncio
async def test_on_message_exception_caught_not_raised() -> None:
"""Missing required attributes should not crash _on_message."""
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
# Construct a message-like object that lacks 'author' — triggers AttributeError
bad_data = SimpleNamespace(id="x1", content="hi")
# Should not raise
await channel._on_message(bad_data, is_group=False)
assert channel._client.api.c2c_calls == []
assert channel._client.api.group_calls == []
@pytest.mark.asyncio
async def test_on_message_with_attachments() -> None:
"""Messages with attachments produce media_paths and formatted content."""
import tempfile
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
channel._client = _FakeClient()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
saved_path = f.name
att = SimpleNamespace(url="", filename="screenshot.png", content_type="image/png")
# Patch _download_to_media_dir_chunked to return the temp file path
async def fake_download(url, filename_hint=""):
return saved_path
try:
with patch.object(channel, "_download_to_media_dir_chunked", side_effect=fake_download):
data = SimpleNamespace(
id="att1",
content="look at this",
author=SimpleNamespace(user_openid="u1"),
attachments=[att],
)
await channel._on_message(data, is_group=False)
msg = await channel.bus.consume_inbound()
assert "look at this" in msg.content
assert "screenshot.png" in msg.content
assert "Received files:" in msg.content
assert len(msg.media) == 1
assert msg.media[0] == saved_path
finally:
import os
os.unlink(saved_path)
# ── _post_base64file() ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_base64file_omits_file_name_for_images() -> None:
"""file_type=1 (image) → payload must not contain file_name."""
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
channel._client = _FakeClient(http_return={"file_info": "img_abc"})
await channel._post_base64file(
chat_id="user1",
is_group=False,
file_type=QQ_FILE_TYPE_IMAGE,
file_data="ZmFrZQ==",
file_name="photo.png",
)
http = channel._client.api._http
assert len(http.calls) == 1
payload = http.calls[0][1]["json"]
assert "file_name" not in payload
assert payload["file_type"] == QQ_FILE_TYPE_IMAGE
@pytest.mark.asyncio
async def test_post_base64file_includes_file_name_for_files() -> None:
"""file_type=4 (file) → payload must contain file_name."""
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
channel._client = _FakeClient(http_return={"file_info": "file_abc"})
await channel._post_base64file(
chat_id="user1",
is_group=False,
file_type=QQ_FILE_TYPE_FILE,
file_data="ZmFrZQ==",
file_name="report.pdf",
)
http = channel._client.api._http
assert len(http.calls) == 1
payload = http.calls[0][1]["json"]
assert payload["file_name"] == "report.pdf"
assert payload["file_type"] == QQ_FILE_TYPE_FILE
@pytest.mark.asyncio
async def test_post_base64file_filters_response_to_file_info() -> None:
"""Response with file_info + extra fields must be filtered to only file_info."""
channel = QQChannel(QQConfig(app_id="app", secret="secret"), MessageBus())
channel._client = _FakeClient(http_return={
"file_info": "fi_123",
"file_uuid": "uuid_xxx",
"ttl": 3600,
})
result = await channel._post_base64file(
chat_id="user1",
is_group=False,
file_type=QQ_FILE_TYPE_FILE,
file_data="ZmFrZQ==",
file_name="doc.pdf",
)
assert result == {"file_info": "fi_123"}
assert "file_uuid" not in result
assert "ttl" not in result
File diff suppressed because it is too large Load Diff
-525
View File
@@ -1,525 +0,0 @@
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
from nanobot.channels.signal import _markdown_to_signal, _partition_styles
from nanobot.utils.helpers import split_message
def _utf16_len(s: str) -> int:
return len(s.encode("utf-16-le")) // 2
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Return a dict mapping each styled substring to its style list."""
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = plain[start : start + length]
result.setdefault(span, []).append(style)
return result
def utf16_styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Like styles_for, but slices `plain` using UTF-16 offsets (Signal's units)."""
encoded = plain.encode("utf-16-le")
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = encoded[start * 2 : (start + length) * 2].decode("utf-16-le")
result.setdefault(span, []).append(style)
return result
# ---------------------------------------------------------------------------
# Basic cases
# ---------------------------------------------------------------------------
def test_empty():
plain, styles = _markdown_to_signal("")
assert plain == ""
assert styles == []
def test_plain_text():
plain, styles = _markdown_to_signal("hello world")
assert plain == "hello world"
assert styles == []
def test_bold_stars():
plain, styles = _markdown_to_signal("say **hello** now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_bold_underscores():
plain, styles = _markdown_to_signal("say __hello__ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_italic_star():
plain, styles = _markdown_to_signal("say *hello* now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_italic_underscore():
plain, styles = _markdown_to_signal("say _hello_ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_strikethrough():
plain, styles = _markdown_to_signal("say ~~hello~~ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
# ---------------------------------------------------------------------------
# Code
# ---------------------------------------------------------------------------
def test_inline_code():
plain, styles = _markdown_to_signal("run `ls -la` here")
assert plain == "run ls -la here"
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
def test_code_block():
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
assert "print('hi')" in plain
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or "MONOSPACE" in str(
styles_for(plain, styles)
)
def test_code_block_with_lang():
plain, styles = _markdown_to_signal("```python\ncode\n```")
assert "code" in plain
assert any("MONOSPACE" in s for s in styles)
def test_code_block_not_processed_further():
"""Markdown inside a code block must not be styled."""
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
assert "**not bold**" in plain
# Only MONOSPACE should be applied, no BOLD
for entry in styles:
assert "BOLD" not in entry
def test_inline_code_not_processed_further():
"""Markdown inside inline code must not be styled."""
plain, styles = _markdown_to_signal("use `**raw**` please")
assert "**raw**" in plain
for entry in styles:
assert "BOLD" not in entry
# ---------------------------------------------------------------------------
# Headers
# ---------------------------------------------------------------------------
def test_header_becomes_bold():
plain, styles = _markdown_to_signal("# My Title")
assert plain == "My Title"
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
def test_h2_becomes_bold():
plain, styles = _markdown_to_signal("## Sub-section")
assert plain == "Sub-section"
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
# ---------------------------------------------------------------------------
# Blockquotes
# ---------------------------------------------------------------------------
def test_blockquote_strips_marker():
plain, styles = _markdown_to_signal("> some quote")
assert plain == "some quote"
assert styles == []
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
def test_bullet_dash():
plain, styles = _markdown_to_signal("- item one")
assert plain == "• item one"
def test_bullet_star():
plain, styles = _markdown_to_signal("* item two")
assert plain == "• item two"
def test_numbered_list():
plain, styles = _markdown_to_signal("1. first\n2. second")
assert "1. first" in plain
assert "2. second" in plain
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
def test_link_text_differs_from_url():
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
assert plain == "Click here (https://example.com)"
assert styles == []
def test_link_text_equals_url():
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
assert plain == "https://example.com"
assert styles == []
def test_link_text_equals_url_without_scheme():
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
assert plain == "https://example.com"
# ---------------------------------------------------------------------------
# Mixed / nesting
# ---------------------------------------------------------------------------
def test_bold_and_italic_adjacent():
plain, styles = _markdown_to_signal("**bold** and *italic*")
assert plain == "bold and italic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_header_with_inline_code():
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
plain, styles = _markdown_to_signal("# Use `grep`")
assert plain == "Use grep"
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
assert "MONOSPACE" in sd.get("grep", [])
def test_multiline_mixed():
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
plain, styles = _markdown_to_signal(md)
assert "Title" in plain
assert "italic" in plain
assert "• bullet" in plain
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Title", [])
assert "ITALIC" in sd.get("italic", [])
# ---------------------------------------------------------------------------
# Table rendering
# ---------------------------------------------------------------------------
def test_table_rendered_as_monospace():
md = "| A | B |\n| - | - |\n| 1 | 2 |"
plain, styles = _markdown_to_signal(md)
assert "A" in plain and "B" in plain
assert any("MONOSPACE" in s for s in styles)
# ---------------------------------------------------------------------------
# Style range format
# ---------------------------------------------------------------------------
def test_style_range_format():
"""Each style entry must be 'start:length:STYLE'."""
_, styles = _markdown_to_signal("**bold** text")
for entry in styles:
parts = entry.split(":")
assert len(parts) == 3
assert parts[0].isdigit()
assert parts[1].isdigit()
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
def test_style_ranges_are_within_bounds():
text = "hello **world** end"
plain, styles = _markdown_to_signal(text)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= len(plain)
# ---------------------------------------------------------------------------
# Non-BMP / UTF-16 offsets
#
# Signal's BodyRange (and signal-cli's textStyle) interprets start/length in
# UTF-16 code units. Python's len() counts code points, so characters outside
# the BMP (emojis, supplementary CJK) shift offsets by +1 per occurrence.
# ---------------------------------------------------------------------------
def assert_within_utf16_bounds(plain: str, styles: list[str]) -> None:
limit = _utf16_len(plain)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= limit, f"range {entry} exceeds utf-16 length {limit} of {plain!r}"
def test_bold_with_emoji_inside():
plain, styles = _markdown_to_signal("**hi 🎉 bye**")
assert plain == "hi 🎉 bye"
assert utf16_styles_for(plain, styles) == {"hi 🎉 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_italic_with_trailing_emoji():
plain, styles = _markdown_to_signal("*bye 🎉*")
assert plain == "bye 🎉"
assert utf16_styles_for(plain, styles) == {"bye 🎉": ["ITALIC"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_emoji_prefix():
plain, styles = _markdown_to_signal("🎉 **bold**")
assert plain == "🎉 bold"
assert utf16_styles_for(plain, styles) == {"bold": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_and_inside_emoji():
plain, styles = _markdown_to_signal("🎉 **a 🎊 b**")
assert plain == "🎉 a 🎊 b"
assert utf16_styles_for(plain, styles) == {"a 🎊 b": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_supplementary_cjk_in_bold():
"""Non-BMP CJK (U+20BB7) proves the bug is UTF-16, not emoji-specific."""
plain, styles = _markdown_to_signal("**𠮷野家**")
assert plain == "𠮷野家"
assert utf16_styles_for(plain, styles) == {"𠮷野家": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_zwj_emoji_in_bold():
"""ZWJ family sequence = multiple surrogate pairs + BMP ZWJs."""
plain, styles = _markdown_to_signal("**hi 👨‍👩‍👧 bye**")
assert plain == "hi 👨‍👩‍👧 bye"
assert utf16_styles_for(plain, styles) == {"hi 👨‍👩‍👧 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_ascii_offsets_unchanged():
"""ASCII-only path must produce the same offsets as before the UTF-16 fix."""
plain, styles = _markdown_to_signal("**bold** plain *it*")
assert plain == "bold plain it"
assert sorted(styles) == sorted(["0:4:BOLD", "11:2:ITALIC"])
def test_reported_daily_brief_pattern():
"""Regression for the reported bug: a single non-BMP emoji shifts every
subsequent styled span left by 1 UTF-16 unit, lopping off the last letter.
"""
md = (
"**Weather**\n"
"- Conditions: 🌩️ Thunderstorms\n\n"
"**News**\n"
"*World*\n"
"*Local*\n\n"
"**Quote of the Day**"
)
plain, styles = _markdown_to_signal(md)
sd = utf16_styles_for(plain, styles)
assert sd.get("Weather") == ["BOLD"]
assert sd.get("News") == ["BOLD"]
assert sd.get("World") == ["ITALIC"]
assert sd.get("Local") == ["ITALIC"]
assert sd.get("Quote of the Day") == ["BOLD"]
assert_within_utf16_bounds(plain, styles)
# ---------------------------------------------------------------------------
# Chunk redistribution
#
# split_message can break a long Signal payload into multiple chunks. The
# style ranges from _markdown_to_signal are anchored to the full text, so
# they must be redistributed per-chunk with rebased offsets — otherwise
# styles for chunks 1..N are silently lost.
# ---------------------------------------------------------------------------
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
"""Helper: full markdown → signal pipeline, including chunking."""
plain, styles = _markdown_to_signal(text)
chunks = split_message(plain, max_len) if plain else [""]
return chunks, _partition_styles(plain, chunks, styles)
def test_partition_styles_single_chunk_passthrough():
plain, styles = _markdown_to_signal("**bold** plain *it*")
parts = _partition_styles(plain, [plain], styles)
assert parts == [styles]
def test_partition_styles_no_styles():
plain = "hello world"
assert _partition_styles(plain, [plain], []) == [[]]
assert _partition_styles(plain, ["hello", "world"], []) == [[], []]
def test_partition_styles_drops_styles_outside_chunks():
"""Whitespace trimmed by split_message must not carry a style range."""
plain = "a b"
# Fake a style spanning the trimmed whitespace only.
chunks = ["a", "b"]
parts = _partition_styles(plain, chunks, ["1:3:BOLD"])
assert parts == [[], []]
def test_partition_styles_long_message_preserves_chunk_one_styles():
"""A bold span deep in the message must follow the message into chunk 1."""
# Two ~30-char paragraphs separated by a blank line, then **tail**.
line_a = "alpha " * 5 # 30 chars, ends with space
line_b = "beta " * 5
md = f"{line_a.strip()}\n\n{line_b.strip()}\n\n**tail**"
plain, styles = _markdown_to_signal(md)
# Force a split between the paragraphs.
max_len = len(line_a.strip()) + 2 # fits paragraph A + the "\n\n"
chunks = split_message(plain, max_len)
assert len(chunks) >= 2, "test setup must produce a split"
parts = _partition_styles(plain, chunks, styles)
# The bold "tail" should land in the last chunk, with chunk-relative offset.
final_chunk = chunks[-1]
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_chunk_zero_styles_unchanged():
"""Styles entirely in chunk 0 keep their original offsets."""
md = "**head** middle and **tail**"
plain, styles = _markdown_to_signal(md)
# Split so chunk 0 contains "head" and part of the rest, chunk 1 contains "tail".
chunks = split_message(plain, 12)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
# "head" lives in chunk 0; assert its offset is unchanged (chunk 0 starts at 0).
head_entries = [s for s in parts[0] if "BOLD" in s]
assert any(s.startswith("0:4:") for s in head_entries)
def test_partition_styles_with_non_bmp_chunk_offset():
"""Chunk-start offsets must be expressed in UTF-16 code units."""
# Emoji in chunk 0, bold in chunk 1.
md = "🎉 alpha beta gamma\n\n**tail**"
plain, styles = _markdown_to_signal(md)
chunks = split_message(plain, 18)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
final_chunk = chunks[-1]
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_range_spanning_chunks_is_split():
"""A style range that straddles a chunk boundary gets sliced into both chunks."""
# Construct manually: plain = "abc def", style covers "abc def" (whole thing).
plain = "abc def"
chunks = split_message(plain, 4) # "abc" / "def"
assert chunks == ["abc", "def"]
parts = _partition_styles(plain, chunks, ["0:7:BOLD"])
# Chunk 0 holds 0:3:BOLD, chunk 1 holds 0:3:BOLD (length=3 each, "def" only
# since the space was trimmed by lstrip).
assert parts[0] == ["0:3:BOLD"]
assert parts[1] == ["0:3:BOLD"]
# ---------------------------------------------------------------------------
# Adjacency, nesting, and malformed input
# ---------------------------------------------------------------------------
def test_bold_italic_combo_outer_bold_inner_italic():
"""`**_combo_**` carries both BOLD and ITALIC over the same span."""
plain, styles = _markdown_to_signal("**_combo_**")
assert plain == "combo"
sd = styles_for(plain, styles)
assert set(sd.get("combo", [])) == {"BOLD", "ITALIC"}
def test_bold_and_italic_adjacent_no_separator():
"""`**bold***italic*` produces BOLD on `bold` and ITALIC on `italic`."""
plain, styles = _markdown_to_signal("**bold***italic*")
assert plain == "bolditalic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_unclosed_bold_falls_through_as_plain():
"""An unmatched `**` opener round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("**bold")
assert plain == "**bold"
assert styles == []
def test_unclosed_inline_code_falls_through_as_plain():
"""An unmatched backtick round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("use `grep")
assert plain == "use `grep"
assert styles == []
def test_inline_code_inside_blockquote():
"""Blockquote prefix is stripped; inline code becomes MONOSPACE."""
plain, styles = _markdown_to_signal("> use `grep`")
assert plain == "use grep"
sd = styles_for(plain, styles)
assert sd.get("grep") == ["MONOSPACE"]
def test_header_with_inner_bold_produces_contiguous_bold_ranges():
"""`# **wrap** me` — header forces BOLD over the whole line; the inner `**`
splits the run, yielding two contiguous BOLD ranges that together cover
"wrap me". This is intentional Signal renders adjacent same-style ranges
as a single visual span.
"""
plain, styles = _markdown_to_signal("# **wrap** me")
assert plain == "wrap me"
# Both ranges are BOLD; collectively they cover the whole "wrap me".
bold_ranges = [s for s in styles if s.endswith(":BOLD")]
assert len(bold_ranges) == 2
covered = set()
for entry in bold_ranges:
start, length, _ = entry.split(":", 2)
for i in range(int(start), int(start) + int(length)):
covered.add(i)
assert covered == set(range(len(plain)))
-716
View File
@@ -1,716 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
# Check optional Slack dependencies before running tests
try:
import slack_sdk # noqa: F401
except ImportError:
pytest.skip("Slack dependencies not installed (slack-sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.slack import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig
class _FakeAsyncWebClient:
def __init__(self) -> None:
self.chat_post_calls: list[dict[str, object | None]] = []
self.file_upload_calls: list[dict[str, object | None]] = []
self.reactions_add_calls: list[dict[str, object | None]] = []
self.reactions_remove_calls: list[dict[str, object | None]] = []
self.conversations_list_calls: list[dict[str, object | None]] = []
self.conversations_replies_calls: list[dict[str, object | None]] = []
self.users_list_calls: list[dict[str, object | None]] = []
self.conversations_open_calls: list[dict[str, object | None]] = []
self._conversations_pages: list[dict[str, object]] = []
self._conversations_replies_response: dict[str, object] = {"messages": []}
self._users_pages: list[dict[str, object]] = []
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}}
async def chat_postMessage( # noqa: N802 - mirrors Slack SDK method name
self,
*,
channel: str,
text: str,
thread_ts: str | None = None,
blocks: list[dict[str, object]] | None = None,
) -> None:
call: dict[str, object | None] = {
"channel": channel,
"text": text,
"thread_ts": thread_ts,
}
if blocks is not None:
call["blocks"] = blocks
self.chat_post_calls.append(call)
async def files_upload_v2(
self,
*,
channel: str,
file: str,
thread_ts: str | None = None,
) -> None:
self.file_upload_calls.append(
{
"channel": channel,
"file": file,
"thread_ts": thread_ts,
}
)
async def reactions_add(
self,
*,
channel: str,
name: str,
timestamp: str,
) -> None:
self.reactions_add_calls.append(
{
"channel": channel,
"name": name,
"timestamp": timestamp,
}
)
async def reactions_remove(
self,
*,
channel: str,
name: str,
timestamp: str,
) -> None:
self.reactions_remove_calls.append(
{
"channel": channel,
"name": name,
"timestamp": timestamp,
}
)
async def conversations_list(self, **kwargs):
self.conversations_list_calls.append(kwargs)
if self._conversations_pages:
return self._conversations_pages.pop(0)
return {"channels": [], "response_metadata": {"next_cursor": ""}}
async def conversations_replies(self, **kwargs):
self.conversations_replies_calls.append(kwargs)
return self._conversations_replies_response
async def users_list(self, **kwargs):
self.users_list_calls.append(kwargs)
if self._users_pages:
return self._users_pages.pop(0)
return {"members": [], "response_metadata": {"next_cursor": ""}}
async def conversations_open(self, **kwargs):
self.conversations_open_calls.append(kwargs)
return self._open_dm_response
@pytest.mark.asyncio
async def test_send_uses_thread_for_channel_messages() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="C123",
content="hello",
media=["/tmp/demo.txt"],
metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}},
)
)
assert len(fake_web.chat_post_calls) == 1
assert fake_web.chat_post_calls[0]["text"] == "hello"
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_send_omits_thread_for_dm_root_messages() -> None:
"""DM root replies should not be threaded; metadata carries thread_ts=None."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="D123",
content="hello",
media=["/tmp/demo.txt"],
metadata={"slack": {"thread_ts": None, "channel_type": "im"}},
)
)
assert len(fake_web.chat_post_calls) == 1
assert fake_web.chat_post_calls[0]["text"] == "hello"
assert fake_web.chat_post_calls[0]["thread_ts"] is None
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] is None
@pytest.mark.asyncio
async def test_send_keeps_thread_for_dm_thread_messages() -> None:
"""When the user replies inside a DM thread, bot replies stay in the same thread."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="D123",
content="hello",
media=["/tmp/demo.txt"],
metadata={
"slack": {
"thread_ts": "1700000000.000100",
"channel_type": "im",
"event": {"channel": "D123"},
}
},
)
)
assert len(fake_web.chat_post_calls) == 1
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_send_splits_long_messages() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="C123",
content="x" * (SLACK_MAX_MESSAGE_LEN + 10),
)
)
assert len(fake_web.chat_post_calls) == 2
assert all(len(str(call["text"])) <= SLACK_MAX_MESSAGE_LEN for call in fake_web.chat_post_calls)
@pytest.mark.asyncio
async def test_send_renders_buttons_on_last_message_chunk() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="C123",
content="Choose one",
buttons=[["Yes", "No"]],
)
)
assert len(fake_web.chat_post_calls) == 1
blocks = fake_web.chat_post_calls[0]["blocks"]
assert isinstance(blocks, list)
assert blocks[-1] == {
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Yes"},
"value": "Yes",
"action_id": "btn_Yes",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "No"},
"value": "No",
"action_id": "btn_No",
},
],
}
@pytest.mark.asyncio
async def test_send_updates_reaction_when_final_response_sent() -> None:
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="C123",
content="done",
metadata={
"slack": {"event": {"ts": "1700000000.000100"}, "channel_type": "channel"},
},
)
)
assert fake_web.reactions_remove_calls == [
{"channel": "C123", "name": "eyes", "timestamp": "1700000000.000100"}
]
assert fake_web.reactions_add_calls == [
{"channel": "C123", "name": "white_check_mark", "timestamp": "1700000000.000100"}
]
@pytest.mark.asyncio
async def test_send_resolves_channel_name_to_channel_id() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="#channel_x",
content="hello",
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "hello", "thread_ts": None}
]
assert len(fake_web.conversations_list_calls) == 1
@pytest.mark.asyncio
async def test_send_resolves_user_handle_to_dm_channel() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._users_pages = [
{
"members": [
{
"id": "U234",
"name": "alice",
"profile": {"display_name": "Alice"},
}
],
"response_metadata": {"next_cursor": ""},
}
]
fake_web._open_dm_response = {"channel": {"id": "D234"}}
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="@alice",
content="hello",
)
)
assert fake_web.conversations_open_calls == [{"users": "U234"}]
assert fake_web.chat_post_calls == [
{"channel": "D234", "text": "hello", "thread_ts": None}
]
@pytest.mark.asyncio
async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send() -> None:
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="channel_x",
content="done",
metadata={
"slack": {
"event": {"ts": "1700000000.000100", "channel": "D_ORIGIN"},
"channel_type": "im",
},
},
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "done", "thread_ts": None}
]
assert fake_web.reactions_remove_calls == [
{"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"}
]
assert fake_web.reactions_add_calls == [
{"channel": "D_ORIGIN", "name": "white_check_mark", "timestamp": "1700000000.000100"}
]
@pytest.mark.asyncio
async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
fake_web._conversations_pages = [
{
"channels": [{"id": "C999", "name": "channel_x"}],
"response_metadata": {"next_cursor": ""},
}
]
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="channel_x",
content="done",
metadata={
"slack": {
"event": {"ts": "1700000000.000100", "channel": "C_ORIGIN"},
"thread_ts": "1700000000.000200",
"channel_type": "channel",
},
},
)
)
assert fake_web.chat_post_calls == [
{"channel": "C999", "text": "done", "thread_ts": None}
]
@pytest.mark.asyncio
async def test_send_raises_when_named_target_cannot_be_resolved() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
with pytest.raises(ValueError, match="was not found"):
await channel.send(
OutboundMessage(
channel="slack",
chat_id="#missing-channel",
content="hello",
)
)
@pytest.mark.asyncio
async def test_with_thread_context_fetches_root_once() -> None:
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
fake_web = _FakeAsyncWebClient()
fake_web._conversations_replies_response = {
"messages": [
{"ts": "111.000", "user": "UROOT", "text": "drink water"},
{"ts": "112.000", "user": "U2", "text": "good idea"},
{"ts": "112.500", "user": "UBOT", "text": "I'll remind you."},
{"ts": "113.000", "user": "U3", "text": "<@UBOT> what did you see?"},
]
}
channel._web_client = fake_web
content = await channel._with_thread_context(
"what did you see?",
chat_id="C123",
channel_type="channel",
thread_ts="111.000",
raw_thread_ts="111.000",
current_ts="113.000",
)
assert fake_web.conversations_replies_calls == [
{"channel": "C123", "ts": "111.000", "limit": 20}
]
assert "Slack thread context before this mention:" in content
assert "- <@UROOT>: drink water" in content
assert "- <@U2>: good idea" in content
assert "- bot: I'll remind you." in content
assert "U3" not in content
assert content.endswith("Current message:\nwhat did you see?")
second = await channel._with_thread_context(
"again",
chat_id="C123",
channel_type="channel",
thread_ts="111.000",
raw_thread_ts="111.000",
current_ts="114.000",
)
assert second == "again"
assert len(fake_web.conversations_replies_calls) == 1
@pytest.mark.asyncio
async def test_with_thread_context_fetches_replies_in_dm_thread() -> None:
"""DM threads should also pull thread history (not only channel threads)."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
fake_web = _FakeAsyncWebClient()
fake_web._conversations_replies_response = {
"messages": [
{"ts": "211.000", "user": "UA", "text": "here is the file"},
{"ts": "212.000", "user": "UA", "text": "please read it"},
]
}
channel._web_client = fake_web
content = await channel._with_thread_context(
"what did you see?",
chat_id="D123",
channel_type="im",
thread_ts="211.000",
raw_thread_ts="211.000",
current_ts="213.000",
)
assert fake_web.conversations_replies_calls == [
{"channel": "D123", "ts": "211.000", "limit": 20}
]
assert "Slack thread context before this mention:" in content
assert "- <@UA>: here is the file" in content
@pytest.mark.asyncio
async def test_dm_root_message_has_no_thread_ts_and_no_thread_session() -> None:
"""A top-level DM should not synthesize a thread_ts and uses the default session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-dm-root",
payload={
"event": {
"type": "message",
"user": "U1",
"channel": "D123",
"channel_type": "im",
"text": "hello",
"ts": "1700000000.000100",
}
},
)
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] is None
assert kwargs["metadata"]["slack"]["thread_ts"] is None
@pytest.mark.asyncio
async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
"""A DM message inside a real thread should preserve thread_ts and isolate the session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-dm-thread",
payload={
"event": {
"type": "message",
"user": "U1",
"channel": "D123",
"channel_type": "im",
"text": "hello",
"ts": "1700000000.000200",
"thread_ts": "1700000000.000100",
}
},
)
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:D123:1700000000.000100"
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
channel._bot_user_id = "UBOT"
channel._with_thread_context = AsyncMock(return_value="wrapped") # type: ignore[method-assign]
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-1",
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> /restart",
"thread_ts": "111.000",
"ts": "112.000",
}
},
)
await channel._on_socket_request(client, req)
channel._with_thread_context.assert_not_awaited()
channel._handle_message.assert_awaited_once()
assert channel._handle_message.await_args.kwargs["content"] == "/restart"
@pytest.mark.asyncio
async def test_slack_file_share_downloads_media_and_reaches_agent() -> None:
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
channel._download_slack_file = AsyncMock( # type: ignore[method-assign]
return_value=("/tmp/report.pdf", "[file: report.pdf]")
)
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-file",
payload={
"event": {
"type": "message",
"subtype": "file_share",
"user": "U1",
"channel": "D123",
"channel_type": "im",
"text": "please read this",
"ts": "1700000000.000100",
"files": [
{
"id": "F123",
"name": "report.pdf",
"mimetype": "application/pdf",
"url_private_download": "https://files.slack.com/report.pdf",
}
],
}
},
)
await channel._on_socket_request(client, req)
channel._download_slack_file.assert_awaited_once()
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["content"] == "please read this\n[file: report.pdf]"
assert kwargs["media"] == ["/tmp/report.pdf"]
def test_slack_download_rejects_login_html() -> None:
html_response = httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"<!doctype html><html><title>Sign in to Slack</title>",
)
markdown_response = httpx.Response(
200,
headers={"content-type": "text/markdown"},
content=b"# PR Extraction Guide\n",
)
assert SlackChannel._looks_like_html_download(html_response) is True
assert SlackChannel._looks_like_html_download(markdown_response) is False
def test_slack_download_failure_marker_is_actionable() -> None:
marker = SlackChannel._download_failure_marker("image", "screenshot.png", "download failed")
assert "not available to nanobot" in marker
assert "files:read" in marker
assert "reinstall the Slack app" in marker
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
assert channel.is_allowed("U1") is True
assert channel._is_allowed("U1", "C123", "channel") is True
def test_mention_policy_responds_to_mentions_in_any_channel() -> None:
channel = SlackChannel(SlackConfig(enabled=True, group_policy="mention"), MessageBus())
channel._bot_user_id = "UBOT"
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C123") is True
assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C999") is True
assert channel._should_respond_in_channel("message", "no mention here", "C123") is False
def test_allowlist_policy_restricts_to_approved_channels() -> None:
channel = SlackChannel(
SlackConfig(enabled=True, group_policy="allowlist", group_allow_from=["C_OK"]),
MessageBus(),
)
channel._bot_user_id = "UBOT"
# In an approved channel without require_mention, respond to anything.
assert channel._should_respond_in_channel("message", "anything", "C_OK") is True
# An unapproved channel is always rejected.
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False
# _is_allowed also gates on the channel allowlist.
assert channel._is_allowed("U1", "C_OK", "channel") is True
assert channel._is_allowed("U1", "C_NOPE", "channel") is False
def test_allowlist_with_require_mention_needs_both_channel_and_mention() -> None:
channel = SlackChannel(
SlackConfig(
enabled=True,
group_policy="allowlist",
group_allow_from=["C_OK"],
group_require_mention=True,
),
MessageBus(),
)
channel._bot_user_id = "UBOT"
# Approved channel + mention -> respond.
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_OK") is True
assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C_OK") is True
# Approved channel but no mention -> stay quiet.
assert channel._should_respond_in_channel("message", "just chatting", "C_OK") is False
# Mention in an unapproved channel -> stay quiet.
assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False
def test_group_require_mention_accepts_camel_case_alias() -> None:
config = SlackConfig.model_validate(
{
"enabled": True,
"groupPolicy": "allowlist",
"groupAllowFrom": ["C_OK"],
"groupRequireMention": True,
}
)
assert config.group_require_mention is True
assert config.group_allow_from == ["C_OK"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,548 +0,0 @@
"""Tests for WS envelope media handling (client attachment upload path).
Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch:
decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted
payloads, preserving backward compatibility with media-less frames, and
forwarding saved paths to ``_handle_message``.
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.channels.websocket import (
WebSocketChannel,
WebSocketConfig,
)
from nanobot.webui.gateway_services import build_gateway_services
def _tiny_png_data_url() -> str:
"""A 1-pixel PNG prefixed as a data URL — just enough for magic-bytes sniffing."""
# 1x1 transparent PNG
png = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx"
b"\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\x00\x18\xdd\x8d\xb4\x00"
b"\x00\x00\x00IEND\xaeB`\x82"
)
return f"data:image/png;base64,{base64.b64encode(png).decode()}"
def _data_url(mime: str, payload: bytes) -> str:
return f"data:{mime};base64,{base64.b64encode(payload).decode()}"
def _make_channel() -> WebSocketChannel:
bus = MagicMock()
bus.publish_inbound = AsyncMock()
cfg = {"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False}
parsed = WebSocketConfig.model_validate(cfg)
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
channel = WebSocketChannel(cfg, bus, gateway=gateway)
channel._handle_message = AsyncMock() # type: ignore[method-assign]
return channel
# -- max_message_bytes bump ----------------------------------------------------
def test_max_message_bytes_default_supports_multi_image_frame() -> None:
"""Default 36 MB must comfortably hold 4 × 6 MB base64-encoded images."""
from nanobot.channels.websocket import WebSocketConfig
default = WebSocketConfig().max_message_bytes
# 4 images × 6 MB × 1.37 base64 overhead ≈ 33 MB
assert default >= 33 * 1024 * 1024
# Upper bound 40 MB matches plan
with pytest.raises(Exception):
WebSocketConfig(max_message_bytes=41_943_040 + 1)
# -- _dispatch_envelope message branch + media --------------------------------
@pytest.mark.asyncio
async def test_message_without_media_backward_compatible() -> None:
"""Existing clients that don't send ``media`` keep working unchanged."""
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {"type": "message", "chat_id": "abc123", "content": "hello"}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
call = channel._handle_message.call_args
assert call.kwargs["chat_id"] == "abc123"
assert call.kwargs["content"] == "hello"
# When no media, we pass ``media=None`` so downstream treats it as absent.
assert call.kwargs["media"] is None
@pytest.mark.asyncio
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "" * 22_000,
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err == {
"event": "error",
"chat_id": "abc123",
"detail": "message_rejected",
"reason": "text_too_large",
}
@pytest.mark.asyncio
async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "please use @drawio",
"webui": True,
"cli_apps": [
{
"name": "DrawIO",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
},
{"name": "bad name", "entry_point": "nope"},
],
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata["webui"] is True
assert metadata["cli_apps"] == [{
"name": "drawio",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
}]
@pytest.mark.asyncio
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "look at this",
"media": [{"data_url": _tiny_png_data_url(), "name": "shot.png"}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
paths = channel._handle_message.call_args.kwargs["media"]
assert isinstance(paths, list) and len(paths) == 1
saved = Path(paths[0])
assert saved.exists()
assert saved.suffix == ".png"
assert saved.is_relative_to(tmp_path)
@pytest.mark.asyncio
async def test_message_with_multiple_images(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "a couple",
"media": [
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
paths = channel._handle_message.call_args.kwargs["media"]
assert len(paths) == 3
# Saved filenames must be unique.
assert len({Path(p).name for p in paths}) == 3
@pytest.mark.asyncio
async def test_image_only_message_allows_empty_text(tmp_path) -> None:
"""When media is attached, empty text is acceptable."""
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "",
"media": [{"data_url": _tiny_png_data_url()}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
# Error event NOT sent.
mock_conn.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "hi",
"media": [{"data_url": _tiny_png_data_url()}] * 5,
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
mock_conn.send.assert_awaited_once()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["event"] == "error"
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "too_many_images"
@pytest.mark.asyncio
async def test_message_rejected_when_too_many_total_attachments(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "mixed",
"media": [
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"},
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "too_many_attachments"
@pytest.mark.asyncio
async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
oversized = b"x" * (9 * 1024 * 1024) # > 8 MB WS limit
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "big",
"media": [{"data_url": _data_url("image/png", oversized)}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "size"
@pytest.mark.asyncio
async def test_message_with_pdf_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "pdf?",
"media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
paths = channel._handle_message.call_args.kwargs["media"]
assert isinstance(paths, list) and len(paths) == 1
saved = Path(paths[0])
assert saved.exists()
assert saved.suffix == ".pdf"
assert saved.name.endswith("_report.pdf")
assert saved.read_bytes() == b"%PDF-1.4"
@pytest.mark.asyncio
async def test_message_with_csv_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "summarize",
"media": [
{"data_url": _data_url("text/csv", b"name,value\nnanobot,1"), "name": "report.csv"}
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
paths = channel._handle_message.call_args.kwargs["media"]
saved = Path(paths[0])
assert saved.suffix == ".csv"
assert saved.name.endswith("_report.csv")
assert saved.read_bytes() == b"name,value\nnanobot,1"
@pytest.mark.asyncio
async def test_message_rejected_on_unsupported_file_mime(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "zip?",
"media": [{"data_url": _data_url("application/zip", b"PK")}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "mime"
@pytest.mark.asyncio
async def test_message_rejected_on_svg_mime(tmp_path) -> None:
"""SVG is explicitly rejected — XSS surface inside embedded scripts."""
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "svg",
"media": [{"data_url": _data_url("image/svg+xml", b"<svg/>")}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["reason"] == "mime"
@pytest.mark.asyncio
async def test_message_rejected_on_malformed_data_url(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "nope",
"media": [{"data_url": "http://evil.example/image.png"}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["reason"] == "decode"
@pytest.mark.asyncio
async def test_message_rejected_on_broken_base64(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "nope",
"media": [{"data_url": "data:image/png;base64,not-valid-base64!!!"}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["reason"] == "decode"
@pytest.mark.asyncio
async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "huh",
# Not a dict — plain string at the top level.
"media": ["data:image/png;base64,XXXX"],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["reason"] == "malformed"
@pytest.mark.asyncio
async def test_message_rejected_when_media_field_is_not_list() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "huh",
"media": "not-a-list",
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "malformed"
@pytest.mark.asyncio
async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
"""If the second attachment is invalid, the first must not be forwarded.
Also: files already written in this call are cleaned up on failure, so
a mixed-valid/invalid batch never leaves orphan files in the media dir.
"""
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "mixed",
"media": [
{"data_url": _tiny_png_data_url()},
{"data_url": _data_url("image/svg+xml", b"<svg/>")},
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["reason"] == "mime"
# Partial-batch failures must not leak files to disk.
leftover = [p for p in tmp_path.iterdir() if p.is_file()]
assert leftover == [], f"orphan media after rejected batch: {leftover}"
@pytest.mark.asyncio
async def test_rejects_empty_text_without_media() -> None:
"""When no media is attached, whitespace-only content is still rejected
(matches the existing behavior for backward compat)."""
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": " ",
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "missing content"
@pytest.mark.asyncio
async def test_non_string_content_still_rejected() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": 42,
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "missing content"
File diff suppressed because it is too large Load Diff
@@ -1,531 +0,0 @@
"""Integration tests for the WebSocket channel using WsTestClient.
Complements the unit/lightweight tests in test_websocket_channel.py by covering
multi-client scenarios, edge cases, and realistic usage patterns.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import websockets
from ws_test_client import WsTestClient, issue_token, issue_token_ok
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
cfg: dict[str, Any] = {
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/",
"websocketRequiresToken": False,
}
cfg.update(kw)
parsed = WebSocketConfig.model_validate(cfg)
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
def bus() -> MagicMock:
b = MagicMock()
b.publish_inbound = AsyncMock()
return b
# -- Connection basics ----------------------------------------------------
@pytest.mark.asyncio
async def test_ready_event_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29901)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29901/", client_id="c1") as c:
r = await c.recv_ready()
assert r.event == "ready"
assert len(r.chat_id) == 36
assert r.client_id == "c1"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
ch = _ch(bus, 29902)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29902/", client_id="") as c:
r = await c.recv_ready()
assert r.client_id.startswith("anon-")
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
ch = _ch(bus, 29903)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="a") as c1:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2:
assert (await c1.recv_ready()).chat_id != (await c2.recv_ready()).chat_id
finally:
await ch.stop()
await t
# -- Inbound messages (client -> server) ----------------------------------
@pytest.mark.asyncio
async def test_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29904)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29904/", client_id="p") as c:
await c.recv_ready()
await c.send_text("hello world")
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.call_args[0][0]
assert inbound.content == "hello world"
assert inbound.sender_id == "p"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_json_content_field(bus: MagicMock) -> None:
ch = _ch(bus, 29905)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29905/", client_id="j") as c:
await c.recv_ready()
await c.send_json({"content": "structured"})
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "structured"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_json_text_and_message_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29906)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29906/", client_id="x") as c:
await c.recv_ready()
await c.send_json({"text": "via text"})
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "via text"
await c.send_json({"message": "via message"})
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "via message"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_empty_payload_ignored(bus: MagicMock) -> None:
ch = _ch(bus, 29907)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29907/", client_id="e") as c:
await c.recv_ready()
await c.send_text(" ")
await c.send_json({})
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_messages_preserve_order(bus: MagicMock) -> None:
ch = _ch(bus, 29908)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29908/", client_id="o") as c:
await c.recv_ready()
for i in range(5):
await c.send_text(f"msg-{i}")
await asyncio.sleep(0.2)
contents = [call[0][0].content for call in bus.publish_inbound.call_args_list]
assert contents == [f"msg-{i}" for i in range(5)]
finally:
await ch.stop()
await t
# -- Outbound messages (server -> client) ---------------------------------
@pytest.mark.asyncio
async def test_server_send_message(bus: MagicMock) -> None:
ch = _ch(bus, 29909)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29909/", client_id="r") as c:
ready = await c.recv_ready()
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content="reply",
))
msg = await c.recv_message()
assert msg.text == "reply"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""Tool-hint progress events surface as ``kind: "tool_hint"``."""
ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
ready = await c.recv_ready()
# Plain reply: no "kind" field.
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content="hi",
))
plain = await c.recv_message()
assert plain.raw.get("kind") is None
# Tool-hint breadcrumb: kind == "tool_hint".
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content='weather("get")',
event=ProgressEvent(content='weather("get")', tool_hint=True),
))
hint = await c.recv_message()
assert hint.raw.get("kind") == "tool_hint"
assert hint.text == 'weather("get")'
# Generic progress (non-tool-hint) gets the softer "progress" label.
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id,
content="thinking…",
event=ProgressEvent(content="thinking…"),
))
prog = await c.recv_message()
assert prog.raw.get("kind") == "progress"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
ch = _ch(bus, 29910)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29910/", client_id="m") as c:
ready = await c.recv_ready()
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content="img",
media=["/tmp/a.png"], reply_to="m1",
))
msg = await c.recv_message()
assert msg.text == "img"
assert msg.media == ["/tmp/a.png"]
assert msg.reply_to == "m1"
finally:
await ch.stop()
await t
# -- Streaming ------------------------------------------------------------
@pytest.mark.asyncio
async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
ch = _ch(bus, 29911, streaming=True)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id
for part in ("Hello", " ", "world", "!"):
await ch.send_delta(cid, part, stream_id="s1")
await ch.send_delta(cid, "", stream_id="s1", stream_end=True)
msgs = await c.collect_stream()
deltas = [m for m in msgs if m.event == "delta"]
assert "".join(d.text for d in deltas) == "Hello world!"
ends = [m for m in msgs if m.event == "stream_end"]
assert len(ends) == 1
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_interleaved_streams(bus: MagicMock) -> None:
ch = _ch(bus, 29912, streaming=True)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id
await ch.send_delta(cid, "A1", stream_id="sa")
await ch.send_delta(cid, "B1", stream_id="sb")
await ch.send_delta(cid, "A2", stream_id="sa")
await ch.send_delta(cid, "", stream_id="sa", stream_end=True)
await ch.send_delta(cid, "B2", stream_id="sb")
await ch.send_delta(cid, "", stream_id="sb", stream_end=True)
msgs = await c.recv_n(6)
sa = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sa")
sb = "".join(m.text for m in msgs if m.event == "delta" and m.stream_id == "sb")
assert sa == "A1A2"
assert sb == "B1B2"
finally:
await ch.stop()
await t
# -- Multi-client ---------------------------------------------------------
@pytest.mark.asyncio
async def test_independent_sessions(bus: MagicMock) -> None:
ch = _ch(bus, 29913)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u1") as c1:
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u2") as c2:
r1, r2 = await c1.recv_ready(), await c2.recv_ready()
await ch.send(OutboundMessage(
channel="websocket", chat_id=r1.chat_id, content="for-u1",
))
assert (await c1.recv_message()).text == "for-u1"
await ch.send(OutboundMessage(
channel="websocket", chat_id=r2.chat_id, content="for-u2",
))
assert (await c2.recv_message()).text == "for-u2"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
ch = _ch(bus, 29914)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
chat_id = (await c.recv_ready()).chat_id
# disconnected
await asyncio.sleep(0.1)
await ch.send(OutboundMessage(
channel="websocket", chat_id=chat_id, content="orphan",
))
assert chat_id not in ch._subs
finally:
await ch.stop()
await t
# -- Authentication -------------------------------------------------------
@pytest.mark.asyncio
async def test_static_token_accepted(bus: MagicMock) -> None:
ch = _ch(bus, 29915, token="secret")
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
assert (await c.recv_ready()).client_id == "a"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_static_token_rejected(bus: MagicMock) -> None:
ch = _ch(bus, 29916, token="correct")
t = asyncio.create_task(ch.start())
try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29916/", client_id="b", token="wrong"):
pass
assert exc.value.response.status_code == 401
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_token_issue_full_flow(bus: MagicMock) -> None:
ch = _ch(bus, 29917, path="/ws",
tokenIssuePath="/auth/token", tokenIssueSecret="s",
websocketRequiresToken=True)
t = asyncio.create_task(ch.start())
try:
# no secret -> 401
_, status = await issue_token(port=29917, issue_path="/auth/token")
assert status == 401
# with secret -> token
token = await issue_token_ok(port=29917, issue_path="/auth/token", secret="s")
# no token -> 401
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29917/ws", client_id="x"):
pass
assert exc.value.response.status_code == 401
# valid token -> ok
async with WsTestClient("ws://127.0.0.1:29917/ws", client_id="ok", token=token) as c:
assert (await c.recv_ready()).client_id == "ok"
# reuse -> 401
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29917/ws", client_id="r", token=token):
pass
assert exc.value.response.status_code == 401
finally:
await ch.stop()
await t
# -- Path routing ---------------------------------------------------------
@pytest.mark.asyncio
async def test_custom_path(bus: MagicMock) -> None:
ch = _ch(bus, 29918, path="/my-chat")
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
assert (await c.recv_ready()).event == "ready"
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_wrong_path_404(bus: MagicMock) -> None:
ch = _ch(bus, 29919, path="/ws")
t = asyncio.create_task(ch.start())
try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29919/wrong", client_id="x"):
pass
assert exc.value.response.status_code == 404
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_trailing_slash_normalized(bus: MagicMock) -> None:
ch = _ch(bus, 29920, path="/ws")
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
assert (await c.recv_ready()).event == "ready"
finally:
await ch.stop()
await t
# -- Edge cases -----------------------------------------------------------
@pytest.mark.asyncio
async def test_large_message(bus: MagicMock) -> None:
ch = _ch(bus, 29921)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29921/", client_id="big") as c:
await c.recv_ready()
big = "x" * 100_000
await c.send_text(big)
await asyncio.sleep(0.2)
assert bus.publish_inbound.call_args[0][0].content == big
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_unicode_roundtrip(bus: MagicMock) -> None:
ch = _ch(bus, 29922)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29922/", client_id="u") as c:
ready = await c.recv_ready()
text = "你好世界 🌍 日本語テスト"
await c.send_text(text)
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == text
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content=text,
))
assert (await c.recv_message()).text == text
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_rapid_fire(bus: MagicMock) -> None:
ch = _ch(bus, 29923)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29923/", client_id="r") as c:
ready = await c.recv_ready()
for i in range(50):
await c.send_text(f"in-{i}")
await asyncio.sleep(0.5)
assert bus.publish_inbound.await_count == 50
for i in range(50):
await ch.send(OutboundMessage(
channel="websocket", chat_id=ready.chat_id, content=f"out-{i}",
))
received = [(await c.recv_message()).text for _ in range(50)]
assert received == [f"out-{i}" for i in range(50)]
finally:
await ch.stop()
await t
@pytest.mark.asyncio
async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29924)
t = asyncio.create_task(ch.start())
try:
async with WsTestClient("ws://127.0.0.1:29924/", client_id="j") as c:
await c.recv_ready()
await c.send_text("{broken json")
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "{broken json"
finally:
await ch.stop()
await t
@@ -1,561 +0,0 @@
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and its replay
integration on ``/api/sessions/<key>/messages``.
The route is the return path for images attached to persisted user turns:
:meth:`WebSocketChannel.gateway.media.sign_media_path` mints URLs during session reads,
and :meth:`GatewayHTTPHandler._handle_media_fetch` serves the bytes back.
These tests cover the two halves end-to-end plus the adversarial edges
(bad signatures, ``..`` traversal, non-existent files, non-image types).
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from ws_test_client import InProcessHttpChannel
from ws_test_client import http_get as _http_get
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.media_api import (
b64url_decode,
b64url_encode,
)
# PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte
# round-trip of the served payload. Stays under mimetype + size limits.
_PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
def _ch(
bus: Any,
*,
session_manager: SessionManager | None = None,
workspace_path: Path | None = None,
port: int,
) -> WebSocketChannel:
cfg = {
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/",
"websocketRequiresToken": False,
}
parsed = WebSocketConfig.model_validate(cfg)
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=session_manager,
static_dist_path=None,
workspace_path=workspace_path or Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
return InProcessHttpChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
def bus() -> MagicMock:
b = MagicMock()
b.publish_inbound = AsyncMock()
return b
def _fake_media_dir(root: Path):
def inner(channel: str | None = None) -> Path:
path = root / channel if channel else root
path.mkdir(parents=True, exist_ok=True)
return path
return inner
# ---------------------------------------------------------------------------
# gateway.media.sign_media_path: the URL minter
# ---------------------------------------------------------------------------
def test_sign_media_path_rejects_paths_outside_media_root(
bus: MagicMock, tmp_path: Path
) -> None:
"""Paths that resolve outside ``get_media_dir()`` must not be signed.
This is the single most important invariant of the whole scheme:
if the minter ever signed an arbitrary path, the HMAC would legitimise
it for the fetch handler and we'd hand out a disk-read primitive.
"""
outside = tmp_path / "secrets" / "cred.txt"
outside.parent.mkdir()
outside.write_text("nope")
media = tmp_path / "media"
media.mkdir()
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
assert channel.gateway.media.sign_media_path(outside) is None
# Traversal via the media root is also rejected — the resolve() step
# normalises ``..`` out before the relative_to check.
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
def test_sign_media_path_round_trips_via_hmac(
bus: MagicMock, tmp_path: Path
) -> None:
"""The signature embeds exactly ``HMAC-SHA256(secret, payload)[:16]``."""
media = tmp_path / "media"
media.mkdir()
(media / "a.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url = channel.gateway.media.sign_media_path(media / "a.png")
assert url is not None
assert url.startswith("/api/media/")
sig, payload = url[len("/api/media/"):].split("/", 1)
expected = hmac.new(
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
assert b64url_decode(sig) == expected
# The payload decodes back to the *relative* path — no absolute-path leaks.
assert b64url_decode(payload).decode() == "a.png"
def test_local_markdown_image_is_staged_and_rewritten(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "demo_arch.png").write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)"
)
assert "![Cloud Architecture Diagram](/api/media/" in rewritten
staged = list((media / "websocket").iterdir())
assert len(staged) == 1
assert staged[0].read_bytes() == _PNG_BYTES
def test_local_markdown_video_is_staged_and_rewritten(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
video_bytes = b"fake mp4"
(workspace / "nanobot-intro.mp4").write_bytes(video_bytes)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![nanobot-intro.mp4](nanobot-intro.mp4)"
)
assert "![nanobot-intro.mp4](/api/media/" in rewritten
staged = list((media / "websocket").iterdir())
assert len(staged) == 1
assert staged[0].read_bytes() == video_bytes
def test_local_markdown_image_rejects_workspace_escape(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.png"
outside.write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
text = "![nope](../outside.png)"
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
assert channel.gateway.media.rewrite_local_markdown_images(text) == text
assert not (media / "websocket").exists()
# ---------------------------------------------------------------------------
# /api/media/<sig>/<payload>: the serving handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_media_route_serves_signed_file(
bus: MagicMock, tmp_path: Path
) -> None:
"""Valid signature + existing file => 200 with correct bytes + MIME."""
media = tmp_path / "media"
media.mkdir()
target = media / "round-trip.png"
target.write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29920)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 200
assert resp.content == _PNG_BYTES
assert resp.headers["content-type"].startswith("image/png")
# Immutable cache header lets the browser skip round-trips on replay.
assert "immutable" in resp.headers.get("cache-control", "")
# Video players rely on byte ranges; images get the header for consistency.
assert resp.headers.get("accept-ranges") == "bytes"
# nosniff keeps the browser from second-guessing our Content-Type.
assert resp.headers.get("x-content-type-options") == "nosniff"
@pytest.mark.asyncio
async def test_media_route_serves_video_byte_ranges(
bus: MagicMock, tmp_path: Path
) -> None:
"""MP4 playback needs HTTP Range support for mid-stream reads and seeking."""
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29927)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(
f"http://127.0.0.1:29927{url_path}",
headers={"Range": "bytes=2-5"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 206
assert resp.content == b"2345"
assert resp.headers["content-type"].startswith("video/mp4")
assert resp.headers.get("accept-ranges") == "bytes"
assert resp.headers.get("content-range") == "bytes 2-5/10"
assert resp.headers.get("content-length") == "4"
@pytest.mark.asyncio
async def test_media_route_serves_suffix_video_byte_ranges(
bus: MagicMock, tmp_path: Path
) -> None:
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(
f"http://127.0.0.1:29928{url_path}",
headers={"Range": "bytes=-3"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 206
assert resp.content == b"789"
assert resp.headers.get("content-range") == "bytes 7-9/10"
@pytest.mark.asyncio
async def test_media_route_rejects_unsatisfiable_byte_range(
bus: MagicMock, tmp_path: Path
) -> None:
media = tmp_path / "media"
media.mkdir()
target = media / "clip.mp4"
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29929)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(
f"http://127.0.0.1:29929{url_path}",
headers={"Range": "bytes=100-200"},
)
finally:
await channel.stop()
await server_task
assert resp.status_code == 416
assert resp.headers.get("accept-ranges") == "bytes"
assert resp.headers.get("content-range") == "bytes */10"
@pytest.mark.asyncio
async def test_media_route_rejects_bad_signature(
bus: MagicMock, tmp_path: Path
) -> None:
"""A payload re-signed with a different secret must 401.
Protects against a restart: old URLs baked into a stale tab become
un-forgeable once ``gateway.media.secret`` regenerates.
"""
media = tmp_path / "media"
media.mkdir()
(media / "f.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29921)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
good = channel.gateway.media.sign_media_path(media / "f.png")
assert good is not None
_, payload = good[len("/api/media/"):].split("/", 1)
# Forge a sig with a *different* secret.
forged_mac = hmac.new(
b"\x00" * 32, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
forged = f"/api/media/{b64url_encode(forged_mac)}/{payload}"
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29921{forged}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_media_route_rejects_path_traversal_payload(
bus: MagicMock, tmp_path: Path
) -> None:
"""Even a validly-signed ``..`` payload must not escape the media root.
The signer never *emits* such payloads, but an attacker who somehow
obtained the secret (or the channel was misconfigured) must still be
stopped by the resolve()+relative_to() guard in the serving path.
"""
media = tmp_path / "media"
media.mkdir()
secret_file = tmp_path / "secret.txt"
secret_file.write_text("classified")
channel = _ch(bus, port=29922)
# Hand-craft a traversal payload the legit signer would refuse to mint.
payload = b64url_encode(b"../secret.txt")
mac = hmac.new(
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}"
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29922{url}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 404
assert b"classified" not in resp.content
@pytest.mark.asyncio
async def test_media_route_404s_missing_file(
bus: MagicMock, tmp_path: Path
) -> None:
"""A signed URL for a file that no longer exists degrades to 404 so the
client can fall back to the placeholder tile instead of breaking."""
media = tmp_path / "media"
media.mkdir()
target = media / "gone.png"
target.write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29923)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_media_route_degrades_non_image_to_octet_stream(
bus: MagicMock, tmp_path: Path
) -> None:
"""A non-image extension must not be served as its native MIME.
Defence-in-depth: if media_dir ever contained (say) an HTML file, we
do not want the browser to render it as HTML via the signed route.
"""
media = tmp_path / "media"
media.mkdir()
(media / "scary.html").write_bytes(b"<script>alert(1)</script>")
channel = _ch(bus, port=29924)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
payload = b64url_encode(b"scary.html")
mac = hmac.new(
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}"
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29924{url}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/octet-stream")
# nosniff is the actual defence when we downgrade to octet-stream:
# without it the browser might still sniff the bytes as HTML.
assert resp.headers.get("x-content-type-options") == "nosniff"
@pytest.mark.asyncio
async def test_media_route_serves_svg_with_strict_csp(
bus: MagicMock, tmp_path: Path
) -> None:
"""Generated SVG can preview as an image without becoming executable HTML."""
media = tmp_path / "media"
media.mkdir()
target = media / "chart.svg"
target.write_text("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>")
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
finally:
await channel.stop()
await server_task
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/svg+xml")
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
assert "sandbox" in resp.headers.get("content-security-policy", "")
# ---------------------------------------------------------------------------
# /api/sessions/<key>/messages: media_urls hydration on session read
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_messages_exposes_signed_media_urls(
bus: MagicMock, tmp_path: Path
) -> None:
"""The read path must map persisted ``media`` paths onto signed URLs
and strip the raw path the client never learns the server's layout."""
media = tmp_path / "media"
media.mkdir()
img = media / "u.png"
img.write_bytes(_PNG_BYTES)
sm = SessionManager(tmp_path / "ws_state")
sess = Session(key="websocket:media-hydrate")
sess.add_message("user", "look at this", media=[str(img)])
sess.add_message("assistant", "nice")
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29925)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
headers=auth,
)
body = resp.json()
# The signed URL round-trips end-to-end: fetching it yields the same bytes.
user_msg = next(m for m in body["messages"] if m["role"] == "user")
urls = user_msg["media_urls"]
assert isinstance(urls, list) and len(urls) == 1
assert urls[0]["name"] == "u.png"
assert urls[0]["url"].startswith("/api/media/")
# Raw paths must not leak to the wire.
assert "media" not in user_msg
# And the URL actually works.
fetched = await _http_get(f"http://127.0.0.1:29925{urls[0]['url']}")
assert fetched.status_code == 200
assert fetched.content == _PNG_BYTES
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_messages_skips_vanished_media(
bus: MagicMock, tmp_path: Path
) -> None:
"""Paths that no longer resolve inside the media root produce no URL —
the message is still delivered, just without the preview."""
media = tmp_path / "media"
media.mkdir()
sm = SessionManager(tmp_path / "ws_state")
sess = Session(key="websocket:vanished")
sess.add_message("user", "missing pic", media=[str(media / "absent.png")])
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29926)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
resp = await _http_get(
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
headers={"Authorization": f"Bearer {token}"},
)
user_msg = next(m for m in resp.json()["messages"] if m["role"] == "user")
# absent.png lives inside the media root so it *does* get a signed
# URL (we don't stat the file at signing time — that would slow
# the listing). Fetching the URL is where the 404 surfaces.
urls = user_msg.get("media_urls") or []
assert len(urls) == 1
fetched = await _http_get(f"http://127.0.0.1:29926{urls[0]['url']}")
assert fetched.status_code == 404
assert "media" not in user_msg
finally:
await channel.stop()
await server_task
@@ -1,57 +0,0 @@
"""Boundary tests for pure WebSocket protocol helpers."""
from __future__ import annotations
import pytest
from nanobot.channels.websocket import (
_is_valid_chat_id,
_parse_envelope,
)
def test_chat_id_validator_accepts_only_compact_capability_keys() -> None:
valid = [
"a",
"A-Z_09:chat-id",
"x" * 64,
]
invalid = [
"",
"x" * 65,
"../escape",
"chat/id",
"chat id",
"chat\nid",
None,
123,
]
for value in valid:
assert _is_valid_chat_id(value), value
for value in invalid:
assert not _is_valid_chat_id(value), repr(value)
@pytest.mark.parametrize(
("raw", "expected_type"),
[
("plain text", None),
("{not json", None),
("[]", None),
("{}", None),
('{"type": 42}', None),
('{"type": "message", "content": "hi"}', "message"),
(' {"type": "new_chat"} ', "new_chat"),
],
)
def test_parse_envelope_only_accepts_typed_json_objects(
raw: str,
expected_type: str | None,
) -> None:
parsed = _parse_envelope(raw)
if expected_type is None:
assert parsed is None
else:
assert parsed is not None
assert parsed["type"] == expected_type
@@ -1,59 +0,0 @@
"""Test websocket subscribe hydration only replays known active turns."""
from unittest.mock import MagicMock, patch
import pytest
from nanobot.channels.websocket import WebSocketChannel
@pytest.mark.asyncio
async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
"""Subscribe hydration must not inject an idle event into normal message order."""
channel = WebSocketChannel.__new__(WebSocketChannel)
channel.gateway = MagicMock()
channel.gateway.session_manager = MagicMock()
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
sent_events = []
async def mock_send_goal_state(chat_id, blob):
sent_events.append(("goal_state", chat_id, blob))
async def mock_send_goal_status(chat_id, status, **kwargs):
sent_events.append(("goal_status", chat_id, status, kwargs))
channel.send_goal_state = mock_send_goal_state
channel.send_goal_status = mock_send_goal_status
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=None):
await channel._hydrate_after_subscribe("test-chat")
assert sent_events == []
@pytest.mark.asyncio
async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
"""Reconnecting client should receive running status when turn is active."""
channel = WebSocketChannel.__new__(WebSocketChannel)
channel.gateway = MagicMock()
channel.gateway.session_manager = MagicMock()
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
sent_events = []
async def mock_send_goal_state(chat_id, blob):
sent_events.append(("goal_state", chat_id, blob))
async def mock_send_goal_status(chat_id, status, **kwargs):
sent_events.append(("goal_status", chat_id, status, kwargs))
channel.send_goal_state = mock_send_goal_state
channel.send_goal_status = mock_send_goal_status
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=1234567890.0):
await channel._hydrate_after_subscribe("test-chat")
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
assert len(running_events) == 1
assert running_events[0][3]["started_at"] == 1234567890.0
-687
View File
@@ -1,687 +0,0 @@
"""Tests for WeCom channel: helpers, download, upload, send, and message processing."""
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
try:
import importlib.util
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
except ImportError:
WECOM_AVAILABLE = False
if not WECOM_AVAILABLE:
pytest.skip("WeCom dependencies not installed (wecom_aibot_sdk)", allow_module_level=True)
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.wecom import (
WecomChannel,
WecomConfig,
_guess_wecom_media_type,
_sanitize_filename,
)
# Try to import the real response class; fall back to a stub if unavailable.
try:
from wecom_aibot_sdk.utils import WsResponse
_RealWsResponse = WsResponse
except ImportError:
_RealWsResponse = None
class _FakeResponse:
"""Minimal stand-in for wecom_aibot_sdk WsResponse."""
def __init__(self, errcode: int = 0, body: dict | None = None, errmsg: str = "ok"):
self.errcode = errcode
self.errmsg = errmsg
self.body = body or {}
class _FakeWsManager:
"""Tracks send_reply calls and returns configurable responses."""
def __init__(self, responses: list[_FakeResponse] | None = None):
self.responses = responses or []
self.calls: list[tuple[str, dict, str]] = []
self._idx = 0
async def send_reply(self, req_id: str, data: dict, cmd: str) -> _FakeResponse:
self.calls.append((req_id, data, cmd))
if self._idx < len(self.responses):
resp = self.responses[self._idx]
self._idx += 1
return resp
return _FakeResponse()
class _FakeFrame:
"""Minimal frame object with a body dict."""
def __init__(self, body: dict | None = None):
self.body = body or {}
class _FakeWeComClient:
"""Fake WeCom client with mock methods."""
def __init__(self, ws_responses: list[_FakeResponse] | None = None):
self._ws_manager = _FakeWsManager(ws_responses)
self.download_file = AsyncMock(return_value=(None, None))
self.reply = AsyncMock()
self.reply_stream = AsyncMock()
self.send_message = AsyncMock()
self.reply_welcome = AsyncMock()
# ── Helper function tests (pure, no async) ──────────────────────────
def test_sanitize_filename_strips_path_traversal() -> None:
assert _sanitize_filename("../../etc/passwd") == "passwd"
def test_sanitize_filename_keeps_chinese_chars() -> None:
assert _sanitize_filename("文件(1.jpg") == "文件(1.jpg"
def test_sanitize_filename_empty_input() -> None:
assert _sanitize_filename("") == ""
def test_guess_wecom_media_type_image() -> None:
for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"):
assert _guess_wecom_media_type(f"photo{ext}") == "image"
def test_guess_wecom_media_type_video() -> None:
for ext in (".mp4", ".avi", ".mov"):
assert _guess_wecom_media_type(f"video{ext}") == "video"
def test_guess_wecom_media_type_voice() -> None:
for ext in (".amr", ".mp3", ".wav", ".ogg"):
assert _guess_wecom_media_type(f"audio{ext}") == "voice"
def test_guess_wecom_media_type_file_fallback() -> None:
for ext in (".pdf", ".doc", ".xlsx", ".zip"):
assert _guess_wecom_media_type(f"doc{ext}") == "file"
def test_guess_wecom_media_type_case_insensitive() -> None:
assert _guess_wecom_media_type("photo.PNG") == "image"
assert _guess_wecom_media_type("photo.Jpg") == "image"
# ── _download_and_save_media() ──────────────────────────────────────
@pytest.mark.asyncio
async def test_download_and_save_success() -> None:
"""Successful download writes file and returns sanitized path."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
fake_data = b"\x89PNG\r\nfake image"
client.download_file.return_value = (fake_data, "raw_photo.png")
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())):
path = await channel._download_and_save_media("https://example.com/img.png", "aes_key", "image", "photo.png")
assert path is not None
assert os.path.isfile(path)
assert os.path.basename(path) == "photo.png"
# Cleanup
os.unlink(path)
@pytest.mark.asyncio
async def test_download_and_save_oversized_rejected() -> None:
"""Data exceeding 200MB is rejected → returns None."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
big_data = b"\x00" * (200 * 1024 * 1024 + 1) # 200MB + 1 byte
client.download_file.return_value = (big_data, "big.bin")
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())):
result = await channel._download_and_save_media("https://example.com/big.bin", "key", "file", "big.bin")
assert result is None
@pytest.mark.asyncio
async def test_download_and_save_failure() -> None:
"""SDK returns None data → returns None."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
client.download_file.return_value = (None, None)
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())):
result = await channel._download_and_save_media("https://example.com/fail.png", "key", "image")
assert result is None
# ── _upload_media_ws() ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_upload_media_ws_success() -> None:
"""Happy path: init → chunk → finish → returns (media_id, media_type)."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp = f.name
try:
responses = [
_FakeResponse(errcode=0, body={"upload_id": "up_1"}),
_FakeResponse(errcode=0, body={}),
_FakeResponse(errcode=0, body={"media_id": "media_abc"}),
]
client = _FakeWeComClient(responses)
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
channel._client = client
with patch("wecom_aibot_sdk.utils.generate_req_id", side_effect=lambda x: f"req_{x}"):
media_id, media_type = await channel._upload_media_ws(client, tmp)
assert media_id == "media_abc"
assert media_type == "image"
finally:
os.unlink(tmp)
@pytest.mark.asyncio
async def test_upload_media_ws_oversized_file() -> None:
"""File >200MB triggers ValueError → returns (None, None)."""
# Instead of creating a real 200MB+ file, mock os.path.getsize and open
with patch("os.path.getsize", return_value=200 * 1024 * 1024 + 1), \
patch("builtins.open", MagicMock()):
client = _FakeWeComClient()
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
channel._client = client
result = await channel._upload_media_ws(client, "/fake/large.bin")
assert result == (None, None)
@pytest.mark.asyncio
async def test_upload_media_ws_init_failure() -> None:
"""Init step returns errcode != 0 → returns (None, None)."""
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
f.write(b"hello")
tmp = f.name
try:
responses = [
_FakeResponse(errcode=50001, errmsg="invalid"),
]
client = _FakeWeComClient(responses)
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
channel._client = client
with patch("wecom_aibot_sdk.utils.generate_req_id", side_effect=lambda x: f"req_{x}"):
result = await channel._upload_media_ws(client, tmp)
assert result == (None, None)
finally:
os.unlink(tmp)
@pytest.mark.asyncio
async def test_upload_media_ws_chunk_failure() -> None:
"""Chunk step returns errcode != 0 → returns (None, None)."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp = f.name
try:
responses = [
_FakeResponse(errcode=0, body={"upload_id": "up_1"}),
_FakeResponse(errcode=50002, errmsg="chunk fail"),
]
client = _FakeWeComClient(responses)
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
channel._client = client
with patch("wecom_aibot_sdk.utils.generate_req_id", side_effect=lambda x: f"req_{x}"):
result = await channel._upload_media_ws(client, tmp)
assert result == (None, None)
finally:
os.unlink(tmp)
@pytest.mark.asyncio
async def test_upload_media_ws_finish_no_media_id() -> None:
"""Finish step returns empty media_id → returns (None, None)."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp = f.name
try:
responses = [
_FakeResponse(errcode=0, body={"upload_id": "up_1"}),
_FakeResponse(errcode=0, body={}),
_FakeResponse(errcode=0, body={}), # no media_id
]
client = _FakeWeComClient(responses)
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
channel._client = client
with patch("wecom_aibot_sdk.utils.generate_req_id", side_effect=lambda x: f"req_{x}"):
result = await channel._upload_media_ws(client, tmp)
assert result == (None, None)
finally:
os.unlink(tmp)
# ── send() ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_send_text_with_frame() -> None:
"""When frame is stored, send uses reply_stream for final text."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._generate_req_id = lambda x: f"req_{x}"
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="hello")
)
client.reply_stream.assert_called_once()
call_args = client.reply_stream.call_args
assert call_args[0][2] == "hello" # content arg
@pytest.mark.asyncio
async def test_send_progress_with_frame() -> None:
"""Progress events use reply_stream with finish=False."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._generate_req_id = lambda x: f"req_{x}"
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(
channel="wecom",
chat_id="chat1",
content="thinking...",
event=ProgressEvent(content="thinking..."),
)
)
client.reply_stream.assert_called_once()
call_args = client.reply_stream.call_args
assert call_args[0][2] == "thinking..." # content arg
assert call_args[1]["finish"] is False
@pytest.mark.asyncio
async def test_send_proactive_without_frame() -> None:
"""Without stored frame, send uses send_message with markdown."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="proactive msg")
)
client.send_message.assert_called_once()
call_args = client.send_message.call_args
assert call_args[0][0] == "chat1"
assert call_args[0][1]["msgtype"] == "markdown"
@pytest.mark.asyncio
async def test_send_media_then_text() -> None:
"""Media files are uploaded and sent before text content."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
tmp = f.name
try:
responses = [
_FakeResponse(errcode=0, body={"upload_id": "up_1"}),
_FakeResponse(errcode=0, body={}),
_FakeResponse(errcode=0, body={"media_id": "media_123"}),
]
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient(responses)
channel._client = client
channel._generate_req_id = lambda x: f"req_{x}"
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="see image", media=[tmp])
)
# Media should have been sent via reply
media_calls = [c for c in client.reply.call_args_list if c[0][1].get("msgtype") == "image"]
assert len(media_calls) == 1
assert media_calls[0][0][1]["image"]["media_id"] == "media_123"
# Text should have been sent via reply_stream
client.reply_stream.assert_called_once()
finally:
os.unlink(tmp)
@pytest.mark.asyncio
async def test_send_media_file_not_found() -> None:
"""Non-existent media path is skipped with a warning."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._generate_req_id = lambda x: f"req_{x}"
channel._chat_frames["chat1"] = _FakeFrame()
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="hello", media=["/nonexistent/file.png"])
)
# reply_stream should still be called for the text part
client.reply_stream.assert_called_once()
# No media reply should happen
media_calls = [c for c in client.reply.call_args_list if c[0][1].get("msgtype") in ("image", "file", "video")]
assert len(media_calls) == 0
@pytest.mark.asyncio
async def test_send_exception_propagates_for_manager_retry() -> None:
"""Delivery failures must propagate to the channel manager."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["*"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._generate_req_id = lambda x: f"req_{x}"
channel._chat_frames["chat1"] = _FakeFrame()
# Make reply_stream raise
client.reply_stream.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
await channel.send(
OutboundMessage(channel="wecom", chat_id="chat1", content="fail test")
)
client.reply_stream.assert_called_once()
# ── _process_message() ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_process_text_message() -> None:
"""Text message is routed to bus with correct fields."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
frame = _FakeFrame(body={
"msgid": "msg_text_1",
"chatid": "chat1",
"chattype": "single",
"from": {"userid": "user1"},
"text": {"content": "hello wecom"},
})
await channel._process_message(frame, "text")
msg = await channel.bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "chat1"
assert msg.content == "hello wecom"
assert msg.metadata["msg_type"] == "text"
@pytest.mark.asyncio
async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel.config.welcome_message = "hello"
await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"}))
client.reply_welcome.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_message_ignores_unauthorized_sender_before_download() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._handle_message = AsyncMock()
frame = _FakeFrame(body={
"msgid": "msg_blocked",
"chatid": "chat1",
"from": {"userid": "blocked"},
"image": {"url": "https://example.com/img.png", "aeskey": "key123"},
})
await channel._process_message(frame, "image")
client.download_file.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel.bus.inbound_size == 0
@pytest.mark.asyncio
async def test_process_image_message() -> None:
"""Image message: download success → media_paths non-empty."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
saved = f.name
client.download_file.return_value = (b"\x89PNG\r\n", "photo.png")
channel._client = client
try:
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))):
frame = _FakeFrame(body={
"msgid": "msg_img_1",
"chatid": "chat1",
"from": {"userid": "user1"},
"image": {"url": "https://example.com/img.png", "aeskey": "key123"},
})
await channel._process_message(frame, "image")
msg = await channel.bus.consume_inbound()
assert len(msg.media) == 1
assert msg.media[0].endswith("photo.png")
assert "[image:" in msg.content
finally:
if os.path.exists(saved):
pass # may have been overwritten; clean up if exists
# Clean up any photo.png in tempdir
p = os.path.join(os.path.dirname(saved), "photo.png")
if os.path.exists(p):
os.unlink(p)
@pytest.mark.asyncio
async def test_process_file_message() -> None:
"""File message: download success → media_paths non-empty (critical fix verification)."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"%PDF-1.4 fake")
saved = f.name
client.download_file.return_value = (b"%PDF-1.4 fake", "report.pdf")
channel._client = client
try:
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))):
frame = _FakeFrame(body={
"msgid": "msg_file_1",
"chatid": "chat1",
"from": {"userid": "user1"},
"file": {"url": "https://example.com/report.pdf", "aeskey": "key456", "name": "report.pdf"},
})
await channel._process_message(frame, "file")
msg = await channel.bus.consume_inbound()
assert len(msg.media) == 1
assert msg.media[0].endswith("report.pdf")
assert "[file: report.pdf]" in msg.content
finally:
p = os.path.join(os.path.dirname(saved), "report.pdf")
if os.path.exists(p):
os.unlink(p)
@pytest.mark.asyncio
async def test_process_file_message_uses_sdk_filename_when_name_missing(tmp_path: Path) -> None:
"""Without `file.name`, fall back to SDK fname instead of saving as 'unknown' (#3737)."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
client.download_file.return_value = (b"%PDF-1.4 fake", "real_name.pdf")
channel._client = client
with patch("nanobot.channels.wecom.get_media_dir", return_value=tmp_path):
frame = _FakeFrame(body={
"msgid": "msg_file_2", "chatid": "chat1", "from": {"userid": "user1"},
"file": {"url": "https://example.com/x", "aeskey": "key456"},
})
await channel._process_message(frame, "file")
msg = await channel.bus.consume_inbound()
assert msg.media == [str(tmp_path / "real_name.pdf")]
assert "[file: real_name.pdf]" in msg.content
@pytest.mark.asyncio
async def test_process_voice_message() -> None:
"""Voice message: transcribed text is included in content."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
frame = _FakeFrame(body={
"msgid": "msg_voice_1",
"chatid": "chat1",
"from": {"userid": "user1"},
"voice": {"content": "transcribed text here"},
})
await channel._process_message(frame, "voice")
msg = await channel.bus.consume_inbound()
assert "transcribed text here" in msg.content
assert "[voice]" in msg.content
@pytest.mark.asyncio
async def test_process_mixed_message() -> None:
"""Mixed message: contains picture and text message types."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n")
saved = f.name
client.download_file.return_value = (b"\x89PNG\r\n", "photo.png")
channel._client = client
try:
with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))):
frame = _FakeFrame(body={
"msgid": "msg_mixed_1",
"chatid": "chat1",
"msgtype": "mixed",
"from": {"userid": "user1"},
"mixed": {
"msg_item": [
{"msgtype": "text", "text": {"content": "hello wecom"}},
{"msgtype": "image", "image": {"url": "https://example.com/img.png", "aeskey": "key123"}}
]
}
})
await channel._process_message(frame, "mixed")
msg = await channel.bus.consume_inbound()
assert msg.sender_id == "user1"
assert msg.chat_id == "chat1"
assert msg.content.startswith("hello wecom")
assert msg.metadata["msg_type"] == "mixed"
assert len(msg.media) == 1
assert msg.media[0].endswith("photo.png")
assert "[image:" in msg.content
finally:
# Clean up any photo.png in tempdir
p = os.path.join(os.path.dirname(saved), "photo.png")
if os.path.exists(p):
os.unlink(p)
@pytest.mark.asyncio
async def test_process_message_deduplication() -> None:
"""Same msg_id is not processed twice."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
frame = _FakeFrame(body={
"msgid": "msg_dup_1",
"chatid": "chat1",
"from": {"userid": "user1"},
"text": {"content": "once"},
})
await channel._process_message(frame, "text")
await channel._process_message(frame, "text")
msg = await channel.bus.consume_inbound()
assert msg.content == "once"
# Second message should not appear on the bus
assert channel.bus.inbound.empty()
@pytest.mark.asyncio
async def test_process_message_empty_content_skipped() -> None:
"""Message with empty content produces no bus message."""
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
frame = _FakeFrame(body={
"msgid": "msg_empty_1",
"chatid": "chat1",
"from": {"userid": "user1"},
"text": {"content": ""},
})
await channel._process_message(frame, "text")
assert channel.bus.inbound.empty()
File diff suppressed because it is too large Load Diff
-573
View File
@@ -1,573 +0,0 @@
from __future__ import annotations
import asyncio
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels import whatsapp as whatsapp_module
from nanobot.channels.whatsapp import WhatsAppChannel, _legacy_bridge_config_fields, _NeonizeAPI
class _Proto:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility
return _is_set(getattr(self, name, None))
def ListFields(self): # noqa: N802 - protobuf compatibility
return [
(SimpleNamespace(name=name), value)
for name, value in self.__dict__.items()
if _is_set(value)
]
def _is_set(value) -> bool:
if value is None:
return False
if isinstance(value, (str, bytes, list, tuple, dict, set)):
return bool(value)
return True
def _jid(user: str, server: str) -> _Proto:
return _Proto(User=user, Server=server, IsEmpty=False)
def _event(
*,
message: _Proto,
message_id: str = "m1",
chat: _Proto | None = None,
sender: _Proto | None = None,
sender_alt: _Proto | None = None,
is_group: bool = False,
timestamp: int = 1,
is_from_me: bool = False,
) -> _Proto:
source = _Proto(
Chat=chat or _jid("15551234567", "s.whatsapp.net"),
Sender=sender,
SenderAlt=sender_alt,
IsGroup=is_group,
IsFromMe=is_from_me,
)
return _Proto(
Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
Message=message,
)
def _make_channel(config: dict | None = None) -> WhatsAppChannel:
merged = {"enabled": True, "allowFrom": ["*"]}
if config:
merged.update(config)
ch = WhatsAppChannel(merged, MagicMock())
ch._started_at = 0
return ch
def _patch_neonize_api(monkeypatch) -> None:
monkeypatch.setattr(
whatsapp_module,
"_NEONIZE_API",
_NeonizeAPI(
NewAClient=object,
ConnectedEv=object(),
DisconnectedEv=object(),
MessageEv=object(),
PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server),
),
)
def _patch_receipt_type(monkeypatch):
neonize = types.ModuleType("neonize")
utils = types.ModuleType("neonize.utils")
enum = types.ModuleType("neonize.utils.enum")
class ReceiptType:
READ = "read"
enum.ReceiptType = ReceiptType
neonize.utils = utils
utils.enum = enum
monkeypatch.setitem(sys.modules, "neonize", neonize)
monkeypatch.setitem(sys.modules, "neonize.utils", utils)
monkeypatch.setitem(sys.modules, "neonize.utils.enum", enum)
return ReceiptType
class _FakeLoginClient:
def __init__(self) -> None:
self.handlers = {}
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
self.stop = AsyncMock()
def event(self, event_type):
def register(func):
self.handlers[event_type] = func
return func
return register
def qr(self, func):
self.qr_handler = func
return func
async def connect(self) -> None:
await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())
class _FailingConnectLoginClient(_FakeLoginClient):
async def connect(self) -> asyncio.Task[None]:
async def fail() -> None:
raise RuntimeError("dial failed")
return asyncio.create_task(fail())
def test_default_config_has_no_bridge_fields() -> None:
config = WhatsAppChannel.default_config()
assert "bridgeUrl" not in config
assert "bridgeToken" not in config
assert config["databasePath"] == ""
def test_legacy_bridge_config_fields_are_detected() -> None:
assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"]
assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"]
@pytest.mark.asyncio
async def test_login_succeeds_when_connected(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FakeLoginClient()
ch = _make_channel()
ch._new_client = MagicMock(return_value=client)
assert await ch.login() is True
assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FailingConnectLoginClient()
ch = _make_channel()
ch._new_client = MagicMock(return_value=client)
assert await ch.login() is False
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))
client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")
@pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
client.send_document.assert_awaited_once_with(
jid,
"report.pdf",
filename="report.pdf",
mimetype="application/pdf",
)
@pytest.mark.asyncio
async def test_send_when_disconnected_raises() -> None:
ch = _make_channel()
with pytest.raises(RuntimeError, match="not connected"):
await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))
@pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hello group"),
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
ch._handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=message,
chat=_jid("120363000", "g.us"),
sender=_jid("LID99", "lid"),
sender_alt=_jid("15559998888", "s.whatsapp.net"),
is_group=True,
),
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "15559998888"
assert kwargs["chat_id"] == "120363000@g.us"
assert kwargs["metadata"]["lid"] == "LID99"
assert kwargs["metadata"]["phone"] == "15559998888"
@pytest.mark.asyncio
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock()
context = _Proto(participant="bot@s.whatsapp.net")
message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=message,
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["metadata"]["is_reply_to_bot"] is True
@pytest.mark.asyncio
async def test_group_sender_id_uses_participant_not_group_jid() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock()
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hi"),
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "SENDERLID"
assert kwargs["metadata"]["participant"] == "SENDERLID@lid"
@pytest.mark.parametrize("allowed_group", ["120363000@g.us", "120363000"])
@pytest.mark.asyncio
async def test_group_allow_from_accepts_group_jid_or_bare_id(allowed_group: str) -> None:
bus = MessageBus()
ch = WhatsAppChannel({"enabled": True, "allowFrom": [allowed_group]}, bus)
ch._started_at = 0
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hi"),
chat=_jid("120363000", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
assert bus.inbound_size == 1
msg = await bus.consume_inbound()
assert msg.sender_id == "SENDERLID"
assert msg.chat_id == "120363000@g.us"
assert msg.content == "hi"
assert msg.metadata["participant"] == "SENDERLID@lid"
@pytest.mark.asyncio
async def test_group_allow_from_does_not_allow_same_participant_in_other_group() -> None:
bus = MessageBus()
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["120363000"]}, bus)
ch._started_at = 0
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="hi"),
chat=_jid("120363999", "g.us"),
sender=_jid("SENDERLID", "lid"),
is_group=True,
),
)
assert bus.inbound_size == 0
@pytest.mark.asyncio
async def test_read_receipt_is_requested_once_after_dedup() -> None:
ch = _make_channel()
ch._send_read_receipt = AsyncMock()
ch._handle_message = AsyncMock()
client = SimpleNamespace(download_any=AsyncMock())
event = _event(
message=_Proto(conversation="hi"),
sender=_jid("15551234567", "s.whatsapp.net"),
)
await ch._handle_neonize_message(client, event)
await ch._handle_neonize_message(client, event)
ch._send_read_receipt.assert_awaited_once_with(
client,
event.Info.MessageSource,
"m1",
)
ch._handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_read_receipt_uses_mark_read_and_swallows_failures(monkeypatch) -> None:
receipt_type = _patch_receipt_type(monkeypatch)
ch = _make_channel()
source = _event(
message=_Proto(conversation="hi"),
sender=_jid("15551234567", "s.whatsapp.net"),
).Info.MessageSource
client = SimpleNamespace(
mark_read=AsyncMock(),
download_any=AsyncMock(),
)
await ch._send_read_receipt(client, source, "m1")
client.mark_read.assert_awaited_once_with(
"m1",
chat=source.Chat,
sender=source.Sender,
receipt=receipt_type.READ,
)
failing_client = SimpleNamespace(
mark_read=AsyncMock(side_effect=RuntimeError("boom")),
download_any=AsyncMock(),
)
await ch._send_read_receipt(failing_client, source, "m2")
failing_client.mark_read.assert_awaited_once()
@pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
ch = _make_channel()
ch._handle_message = AsyncMock()
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="first"),
message_id="c1",
chat=_jid("LID99", "lid"),
sender=_jid("LID99", "lid"),
sender_alt=_jid("5559999", "s.whatsapp.net"),
),
)
await ch._handle_neonize_message(
SimpleNamespace(download_any=AsyncMock()),
_event(
message=_Proto(conversation="second"),
message_id="c2",
chat=_jid("LID99", "lid"),
sender=_jid("LID99", "lid"),
),
)
assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"
def test_lid_mappings_from_config() -> None:
ch = WhatsAppChannel(
{"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
MagicMock(),
)
assert ch._lid_to_phone == {"123456789012345": "15551234567"}
@pytest.mark.asyncio
async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = _make_channel()
ch._handle_message = AsyncMock()
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(
imageMessage=_Proto(
caption="look",
mimetype="image/jpeg",
)
)
await ch._handle_neonize_message(
client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
)
client.download_any.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"].startswith("look\n[image: ")
assert len(kwargs["media"]) == 1
assert kwargs["media"][0].endswith(".jpg")
@pytest.mark.asyncio
async def test_voice_message_transcribes_and_drops_media_when_successful(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = _make_channel()
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))
await ch._handle_neonize_message(
client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
)
ch.transcribe_audio.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "Hello from audio"
assert kwargs["media"] == []
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="blocked audio")
client = SimpleNamespace(download_any=AsyncMock())
await ch._handle_neonize_message(
client,
_event(
message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "blocked"
assert kwargs["content"] == ""
assert kwargs["media"] == []
assert kwargs["is_dm"] is True
@pytest.mark.asyncio
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
ch._client = client
ch._connected = True
ch._started_at = 0
await ch._handle_neonize_message(
client,
_event(
message=_Proto(conversation="hello"),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
client.send_message.assert_awaited_once()
assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
assert "ABCD-EFGH" in client.send_message.await_args.args[1]
def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
db = tmp_path / "neonize.db"
wal = tmp_path / "neonize.db-wal"
shm = tmp_path / "neonize.db-shm"
for path in (db, wal, shm):
path.write_text("x", encoding="utf-8")
WhatsAppChannel._reset_database(db)
assert not db.exists()
assert not wal.exists()
assert not shm.exists()
-309
View File
@@ -1,309 +0,0 @@
"""Lightweight WebSocket test client for integration testing the nanobot WebSocket channel.
Provides an async ``WsTestClient`` class and token-issuance helpers that
integration tests can import and use directly::
from ws_test_client import WsTestClient
async with WsTestClient("ws://127.0.0.1:8765/", client_id="t") as c:
ready = await c.recv_ready()
await c.send_text("hello")
msg = await c.recv_message()
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any
import httpx
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from nanobot.channels.websocket import WebSocketChannel
from nanobot.webui.http_utils import http_response
_IN_PROCESS_HTTP_CHANNELS: dict[int, InProcessHttpChannel] = {}
class _HttpConnection:
remote_address = ("127.0.0.1", 12345)
@staticmethod
def respond(status: int, body: str) -> object:
return http_response(body.encode("utf-8"), status=status)
class InProcessHttpChannel(WebSocketChannel):
"""Exercise gateway HTTP dispatch without booting a socket per route test."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._test_stop_event = asyncio.Event()
_IN_PROCESS_HTTP_CHANNELS[self.config.port] = self
async def start(self) -> None:
self._running = True
await self._test_stop_event.wait()
self._running = False
async def stop(self) -> None:
self._test_stop_event.set()
if _IN_PROCESS_HTTP_CHANNELS.get(self.config.port) is self:
_IN_PROCESS_HTTP_CHANNELS.pop(self.config.port, None)
async def _in_process_http_get(
channel: InProcessHttpChannel,
request: httpx.Request,
) -> httpx.Response:
ws_request = WsRequest(
request.url.raw_path.decode("ascii"),
Headers(list(request.headers.multi_items())),
)
response = await channel._dispatch_http(_HttpConnection(), ws_request)
assert response is not None
return httpx.Response(
response.status_code,
headers=list(response.headers.raw_items()),
content=response.body,
request=request,
)
@dataclass
class WsMessage:
"""A parsed message received from the WebSocket server."""
event: str
raw: dict[str, Any] = field(repr=False)
@property
def text(self) -> str | None:
return self.raw.get("text")
@property
def chat_id(self) -> str | None:
return self.raw.get("chat_id")
@property
def client_id(self) -> str | None:
return self.raw.get("client_id")
@property
def media(self) -> list[str] | None:
return self.raw.get("media")
@property
def reply_to(self) -> str | None:
return self.raw.get("reply_to")
@property
def stream_id(self) -> str | None:
return self.raw.get("stream_id")
def __eq__(self, other: object) -> bool:
if not isinstance(other, WsMessage):
return NotImplemented
return self.event == other.event and self.raw == other.raw
class WsTestClient:
"""Async WebSocket test client with helper methods for common operations.
Usage::
async with WsTestClient("ws://127.0.0.1:8765/", client_id="tester") as client:
ready = await client.recv_ready()
await client.send_text("hello")
msg = await client.recv_message(timeout=5.0)
"""
def __init__(
self,
uri: str,
*,
client_id: str = "test-client",
token: str = "",
extra_headers: dict[str, str] | None = None,
) -> None:
params: list[str] = []
if client_id:
params.append(f"client_id={client_id}")
if token:
params.append(f"token={token}")
sep = "&" if "?" in uri else "?"
self._uri = uri + sep + "&".join(params) if params else uri
self._extra_headers = extra_headers
self._ws: ClientConnection | None = None
async def connect(self, timeout: float = 2.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while True:
try:
self._ws = await websockets.connect(
self._uri,
additional_headers=self._extra_headers,
)
return
except OSError:
if asyncio.get_running_loop().time() >= deadline:
raise
await asyncio.sleep(0.01)
async def close(self) -> None:
if self._ws:
await self._ws.close()
self._ws = None
async def __aenter__(self) -> WsTestClient:
await self.connect()
return self
async def __aexit__(self, *args: Any) -> None:
await self.close()
@property
def ws(self) -> ClientConnection:
assert self._ws is not None, "Client is not connected"
return self._ws
# -- Receiving --------------------------------------------------------
async def recv_raw(self, timeout: float = 10.0) -> dict[str, Any]:
"""Receive and parse one raw JSON message with timeout."""
raw = await asyncio.wait_for(self.ws.recv(), timeout=timeout)
return json.loads(raw)
async def recv(self, timeout: float = 10.0) -> WsMessage:
"""Receive one message, returning a WsMessage wrapper."""
data = await self.recv_raw(timeout)
return WsMessage(event=data.get("event", ""), raw=data)
async def recv_ready(self, timeout: float = 5.0) -> WsMessage:
"""Receive and validate the 'ready' event."""
msg = await self.recv(timeout)
assert msg.event == "ready", f"Expected 'ready' event, got '{msg.event}'"
return msg
async def recv_message(self, timeout: float = 10.0) -> WsMessage:
"""Receive and validate a 'message' event."""
msg = await self.recv(timeout)
assert msg.event == "message", f"Expected 'message' event, got '{msg.event}'"
return msg
async def recv_delta(self, timeout: float = 10.0) -> WsMessage:
"""Receive and validate a 'delta' event."""
msg = await self.recv(timeout)
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
return msg
async def recv_stream_end(self, timeout: float = 10.0) -> WsMessage:
"""Receive and validate a 'stream_end' event."""
msg = await self.recv(timeout)
assert msg.event == "stream_end", f"Expected 'stream_end' event, got '{msg.event}'"
return msg
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
"""Collect all deltas and the final stream_end into a list."""
messages: list[WsMessage] = []
while True:
msg = await self.recv(timeout)
messages.append(msg)
if msg.event == "stream_end":
break
return messages
async def recv_n(self, n: int, timeout: float = 10.0) -> list[WsMessage]:
"""Receive exactly *n* messages."""
return [await self.recv(timeout) for _ in range(n)]
# -- Sending ----------------------------------------------------------
async def send_text(self, text: str) -> None:
"""Send a plain text frame."""
await self.ws.send(text)
async def send_json(self, data: dict[str, Any]) -> None:
"""Send a JSON frame."""
await self.ws.send(json.dumps(data, ensure_ascii=False))
async def send_content(self, content: str) -> None:
"""Send content in the preferred JSON format ``{"content": ...}``."""
await self.send_json({"content": content})
# -- Connection introspection -----------------------------------------
@property
def closed(self) -> bool:
return self._ws is None or self._ws.closed
# -- Token issuance helpers -----------------------------------------------
async def http_get(
url: str,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""GET a local test server without loading an unused TLS trust store."""
request = httpx.Request("GET", url, headers=headers or {})
channel = _IN_PROCESS_HTTP_CHANNELS.get(request.url.port)
if channel is not None:
return await _in_process_http_get(channel, request)
deadline = asyncio.get_running_loop().time() + 2.0
while True:
try:
async with httpx.AsyncClient(
timeout=5.0,
trust_env=False,
verify=False,
) as client:
return await client.get(url, headers=headers or {})
except httpx.ConnectError:
if asyncio.get_running_loop().time() >= deadline:
raise
await asyncio.sleep(0.01)
async def issue_token(
host: str = "127.0.0.1",
port: int = 8765,
issue_path: str = "/auth/token",
secret: str = "",
) -> tuple[dict[str, Any] | None, int]:
"""Request a short-lived token from the token-issue HTTP endpoint.
Returns ``(parsed_json_or_None, status_code)``.
"""
url = f"http://{host}:{port}{issue_path}"
headers: dict[str, str] = {}
if secret:
headers["Authorization"] = f"Bearer {secret}"
resp = await http_get(url, headers)
try:
data = resp.json()
except Exception:
data = None
return data, resp.status_code
async def issue_token_ok(
host: str = "127.0.0.1",
port: int = 8765,
issue_path: str = "/auth/token",
secret: str = "",
) -> str:
"""Request a token, asserting success, and return the token string."""
(data, status) = await issue_token(host, port, issue_path, secret)
assert status == 200, f"Token issue failed with status {status}"
assert data is not None
token = data["token"]
assert token.startswith("nbwt_"), f"Unexpected token format: {token}"
return token