feat(01-03): Channel Gateway (Slack adapter) and Message Router

- gateway/normalize.py: normalize_slack_event -> KonstructMessage (strips bot mention)
- gateway/channels/slack.py: register_slack_handlers for app_mention + DM events
  - rate limit check -> ephemeral rejection on exceeded
  - idempotency dedup (Slack retry protection)
  - placeholder 'Thinking...' message posted in-thread before Celery dispatch
  - auto-follow engaged threads with 30-minute TTL
  - HTTP 200 returned immediately; all LLM work dispatched to Celery
- gateway/main.py: FastAPI on port 8001, /slack/events + /health
- router/tenant.py: resolve_tenant workspace_id -> tenant_id (RLS-bypass query)
- router/ratelimit.py: check_rate_limit Redis token bucket, RateLimitExceeded exception
- router/idempotency.py: is_duplicate + mark_processed (SET NX, 24h TTL)
- router/context.py: load_agent_for_tenant with RLS ContextVar setup
- orchestrator/tasks.py: handle_message now extracts placeholder_ts/channel_id,
  calls _update_slack_placeholder via chat.update after LLM response
- docker-compose.yml: gateway service on port 8001
- pyproject.toml: added redis, konstruct-router, konstruct-orchestrator deps
This commit is contained in:
2026-03-23 10:27:59 -06:00
parent dcd89cc8fd
commit 6f30705e1a
17 changed files with 1166 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
"""
Konstruct Channel Gateway.
Unified ingress for all messaging platforms. Each channel adapter normalizes
inbound events into KonstructMessage format before dispatching to the
Message Router / Celery orchestrator.
"""

View File

@@ -0,0 +1,6 @@
"""
Channel adapter modules.
Each adapter handles the channel-specific event format and normalizes
events into KonstructMessage before dispatching to the orchestrator.
"""

View File

@@ -0,0 +1,280 @@
"""
Slack channel adapter.
Handles Slack Events API events via slack-bolt AsyncApp.
EVENT FLOW:
1. Slack sends event to /slack/events (HTTP 200 must be returned in <3s)
2. Handler normalizes event -> KonstructMessage
3. Tenant resolved from workspace_id
4. Rate limit checked
5. Idempotency checked (prevents duplicate processing on Slack retries)
6. Placeholder "Thinking..." message posted in-thread (typing indicator)
7. Celery task dispatched with message + placeholder details
8. Celery worker calls LLM pool, replaces placeholder with real response
CRITICAL: DO NOT perform any LLM work inside event handlers.
Slack retries after 3 seconds if HTTP 200 is not received. All heavyweight
work must be dispatched to Celery before returning.
THREAD FOLLOW-UP:
After the first @mention in a thread, subsequent messages in that thread
(even without @mention) trigger a response for 30 minutes of idle time.
This is tracked via Redis engaged_thread_key with a 30-minute TTL.
"""
from __future__ import annotations
import logging
from redis.asyncio import Redis
from slack_bolt.async_app import AsyncApp
from sqlalchemy.ext.asyncio import AsyncSession
from gateway.normalize import normalize_slack_event
from router.idempotency import is_duplicate
from router.ratelimit import RateLimitExceeded, check_rate_limit
from router.tenant import resolve_tenant
from shared.redis_keys import engaged_thread_key
logger = logging.getLogger(__name__)
# How long a thread stays "engaged" after the last @mention (seconds)
_ENGAGED_THREAD_TTL = 1800 # 30 minutes
def register_slack_handlers(
slack_app: AsyncApp,
redis: Redis, # type: ignore[type-arg]
get_session: object, # Callable returning AsyncSession context manager
) -> None:
"""
Register Slack event handlers on the slack-bolt AsyncApp.
Call this once at application startup after creating the AsyncApp instance.
Args:
slack_app: The slack-bolt AsyncApp instance.
redis: Async Redis client for rate limiting + idempotency.
get_session: Async context manager factory for DB sessions.
Typically ``shared.db.async_session_factory``.
"""
@slack_app.event("app_mention")
async def handle_app_mention(event: dict, say: object, client: object) -> None:
"""
Handle @mention events in channels.
Called when a user @mentions the bot in any channel the bot belongs to.
"""
await _handle_slack_event(
event=event,
say=say,
client=client,
redis=redis,
get_session=get_session,
event_type="app_mention",
)
@slack_app.event("message")
async def handle_message(event: dict, say: object, client: object) -> None:
"""
Handle direct messages (DMs).
Filtered to channel_type=="im" only — ignores channel messages
to avoid double-processing @mentions.
"""
# Only handle DMs to prevent double-triggering alongside app_mention
if event.get("channel_type") != "im":
return
# Ignore bot messages to prevent infinite response loops
if event.get("bot_id") or event.get("subtype") == "bot_message":
return
await _handle_slack_event(
event=event,
say=say,
client=client,
redis=redis,
get_session=get_session,
event_type="dm",
)
async def _handle_slack_event(
event: dict,
say: object,
client: object,
redis: Redis, # type: ignore[type-arg]
get_session: object,
event_type: str,
) -> None:
"""
Shared handler logic for app_mention and DM message events.
Performs: normalize -> tenant resolve -> rate limit -> idempotency ->
post placeholder -> dispatch Celery -> mark thread engaged.
All work is dispatched to Celery before returning. HTTP 200 is returned
to Slack immediately by slack-bolt after this coroutine completes.
"""
# Ignore bot messages (double-check here for safety)
if event.get("bot_id") or event.get("subtype") == "bot_message":
return
# Extract workspace_id from the outer context — injected via middleware
# or extracted from the Slack signing payload.
workspace_id: str = event.get("_workspace_id", "")
bot_user_id: str = event.get("_bot_user_id", "")
# Step 1: Normalize to KonstructMessage
msg = normalize_slack_event(
event=event,
workspace_id=workspace_id,
bot_user_id=bot_user_id,
)
# Step 2: Resolve tenant from workspace_id
tenant_id: str | None = None
async with get_session() as session: # type: ignore[attr-defined]
tenant_id = await resolve_tenant(
workspace_id=workspace_id,
channel_type="slack",
session=session,
)
if tenant_id is None:
logger.warning(
"handle_slack_event: unknown workspace_id=%r event_type=%s — ignoring",
workspace_id,
event_type,
)
return
msg.tenant_id = tenant_id
# Step 3: Check rate limit — post ephemeral rejection if exceeded
try:
await check_rate_limit(
tenant_id=tenant_id,
channel="slack",
redis=redis,
)
except RateLimitExceeded as exc:
logger.info(
"Rate limit exceeded: tenant=%s — posting ephemeral rejection",
tenant_id,
)
# Post ephemeral message visible only to the requesting user
try:
await client.chat_postEphemeral( # type: ignore[union-attr]
channel=event.get("channel", ""),
user=event.get("user", ""),
text=(
f"I'm receiving too many requests right now. "
f"Please try again in about {exc.remaining_seconds} seconds."
),
)
except Exception:
logger.exception("Failed to post rate limit ephemeral message")
return
# Step 4: Idempotency check — skip duplicate events (Slack retry protection)
event_ts: str = event.get("ts", msg.id)
if await is_duplicate(tenant_id, event_ts, redis):
logger.debug(
"Duplicate Slack event: tenant=%s event_ts=%s — skipping",
tenant_id,
event_ts,
)
return
# Step 5: Check thread engagement — auto-follow messages in engaged threads
thread_id = msg.thread_id or event_ts
is_engaged = await _is_engaged_thread(tenant_id, thread_id, redis)
# For channel messages: only respond to @mentions or engaged threads
if event_type != "dm" and not is_engaged:
# This shouldn't happen for app_mention, but guard defensively
# (e.g., if the event somehow arrives without mention metadata)
pass # Fall through to dispatch — app_mention always warrants a response
# Step 6: Post placeholder "Thinking..." message in thread
channel_id: str = event.get("channel", "")
placeholder_ts: str = ""
try:
placeholder_resp = await client.chat_postMessage( # type: ignore[union-attr]
channel=channel_id,
thread_ts=thread_id,
text="_Thinking..._",
)
placeholder_ts = placeholder_resp.get("ts", "") # type: ignore[union-attr]
except Exception:
logger.exception(
"Failed to post placeholder message: tenant=%s channel=%s thread=%s",
tenant_id,
channel_id,
thread_id,
)
# Continue even if placeholder fails — still dispatch to Celery
# The Celery task will post a new message instead of updating
# Step 7: Dispatch to Celery (fire-and-forget)
# Import here to avoid circular imports at module load time
from orchestrator.tasks import handle_message as handle_message_task # noqa: PLC0415
task_payload = msg.model_dump() | {
"placeholder_ts": placeholder_ts,
"channel_id": channel_id,
}
try:
handle_message_task.delay(task_payload)
except Exception:
logger.exception(
"Failed to dispatch handle_message task: tenant=%s msg_id=%s",
tenant_id,
msg.id,
)
return
# Step 8: Mark thread as engaged (auto-follow for 30 minutes)
await _mark_thread_engaged(tenant_id, thread_id, redis)
logger.info(
"Dispatched: event_type=%s tenant=%s msg_id=%s thread=%s",
event_type,
tenant_id,
msg.id,
thread_id,
)
async def _is_engaged_thread(
tenant_id: str,
thread_id: str,
redis: Redis, # type: ignore[type-arg]
) -> bool:
"""Check if a thread is currently engaged (bot responded recently)."""
key = engaged_thread_key(tenant_id, thread_id)
try:
result = await redis.exists(key)
return bool(result)
except Exception:
logger.exception("Failed to check engaged thread: tenant=%s thread=%s", tenant_id, thread_id)
return False
async def _mark_thread_engaged(
tenant_id: str,
thread_id: str,
redis: Redis, # type: ignore[type-arg]
) -> None:
"""Mark a thread as engaged with a 30-minute TTL."""
key = engaged_thread_key(tenant_id, thread_id)
try:
await redis.set(key, "1", ex=_ENGAGED_THREAD_TTL)
except Exception:
logger.exception("Failed to mark thread engaged: tenant=%s thread=%s", tenant_id, thread_id)

View File

@@ -0,0 +1,107 @@
"""
Channel Gateway — FastAPI application.
Mounts the slack-bolt AsyncApp as a sub-application at /slack/events.
All other channels will be added as additional sub-applications in Phase 2.
Port: 8001
Endpoints:
POST /slack/events — Slack Events API webhook (handled by slack-bolt)
GET /health — Health check
Startup sequence:
1. Create Redis connection
2. Create slack-bolt AsyncApp (signing_secret=...)
3. Register Slack event handlers
4. Mount slack-bolt request handler at /slack/events
5. Expose /health
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request, Response
from redis.asyncio import Redis
from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
from slack_bolt.async_app import AsyncApp
from gateway.channels.slack import register_slack_handlers
from shared.config import settings
from shared.db import async_session_factory
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(
title="Konstruct Channel Gateway",
description="Unified ingress for all messaging platforms",
version="0.1.0",
)
# ---------------------------------------------------------------------------
# Slack bolt app — initialized at module import time.
# signing_secret="" is safe for local dev/testing; set via env in production.
# ---------------------------------------------------------------------------
slack_app = AsyncApp(
token=settings.slack_bot_token or None,
signing_secret=settings.slack_signing_secret or None,
# In HTTP mode (Events API), token_verification_enabled must be True
# slack-bolt validates signing_secret on every inbound request
)
# Async Redis client — shared across all request handlers
_redis: Redis | None = None # type: ignore[type-arg]
def _get_redis() -> Redis: # type: ignore[type-arg]
"""Return the module-level Redis client, creating it if necessary."""
global _redis
if _redis is None:
_redis = Redis.from_url(settings.redis_url, decode_responses=True)
return _redis
# ---------------------------------------------------------------------------
# Register Slack event handlers
# ---------------------------------------------------------------------------
register_slack_handlers(
slack_app=slack_app,
redis=_get_redis(),
get_session=async_session_factory,
)
# ---------------------------------------------------------------------------
# Slack request handler — adapts slack-bolt AsyncApp to FastAPI
# ---------------------------------------------------------------------------
slack_handler = AsyncSlackRequestHandler(slack_app)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.post("/slack/events")
async def slack_events(request: Request) -> Response:
"""
Slack Events API webhook endpoint.
slack-bolt's AsyncSlackRequestHandler handles:
- Slack signature verification (X-Slack-Signature)
- URL verification challenge (type=url_verification)
- Event routing to registered handlers
CRITICAL: This endpoint MUST return HTTP 200 within 3 seconds.
All LLM/heavy work is dispatched to Celery inside the event handlers.
"""
return await slack_handler.handle(request)
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok", "service": "gateway"}

View File

@@ -0,0 +1,100 @@
"""
Slack event normalization.
Converts Slack Events API payloads into KonstructMessage format.
All channel adapters produce KonstructMessage — the router and orchestrator
never inspect Slack-specific fields directly.
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from shared.models.message import (
ChannelType,
KonstructMessage,
MessageContent,
SenderInfo,
)
# Pattern to strip <@BOT_USER_ID> mentions from message text.
# Slack injects <@U...> tokens for @mentions — we strip the bot mention
# so the agent sees clean user text, not the mention syntax.
_BOT_MENTION_RE = re.compile(r"<@[A-Z0-9]+>")
def normalize_slack_event(
event: dict,
workspace_id: str,
bot_user_id: str = "",
) -> KonstructMessage:
"""
Normalize a Slack Events API event payload into a KonstructMessage.
Handles both ``app_mention`` events (where the bot is @mentioned in a
channel) and ``message`` events in DMs (``channel_type == "im"``).
The bot mention token (``<@BOT_USER_ID>``) is stripped from the beginning
of the text for ``app_mention`` events so the agent receives clean input.
Args:
event: The inner ``event`` dict from the Slack Events API payload.
workspace_id: The Slack workspace ID (team_id from the outer payload).
bot_user_id: The bot's Slack user ID (used for mention stripping).
Returns:
A fully-populated KonstructMessage. ``tenant_id`` is ``None`` at this
stage — the Message Router populates it via channel_connections lookup.
"""
# Extract and clean user text
raw_text: str = event.get("text", "") or ""
# Strip any <@BOT_ID> mention tokens from the message
clean_text = _BOT_MENTION_RE.sub("", raw_text).strip()
# Slack thread_ts is the canonical thread identifier
thread_ts: str | None = event.get("thread_ts") or event.get("ts")
# Timestamp — Slack uses Unix float strings ("1234567890.123456")
ts_raw = event.get("ts", "0")
try:
ts_float = float(ts_raw)
timestamp = datetime.fromtimestamp(ts_float, tz=timezone.utc)
except (ValueError, TypeError):
timestamp = datetime.now(tz=timezone.utc)
# User info — Slack provides user_id; display name is enriched later
sender_user_id: str = event.get("user", "") or ""
is_bot = bool(event.get("bot_id") or event.get("subtype") == "bot_message")
# Build the set of mentions present in the original text
mentions: list[str] = _BOT_MENTION_RE.findall(raw_text)
# Strip angle brackets from extracted tokens: <@U123> -> U123
mentions = [m.strip("<>@") for m in mentions]
return KonstructMessage(
id=str(uuid.uuid4()),
tenant_id=None, # Populated by Message Router
channel=ChannelType.SLACK,
channel_metadata={
"workspace_id": workspace_id,
"channel_id": event.get("channel", ""),
"thread_ts": thread_ts,
"bot_user_id": bot_user_id,
"event_ts": ts_raw,
"channel_type": event.get("channel_type", ""),
},
sender=SenderInfo(
user_id=sender_user_id,
display_name=sender_user_id, # Enriched later if needed
is_bot=is_bot,
),
content=MessageContent(
text=clean_text,
mentions=mentions,
),
timestamp=timestamp,
thread_id=thread_ts,
)

View File

@@ -0,0 +1,64 @@
"""
Slack request signature verification.
slack-bolt's AsyncApp handles signature verification automatically when
initialized with a signing_secret. This module provides a standalone
helper for contexts that require manual verification (e.g., testing,
custom middleware layers).
In production, prefer slack-bolt's built-in verification — do NOT disable
it or bypass it.
"""
from __future__ import annotations
import hashlib
import hmac
import time
def verify_slack_signature(
body: bytes,
timestamp: str,
signature: str,
signing_secret: str,
max_age_seconds: int = 300,
) -> bool:
"""
Verify a Slack webhook request signature.
Implements Slack's signing secret verification algorithm:
https://api.slack.com/authentication/verifying-requests-from-slack
Args:
body: Raw request body bytes.
timestamp: Value of the ``X-Slack-Request-Timestamp`` header.
signature: Value of the ``X-Slack-Signature`` header.
signing_secret: App's signing secret from Slack dashboard.
max_age_seconds: Reject requests older than this (replay protection).
Returns:
True if signature is valid and request is fresh, False otherwise.
"""
# Replay attack prevention — reject stale requests
try:
request_age = abs(int(time.time()) - int(timestamp))
except (ValueError, TypeError):
return False
if request_age > max_age_seconds:
return False
# Compute expected signature
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8', errors='replace')}"
computed = (
"v0="
+ hmac.new(
signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
)
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed, signature)

View File

@@ -9,14 +9,19 @@ description = "Channel Gateway — unified ingress for all messaging platforms"
requires-python = ">=3.12"
dependencies = [
"konstruct-shared",
"konstruct-router",
"konstruct-orchestrator",
"fastapi[standard]>=0.115.0",
"slack-bolt>=1.22.0",
"python-telegram-bot>=21.0",
"httpx>=0.28.0",
"redis>=5.0.0",
]
[tool.uv.sources]
konstruct-shared = { workspace = true }
konstruct-router = { workspace = true }
konstruct-orchestrator = { workspace = true }
[tool.hatch.build.targets.wheel]
packages = ["gateway"]