diff --git a/src/backend/adapters/message_router.py b/src/backend/adapters/message_router.py index c6a78737c..a8a61403a 100644 --- a/src/backend/adapters/message_router.py +++ b/src/backend/adapters/message_router.py @@ -784,6 +784,20 @@ async def _resolve_agent_and_token( (the caller short-circuits). Both return silently — there is no token to reply with on the no-token path. """ + # ent#222: if the event was received by an agent's DEDICATED Slack bot, + # that agent wins over the channel binding and its own bot token is used + # for the reply. Inert for non-Slack / OSS-only (no resolver registered). + from adapters import per_agent_bot + pab_agent = per_agent_bot.resolve_from_message(message) + if pab_agent: + pab_token = per_agent_bot.get_token(pab_agent) + if pab_token: + logger.debug(f"[ROUTER:{channel}] per-agent bot → agent {pab_agent}") + return pab_agent, pab_token + # Binding without a usable token → fall through to channel routing. + logger.warning(f"[ROUTER:{channel}] per-agent bot for {pab_agent} has no token; " + "falling back to channel routing") + agent_name = await adapter.get_agent_name(message) logger.debug(f"[ROUTER:{channel}] Step 1 - resolved agent: {agent_name}") if not agent_name: diff --git a/src/backend/adapters/per_agent_bot.py b/src/backend/adapters/per_agent_bot.py new file mode 100644 index 000000000..bd46987f7 --- /dev/null +++ b/src/backend/adapters/per_agent_bot.py @@ -0,0 +1,75 @@ +"""Per-agent bot resolution seam (ent#222) — the OSS half of per-agent Slack bots. + +Edition-agnostic and **inert by default**: with no resolver registered (OSS-only +builds, or before the enterprise module loads) every function here is a no-op and +channel routing is byte-for-byte unchanged. The private ``slack_per_agent_bots`` +enterprise module registers a resolver + token provider at startup; from then on +an inbound Slack event received by an agent's *dedicated* bot resolves to that +agent (over the channel binding) and replies through that bot's own token. + +Mirrors the connector-seam pattern (#118): the OSS code owns the seam, the +enterprise module owns the policy. Both hooks fail **open** — any error falls +back to normal channel routing, so a bug here can never take Slack offline. +""" +from __future__ import annotations + +import logging +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# (team_id, bot_user_id, app_id) -> agent_name | None +_resolver: Optional[Callable[[Optional[str], Optional[str], Optional[str]], Optional[str]]] = None +# agent_name -> per-agent bot token | None +_token_provider: Optional[Callable[[str], Optional[str]]] = None + + +def set_resolver(fn: Optional[Callable]) -> None: + """Install the enterprise resolver. ``None`` disables (returns to inert).""" + global _resolver + _resolver = fn + + +def set_token_provider(fn: Optional[Callable]) -> None: + global _token_provider + _token_provider = fn + + +def is_active() -> bool: + return _resolver is not None + + +def resolve_from_message(message) -> Optional[str]: + """The agent whose dedicated bot RECEIVED this event, or None. + + Reads only the receiving-bot identity the Slack adapter stamps into + ``message.metadata`` (``slack_team_id`` + ``slack_recipient_bot_user_id`` / + ``slack_recipient_app_id``). Non-Slack messages carry none of these, so this + is inert for every other channel. Fail-open: any error → None → normal + channel routing. + """ + if _resolver is None: + return None + md = getattr(message, "metadata", None) or {} + team_id = md.get("slack_team_id") + bot_user_id = md.get("slack_recipient_bot_user_id") + app_id = md.get("slack_recipient_app_id") + if not team_id or not (bot_user_id or app_id): + return None + try: + return _resolver(team_id, bot_user_id, app_id) + except Exception as e: # noqa: BLE001 — never break routing + logger.warning("per-agent bot resolver raised; falling back to channel routing: %s", e) + return None + + +def get_token(agent_name: str) -> Optional[str]: + """The dedicated bot token for a per-agent-routed reply, or None to fall back + to the workspace token. Fail-open.""" + if _token_provider is None or not agent_name: + return None + try: + return _token_provider(agent_name) + except Exception as e: # noqa: BLE001 + logger.warning("per-agent bot token provider raised; falling back: %s", e) + return None diff --git a/src/backend/adapters/slack_adapter.py b/src/backend/adapters/slack_adapter.py index 2a3570307..f3ef854dd 100644 --- a/src/backend/adapters/slack_adapter.py +++ b/src/backend/adapters/slack_adapter.py @@ -109,19 +109,40 @@ def parse_message(self, raw_event: dict) -> Optional[NormalizedMessage]: team_id = raw_event.get("team_id") event_type = event.get("type") + message = None # Handle DM messages if event_type == "message" and event.get("channel_type") == "im": - return self._parse_dm(event, team_id) - + message = self._parse_dm(event, team_id) # Handle @mentions in channels - if event_type == "app_mention": - return self._parse_mention(event, team_id) - + elif event_type == "app_mention": + message = self._parse_mention(event, team_id) # Handle thread replies in bot channels (no @mention needed) - if event_type == "message" and event.get("thread_ts"): - return self._parse_thread_reply(event, team_id) + elif event_type == "message" and event.get("thread_ts"): + message = self._parse_thread_reply(event, team_id) - return None + if message is None: + return None + return self._stamp_recipient_bot(message, raw_event, team_id) + + def _stamp_recipient_bot(self, message, raw_event: dict, team_id): + """ent#222: record WHICH bot received this event so the router can route a + per-agent dedicated bot (over the channel binding). ``authorizations[0] + .user_id`` is the bot the event was delivered to; ``api_app_id`` its app. + Additive metadata — inert unless a per-agent resolver is registered.""" + auths = raw_event.get("authorizations") or [] + recipient_bot = ( + auths[0].get("user_id") if auths and isinstance(auths[0], dict) else None + ) + md = dict(getattr(message, "metadata", None) or {}) + md.update({ + "slack_team_id": team_id, + "slack_recipient_bot_user_id": recipient_bot, + "slack_recipient_app_id": raw_event.get("api_app_id"), + }) + try: + return message.model_copy(update={"metadata": md}) + except Exception: # noqa: BLE001 — never fail parsing over metadata + return message def format_response(self, text: str) -> str: """Convert standard markdown to Slack mrkdwn format. diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue index b83073287..cfe7ec5ff 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -169,6 +169,8 @@ :derive-status="slackStatus" > + + + +
+
+
+

+ Dedicated Slack bot +

+

+ Give this agent its own Slack bot identity — its own name and avatar, + directly DM-able and @mention-able, alongside other agents + in the same channel. +

+
+ + {{ status.enabled ? 'Active' : 'Disabled' }} + +
+ +

Loading…

+ + +
+
+ {{ status.bot_name || 'bot' }} + + · {{ status.bot_user_id }} · team {{ status.team_id }} + +
+
+ + + +
+
+ +

+ Not configured — this agent posts under the shared workspace bot. +

+ + +
+ + + +

+ Tokens are validated against Slack and stored encrypted; they are never shown again. +

+
+ +

+ {{ message.text }} +

+
+ + + diff --git a/tests/unit/test_222_per_agent_bot_seam.py b/tests/unit/test_222_per_agent_bot_seam.py new file mode 100644 index 000000000..40002b5b2 --- /dev/null +++ b/tests/unit/test_222_per_agent_bot_seam.py @@ -0,0 +1,74 @@ +"""The OSS per-agent bot resolution seam (ent#222). + +The seam is edition-agnostic and inert by default: with no resolver registered, +every path is a no-op and channel routing is unchanged. Once the enterprise +module registers a resolver + token provider, an event received by an agent's +dedicated bot resolves to that agent. Both hooks fail OPEN. +""" +from __future__ import annotations + +import types + +import pytest + +from adapters import per_agent_bot + + +def _msg(metadata=None): + return types.SimpleNamespace(metadata=metadata or {}) + + +@pytest.fixture(autouse=True) +def _reset_hooks(): + per_agent_bot.set_resolver(None) + per_agent_bot.set_token_provider(None) + yield + per_agent_bot.set_resolver(None) + per_agent_bot.set_token_provider(None) + + +def test_inert_by_default(): + assert per_agent_bot.is_active() is False + # A real Slack-shaped message still resolves to nothing with no resolver. + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "UBOT"}) + assert per_agent_bot.resolve_from_message(m) is None + assert per_agent_bot.get_token("analytics") is None + + +def test_resolver_reads_recipient_bot_and_returns_agent(): + seen = {} + + def resolver(team_id, bot_user_id, app_id): + seen.update(team_id=team_id, bot_user_id=bot_user_id, app_id=app_id) + return "analytics" if bot_user_id == "U_ANALYTICS" else None + + per_agent_bot.set_resolver(resolver) + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "U_ANALYTICS", + "slack_recipient_app_id": "A1"}) + assert per_agent_bot.resolve_from_message(m) == "analytics" + assert seen == {"team_id": "T1", "bot_user_id": "U_ANALYTICS", "app_id": "A1"} + + +def test_non_slack_message_is_ignored(): + per_agent_bot.set_resolver(lambda *a: "should-not-be-used") + # A Telegram/other message carries none of the slack_* metadata → inert. + assert per_agent_bot.resolve_from_message(_msg({"telegram_chat_id": "123"})) is None + assert per_agent_bot.resolve_from_message(_msg(None)) is None + + +def test_resolver_and_token_provider_fail_open(): + def boom(*a): + raise RuntimeError("db down") + + per_agent_bot.set_resolver(boom) + per_agent_bot.set_token_provider(boom) + m = _msg({"slack_team_id": "T1", "slack_recipient_bot_user_id": "UBOT"}) + # Both swallow the error and fall back (None) rather than break routing. + assert per_agent_bot.resolve_from_message(m) is None + assert per_agent_bot.get_token("analytics") is None + + +def test_token_provider_returns_per_agent_token(): + per_agent_bot.set_token_provider(lambda name: "xoxb-analytics" if name == "analytics" else None) + assert per_agent_bot.get_token("analytics") == "xoxb-analytics" + assert per_agent_bot.get_token("other") is None