530 lines
25 KiB
Python
530 lines
25 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from yuxi.channel.exceptions import ChannelErrorClassification
|
|||
|
|
from yuxi.channel.message.models import RunAcceptedResponse
|
|||
|
|
from yuxi.channel.plugins.builders import _BaseChannelPlugin
|
|||
|
|
from yuxi.channel.plugins.protocol import (
|
|||
|
|
BindingConversationRef,
|
|||
|
|
BindingRoute,
|
|||
|
|
ChannelCapability,
|
|||
|
|
ChannelHealthStatus,
|
|||
|
|
ChannelMeta,
|
|||
|
|
ChannelPlugin,
|
|||
|
|
DeliveryCapabilities,
|
|||
|
|
DeliveryMode,
|
|||
|
|
InboundMedia,
|
|||
|
|
InboundMessage,
|
|||
|
|
InboundRequest,
|
|||
|
|
OutboundMessage,
|
|||
|
|
QRLoginSession,
|
|||
|
|
SessionConversationRef,
|
|||
|
|
Transport,
|
|||
|
|
TransportState,
|
|||
|
|
TransportType,
|
|||
|
|
)
|
|||
|
|
from yuxi.channel.ports import (
|
|||
|
|
ConfigPort,
|
|||
|
|
InboundPort,
|
|||
|
|
LifecyclePort,
|
|||
|
|
MetaPort,
|
|||
|
|
OutboundPort,
|
|||
|
|
SecurityPort,
|
|||
|
|
SessionPort,
|
|||
|
|
StatusPort,
|
|||
|
|
TransportPort,
|
|||
|
|
)
|
|||
|
|
from yuxi.channel.security.models import DmPolicy, GroupPolicy, RateLimitPolicy
|
|||
|
|
from yuxi.channel.security.policy import SecurityCheckResult
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestEnumsAndCapabilities:
|
|||
|
|
def test_channel_capability_flags(self):
|
|||
|
|
caps = ChannelCapability.TEXT | ChannelCapability.MARKDOWN
|
|||
|
|
assert ChannelCapability.TEXT in caps
|
|||
|
|
assert ChannelCapability.IMAGE not in caps
|
|||
|
|
|
|||
|
|
def test_delivery_mode_values(self):
|
|||
|
|
assert DeliveryMode.DIRECT == "direct"
|
|||
|
|
assert DeliveryMode.GATEWAY == "gateway"
|
|||
|
|
assert DeliveryMode.HYBRID == "hybrid"
|
|||
|
|
|
|||
|
|
def test_transport_type_values(self):
|
|||
|
|
assert TransportType.WEBHOOK == "webhook"
|
|||
|
|
assert TransportType.WEBSOCKET == "websocket"
|
|||
|
|
assert TransportType.POLLING == "polling"
|
|||
|
|
|
|||
|
|
def test_transport_state_values(self):
|
|||
|
|
assert TransportState.CONNECTED == "connected"
|
|||
|
|
assert TransportState.DISCONNECTED == "disconnected"
|
|||
|
|
|
|||
|
|
def test_channel_meta_defaults(self):
|
|||
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|||
|
|
assert meta.aliases == []
|
|||
|
|
assert meta.capabilities == ChannelCapability.TEXT
|
|||
|
|
assert meta.delivery_mode == DeliveryMode.DIRECT
|
|||
|
|
assert meta.transport_type == TransportType.WEBHOOK
|
|||
|
|
assert meta.config_schema == {}
|
|||
|
|
assert meta.ui_hints == {}
|
|||
|
|
assert meta.sort_weight == 0
|
|||
|
|
assert meta.icon is None
|
|||
|
|
assert meta.description == ""
|
|||
|
|
|
|||
|
|
def test_channel_meta_capability_matrix_defaults(self):
|
|||
|
|
from yuxi.channel.capabilities import CapabilityMatrix
|
|||
|
|
|
|||
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|||
|
|
assert meta.capability_matrix is not None
|
|||
|
|
assert isinstance(meta.capability_matrix, CapabilityMatrix)
|
|||
|
|
assert meta.capability_matrix.text is True
|
|||
|
|
assert meta.capability_matrix.markdown.value == "none"
|
|||
|
|
|
|||
|
|
def test_inbound_media_defaults(self):
|
|||
|
|
media = InboundMedia(media_type="image")
|
|||
|
|
assert media.url is None
|
|||
|
|
assert media.file_key is None
|
|||
|
|
assert media.file_name is None
|
|||
|
|
assert media.size is None
|
|||
|
|
assert media.mime_type is None
|
|||
|
|
|
|||
|
|
def test_outbound_message_defaults(self):
|
|||
|
|
msg = OutboundMessage(content="hello")
|
|||
|
|
assert msg.content_type == "text"
|
|||
|
|
assert msg.media == []
|
|||
|
|
assert msg.reply_to_channel_message_id is None
|
|||
|
|
assert msg.thread_id is None
|
|||
|
|
assert msg.extra == {}
|
|||
|
|
|
|||
|
|
def test_channel_health_status_defaults(self):
|
|||
|
|
status = ChannelHealthStatus(healthy=True, state="ok", enabled=True)
|
|||
|
|
assert status.last_connected_at is None
|
|||
|
|
assert status.last_message_at is None
|
|||
|
|
assert status.last_error is None
|
|||
|
|
assert status.reconnect_attempts == 0
|
|||
|
|
|
|||
|
|
def test_delivery_capabilities_defaults(self):
|
|||
|
|
caps = DeliveryCapabilities()
|
|||
|
|
assert caps.max_text_length == 4000
|
|||
|
|
assert caps.supports_markdown is False
|
|||
|
|
assert caps.supports_interactive is False
|
|||
|
|
assert caps.supports_media is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestRuntimeCheckableProtocols:
|
|||
|
|
def test_inbound_request_protocol_with_fake_request(self):
|
|||
|
|
from yuxi.channel.common.fake_request import FakeRequest
|
|||
|
|
|
|||
|
|
request = FakeRequest(config={}, body={})
|
|||
|
|
assert isinstance(request, InboundRequest)
|
|||
|
|
|
|||
|
|
def test_channel_plugin_protocol_with_port_based_minimal_implementation(self):
|
|||
|
|
class MinimalPlugin(
|
|||
|
|
MetaPort,
|
|||
|
|
ConfigPort,
|
|||
|
|
InboundPort,
|
|||
|
|
OutboundPort,
|
|||
|
|
TransportPort,
|
|||
|
|
SecurityPort,
|
|||
|
|
StatusPort,
|
|||
|
|
LifecyclePort,
|
|||
|
|
SessionPort,
|
|||
|
|
):
|
|||
|
|
def get_meta(self) -> ChannelMeta:
|
|||
|
|
return ChannelMeta(channel_type="test", display_name="Test")
|
|||
|
|
|
|||
|
|
def validate_config(self, config: dict) -> tuple[bool, list[str]]:
|
|||
|
|
return True, []
|
|||
|
|
|
|||
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def resolve_account(self, config: dict, account_id: str | None = None) -> dict:
|
|||
|
|
return config
|
|||
|
|
|
|||
|
|
async def validate_webhook(self, request: InboundRequest) -> bool:
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
async def preprocess_event(
|
|||
|
|
self, request: InboundRequest, config: dict, account_id: str
|
|||
|
|
) -> tuple[bool, dict[str, Any] | None, dict | None]:
|
|||
|
|
return True, None, {}
|
|||
|
|
|
|||
|
|
async def normalize_inbound(self, request: InboundRequest) -> InboundMessage:
|
|||
|
|
return InboundMessage(channel_type="test", account_id="acc")
|
|||
|
|
|
|||
|
|
async def build_webhook_response(
|
|||
|
|
self, request: InboundRequest, inbound: InboundMessage
|
|||
|
|
) -> dict[str, Any] | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def resolve_account_id_from_webhook(self, request: InboundRequest) -> str | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def format_outbound(self, message: OutboundMessage, config: dict | None = None) -> dict:
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
async def send_message(
|
|||
|
|
self, channel_session_id: str, payload: dict, config: dict | None = None
|
|||
|
|
) -> str | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def chunk_text(self, text: str, limit: int) -> list[str]:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def get_delivery_capabilities(self) -> DeliveryCapabilities:
|
|||
|
|
return DeliveryCapabilities()
|
|||
|
|
|
|||
|
|
async def enrich_outbound(
|
|||
|
|
self, payload: dict, message: OutboundMessage, config: dict | None = None
|
|||
|
|
) -> dict:
|
|||
|
|
return payload
|
|||
|
|
|
|||
|
|
async def upload_media(self, media: InboundMedia | dict, config: dict | None = None) -> dict | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def supports_batch_send(self, config: dict | None = None) -> bool:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
async def send_batch(
|
|||
|
|
self, channel_session_id: str, payloads: list[dict], config: dict | None = None
|
|||
|
|
) -> list[str | None]:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
async def send_typing_indicator(self, channel_session_id: str, config: dict | None = None) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def supports_content_type(self, content_type: str, config: dict | None = None) -> bool:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
async def create_transport(
|
|||
|
|
self, config: dict, account_id: str, credentials: dict | None = None
|
|||
|
|
) -> Transport | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def on_transport_message(self, raw: bytes, config: dict, account_id: str) -> InboundMessage | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def resolve_dm_policy(self, config: dict, account_id: str | None = None) -> DmPolicy | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def resolve_group_policy(self, config: dict, account_id: str | None = None) -> GroupPolicy | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def resolve_rate_limit_policy(self, config: dict, account_id: str | None = None) -> RateLimitPolicy | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def health_check(self, config: dict, account_id: str) -> ChannelHealthStatus:
|
|||
|
|
return ChannelHealthStatus(healthy=True, state="ok", enabled=True)
|
|||
|
|
|
|||
|
|
async def on_config_changed(self, config: dict, account_id: str) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def on_account_removed(self, account_id: str) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def on_channel_enabled(self, config: dict, account_id: str) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def on_channel_disabled(self, config: dict, account_id: str) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def run_startup_maintenance(self, config: dict, account_id: str) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def resolve_session_conversation(self, chat_type: str, raw_id: str) -> SessionConversationRef | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def parse_session_key(self, inbound: InboundMessage) -> str:
|
|||
|
|
return "key"
|
|||
|
|
|
|||
|
|
assert isinstance(MinimalPlugin(), ChannelPlugin)
|
|||
|
|
|
|||
|
|
def test_channel_plugin_protocol_backward_compatibility(self):
|
|||
|
|
class LegacyMinimalPlugin:
|
|||
|
|
def get_meta(self) -> ChannelMeta: ...
|
|||
|
|
def validate_config(self, config: dict) -> tuple[bool, list[str]]: ...
|
|||
|
|
async def create_transport(self, config: dict, account_id: str) -> Transport | None: ...
|
|||
|
|
async def on_transport_message(
|
|||
|
|
self, raw: bytes, config: dict, account_id: str
|
|||
|
|
) -> InboundMessage | None: ...
|
|||
|
|
async def validate_webhook(self, request: InboundRequest) -> bool: ...
|
|||
|
|
async def preprocess_event(
|
|||
|
|
self, request: InboundRequest, config: dict, account_id: str
|
|||
|
|
) -> tuple[bool, dict[str, Any] | None, dict | None]: ...
|
|||
|
|
async def normalize_inbound(self, request: InboundRequest) -> InboundMessage: ...
|
|||
|
|
async def build_webhook_response(
|
|||
|
|
self, request: InboundRequest, inbound: InboundMessage
|
|||
|
|
) -> dict[str, Any] | None: ...
|
|||
|
|
async def resolve_account_id_from_webhook(self, request: InboundRequest) -> str | None: ...
|
|||
|
|
async def format_outbound(self, message: OutboundMessage, config: dict | None = None) -> dict: ...
|
|||
|
|
async def send_message(
|
|||
|
|
self, channel_session_id: str, payload: dict, config: dict | None = None
|
|||
|
|
) -> str | None: ...
|
|||
|
|
def chunk_text(self, text: str, limit: int) -> list[str]: ...
|
|||
|
|
def get_delivery_capabilities(self) -> DeliveryCapabilities: ...
|
|||
|
|
def resolve_session_conversation(self, chat_type: str, raw_id: str) -> SessionConversationRef | None: ...
|
|||
|
|
def parse_session_key(self, inbound: InboundMessage) -> str: ...
|
|||
|
|
def list_account_ids(self, config: dict) -> list[str]: ...
|
|||
|
|
def resolve_account(self, config: dict, account_id: str | None = None) -> dict: ...
|
|||
|
|
def resolve_dm_policy(self, config: dict, account_id: str | None = None) -> DmPolicy | None: ...
|
|||
|
|
def resolve_group_policy(self, config: dict, account_id: str | None = None) -> GroupPolicy | None: ...
|
|||
|
|
def resolve_rate_limit_policy(
|
|||
|
|
self, config: dict, account_id: str | None = None
|
|||
|
|
) -> RateLimitPolicy | None: ...
|
|||
|
|
def compile_binding(self, config: dict, inbound: InboundMessage) -> Any | None: ...
|
|||
|
|
def match_binding(self, config: dict, binding: Any, inbound: InboundMessage) -> bool: ...
|
|||
|
|
def resolve_reply_to_mode(self, config: dict, inbound: InboundMessage) -> str | None: ...
|
|||
|
|
def resolve_auto_thread_id(self, config: dict, inbound: InboundMessage) -> str | None: ...
|
|||
|
|
def describe_actions(self, config: dict, inbound: InboundMessage) -> list[dict]: ...
|
|||
|
|
def handle_action(self, action: dict, config: dict, inbound: InboundMessage) -> dict | None: ...
|
|||
|
|
async def on_config_changed(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def on_account_removed(self, account_id: str) -> None: ...
|
|||
|
|
async def on_channel_enabled(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def on_channel_disabled(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def run_startup_maintenance(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
def classify_error(
|
|||
|
|
self, exc: Exception, payload: dict | None = None
|
|||
|
|
) -> tuple[ChannelErrorClassification, int | None]: ...
|
|||
|
|
async def upload_media(self, media: InboundMedia | dict, config: dict | None = None) -> dict | None: ...
|
|||
|
|
async def send_typing_indicator(self, channel_session_id: str, config: dict | None = None) -> None: ...
|
|||
|
|
def supports_content_type(self, content_type: str, config: dict | None = None) -> bool: ...
|
|||
|
|
async def setup_webhook(self, config: dict, callback_url: str) -> bool: ...
|
|||
|
|
async def delete_webhook(self, config: dict) -> bool: ...
|
|||
|
|
async def edit_message(
|
|||
|
|
self, channel_session_id: str, channel_message_id: str, new_payload: dict, config: dict | None = None
|
|||
|
|
) -> bool: ...
|
|||
|
|
async def delete_message(
|
|||
|
|
self, channel_session_id: str, channel_message_id: str, config: dict | None = None
|
|||
|
|
) -> bool: ...
|
|||
|
|
def supports_batch_send(self, config: dict | None = None) -> bool: ...
|
|||
|
|
async def send_batch(
|
|||
|
|
self, channel_session_id: str, payloads: list[dict], config: dict | None = None
|
|||
|
|
) -> list[str | None]: ...
|
|||
|
|
async def resolve_user_profile(self, sender_id: str, config: dict | None = None) -> dict | None: ...
|
|||
|
|
async def transform_inbound(self, raw_event: dict, config: dict | None = None) -> dict: ...
|
|||
|
|
async def enrich_outbound(
|
|||
|
|
self, payload: dict, message: OutboundMessage, config: dict | None = None
|
|||
|
|
) -> dict: ...
|
|||
|
|
async def health_check(self, config: dict, account_id: str) -> ChannelHealthStatus: ...
|
|||
|
|
def supports_qr_login(self, config: dict | None = None) -> bool: ...
|
|||
|
|
async def create_qr_login_session(self, config: dict, account_id: str) -> QRLoginSession: ...
|
|||
|
|
async def poll_qr_login_status(
|
|||
|
|
self, session: QRLoginSession, config: dict, account_id: str
|
|||
|
|
) -> QRLoginSession: ...
|
|||
|
|
async def load_qr_credentials(self, config: dict, account_id: str) -> QRLoginSession | None: ...
|
|||
|
|
async def logout_qr_login(self, config: dict, account_id: str) -> bool: ...
|
|||
|
|
def supports_scan_pairing(self, config: dict | None = None) -> bool: ...
|
|||
|
|
async def normalize_scan_event(
|
|||
|
|
self, request: Any, config: dict, account_id: str
|
|||
|
|
) -> InboundMessage | None: ...
|
|||
|
|
async def build_pairing_qr_reply(
|
|||
|
|
self,
|
|||
|
|
pairing_code: str,
|
|||
|
|
qr_content: str,
|
|||
|
|
config: dict,
|
|||
|
|
account_id: str,
|
|||
|
|
) -> OutboundMessage: ...
|
|||
|
|
async def download_attachment(
|
|||
|
|
self, inbound: InboundMessage, media: InboundMedia
|
|||
|
|
) -> tuple[bytes, str] | None: ...
|
|||
|
|
|
|||
|
|
assert isinstance(LegacyMinimalPlugin(), ChannelPlugin)
|
|||
|
|
|
|||
|
|
def test_inbound_message_protocol_uses_dataclass_fields(self):
|
|||
|
|
msg = InboundMessage(channel_type="test", account_id="acc")
|
|||
|
|
assert msg.content == ""
|
|||
|
|
assert msg.content_type == "text"
|
|||
|
|
assert msg.media == []
|
|||
|
|
assert msg.is_at_bot is False
|
|||
|
|
assert msg.timestamp is None
|
|||
|
|
|
|||
|
|
def test_binding_route_dataclass(self):
|
|||
|
|
route = BindingRoute(agent_id="agent", session_key="key", matched_by="rule")
|
|||
|
|
assert route.binding_rule_hash is None
|
|||
|
|
|
|||
|
|
def test_binding_conversation_ref_defaults(self):
|
|||
|
|
ref = BindingConversationRef(session_key="key", channel_type="test", account_id="acc")
|
|||
|
|
assert ref.chat_type is None
|
|||
|
|
assert ref.peer_id is None
|
|||
|
|
assert ref.thread_id is None
|
|||
|
|
assert ref.binding_rule_hash == ""
|
|||
|
|
|
|||
|
|
def test_session_conversation_ref_defaults(self):
|
|||
|
|
ref = SessionConversationRef(session_key="key")
|
|||
|
|
assert ref.chat_type is None
|
|||
|
|
assert ref.channel_sender_id is None
|
|||
|
|
assert ref.parent_conversation_candidates == []
|
|||
|
|
assert ref.channel_metadata == {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestScanPairingExtension:
|
|||
|
|
class _ChannelPluginStub:
|
|||
|
|
def get_meta(self) -> ChannelMeta: ...
|
|||
|
|
def validate_config(self, config: dict) -> tuple[bool, list[str]]: ...
|
|||
|
|
async def create_transport(
|
|||
|
|
self, config: dict, account_id: str, credentials: dict | None = None
|
|||
|
|
) -> Transport | None: ...
|
|||
|
|
async def on_transport_message(self, raw: bytes, config: dict, account_id: str) -> InboundMessage | None: ...
|
|||
|
|
async def validate_webhook(self, request: InboundRequest) -> bool: ...
|
|||
|
|
async def preprocess_event(
|
|||
|
|
self, request: InboundRequest, config: dict, account_id: str
|
|||
|
|
) -> tuple[bool, dict[str, Any] | None, dict | None]: ...
|
|||
|
|
async def normalize_inbound(self, request: InboundRequest) -> InboundMessage: ...
|
|||
|
|
async def build_webhook_response(
|
|||
|
|
self, request: InboundRequest, inbound: InboundMessage
|
|||
|
|
) -> dict[str, Any] | None: ...
|
|||
|
|
async def resolve_account_id_from_webhook(self, request: InboundRequest) -> str | None: ...
|
|||
|
|
async def format_outbound(self, message: OutboundMessage, config: dict | None = None) -> dict: ...
|
|||
|
|
async def send_message(
|
|||
|
|
self, channel_session_id: str, payload: dict, config: dict | None = None
|
|||
|
|
) -> str | None: ...
|
|||
|
|
def chunk_text(self, text: str, limit: int) -> list[str]: ...
|
|||
|
|
def get_delivery_capabilities(self) -> DeliveryCapabilities: ...
|
|||
|
|
def resolve_session_conversation(self, chat_type: str, raw_id: str) -> SessionConversationRef | None: ...
|
|||
|
|
def parse_session_key(self, inbound: InboundMessage) -> str: ...
|
|||
|
|
def list_account_ids(self, config: dict) -> list[str]: ...
|
|||
|
|
def resolve_account(self, config: dict, account_id: str | None = None) -> dict: ...
|
|||
|
|
def resolve_dm_policy(self, config: dict, account_id: str | None = None) -> DmPolicy | None: ...
|
|||
|
|
def resolve_group_policy(self, config: dict, account_id: str | None = None) -> GroupPolicy | None: ...
|
|||
|
|
def resolve_rate_limit_policy(self, config: dict, account_id: str | None = None) -> RateLimitPolicy | None: ...
|
|||
|
|
def compile_binding(self, config: dict, inbound: InboundMessage) -> Any | None: ...
|
|||
|
|
def match_binding(self, config: dict, binding: Any, inbound: InboundMessage) -> bool: ...
|
|||
|
|
def resolve_reply_to_mode(self, config: dict, inbound: InboundMessage) -> str | None: ...
|
|||
|
|
def resolve_auto_thread_id(self, config: dict, inbound: InboundMessage) -> str | None: ...
|
|||
|
|
def describe_actions(self, config: dict, inbound: InboundMessage) -> list[dict]: ...
|
|||
|
|
def handle_action(self, action: dict, config: dict, inbound: InboundMessage) -> dict | None: ...
|
|||
|
|
async def on_config_changed(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def on_account_removed(self, account_id: str) -> None: ...
|
|||
|
|
async def on_channel_enabled(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def on_channel_disabled(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
async def run_startup_maintenance(self, config: dict, account_id: str) -> None: ...
|
|||
|
|
def classify_error(
|
|||
|
|
self, exc: Exception, payload: dict | None = None
|
|||
|
|
) -> tuple[ChannelErrorClassification, int | None]: ...
|
|||
|
|
async def upload_media(self, media: InboundMedia | dict, config: dict | None = None) -> dict | None: ...
|
|||
|
|
async def send_typing_indicator(self, channel_session_id: str, config: dict | None = None) -> None: ...
|
|||
|
|
def supports_content_type(self, content_type: str, config: dict | None = None) -> bool: ...
|
|||
|
|
async def setup_webhook(self, config: dict, callback_url: str) -> bool: ...
|
|||
|
|
async def delete_webhook(self, config: dict) -> bool: ...
|
|||
|
|
async def edit_message(
|
|||
|
|
self,
|
|||
|
|
channel_session_id: str,
|
|||
|
|
channel_message_id: str,
|
|||
|
|
new_payload: dict,
|
|||
|
|
config: dict | None = None,
|
|||
|
|
) -> bool: ...
|
|||
|
|
async def delete_message(
|
|||
|
|
self,
|
|||
|
|
channel_session_id: str,
|
|||
|
|
channel_message_id: str,
|
|||
|
|
config: dict | None = None,
|
|||
|
|
) -> bool: ...
|
|||
|
|
def supports_batch_send(self, config: dict | None = None) -> bool: ...
|
|||
|
|
async def send_batch(
|
|||
|
|
self, channel_session_id: str, payloads: list[dict], config: dict | None = None
|
|||
|
|
) -> list[str | None]: ...
|
|||
|
|
async def resolve_user_profile(self, sender_id: str, config: dict | None = None) -> dict | None: ...
|
|||
|
|
async def transform_inbound(self, raw_event: dict, config: dict | None = None) -> dict: ...
|
|||
|
|
async def enrich_outbound(
|
|||
|
|
self, payload: dict, message: OutboundMessage, config: dict | None = None
|
|||
|
|
) -> dict: ...
|
|||
|
|
def supports_qr_login(self, config: dict | None = None) -> bool: ...
|
|||
|
|
async def create_qr_login_session(self, config: dict, account_id: str) -> QRLoginSession: ...
|
|||
|
|
async def poll_qr_login_status(
|
|||
|
|
self, session: QRLoginSession, config: dict, account_id: str
|
|||
|
|
) -> QRLoginSession: ...
|
|||
|
|
async def load_qr_credentials(self, config: dict, account_id: str) -> QRLoginSession | None: ...
|
|||
|
|
async def logout_qr_login(self, config: dict, account_id: str) -> bool: ...
|
|||
|
|
async def health_check(self, config: dict, account_id: str) -> ChannelHealthStatus: ...
|
|||
|
|
async def download_attachment(
|
|||
|
|
self, inbound: InboundMessage, media: InboundMedia
|
|||
|
|
) -> tuple[bytes, str] | None: ...
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
def test_inbound_message_scan_event_default(self):
|
|||
|
|
msg = InboundMessage(channel_type="test", account_id="acc")
|
|||
|
|
assert msg.is_scan_event is False
|
|||
|
|
scan_msg = InboundMessage(channel_type="test", account_id="acc", is_scan_event=True)
|
|||
|
|
assert scan_msg.is_scan_event is True
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
def test_run_accepted_response_scan_pairing_fields(self):
|
|||
|
|
reply = OutboundMessage(content="scan me")
|
|||
|
|
response = RunAcceptedResponse(
|
|||
|
|
accepted=False,
|
|||
|
|
pairing_code="CODE123",
|
|||
|
|
qr_content="https://example.com/bind?code=CODE123",
|
|||
|
|
qr_reply=reply,
|
|||
|
|
)
|
|||
|
|
assert response.qr_content == "https://example.com/bind?code=CODE123"
|
|||
|
|
assert response.qr_reply is reply
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
def test_security_check_result_scan_pairing_fields(self):
|
|||
|
|
reply = OutboundMessage(content="scan me")
|
|||
|
|
result = SecurityCheckResult(
|
|||
|
|
allowed=False,
|
|||
|
|
pairing_code="CODE123",
|
|||
|
|
qr_content="https://example.com/bind?code=CODE123",
|
|||
|
|
qr_reply=reply,
|
|||
|
|
)
|
|||
|
|
assert result.qr_content == "https://example.com/bind?code=CODE123"
|
|||
|
|
assert result.qr_reply is reply
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
def test_channel_plugin_with_scan_pairing_methods(self):
|
|||
|
|
class PluginWithScanPairing(self._ChannelPluginStub):
|
|||
|
|
def supports_scan_pairing(self, config: dict | None = None) -> bool:
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
async def normalize_scan_event(
|
|||
|
|
self,
|
|||
|
|
request: Any,
|
|||
|
|
config: dict,
|
|||
|
|
account_id: str,
|
|||
|
|
) -> InboundMessage | None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def build_pairing_qr_reply(
|
|||
|
|
self,
|
|||
|
|
pairing_code: str,
|
|||
|
|
qr_content: str,
|
|||
|
|
config: dict,
|
|||
|
|
account_id: str,
|
|||
|
|
) -> OutboundMessage:
|
|||
|
|
return OutboundMessage(content=qr_content)
|
|||
|
|
|
|||
|
|
assert isinstance(PluginWithScanPairing(), ChannelPlugin)
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
def test_channel_plugin_without_scan_pairing_methods_passes_isinstance(self):
|
|||
|
|
"""_BaseChannelPlugin 提供扫码配对默认实现,未重写这些方法仍满足 Protocol。"""
|
|||
|
|
|
|||
|
|
class PluginWithoutScanPairing(_BaseChannelPlugin):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|||
|
|
plugin = PluginWithoutScanPairing(meta=meta, adapters={})
|
|||
|
|
assert isinstance(plugin, ChannelPlugin)
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
async def test_base_channel_plugin_scan_pairing_defaults(self):
|
|||
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|||
|
|
plugin = _BaseChannelPlugin(meta=meta, adapters={})
|
|||
|
|
assert isinstance(plugin, ChannelPlugin)
|
|||
|
|
assert plugin.supports_scan_pairing(None) is False
|
|||
|
|
assert plugin.supports_scan_pairing({}) is False
|
|||
|
|
assert plugin.supports_scan_pairing({"pairing": {"mode": "code"}}) is False
|
|||
|
|
assert plugin.supports_scan_pairing({"pairing": {"mode": "qr"}}) is True
|
|||
|
|
assert plugin.supports_scan_pairing({"pairing": {"mode": "both"}}) is True
|
|||
|
|
|
|||
|
|
normalized = await plugin.normalize_scan_event(None, {}, "acc")
|
|||
|
|
assert normalized is None
|
|||
|
|
|
|||
|
|
reply = await plugin.build_pairing_qr_reply("CODE123", "qr-content", {}, "acc")
|
|||
|
|
assert isinstance(reply, OutboundMessage)
|
|||
|
|
assert reply.content == "请扫描下方二维码完成绑定:CODE123"
|
|||
|
|
assert reply.content_type == "text"
|