Files
openworker/coworker/connectors/gateway.py
T

232 lines
9.4 KiB
Python

"""Gateway — owns the messaging adapters and routes inbound messages.
Lives inside the always-on `openworker-server` (started/stopped in its lifespan). On inbound:
enforce the per-platform allowlist, then hand the message to the registered handler (the
super-agent runner, wired in the next increment). Outbound replies go through the
`send_message` tool, not the gateway — so the gateway stays a thin inbound router here.
"""
from __future__ import annotations
import logging
from asyncio import to_thread
from collections import OrderedDict
from typing import Callable, Optional
from urllib.parse import urlparse
from ..secrets import SecretStore
from .base import (
BasePlatformAdapter,
InteractionEvent,
MessageEvent,
MessageHandler,
SendResult,
SessionSource,
parse_target,
)
from .config import ConnectorSettings, is_authorized, load_settings
logger = logging.getLogger("coworker.connectors")
_RECENT_CAP = 20 # most-recent distinct senders kept for chat-ID auto-capture
class Gateway:
def __init__(
self,
*,
secrets: Optional[SecretStore] = None,
settings: Optional[dict[str, ConnectorSettings]] = None,
handler: Optional[MessageHandler] = None,
reply_resolver: Optional[Callable[[MessageEvent], bool]] = None,
interaction_handler: Optional[Callable] = None,
on_unauthorized: Optional[Callable] = None,
) -> None:
self.secrets = secrets or SecretStore()
self.settings = (
settings if settings is not None else load_settings(self.secrets)
)
self._handler = handler
# Tried before the handler: if an inbound message is an Inbox reply (carries an
# [ow:<id>] token), it resolves the item and is consumed — not routed as a new turn.
self._reply_resolver = reply_resolver
# A button click on an interactive prompt (resolves an Inbox item by id).
self._interaction_handler = interaction_handler
# Called (awaited) with the MessageEvent when the allow-list drops it, so the message
# can be PARKED for one-step allow-and-deliver instead of vanishing.
self._on_unauthorized = on_unauthorized
self._adapters: dict[str, BasePlatformAdapter] = {}
# In-memory recent senders for chat-ID auto-capture (identity only, never persisted).
self._recent: "OrderedDict[tuple[str, str, str], dict]" = OrderedDict()
def set_handler(self, handler: MessageHandler) -> None:
self._handler = handler
def set_reply_resolver(
self, resolver: Optional[Callable[[MessageEvent], bool]]
) -> None:
self._reply_resolver = resolver
def register(self, adapter: BasePlatformAdapter) -> None:
adapter.set_message_handler(self._on_inbound)
if self._interaction_handler is not None:
adapter.set_interaction_handler(self._on_interaction)
self._adapters[adapter.platform] = adapter
async def _on_interaction(self, event: InteractionEvent) -> None:
source = SessionSource(
platform=event.platform,
chat_id=event.chat_id,
user_id=event.user_id,
user_name=event.user_name,
chat_type="channel",
team_id=event.team_id,
)
settings = self.settings.get(event.platform)
if settings is None or not is_authorized(settings, source):
logger.info("rejecting unauthorized interaction from %s", source.label())
await self.reject_interaction(event)
return
if self._interaction_handler is not None:
await self._interaction_handler(event)
async def reject_interaction(
self,
event: InteractionEvent,
text: str = "Only a designated approval owner can respond to this request.",
) -> None:
"""Best-effort private feedback for a rejected Slack button click."""
response_url = str(event.response_url or "")
parsed = urlparse(response_url)
if (
event.platform != "slack"
or parsed.scheme != "https"
or parsed.hostname not in {"hooks.slack.com", "hooks.slack-gov.com"}
):
return
def _post() -> None:
import httpx
try:
httpx.post(
response_url,
json={"response_type": "ephemeral", "text": text},
timeout=10,
)
except Exception:
logger.debug("Slack ephemeral interaction response failed", exc_info=True)
await to_thread(_post)
async def _on_inbound(self, event: MessageEvent) -> None:
self._record_recent(event) # capture identity even from unauthorized senders
settings = self.settings.get(event.source.platform)
if settings is None or not is_authorized(settings, event.source):
logger.info("parking unauthorized inbound from %s", event.source.label())
if self._on_unauthorized is not None:
try:
await self._on_unauthorized(event)
except Exception:
logger.exception("parking unauthorized inbound failed")
return
# An inbound reply that resolves an Inbox item (approval/answer) is consumed here, not
# routed to the super-agent as a new turn. The suspended agent awaiting that item is
# released automatically (InboxStore.resolve fires its waiter).
if self._reply_resolver is not None:
try:
if self._reply_resolver(event):
return
except Exception:
logger.exception("inbox reply resolver failed")
if self._handler is not None:
await self._handler(event)
def _record_recent(self, event: MessageEvent) -> None:
s = event.source
if not s.user_id:
return
# Ids are workspace-scoped, so the same U… in two teams is two senders.
key = (s.platform, s.team_id or "", s.user_id)
self._recent.pop(key, None) # move to most-recent
self._recent[key] = {
"platform": s.platform,
"user_id": s.user_id,
"user_name": s.user_name,
"chat_id": s.chat_id,
"chat_type": s.chat_type,
"target": s.target,
"team_id": s.team_id, # workspace (managed relay); None for socket mode
}
while len(self._recent) > _RECENT_CAP:
self._recent.popitem(last=False)
def recent_senders(self, platform: Optional[str] = None) -> list[dict]:
"""Most-recent-first list of who has messaged (for the allowlist UI)."""
items = list(self._recent.values())[::-1]
return [e for e in items if platform is None or e["platform"] == platform]
async def start(self) -> list[str]:
"""Connect every enabled+registered adapter. Returns the platforms that came up."""
live: list[str] = []
for platform, settings in self.settings.items():
if not settings.enabled:
continue
adapter = self._adapters.get(platform)
if adapter is None:
continue
try:
if await adapter.connect():
live.append(platform)
except Exception: # bad token / network — skip, don't break the server
logger.exception("failed to connect %s adapter", platform)
return live
async def stop(self) -> None:
for adapter in self._adapters.values():
try:
await adapter.disconnect()
except Exception:
logger.exception("error disconnecting %s adapter", adapter.platform)
async def deliver(self, target: str, text: str) -> SendResult:
"""Send via a live adapter (used where the persistent connection is preferred)."""
platform, chat_id, thread_id = parse_target(target)
adapter = self._adapters.get(platform)
if adapter is None:
return SendResult(False, error=f"no adapter for {platform}")
return await adapter.send(chat_id, text, thread_id=thread_id)
async def deliver_interactive(self, target: str, text: str, buttons) -> SendResult:
"""Send a prompt with choice buttons (adapters without interactive support show text only)."""
platform, chat_id, thread_id = parse_target(target)
adapter = self._adapters.get(platform)
if adapter is None:
return SendResult(False, error=f"no adapter for {platform}")
return await adapter.send_interactive(
chat_id, text, buttons, thread_id=thread_id
)
async def update_message(
self, platform: str, chat_id: str, message_id: str, text: str
) -> None:
"""Replace a resolved prompt's buttons with a plain-text outcome, if the adapter supports it."""
adapter = self._adapters.get(platform)
fn = getattr(adapter, "update_message", None)
if fn is not None:
await fn(chat_id, message_id, text)
def status(self) -> list[dict]:
out = []
for platform, settings in self.settings.items():
out.append(
{
"platform": platform,
"enabled": settings.enabled,
"connected": platform in self._adapters,
"allow_all": settings.allow_all,
"allowed_users": len(settings.allowed_users),
}
)
return out