mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
232 lines
9.4 KiB
Python
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
|