mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""Gateway — owns the messaging adapters and routes inbound messages.
|
||||
|
||||
Lives inside the always-on `coworker-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 collections import OrderedDict
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ..secrets import SecretStore
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageHandler,
|
||||
SendResult,
|
||||
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
|
||||
# [ocw:<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) -> None:
|
||||
if self._interaction_handler is not None:
|
||||
await self._interaction_handler(event)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user