Files
2026-07-13 13:12:33 +08:00

898 lines
31 KiB
Python

from __future__ import annotations
import importlib
from pathlib import Path
from types import SimpleNamespace
import pytest
from opensquilla.artifacts import ArtifactStore
from opensquilla.channels.artifact_delivery import (
can_deliver_channel_files,
deliver_artifacts_as_channel_files,
strip_delivered_artifact_image_references,
)
from opensquilla.channels.contract import (
PUBLIC_VENDOR_ADAPTERS,
ChannelCapabilities,
ChannelCapabilityProfile,
ChannelPlatformCapabilityStatus,
ChannelPlatformCategories,
ChannelPlatformManifest,
ChannelSendResult,
ChannelSendStatus,
channel_capability_profile,
channel_platform_manifest,
normalize_channel_send_result,
run_channel_contract,
)
from opensquilla.channels.dingtalk import DingTalkChannel, DingTalkChannelConfig
from opensquilla.channels.discord import DiscordChannel, DiscordChannelConfig
from opensquilla.channels.feishu import FeishuChannel, FeishuChannelConfig
from opensquilla.channels.manager import ChannelManager
from opensquilla.channels.matrix import MatrixChannel, MatrixChannelConfig
from opensquilla.channels.msteams import MSTeamsChannel, MSTeamsChannelConfig
from opensquilla.channels.qq import QQChannel, QQChannelConfig
from opensquilla.channels.slack import SlackChannel
from opensquilla.channels.telegram import TelegramChannel, TelegramChannelConfig
from opensquilla.channels.types import IncomingMessage
from opensquilla.channels.wecom import WeComChannel, WeComChannelConfig
from opensquilla.gateway.routing import build_channel_route_envelope
PlatformCapabilityExpectation = dict[
str,
tuple[ChannelPlatformCapabilityStatus, tuple[str, ...], tuple[str, ...]],
]
def test_channel_capabilities_cover_structured_delivery_and_events() -> None:
assert ChannelCapabilities.ARTIFACT_DELIVERY == "artifact_delivery"
assert ChannelCapabilities.NATIVE_FILE_UPLOAD == "native_file_upload"
assert ChannelCapabilities.MEDIA == "media"
assert ChannelCapabilities.REACTIONS == "reactions"
assert ChannelCapabilities.THREADS == "threads"
assert ChannelCapabilities.EDIT == "edit"
assert ChannelCapabilities.CARDS == "cards"
assert ChannelCapabilities.MEMBER_EVENTS == "member_events"
@pytest.mark.parametrize("adapter_name", PUBLIC_VENDOR_ADAPTERS)
def test_public_vendor_adapters_keep_shared_channel_contract(adapter_name: str) -> None:
module = importlib.import_module(f"opensquilla.channels.{adapter_name}")
run_channel_contract(module)
def test_channel_capability_profile_derives_compatibility_tags() -> None:
profile = ChannelCapabilityProfile(
channel_type="discord",
group_chat=True,
mentions=True,
typing_indicator=True,
native_file_upload=True,
media=True,
reactions=True,
threads=True,
edit=True,
delete=True,
transports=("websocket",),
)
assert profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert profile.supports(ChannelCapabilities.TYPING_INDICATOR)
assert profile.supports(ChannelCapabilities.WEBSOCKET)
assert profile.capability_tags() >= {
ChannelCapabilities.GROUP_CHAT,
ChannelCapabilities.MENTIONS,
ChannelCapabilities.TYPING_INDICATOR,
ChannelCapabilities.NATIVE_FILE_UPLOAD,
ChannelCapabilities.MEDIA,
ChannelCapabilities.REACTIONS,
ChannelCapabilities.THREADS,
ChannelCapabilities.EDIT,
ChannelCapabilities.WEBSOCKET,
}
def test_capability_profile_exposes_precise_channel_features() -> None:
profile = ChannelCapabilityProfile(
channel_type="example",
group_chat=True,
mentions=True,
native_file_upload=True,
artifact_delivery=True,
inbound_reactions=True,
outbound_status_reactions=False,
thread_messages=True,
thread_lifecycle=False,
interactive_cards=False,
card_actions=False,
member_events=True,
transports=("websocket",),
)
tags = profile.capability_tags()
assert ChannelCapabilities.GROUP_CHAT in tags
assert ChannelCapabilities.NATIVE_FILE_UPLOAD in tags
assert ChannelCapabilities.INBOUND_REACTIONS in tags
assert ChannelCapabilities.OUTBOUND_STATUS_REACTIONS not in tags
assert ChannelCapabilities.THREAD_MESSAGES in tags
assert ChannelCapabilities.THREAD_LIFECYCLE not in tags
assert ChannelCapabilities.CARD_ACTIONS not in tags
def test_platform_manifest_derives_honest_boundary_from_profile() -> None:
profile = ChannelCapabilityProfile(
channel_type="example",
group_chat=True,
native_file_upload=True,
media=True,
thread_reply=True,
cards=True,
scope_diagnostics=True,
)
manifest = ChannelPlatformManifest.from_channel_profile(
profile,
has_send_file=True,
has_inbound_attachment_resolver=True,
)
assert manifest.supports(ChannelPlatformCategories.CHAT)
assert manifest.supports(ChannelPlatformCategories.FILES)
assert manifest.supports(ChannelPlatformCategories.ATTACHMENTS)
assert manifest.supports(ChannelPlatformCategories.THREADS)
assert manifest.supports(ChannelPlatformCategories.CARDS)
assert manifest.supports(ChannelPlatformCategories.SCOPES)
assert manifest.get(ChannelPlatformCategories.DOCS).status == (
ChannelPlatformCapabilityStatus.UNSUPPORTED
)
assert manifest.get(ChannelPlatformCategories.PERMISSIONS).status == (
ChannelPlatformCapabilityStatus.UNSUPPORTED
)
@pytest.mark.parametrize(
("adapter_name", "channel"),
[
("slack", SlackChannel(token="xoxb-token", slack_channel_id="C-default")),
("discord", DiscordChannel(DiscordChannelConfig(token="token"))),
(
"feishu",
FeishuChannel(
FeishuChannelConfig(
app_id="app",
app_secret="secret",
connection_mode="websocket",
)
),
),
("dingtalk", DingTalkChannel(DingTalkChannelConfig())),
("wecom", WeComChannel(WeComChannelConfig())),
("qq", QQChannel(QQChannelConfig())),
("msteams", MSTeamsChannel(MSTeamsChannelConfig())),
("matrix", MatrixChannel(MatrixChannelConfig())),
("telegram", TelegramChannel(TelegramChannelConfig(transport_name="webhook"))),
],
)
def test_public_vendor_channels_expose_platform_manifests(
adapter_name: str,
channel: object,
) -> None:
manifest = channel_platform_manifest(channel)
assert isinstance(manifest, ChannelPlatformManifest)
assert manifest.channel_type == adapter_name
assert manifest.get(ChannelPlatformCategories.CHAT).status == (
ChannelPlatformCapabilityStatus.SUPPORTED
)
assert manifest.get(ChannelPlatformCategories.FILES).status in {
ChannelPlatformCapabilityStatus.SUPPORTED,
ChannelPlatformCapabilityStatus.UNSUPPORTED,
ChannelPlatformCapabilityStatus.CONFIG_REQUIRED,
}
assert manifest.get(ChannelPlatformCategories.DOCS).status in {
ChannelPlatformCapabilityStatus.SUPPORTED,
ChannelPlatformCapabilityStatus.UNSUPPORTED,
ChannelPlatformCapabilityStatus.CONFIG_REQUIRED,
}
def test_feishu_platform_manifest_exposes_platform_tool_boundary() -> None:
channel = FeishuChannel(
FeishuChannelConfig(app_id="app", app_secret="secret", connection_mode="websocket")
)
manifest = channel_platform_manifest(channel)
assert isinstance(manifest, ChannelPlatformManifest)
docs = manifest.get(ChannelPlatformCategories.DOCS)
drive = manifest.get(ChannelPlatformCategories.DRIVE)
wiki = manifest.get(ChannelPlatformCategories.WIKI)
scopes = manifest.get(ChannelPlatformCategories.SCOPES)
permissions = manifest.get(ChannelPlatformCategories.PERMISSIONS)
assert docs.status == ChannelPlatformCapabilityStatus.SUPPORTED
assert "feishu_doc_create" in docs.tools
assert "docx:document" in docs.required_scopes
assert drive.status == ChannelPlatformCapabilityStatus.SUPPORTED
assert "feishu_drive_upload_artifact" in drive.tools
assert "drive:drive" in drive.required_scopes
assert wiki.status == ChannelPlatformCapabilityStatus.SUPPORTED
assert "feishu_wiki_list_spaces" in wiki.tools
assert "wiki:space:retrieve" in wiki.required_scopes
assert scopes.status == ChannelPlatformCapabilityStatus.SUPPORTED
assert "feishu_scopes_status" in scopes.tools
assert permissions.status == ChannelPlatformCapabilityStatus.CONFIG_REQUIRED
assert "feishu_perm_grant_member" in permissions.tools
assert permissions.mutates is True
@pytest.mark.parametrize(
("channel", "expectations"),
[
(
SlackChannel(token="xoxb-token", slack_channel_id="C-default"),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("files.getUploadURLExternal", "files.completeUploadExternal"),
("files:write",),
),
ChannelPlatformCategories.THREADS: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("thread_ts",),
(),
),
},
),
(
DiscordChannel(DiscordChannelConfig(token="token")),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("multipart/form-data message attachments",),
(),
),
ChannelPlatformCategories.ATTACHMENTS: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("attachment.url",),
(),
),
},
),
(
TelegramChannel(TelegramChannelConfig(transport_name="webhook")),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("sendDocument", "getFile"),
(),
),
ChannelPlatformCategories.ATTACHMENTS: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("getFile",),
(),
),
},
),
(
MatrixChannel(MatrixChannelConfig()),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("media.upload", "room_send"),
(),
),
ChannelPlatformCategories.ATTACHMENTS: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("media.download",),
(),
),
},
),
(
WeComChannel(WeComChannelConfig()),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("media/upload", "message/send:file"),
(),
),
ChannelPlatformCategories.ATTACHMENTS: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
(),
(),
),
},
),
(
MSTeamsChannel(MSTeamsChannelConfig()),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
("FileConsentCard", "Microsoft Graph file attachments"),
(),
),
ChannelPlatformCategories.ATTACHMENTS: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
("Bot Framework attachments",),
(),
),
},
),
(
DingTalkChannel(DingTalkChannelConfig()),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
(),
(),
),
ChannelPlatformCategories.CARDS: (
ChannelPlatformCapabilityStatus.SUPPORTED,
("MarkdownCardInstance",),
(),
),
},
),
(
QQChannel(QQChannelConfig()),
{
ChannelPlatformCategories.FILES: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
(),
(),
),
ChannelPlatformCategories.MEDIA: (
ChannelPlatformCapabilityStatus.UNSUPPORTED,
(),
(),
),
},
),
],
)
def test_non_feishu_platform_manifests_are_provider_specific(
channel: object,
expectations: PlatformCapabilityExpectation,
) -> None:
manifest = channel_platform_manifest(channel)
assert isinstance(manifest, ChannelPlatformManifest)
for category, (status, tools, required_scopes) in expectations.items():
capability = manifest.get(category)
assert capability.status == status
assert capability.tools == tools
assert capability.required_scopes == required_scopes
assert capability.notes
@pytest.mark.parametrize(
("adapter_name", "channel"),
[
("slack", SlackChannel(token="xoxb-token", slack_channel_id="C-default")),
("discord", DiscordChannel(DiscordChannelConfig(token="token"))),
(
"feishu",
FeishuChannel(
FeishuChannelConfig(
app_id="app",
app_secret="secret",
connection_mode="websocket",
)
),
),
("dingtalk", DingTalkChannel(DingTalkChannelConfig())),
("wecom", WeComChannel(WeComChannelConfig())),
("qq", QQChannel(QQChannelConfig())),
("msteams", MSTeamsChannel(MSTeamsChannelConfig())),
("matrix", MatrixChannel(MatrixChannelConfig())),
("telegram", TelegramChannel(TelegramChannelConfig(transport_name="webhook"))),
],
)
def test_public_vendor_channels_expose_typed_capability_profiles(
adapter_name: str,
channel: object,
) -> None:
profile = channel_capability_profile(channel)
assert isinstance(profile, ChannelCapabilityProfile)
assert profile.channel_type == adapter_name
def test_slack_profile_matches_current_web_api_adapter_surface() -> None:
channel = SlackChannel(
token="xoxb-token",
slack_channel_id="C-default",
status_reactions_enabled=True,
)
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBHOOK)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.THREADS)
assert profile.supports(ChannelCapabilities.THREAD_REPLY)
assert profile.supports(ChannelCapabilities.EDIT)
assert profile.supports(ChannelCapabilities.DELETE)
assert profile.supports(ChannelCapabilities.OUTBOUND_STATUS_REACTIONS)
assert profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert not profile.supports(ChannelCapabilities.CARD_ACTIONS)
def test_telegram_profile_matches_current_bot_api_adapter_surface() -> None:
channel = TelegramChannel(TelegramChannelConfig(transport_name="webhook"))
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBHOOK)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.MEDIA)
assert profile.supports(ChannelCapabilities.REPLY)
assert profile.supports(ChannelCapabilities.THREAD_REPLY)
assert profile.supports(ChannelCapabilities.EDIT)
assert profile.supports(ChannelCapabilities.DELETE)
assert not profile.supports(ChannelCapabilities.TYPING_INDICATOR)
assert profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
def test_matrix_profile_matches_current_sync_adapter_surface() -> None:
channel = MatrixChannel(MatrixChannelConfig())
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBSOCKET)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.MEDIA)
assert profile.supports(ChannelCapabilities.REPLY)
assert profile.supports(ChannelCapabilities.EDIT)
assert profile.supports(ChannelCapabilities.DELETE)
assert not profile.supports(ChannelCapabilities.THREAD_REPLY)
assert profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
def test_msteams_profile_matches_current_bot_framework_adapter_surface() -> None:
channel = MSTeamsChannel(MSTeamsChannelConfig())
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBHOOK)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.REPLY)
assert profile.supports(ChannelCapabilities.EDIT)
assert profile.supports(ChannelCapabilities.DELETE)
assert not profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert not profile.supports(ChannelCapabilities.CARD_ACTIONS)
def test_dingtalk_profile_matches_current_stream_adapter_surface() -> None:
channel = DingTalkChannel(DingTalkChannelConfig())
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBSOCKET)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.REPLY)
assert profile.supports(ChannelCapabilities.CARDS)
assert not profile.supports(ChannelCapabilities.EDIT)
assert not profile.supports(ChannelCapabilities.DELETE)
assert not profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
def test_wecom_profile_matches_current_corp_app_adapter_surface() -> None:
channel = WeComChannel(WeComChannelConfig())
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBHOOK)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.REPLY)
assert not profile.supports(ChannelCapabilities.EDIT)
assert not profile.supports(ChannelCapabilities.DELETE)
assert profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert profile.supports(ChannelCapabilities.MEDIA)
def test_qq_profile_matches_current_official_bot_adapter_surface() -> None:
channel = QQChannel(QQChannelConfig())
profile = channel.capability_profile
assert profile.supports(ChannelCapabilities.WEBSOCKET)
assert profile.supports(ChannelCapabilities.GROUP_CHAT)
assert profile.supports(ChannelCapabilities.MENTIONS)
assert profile.supports(ChannelCapabilities.REPLY)
assert not profile.supports(ChannelCapabilities.EDIT)
assert not profile.supports(ChannelCapabilities.DELETE)
assert not profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert not profile.supports(ChannelCapabilities.MEDIA)
def test_group_thread_metadata_builds_thread_session_key() -> None:
msg = IncomingMessage(
sender_id="user-1",
channel_id="chat-1",
content="hello",
metadata={
"is_group": True,
"conversation_kind": "thread",
"native_thread_id": "thread-9",
"thread_id": "ignored-legacy-thread",
},
)
key = ChannelManager._build_session_key("discord", msg)
assert key == "agent:main:discord:group:chat-1:thread:thread-9"
def test_dm_message_uses_sender_session_even_with_native_message_metadata() -> None:
msg = IncomingMessage(
sender_id="user-1",
channel_id="dm-1",
content="hello",
metadata={
"is_group": False,
"native_message_id": "msg-1",
"native_thread_id": "thread-1",
},
)
key = ChannelManager._build_session_key("feishu", msg)
assert key == "agent:main:feishu:direct:user-1"
def test_channel_send_result_normalizes_legacy_none_success() -> None:
result = normalize_channel_send_result(
None,
capability=ChannelCapabilities.NATIVE_FILE_UPLOAD,
target_id="c1",
)
assert result == ChannelSendResult(
status=ChannelSendStatus.SENT,
capability=ChannelCapabilities.NATIVE_FILE_UPLOAD,
target_id="c1",
)
def test_strip_delivered_artifact_image_references_removes_loose_image_lines() -> None:
text = "Here is the image:\nimage: generated-chart.png\nDone."
artifacts = [{"name": "generated-chart.png"}]
assert strip_delivered_artifact_image_references(text, artifacts) == (
"Here is the image:\nDone."
)
@pytest.mark.asyncio
async def test_artifact_delivery_honors_profile_without_native_file_upload(tmp_path: Path) -> None:
store = ArtifactStore(tmp_path)
ref = store.publish_bytes(
b"report",
session_id="session-1",
session_key="agent:main:channel:session-1",
name="report.txt",
mime="text/plain",
source="test",
)
class TextOnlyChannel:
capability_profile = ChannelCapabilityProfile(
channel_type="text_only",
native_file_upload=False,
media=False,
)
send_file_called = False
async def send_file(self, *_args: object, **_kwargs: object) -> None:
self.send_file_called = True
channel = TextOnlyChannel()
msg = IncomingMessage(
sender_id="u1",
channel_id="c1",
content="",
metadata={"is_group": False},
)
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
assert can_deliver_channel_files(channel) is False
undelivered = await deliver_artifacts_as_channel_files(
channel,
msg,
[ref.to_dict()],
config,
)
assert undelivered == [ref.to_dict()]
assert channel.send_file_called is False
@pytest.mark.asyncio
async def test_artifact_delivery_preserves_fallback_on_structured_failure(
tmp_path: Path,
) -> None:
store = ArtifactStore(tmp_path)
ref = store.publish_bytes(
b"report",
session_id="session-1",
session_key="agent:main:channel:session-1",
name="report.txt",
mime="text/plain",
source="test",
)
class FailingFileChannel:
capability_profile = ChannelCapabilityProfile(
channel_type="files",
native_file_upload=True,
media=True,
)
async def send_file(self, channel_id: str, file_path: str) -> ChannelSendResult:
assert channel_id == "c1"
assert Path(file_path).is_file()
return ChannelSendResult.failed(
capability=ChannelCapabilities.NATIVE_FILE_UPLOAD,
target_id=channel_id,
reason="simulated",
)
msg = IncomingMessage(
sender_id="u1",
channel_id="c1",
content="",
metadata={"is_group": False},
)
config = SimpleNamespace(attachments=SimpleNamespace(media_root=str(tmp_path)))
undelivered = await deliver_artifacts_as_channel_files(
FailingFileChannel(),
msg,
[ref.to_dict()],
config,
)
assert undelivered == [ref.to_dict()]
def test_feishu_profile_and_inbound_group_metadata() -> None:
channel = FeishuChannel(
FeishuChannelConfig(app_id="app", app_secret="secret", connection_mode="websocket")
)
assert channel.capability_profile.supports(ChannelCapabilities.WEBSOCKET)
assert channel.capability_profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
assert channel.capability_profile.supports(ChannelCapabilities.REPLY)
assert not channel.capability_profile.supports(ChannelCapabilities.INBOUND_REACTIONS)
assert not channel.capability_profile.supports(ChannelCapabilities.THREAD_MESSAGES)
assert channel.capability_profile.supports(ChannelCapabilities.THREAD_REPLY)
assert channel.capability_profile.supports(ChannelCapabilities.SCOPE_DIAGNOSTICS)
assert channel.capability_profile.supports(ChannelCapabilities.INTERACTIVE_CARDS)
assert not channel.capability_profile.supports(ChannelCapabilities.CARD_ACTIONS)
group_msg = channel.parse_event(
{
"header": {"event_id": "evt-group"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_group",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": '{"text":"hi"}',
},
},
}
)
direct_msg = channel.parse_event(
{
"header": {"event_id": "evt-dm"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_dm",
"chat_id": "ou_user",
"chat_type": "p2p",
"message_type": "text",
"content": '{"text":"hi"}',
},
},
}
)
assert group_msg.metadata["is_group"] is True
assert direct_msg.metadata["is_group"] is False
reply = channel.build_reply_message("hello", group_msg)
assert reply.reply_to == "oc_group"
assert reply.metadata["reply_message_id"] == "om_group"
def test_feishu_parse_event_preserves_native_conversation_metadata() -> None:
channel = FeishuChannel(
FeishuChannelConfig(
app_id="app",
app_secret="secret",
connection_mode="websocket",
status_reactions_enabled=True,
)
)
assert channel.capability_profile.supports(ChannelCapabilities.OUTBOUND_STATUS_REACTIONS)
msg = channel.parse_event(
{
"header": {"event_id": "evt-thread", "event_type": "im.message.receive_v1"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_message",
"root_id": "om_root",
"parent_id": "om_parent",
"thread_id": "omt_thread",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": '{"text":"@_user_1 hello"}',
"mentions": [
{"key": "@_user_1", "id": {"open_id": "ou_bot"}},
{"key": "@_user_2", "id": {"open_id": "ou_user_2"}},
],
},
},
}
)
assert msg.metadata["conversation_kind"] == "thread"
assert msg.metadata["message_id"] == "om_message"
assert msg.metadata["chat_id"] == "oc_group"
assert msg.metadata["root_id"] == "om_root"
assert msg.metadata["parent_id"] == "om_parent"
assert "thread_id" not in msg.metadata
assert msg.metadata["native_message_id"] == "om_message"
assert msg.metadata["native_chat_id"] == "oc_group"
assert msg.metadata["native_root_id"] == "om_root"
assert msg.metadata["native_parent_id"] == "om_parent"
assert msg.metadata["native_thread_id"] == "omt_thread"
assert msg.metadata["reply_target_id"] == "om_message"
assert msg.metadata["mentions"] == [
{"key": "@_user_1", "id": {"open_id": "ou_bot"}},
{"key": "@_user_2", "id": {"open_id": "ou_user_2"}},
]
assert msg.metadata["mention_map"] == {
"@_user_1": "ou_bot",
"@_user_2": "ou_user_2",
}
channel.bot_open_id = "ou_bot"
assert channel.is_group_mentioned(msg) is True
envelope = build_channel_route_envelope(
msg,
session_key="agent:main:feishu:group:oc_group:thread:omt_thread",
session_prefix="feishu",
)
assert envelope.channel_id == "oc_group"
assert envelope.thread_id is None
assert envelope.reply_target is not None
assert envelope.reply_target.to == "oc_group"
assert envelope.reply_target.thread_id is None
def test_feishu_topic_group_thread_remains_group_thread_session() -> None:
channel = FeishuChannel(
FeishuChannelConfig(app_id="app", app_secret="secret", connection_mode="websocket")
)
msg = channel.parse_event(
{
"header": {"event_id": "evt-topic", "event_type": "im.message.receive_v1"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_topic",
"thread_id": "omt_topic",
"chat_id": "oc_topic",
"chat_type": "topic_group",
"message_type": "text",
"content": '{"text":"topic hello"}',
},
},
}
)
assert msg.metadata["conversation_kind"] == "topic"
assert msg.metadata["is_group"] is True
assert msg.metadata["native_thread_id"] == "omt_topic"
assert "thread_id" not in msg.metadata
assert ChannelManager._build_session_key("feishu", msg) == (
"agent:main:feishu:group:oc_topic:thread:omt_topic"
)
def test_discord_profile_and_inbound_group_metadata() -> None:
channel = DiscordChannel(DiscordChannelConfig(token="token"))
assert channel.capability_profile.supports(ChannelCapabilities.WEBSOCKET)
assert channel.capability_profile.supports(ChannelCapabilities.TYPING_INDICATOR)
assert channel.capability_profile.supports(ChannelCapabilities.NATIVE_FILE_UPLOAD)
group_msg = channel.parse_event(
{
"id": "m1",
"channel_id": "c1",
"guild_id": "g1",
"author": {"id": "u1"},
"content": "hello",
}
)
direct_msg = channel.parse_event(
{
"id": "m2",
"channel_id": "dm1",
"author": {"id": "u1"},
"content": "hello",
}
)
assert group_msg.metadata["is_group"] is True
assert direct_msg.metadata["is_group"] is False
def test_slack_parse_event_sets_explicit_group_metadata() -> None:
channel = SlackChannel(token="xoxb-token", slack_channel_id="C-default")
group_msg = channel.parse_event(
{
"user": "U1",
"channel": "C-general",
"channel_type": "channel",
"text": "hello",
}
)
direct_msg = channel.parse_event(
{
"user": "U1",
"channel": "D-user",
"channel_type": "im",
"text": "hello",
}
)
assert group_msg.metadata["is_group"] is True
assert direct_msg.metadata["is_group"] is False
@pytest.mark.asyncio
async def test_discord_typing_targets_active_channel_before_default() -> None:
requests: list[str] = []
class FakeClient:
async def post(self, path: str, **_kwargs: object) -> object:
requests.append(path)
return object()
channel = DiscordChannel(
DiscordChannelConfig(token="token", default_channel_id="default-channel")
)
channel._client = FakeClient()
await channel.send_typing(channel_id="active-channel")
await channel.send_typing()
assert requests == [
"/channels/active-channel/typing",
"/channels/default-channel/typing",
]