Files
konstruct/packages/gateway/gateway/normalize.py
Adolfo Delorenzo 6f30705e1a 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
2026-03-23 10:27:59 -06:00

101 lines
3.4 KiB
Python

"""
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,
)