refactor(relay): purge platform-specific scope terminology from the relay adapter (D-Q2.5c) (#56016)
The gateway HALF of the D-Q2.5c cleanup (connector half: gateway-gateway #92). Scope is STRICTLY the relay adapter (gateway/relay/) — session.py and every native platform adapter are untouched (SessionSource.guild_id remains for their use; it is NOT relay-only). Within gateway/relay/, drop the D-Q2.5 wire dual-write/dual-read alias AND genericize all platform-specific (Discord "guild") scope terminology: - ws_transport._event_from_wire: read scope_id only (drop the ?? guild_id fallback). - adapter._with_scope: emit scope_id only on outbound metadata (drop the guild_id dual-write); genericize the "GUILD reply" docstring to "SCOPED reply". - adapter._capture_scope: read source.scope_id only; rename the local `guild` var to `scope`; genericize the docstring + the _scope_by_chat/_dm_user_by_chat field comments ("guild_id (Discord)" -> "scope_id (server/workspace scope)"). - __init__.relay_route_keys docstring: "guild_ids" -> "scope_ids". - The ONE real Discord `guild_id` kept: the raw inbound interaction payload field (payload.get("guild_id")), which is Discord's own wire field, mapped straight into the generic scope_id slot — unchanged. Contract doc (docs/relay-connector-contract.md): reframe the `guild_id` row as a legacy alias the connector no longer reads (session.py's agent-wide to_dict() still emits it for non-relay persistence, so it stays documented + wire-present but ignored) — accurate, and keeps the to_dict()-vs-doc conformance test green. Tests (relay only): migrate the wire-key writes + assertions guild_id -> scope_id across test_relay_adapter / _ws_transport / _passthrough / _roundtrip / _roundtrip_telegram / _multiplatform; keep raw Discord `type:2` interaction payloads' guild_id (real Discord field) and the conformance test's guild_id parametrize (validates the kept legacy field stays wire-reachable). Gate: 156 relay tests pass, ruff clean. Cross-repo E2E — all 14 drivers pass BOTH ways: connector#92 (scope_id-only) x agent-main (still dual-reads) AND connector#92 x this worktree (scope_id-only). Deploy-order-safe either way.
This commit is contained in:
parent
a653bb0cbe
commit
729bbb7a30
10 changed files with 70 additions and 75 deletions
|
|
@ -157,7 +157,7 @@ present (may be `null`); the rest are included only when set.
|
|||
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
|
||||
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
|
||||
| `scope_id` | string | no | Platform-neutral **scope** discriminator: Discord guild / Slack workspace / Matrix server. **REQUIRED for Discord/Slack scope isolation.** Session-key discriminator. (Canonical name as of the D-Q2.5 wire migration.) |
|
||||
| `guild_id` | string | no | **Deprecated alias for `scope_id`** — still emitted and read during the cross-repo dual-read/dual-write overlap; readers resolve `scope_id ?? guild_id`. Dropped once both repos deploy on `scope_id`. |
|
||||
| `guild_id` | string | no | **Legacy alias, no longer read by the connector.** As of D-Q2.5c the connector reads and writes only `scope_id`; the gateway's agent-wide `SessionSource.to_dict()` still emits `guild_id` (mirrored to `scope_id`) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. |
|
||||
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
|
||||
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ present (may be `null`); the rest are included only when set.
|
|||
|
||||
### SessionSource discriminators per platform
|
||||
|
||||
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
|
||||
| Platform | chat_id | chat_type | user_id | thread_id | scope_id |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
|
||||
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ def relay_endpoint() -> Optional[str]:
|
|||
|
||||
|
||||
def relay_route_keys() -> list[str]:
|
||||
"""Discriminators (guild_ids / chat_ids / paths) this gateway's tenant owns.
|
||||
"""Discriminators (scope_ids / chat_ids / paths) this gateway's tenant owns.
|
||||
|
||||
Gateway-provided config, paired with ``relay_endpoint()``: the connector
|
||||
writes one route row per (routeKey -> tenant, endpoint), so route keys only
|
||||
|
|
|
|||
|
|
@ -59,15 +59,15 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
self._transport = transport
|
||||
# Capability surface read by stream_consumer (getattr(..., 4096)).
|
||||
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
|
||||
# chat_id -> guild_id (Discord) / workspace scope, learned from inbound
|
||||
# chat_id -> scope_id (server/workspace scope), learned from inbound
|
||||
# events. The connector's egress guard resolves the owning tenant from
|
||||
# the OUTBOUND action's metadata.guild_id; the gateway's generic delivery
|
||||
# the OUTBOUND action's metadata.scope_id; the gateway's generic delivery
|
||||
# path (run.py _thread_metadata_for_source) only carries thread_id, so we
|
||||
# re-attach the scope here from what we saw inbound. Keyed by chat_id
|
||||
# (channel) since that's what send() receives. See routedEgressGuard.ts.
|
||||
self._scope_by_chat: Dict[str, str] = {}
|
||||
# chat_id -> author user_id for DM channels (no guild_id). A DM reply has
|
||||
# no guild discriminator, so the connector resolves its tenant from the
|
||||
# chat_id -> author user_id for DM chats (no scope). A DM reply has
|
||||
# no scope discriminator, so the connector resolves its tenant from the
|
||||
# recipient's author binding; we re-attach this user_id as
|
||||
# metadata.user_id on the outbound action so it can. See _capture_scope.
|
||||
self._dm_user_by_chat: Dict[str, str] = {}
|
||||
|
|
@ -235,15 +235,15 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
tenant resolution. Never raises — scope tracking must not break inbound.
|
||||
|
||||
Two cases, matching the connector's two tenant-resolution paths:
|
||||
- GUILD message: remember chat_id -> guild_id. The connector resolves
|
||||
the tenant from metadata.guild_id (routing table).
|
||||
- DM (no guild_id): remember chat_id -> the authentic author user_id.
|
||||
A DM carries no guild discriminator, so the connector instead resolves
|
||||
- SCOPED message: remember chat_id -> scope_id. The connector resolves
|
||||
the tenant from metadata.scope_id (routing table).
|
||||
- DM (no scope): remember chat_id -> the authentic author user_id.
|
||||
A DM carries no scope discriminator, so the connector instead resolves
|
||||
the tenant from the recipient's author binding (resolveByUser); it
|
||||
needs the user_id on the OUTBOUND action to do that. Without this, a
|
||||
DM reply has no resolvable discriminator and the connector's egress
|
||||
guard declines it as "target not routed to an onboarded tenant".
|
||||
See gateway-gateway routedEgressGuard.ts / discordTenantOf.
|
||||
See gateway-gateway routedEgressGuard.ts / the tenant resolvers.
|
||||
"""
|
||||
try:
|
||||
src = getattr(event, "source", None)
|
||||
|
|
@ -263,9 +263,9 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
platform_value = getattr(platform, "value", platform)
|
||||
if platform_value and platform_value != "relay":
|
||||
self._platform_by_chat[str(chat)] = str(platform_value)
|
||||
guild = getattr(src, "scope_id", None) or getattr(src, "guild_id", None)
|
||||
if guild:
|
||||
self._scope_by_chat[str(chat)] = str(guild)
|
||||
scope = getattr(src, "scope_id", None)
|
||||
if scope:
|
||||
self._scope_by_chat[str(chat)] = str(scope)
|
||||
return
|
||||
# DM: no scope. Remember the authentic author id for outbound
|
||||
# author-binding resolution (the user we're replying to in this DM).
|
||||
|
|
@ -279,28 +279,24 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
"""Ensure the outbound metadata carries the discriminator the connector's
|
||||
egress guard needs to resolve the owning tenant. Two cases:
|
||||
|
||||
- GUILD reply: re-attach metadata.scope_id (routing-table resolution;
|
||||
also mirrored to the deprecated metadata.guild_id during the D-Q2.5
|
||||
wire migration so a connector on either side resolves the tenant).
|
||||
- SCOPED reply: re-attach metadata.scope_id (routing-table resolution).
|
||||
- DM reply: there is no scope, so re-attach metadata.user_id — the
|
||||
authentic author id we saw inbound — which the connector resolves to
|
||||
the tenant via the recipient's author binding (resolveByUser). Without
|
||||
one of these, egress is declined as 'target not routed to an onboarded
|
||||
tenant'. See gateway-gateway routedEgressGuard.ts / discordTenantOf.
|
||||
tenant'. See gateway-gateway routedEgressGuard.ts / the tenant resolvers.
|
||||
|
||||
No-op when the relevant value is already present or unknown for this chat.
|
||||
"""
|
||||
meta: Dict[str, Any] = dict(metadata or {})
|
||||
if not meta.get("scope_id") and not meta.get("guild_id"):
|
||||
if not meta.get("scope_id"):
|
||||
scope = self._scope_by_chat.get(str(chat_id))
|
||||
if scope:
|
||||
# D-Q2.5 dual-write: canonical scope_id + deprecated guild_id alias.
|
||||
meta["scope_id"] = scope
|
||||
meta["guild_id"] = scope
|
||||
# DM author-binding discriminator. Only meaningful when there's no scope
|
||||
# (a guild reply resolves by scope_id); harmless to carry otherwise, but
|
||||
# (a scoped reply resolves by scope_id); harmless to carry otherwise, but
|
||||
# we only set it when this chat is a known DM and the field is absent.
|
||||
if not meta.get("scope_id") and not meta.get("guild_id") and not meta.get("user_id"):
|
||||
if not meta.get("scope_id") and not meta.get("user_id"):
|
||||
dm_user = self._dm_user_by_chat.get(str(chat_id))
|
||||
if dm_user:
|
||||
meta["user_id"] = dm_user
|
||||
|
|
@ -405,14 +401,14 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
member = payload.get("member") or {}
|
||||
user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {}
|
||||
channel_id = str(payload.get("channel_id") or "")
|
||||
guild_id = payload.get("guild_id") # real Discord interaction field
|
||||
guild_id = payload.get("guild_id") # real Discord interaction wire field
|
||||
source = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=channel_id,
|
||||
chat_type="channel" if guild_id else "dm",
|
||||
user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None,
|
||||
user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None,
|
||||
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot (D-Q2.5)
|
||||
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot
|
||||
message_id=str(payload.get("id")) if payload.get("id") else None,
|
||||
)
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
|
||||
|
|
|
|||
|
|
@ -117,8 +117,7 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
|||
chat_topic=src.get("chat_topic"),
|
||||
user_id_alt=src.get("user_id_alt"),
|
||||
chat_id_alt=src.get("chat_id_alt"),
|
||||
# D-Q2.5 dual-read: prefer canonical scope_id, fall back to legacy guild_id.
|
||||
scope_id=src.get("scope_id", src.get("guild_id")),
|
||||
scope_id=src.get("scope_id"),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
# Authentic upstream-trust signal: this event arrived over the
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ class _CaptureTransport:
|
|||
return {"success": True, "message_id": "m1"}
|
||||
|
||||
|
||||
def _make_event(chat_id="chan-1", guild_id="guild-9"):
|
||||
def _make_event(chat_id="chan-1", scope_id="scope-9"):
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
|
@ -137,13 +137,13 @@ def _make_event(chat_id="chan-1", guild_id="guild-9"):
|
|||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="channel",
|
||||
guild_id=guild_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
def _make_dm_event(chat_id="dm-1", user_id="user-42"):
|
||||
"""An inbound DM: no guild_id, carries the authentic author user_id."""
|
||||
"""An inbound DM: no scope_id, carries the authentic author user_id."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
|
@ -151,53 +151,53 @@ def _make_dm_event(chat_id="dm-1", user_id="user-42"):
|
|||
platform=Platform.RELAY,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm",
|
||||
guild_id=None,
|
||||
scope_id=None,
|
||||
user_id=user_id,
|
||||
)
|
||||
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reattaches_guild_id_from_inbound_scope():
|
||||
async def test_send_reattaches_scope_id_from_inbound_scope():
|
||||
"""The connector's egress guard resolves the owning tenant from
|
||||
metadata.guild_id; the gateway's generic delivery path drops it, so the
|
||||
relay adapter must re-attach the guild scope learned from the inbound event.
|
||||
Regression for live 'discord egress declined: target not routed to an
|
||||
metadata.scope_id; the gateway's generic delivery path drops it, so the
|
||||
relay adapter must re-attach the scope learned from the inbound event.
|
||||
Regression for live 'egress declined: target not routed to an
|
||||
onboarded tenant'."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
# Simulate the connector delivering an inbound message in guild-9 / chan-1,
|
||||
# Simulate the connector delivering an inbound message in scope-9 / chan-1,
|
||||
# but don't run the full handle_message pipeline — just the scope capture.
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
|
||||
await a.send("chan-1", "the reply")
|
||||
|
||||
assert t.sent["metadata"].get("guild_id") == "guild-9"
|
||||
assert t.sent["metadata"].get("scope_id") == "scope-9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_without_known_scope_omits_guild_id():
|
||||
"""A chat we never saw inbound (e.g. a DM) gets no guild_id — no-op, never
|
||||
async def test_send_without_known_scope_omits_scope_id():
|
||||
"""A chat we never saw inbound (e.g. a DM) gets no scope_id — no-op, never
|
||||
invents a scope."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
await a.send("unknown-chat", "hi")
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_preserves_explicit_guild_id():
|
||||
"""An explicitly-provided metadata.guild_id is never overwritten."""
|
||||
async def test_send_preserves_explicit_scope_id():
|
||||
"""An explicitly-provided metadata.scope_id is never overwritten."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"})
|
||||
assert t.sent["metadata"]["guild_id"] == "explicit-1"
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
await a.send("chan-1", "hi", metadata={"scope_id": "explicit-1"})
|
||||
assert t.sent["metadata"]["scope_id"] == "explicit-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reattaches_dm_user_id_from_inbound_scope():
|
||||
"""A DM reply has no guild_id, so the connector resolves the tenant from the
|
||||
"""A DM reply has no scope_id, so the connector resolves the tenant from the
|
||||
recipient's author binding — it needs metadata.user_id. The adapter must
|
||||
re-attach the authentic author id learned from the inbound DM. Regression for
|
||||
live 'discord egress declined: target not routed to an onboarded tenant' on
|
||||
|
|
@ -209,8 +209,8 @@ async def test_send_reattaches_dm_user_id_from_inbound_scope():
|
|||
await a.send("dm-1", "the reply")
|
||||
|
||||
assert t.sent["metadata"].get("user_id") == "user-42"
|
||||
# A DM carries no guild_id — only the author discriminator.
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
# A DM carries no scope_id — only the author discriminator.
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -220,7 +220,7 @@ async def test_send_dm_does_not_invent_user_id_for_unknown_chat():
|
|||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
await a.send("unknown-dm", "hi")
|
||||
assert "user_id" not in t.sent["metadata"]
|
||||
assert "guild_id" not in t.sent["metadata"]
|
||||
assert "scope_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -234,15 +234,15 @@ async def test_send_preserves_explicit_user_id():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guild_reply_does_not_carry_user_id():
|
||||
"""A guild reply resolves by guild_id and must NOT carry a DM user_id even if
|
||||
the same chat_id was somehow seen — guild capture wins and user_id stays out
|
||||
(guild_id is the discriminator; user_id is the DM-only fallback)."""
|
||||
async def test_scoped_reply_does_not_carry_user_id():
|
||||
"""A scoped reply resolves by scope_id and must NOT carry a DM user_id even if
|
||||
the same chat_id was somehow seen — scope capture wins and user_id stays out
|
||||
(scope_id is the discriminator; user_id is the DM-only fallback)."""
|
||||
t = _CaptureTransport()
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
|
||||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))
|
||||
await a.send("chan-1", "hi")
|
||||
assert t.sent["metadata"].get("guild_id") == "guild-9"
|
||||
assert t.sent["metadata"].get("scope_id") == "scope-9"
|
||||
assert "user_id" not in t.sent["metadata"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async def test_adapter_stamps_per_frame_platform_from_inbound(monkeypatch):
|
|||
MessageEvent(
|
||||
text="yo",
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", guild_id="g-1"),
|
||||
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", scope_id="g-1"),
|
||||
)
|
||||
)
|
||||
await adapter.send("dc-1", "a discord reply")
|
||||
|
|
|
|||
|
|
@ -120,10 +120,10 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey
|
|||
ev = seen[0]
|
||||
assert ev.text == "summarize"
|
||||
assert ev.source.chat_id == "chan-9"
|
||||
assert ev.source.guild_id == "guild-7"
|
||||
assert ev.source.scope_id == "guild-7"
|
||||
assert ev.source.user_id == "user-3"
|
||||
assert ev.source.chat_type == "channel"
|
||||
# Scope captured so the agent's reply re-asserts guild_id for egress.
|
||||
# Scope captured so the agent's reply re-asserts scope_id for egress.
|
||||
assert adapter._scope_by_chat.get("chan-9") == "guild-7"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Proves the gateway side of the relay works with no real connector:
|
||||
- connect() registers the inbound handler,
|
||||
- a connector-delivered MessageEvent reaches the adapter's message path,
|
||||
- SessionSource discriminators (guild_id) drive build_session_key isolation,
|
||||
- SessionSource discriminators (scope_id) drive build_session_key isolation,
|
||||
- an outbound send round-trips through the transport.
|
||||
|
||||
These target the transport contract + session-key derivation (Task 1.2's gate),
|
||||
|
|
@ -40,14 +40,14 @@ def _discord_descriptor() -> CapabilityDescriptor:
|
|||
)
|
||||
|
||||
|
||||
def _discord_event(guild_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
|
||||
def _discord_event(scope_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
|
||||
"""Synthetic inbound the connector would build from a discord.js message."""
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=channel_id,
|
||||
chat_type="group",
|
||||
user_id=user_id,
|
||||
guild_id=guild_id,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
|
||||
|
||||
|
|
@ -79,18 +79,18 @@ async def test_inbound_event_reaches_adapter(wired, monkeypatch):
|
|||
await stub.push_inbound(ev)
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "hello"
|
||||
assert captured[0].source.guild_id == "guildA"
|
||||
assert captured[0].source.scope_id == "guildA"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_guilds_isolate_into_distinct_session_keys(wired):
|
||||
async def test_two_scopes_isolate_into_distinct_session_keys(wired):
|
||||
adapter, _ = wired
|
||||
ev_a = _discord_event("guildA", "chan1", "userX", "hi from A")
|
||||
ev_b = _discord_event("guildB", "chan2", "userX", "hi from B")
|
||||
key_a = build_session_key(ev_a.source)
|
||||
key_b = build_session_key(ev_b.source)
|
||||
assert key_a != key_b
|
||||
# Same guild + channel + user collapses to one session.
|
||||
# Same scope + channel + user collapses to one session.
|
||||
ev_a2 = _discord_event("guildA", "chan1", "userX", "again")
|
||||
assert build_session_key(ev_a2.source) == key_a
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ descriptors to round-trip and their inbound ``MessageEvent``s to drive
|
|||
``build_session_key()`` correctly.
|
||||
|
||||
Telegram's discriminator profile differs from Discord's, which is the point:
|
||||
- No ``guild_id``; isolation between chats comes from ``chat_id`` alone.
|
||||
- No ``scope_id``; isolation between chats comes from ``chat_id`` alone.
|
||||
- Forum topics live inside ONE ``chat_id`` and isolate by ``thread_id`` (the
|
||||
Telegram analog of Discord's per-guild isolation).
|
||||
Telegram analog of Discord's per-scope isolation).
|
||||
- Forum/thread sessions are shared across participants by default
|
||||
(``thread_sessions_per_user=False``) — user_id is NOT appended in a thread.
|
||||
- ``len_unit="utf16"`` (Telegram counts UTF-16 code units) and
|
||||
|
|
@ -51,7 +51,7 @@ def _tg_group_event(chat_id: str, user_id: str, text: str, thread_id: str | None
|
|||
"""Synthetic inbound the connector would build from a Telegram update.
|
||||
|
||||
A plain group message has no thread_id; a forum-topic message carries the
|
||||
topic id as thread_id (no guild_id — Telegram has no guild concept).
|
||||
topic id as thread_id (no scope_id — Telegram has no scope concept).
|
||||
"""
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
|
|
@ -105,13 +105,13 @@ async def test_inbound_telegram_event_reaches_adapter(wired, monkeypatch):
|
|||
assert len(captured) == 1
|
||||
assert captured[0].text == "hello"
|
||||
assert captured[0].source.platform == Platform.TELEGRAM
|
||||
assert captured[0].source.guild_id is None # Telegram has no guild
|
||||
assert captured[0].source.scope_id is None # Telegram has no scope
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_telegram_chats_isolate_by_chat_id(wired):
|
||||
"""No guild_id on Telegram — two distinct chats must still isolate, keyed
|
||||
on chat_id alone (the Discord-guild role is played by chat_id here)."""
|
||||
"""No scope_id on Telegram — two distinct chats must still isolate, keyed
|
||||
on chat_id alone (the Discord-scope role is played by chat_id here)."""
|
||||
ev_a = _tg_group_event("chat-A", "userX", "hi A")
|
||||
ev_b = _tg_group_event("chat-B", "userX", "hi B")
|
||||
key_a = build_session_key(ev_a.source)
|
||||
|
|
@ -125,7 +125,7 @@ async def test_two_telegram_chats_isolate_by_chat_id(wired):
|
|||
@pytest.mark.asyncio
|
||||
async def test_forum_topics_isolate_by_thread_id_within_one_chat(wired):
|
||||
"""Telegram forum topics share a single chat_id and isolate by thread_id —
|
||||
the Telegram analog of Discord per-guild isolation. Two topics in the same
|
||||
the Telegram analog of Discord per-scope isolation. Two topics in the same
|
||||
forum must NOT collide, and (threads shared across participants by default)
|
||||
a second user in the same topic shares the session."""
|
||||
topic1 = _tg_group_event("forum-1", "userX", "in topic 1", thread_id="t-1")
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ async def test_inbound_frame_reaches_handler(server):
|
|||
"event": {
|
||||
"text": "hello from connector",
|
||||
"message_type": "text",
|
||||
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
|
||||
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "scope_id": "guildA"},
|
||||
},
|
||||
"bufferId": "buf-1",
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ async def test_inbound_frame_reaches_handler(server):
|
|||
await asyncio.sleep(0.05)
|
||||
assert len(received) == 1
|
||||
assert received[0].text == "hello from connector"
|
||||
assert received[0].source.guild_id == "guildA"
|
||||
assert received[0].source.scope_id == "guildA"
|
||||
finally:
|
||||
await t.disconnect()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue