Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/backend/adapters/message_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
75 changes: 75 additions & 0 deletions src/backend/adapters/per_agent_bot.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 29 additions & 8 deletions src/backend/adapters/slack_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/components/SharingPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
:derive-status="slackStatus"
>
<SlackChannelPanel :agent-name="agentName" />
<!-- ent#222: dedicated per-agent Slack bot (entitlement-gated, self-hiding) -->
<SlackAgentBotPanel :agent-name="agentName" />
</ChannelConfigRow>

<ChannelConfigRow
Expand Down Expand Up @@ -291,6 +293,7 @@ import ChannelDisclosure from './ChannelDisclosure.vue'
import ChannelConfigRow from './ChannelConfigRow.vue'
import PublicLinksPanel from './PublicLinksPanel.vue'
import SlackChannelPanel from './SlackChannelPanel.vue'
import SlackAgentBotPanel from './SlackAgentBotPanel.vue'
import TelegramChannelPanel from './TelegramChannelPanel.vue'
import WhatsAppChannelPanel from './WhatsAppChannelPanel.vue'
import VoipChannelPanel from './VoipChannelPanel.vue'
Expand Down
212 changes: 212 additions & 0 deletions src/frontend/src/components/SlackAgentBotPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<template>
<!-- Per-agent Slack bot identity (ent#222). Entitlement-gated: hidden entirely
in OSS / unentitled builds, never a blank or broken section. -->
<div v-if="entitled" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">
Dedicated Slack bot
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Give this agent its own Slack bot identity — its own name and avatar,
directly DM-able and <code>@mention</code>-able, alongside other agents
in the same channel.
</p>
</div>
<span
v-if="status.configured"
class="shrink-0 inline-flex items-center gap-1 text-xs font-medium rounded px-2 py-1"
:class="status.enabled
? 'text-status-success-700 dark:text-status-success-300 bg-status-success-50 dark:bg-status-success-900/30'
: 'text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700'"
>
{{ status.enabled ? 'Active' : 'Disabled' }}
</span>
</div>

<p v-if="loading" class="text-xs text-gray-500 dark:text-gray-400 mt-3">Loading…</p>

<!-- Configured -->
<div v-else-if="status.configured" class="mt-3">
<div class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ status.bot_name || 'bot' }}</span>
<span class="text-gray-500 dark:text-gray-400">
· {{ status.bot_user_id }} · team {{ status.team_id }}
</span>
</div>
<div class="flex items-center gap-3 mt-3">
<button
type="button"
@click="toggleEnabled"
:disabled="busy"
class="text-xs px-2 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed
border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700"
>
{{ status.enabled ? 'Disable' : 'Enable' }}
</button>
<button
type="button"
@click="showForm = !showForm"
:disabled="busy"
class="text-xs px-2 py-1 border rounded disabled:opacity-50
border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700"
>
Replace tokens
</button>
<button
type="button"
@click="removeBot"
:disabled="busy"
class="text-xs text-status-danger-600 dark:text-status-danger-400
hover:text-status-danger-800 disabled:opacity-50"
>
Remove
</button>
</div>
</div>

<p v-else class="text-xs text-gray-500 dark:text-gray-400 mt-3">
Not configured — this agent posts under the shared workspace bot.
</p>

<!-- Token form -->
<div v-if="showForm || (!status.configured && !loading)" class="mt-3 space-y-2">
<input
v-model="botToken"
type="password"
autocomplete="off"
placeholder="Bot token (xoxb-…)"
class="w-full text-sm px-2 py-1.5 rounded border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
<input
v-model="appToken"
type="password"
autocomplete="off"
placeholder="App-level token (xapp-…, needs connections:write)"
class="w-full text-sm px-2 py-1.5 rounded border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
<button
type="button"
@click="save"
:disabled="busy || !botToken || !appToken"
class="text-xs px-3 py-1.5 rounded bg-action-primary-600 text-white
hover:bg-action-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ busy ? 'Validating…' : 'Save & validate' }}
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">
Tokens are validated against Slack and stored encrypted; they are never shown again.
</p>
</div>

<p
v-if="message"
class="text-xs mt-2"
:class="message.type === 'error'
? 'text-status-danger-600 dark:text-status-danger-400'
: 'text-status-success-600 dark:text-status-success-400'"
>
{{ message.text }}
</p>
</div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { useEnterpriseStore } from '../stores/enterprise'

const props = defineProps({
agentName: { type: String, required: true },
})

const enterpriseStore = useEnterpriseStore()
const entitled = computed(() => enterpriseStore.isEntitled('slack_per_agent_bots'))

const status = ref({ configured: false })
const loading = ref(false)
const busy = ref(false)
const showForm = ref(false)
const botToken = ref('')
const appToken = ref('')
const message = ref(null)

const BASE = '/api/enterprise/slack-agent-bots/agents'

function flash(type, text) {
message.value = { type, text }
if (type === 'success') setTimeout(() => { message.value = null }, 3000)
}

// The backend returns a NAMED code for every refusal (wrong token type, bot
// already bound elsewhere, Slack unreachable) — surface it rather than a generic
// failure, so the operator knows what to fix.
function describe(e, fallback) {
const d = e?.response?.data?.detail
return (d && (d.message || d)) || fallback
}

async function load() {
if (!entitled.value) return
loading.value = true
try {
const { data } = await axios.get(`${BASE}/${props.agentName}`)
status.value = data || { configured: false }
showForm.value = false
} catch (e) {
status.value = { configured: false }
} finally {
loading.value = false
}
}

async function save() {
busy.value = true
message.value = null
try {
await axios.put(`${BASE}/${props.agentName}`, {
bot_token: botToken.value.trim(),
app_token: appToken.value.trim(),
})
botToken.value = ''
appToken.value = ''
flash('success', 'Slack bot configured')
await load()
} catch (e) {
flash('error', describe(e, 'Failed to configure the Slack bot'))
} finally {
busy.value = false
}
}

async function toggleEnabled() {
busy.value = true
try {
await axios.put(`${BASE}/${props.agentName}/enabled`, { enabled: !status.value.enabled })
await load()
} catch (e) {
flash('error', describe(e, 'Failed to update'))
} finally {
busy.value = false
}
}

async function removeBot() {
busy.value = true
try {
await axios.delete(`${BASE}/${props.agentName}`)
flash('success', 'Dedicated bot removed')
await load()
} catch (e) {
flash('error', describe(e, 'Failed to remove'))
} finally {
busy.value = false
}
}

onMounted(load)
</script>
Loading
Loading