Files
openworker/coworker/connectors/adapters.py
T

481 lines
19 KiB
Python

"""Real inbound adapters — Telegram (long-poll) and Slack (Socket Mode).
The heavy SDKs are **lazy-imported inside `connect()`** so the module imports without them
and they're optional extras. Outbound reuses the stateless senders. The raw-event → MessageEvent
mappers are pure functions (testable with plain objects/dicts, no SDK).
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from typing import Any, Optional
from .base import (
BasePlatformAdapter,
InteractionEvent,
MessageEvent,
SendResult,
SessionSource,
)
from .senders import _send_slack, _send_slack_interactive, _send_telegram
logger = logging.getLogger("coworker.connectors")
# Slack encodes an @-mention in message text as `<@U0123>` (legacy: `<@U0123|name>`) — a token,
# not the display name. Resolved at ingestion so every surface (parked cards, transcripts, the
# channel buffer) shows "@name" instead of the raw id.
_SLACK_MENTION_RE = re.compile(r"<@([UW][A-Z0-9]+)(?:\|[^>]*)?>")
# -- pure mappers --------------------------------------------------------------
def telegram_message_to_event(msg: Any) -> Optional[MessageEvent]:
text = getattr(msg, "text", None)
if not text:
return None
chat = msg.chat
user = getattr(msg, "from_user", None)
chat_type = (
"dm"
if str(getattr(chat, "type", "private")).lower().endswith("private")
else "group"
)
thread = getattr(msg, "message_thread_id", None)
source = SessionSource(
platform="telegram",
chat_id=str(chat.id),
user_id=str(user.id) if user else None,
user_name=getattr(user, "full_name", None) if user else None,
chat_type=chat_type,
thread_id=str(thread) if thread else None,
)
return MessageEvent(
text=text, source=source, message_id=str(getattr(msg, "message_id", ""))
)
def slack_event_to_event(
event: dict, bot_user_id: Optional[str]
) -> Optional[MessageEvent]:
# Skip bot echoes / message edits / joins etc. (reply-loop guard).
if event.get("bot_id") or event.get("subtype"):
return None
if bot_user_id and event.get("user") == bot_user_id:
return None
text = event.get("text") or ""
if not text:
return None
chat_type = "dm" if event.get("channel_type") == "im" else "channel"
source = SessionSource(
platform="slack",
chat_id=str(event.get("channel", "")),
user_id=event.get("user"),
chat_type=chat_type,
thread_id=event.get("thread_ts"),
)
# Mention detection runs on the RAW text (the `<@U…>` token form, legacy `<@U…|name>`
# included) — callers rewrite mentions to @display-name only after mapping.
mentions_me = bool(
bot_user_id and re.search(rf"<@{re.escape(bot_user_id)}(?:\|[^>]*)?>", text)
)
return MessageEvent(
text=text, source=source, message_id=event.get("ts"), mentions_me=mentions_me
)
# -- adapters ------------------------------------------------------------------
class TelegramAdapter(BasePlatformAdapter):
platform = "telegram"
def __init__(self, token: str) -> None:
super().__init__()
self.token = token
self._app = None
async def connect(self) -> bool:
try:
from telegram.ext import Application, MessageHandler, filters
except ImportError:
logger.warning(
"python-telegram-bot not installed — `pip install coworker[messaging]`"
)
return False
self._app = Application.builder().token(self.token).build()
async def _on_update(update, _context):
event = telegram_message_to_event(update.effective_message)
if event is not None:
await self.handle_message(event)
self._app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, _on_update)
)
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(drop_pending_updates=True)
logger.info("telegram adapter polling")
return True
async def disconnect(self) -> None:
if self._app is None:
return
try:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
finally:
self._app = None
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
return _send_telegram(self.token, chat_id, text, thread_id)
class SlackAdapter(BasePlatformAdapter):
platform = "slack"
# Watchdog cadence: how often to check the live Socket Mode connection and force a reconnect
# if it has silently died. `start_async()` sleeps forever, so a dead socket looks alive to us
# unless we poll the client's own is_connected(). Overridable for tests.
_WATCHDOG_INTERVAL = 20.0
def __init__(
self,
bot_token: str,
app_token: str,
*,
watchdog_interval: Optional[float] = None,
auto_reconnect: bool = True,
) -> None:
super().__init__()
self.bot_token = bot_token
self.app_token = app_token
self._app = None
self._socket = None
self._task: Optional[asyncio.Task] = None
self._watchdog_task: Optional[asyncio.Task] = None
self._closing = False
self._reconnects = (
0 # observable: how many times the watchdog revived the connection
)
self._watchdog_interval = (
watchdog_interval
if watchdog_interval is not None
else self._WATCHDOG_INTERVAL
)
# slack_sdk's own reconnect stays on in production (seamless on Slack's graceful cycling);
# tests turn it off so the watchdog is the sole, deterministic recovery path.
self._auto_reconnect = auto_reconnect
self._bot_user_id: Optional[str] = None
self._name_cache: dict[str, str] = (
{}
) # user_id → display name (resolved once via users.info)
self._channel_cache: dict[str, str] = (
{}
) # chat_id → channel name (resolved once via conversations.info)
async def connect(self) -> bool:
try:
from slack_bolt.adapter.socket_mode.async_handler import (
AsyncSocketModeHandler,
)
from slack_bolt.async_app import AsyncApp
from slack_sdk.web.async_client import AsyncWebClient
except ImportError:
logger.warning(
"slack-bolt not installed — `pip install coworker[messaging]`"
)
return False
# Base-URL override so tests (and the FakeSlack harness) can redirect every Web API
# call — auth.test/users.info/conversations.info/chat.update AND Socket Mode's
# apps.connections.open, which the handler issues on this same client. Default is the
# real Slack API. See platform/docs/FAKE-SLACK-SPEC.md.
base_url = os.environ.get("SLACK_API_URL", "https://slack.com/api/")
client = AsyncWebClient(token=self.bot_token, base_url=base_url)
self._app = AsyncApp(client=client)
try:
auth = await self._app.client.auth_test()
self._bot_user_id = auth.get("user_id")
except Exception:
logger.exception("slack auth_test failed")
return False
@self._app.event("message")
async def _on_message(event, _say):
mapped = slack_event_to_event(event, self._bot_user_id)
if mapped is not None:
# Slack message events carry only the user id; resolve a friendly name so recent
# senders / the allow-list don't read "unknown".
if not mapped.source.user_name:
mapped.source.user_name = await self._display_name(
mapped.source.user_id
)
# ...and a friendly channel/DM name so the GUI card shows "#ocw-test", not "C…".
if not mapped.source.chat_name:
mapped.source.chat_name = await self._channel_name(
mapped.source.chat_id
)
# ...and rewrite <@U…> mention tokens in the text to @name ("@ocw hi", not
# "<@U0BDKMA4DFF> hi").
mapped.text = await self._resolve_mentions(mapped.text)
await self.handle_message(mapped)
# Button clicks on interactive prompts (action_id `ocw_*`). Socket mode delivers these over
# the same connection — no public endpoint, just "Interactivity" enabled in the Slack app.
import re as _re
@self._app.action(_re.compile(r"^ocw_"))
async def _on_action(ack, body):
await ack()
actions = body.get("actions") or [{}]
value = actions[0].get("value", "")
user = body.get("user") or {}
channel = (body.get("channel") or {}).get("id", "")
ts = (body.get("message") or {}).get("ts")
await self.handle_interaction(
InteractionEvent(
platform="slack",
chat_id=str(channel),
message_id=ts,
value=str(value),
user_id=user.get("id"),
user_name=user.get("username") or user.get("name"),
response_url=body.get("response_url"),
)
)
self._closing = False
self._socket = AsyncSocketModeHandler(self._app, self.app_token)
self._socket.client.auto_reconnect_enabled = self._auto_reconnect
self._task = asyncio.create_task(self._socket.start_async())
# Supervise the connection: start_async() sleeps forever even if the socket dies, so poll
# the client's real state and force a reconnect if it drops (the silent-stall fix).
self._watchdog_task = asyncio.create_task(self._watchdog())
logger.info("slack adapter connected (socket mode) as %s", self._bot_user_id)
return True
async def _watchdog(self) -> None:
"""Reconnect the Socket Mode connection if it silently dies. slack_sdk maintains the socket
in background tasks and normally auto-reconnects, but it can give up after a transient
error during Slack's periodic connection cycling — leaving a dead socket that never
recovers. We poll is_connected() and re-open a fresh endpoint when it's down."""
# Let the initial connect settle before the first check.
while not self._closing:
try:
await asyncio.sleep(self._watchdog_interval)
except asyncio.CancelledError:
break
if self._closing or self._socket is None:
break
client = getattr(self._socket, "client", None)
try:
alive = bool(client and client.is_connected())
except Exception:
alive = False
if alive:
continue
logger.warning(
"slack socket mode connection down — reconnecting (watchdog)"
)
try:
await client.connect_to_new_endpoint(force=True)
self._reconnects += 1
logger.info(
"slack socket mode reconnected (watchdog, #%d)", self._reconnects
)
except asyncio.CancelledError:
break
except Exception:
logger.exception("slack watchdog reconnect failed — will retry")
async def _display_name(self, uid: Optional[str]) -> Optional[str]:
"""Resolve a user id to a display name via users.info, cached. Best-effort: None on failure
(the caller falls back to the id)."""
if not uid:
return None
if uid in self._name_cache:
return self._name_cache[uid]
try:
info = await self._app.client.users_info(user=uid)
u = info.get("user") or {}
prof = u.get("profile") or {}
name = (
prof.get("display_name")
or prof.get("real_name")
or u.get("real_name")
or u.get("name")
)
except Exception:
name = None
if name:
self._name_cache[uid] = name
return name
async def _resolve_mentions(self, text: str) -> str:
"""Rewrite `<@U…>` mention tokens to `@display-name` (cached users.info, same cache as
sender names). Best-effort: an id that won't resolve (missing scope, deleted user)
keeps its token."""
out = text
for uid in set(_SLACK_MENTION_RE.findall(text or "")):
name = await self._display_name(uid)
if name:
out = re.sub(rf"<@{re.escape(uid)}(?:\|[^>]*)?>", f"@{name}", out)
return out
async def _channel_name(self, chat_id: Optional[str]) -> Optional[str]:
"""Resolve a channel/DM id to a display name via conversations.info, cached. Best-effort:
None on failure (the caller falls back to the id). Mirrors `_display_name`."""
if not chat_id:
return None
if chat_id in self._channel_cache:
return self._channel_cache[chat_id]
try:
info = await self._app.client.conversations_info(channel=chat_id)
chan = info.get("channel") or {}
name = chan.get("name") or chan.get("name_normalized")
except Exception:
name = None
if name:
self._channel_cache[chat_id] = name
return name
async def resolve_user_name(self, user_id: Optional[str]) -> Optional[str]:
"""Public §2.1 wrapper over the cached user-name resolution."""
return await self._display_name(user_id)
async def resolve_channel_name(self, chat_id: Optional[str]) -> Optional[str]:
"""Public §2.1 wrapper over the cached channel-name resolution."""
return await self._channel_name(chat_id)
async def disconnect(self) -> None:
self._closing = True
if self._watchdog_task is not None:
self._watchdog_task.cancel()
self._watchdog_task = None
if self._socket is not None:
try:
await self._socket.close_async()
except Exception:
pass
if self._task is not None:
self._task.cancel()
self._task = None
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
# The stateless senders use blocking httpx; offload so an outbound from the event loop
# (e.g. mirror_inbox_item / _on_interaction, which await this directly) never blocks the
# server loop on the Slack round-trip.
return await asyncio.to_thread(
_send_slack, self.bot_token, chat_id, text, thread_id
)
async def send_interactive(
self, chat_id: str, text: str, buttons, *, thread_id: Optional[str] = None
) -> SendResult:
return await asyncio.to_thread(
_send_slack_interactive, self.bot_token, chat_id, text, buttons, thread_id
)
async def update_message(self, chat_id: str, message_id: str, text: str) -> None:
"""Replace a resolved prompt's buttons with a plain-text outcome ("✅ Approved by …")."""
if self._app is None or not message_id:
return
try:
await self._app.client.chat_update(
channel=chat_id, ts=message_id, text=text, blocks=[]
)
except Exception:
logger.debug("slack chat_update failed", exc_info=True)
def _load_slack_teams(secrets) -> dict[str, dict]:
"""Per-team bot tokens for managed relay, from `slack:team:<team_id>` profiles
(written by the managed OAuth install). Returns {team_id: {bot_token, bot_user_id}}.
"""
teams: dict[str, dict] = {}
if secrets is None:
return teams
for entry in secrets.status():
prof = entry.get("profile", "")
if not prof.startswith("slack:team:"):
continue
team_id = prof[len("slack:team:") :]
data = secrets.get(prof) or {}
if data.get("bot_token"):
teams[team_id] = {
"bot_token": data["bot_token"],
"bot_user_id": data.get("bot_user_id"),
}
return teams
def make_adapter(
platform: str,
profile: dict,
*,
secrets=None,
token_provider=None,
relay_url: Optional[str] = None,
relay_hub=None,
github_token_client=None,
) -> Optional[BasePlatformAdapter]:
"""Build the adapter for a connected platform from its SecretStore profile.
Slack supports two mutually-exclusive modes, the user's choice:
- `mode == "relay"` → managed cloud relay (`SlackRelayAdapter`): needs the
cloud sign-in `token_provider` + `relay_url`; per-team tokens come from
`slack:team:*` profiles. No manual tokens.
- otherwise → Socket Mode (`SlackAdapter`): manual bot + app tokens, one
workspace.
Relay adapters share ONE cloud socket: pass the same `relay_hub` to every
relay-mode platform (the caller owns it); without one, each adapter builds
its own (fine for a single relay platform).
"""
if platform == "telegram" and profile.get("bot_token"):
return TelegramAdapter(profile["bot_token"])
if platform == "slack":
if profile.get("mode") == "relay":
if not (relay_url and token_provider):
logger.warning(
"slack managed-relay configured but relay endpoint / sign-in unavailable "
"— sign in and set cloud_relay_ws_url; skipping"
)
return None
from .relay_client import SlackRelayAdapter
return SlackRelayAdapter(
relay_url,
token_provider,
teams=_load_slack_teams(secrets),
hub=relay_hub,
)
if profile.get("bot_token") and profile.get("app_token"):
return SlackAdapter(profile["bot_token"], profile["app_token"])
if platform == "github" and profile.get("mode") == "relay":
if not (relay_url and token_provider):
logger.warning(
"github managed-relay configured but relay endpoint / sign-in "
"unavailable — sign in and set cloud_relay_ws_url; skipping"
)
return None
from .github_installs import list_installs
from .github_relay import GitHubRelayAdapter
from .relay_client import RelayHub
hub = relay_hub or RelayHub(relay_url, token_provider)
installs = (
{iid: prof for iid, prof in list_installs(secrets)} if secrets else {}
)
return GitHubRelayAdapter(
hub, installs=installs, token_client=github_token_client
)
return None