feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
"""Messaging connectors — Slack/Telegram adapters, the gateway, and the send_message tool."""
from __future__ import annotations
from .base import (
BasePlatformAdapter,
MessageEvent,
MessageSource,
MessageType,
SendResult,
SessionSource,
format_target,
parse_target,
)
from .adapters import (
SlackAdapter,
TelegramAdapter,
make_adapter,
slack_event_to_event,
telegram_message_to_event,
)
from .config import ConnectorSettings, TeamAuth, is_authorized, load_settings
from .relay_client import SlackRelayAdapter
from .slack_addr import qualify as slack_qualify, split as slack_split
from .descriptors import ConnectorDescriptor, get_descriptor, list_descriptors
from .fake import FakeAdapter
from .gateway import Gateway
from .senders import DEFAULT_SENDERS
from .setup import (
connect_connector,
connector_list,
disconnect_connector,
experimental_enabled,
set_experimental_enabled,
update_connector_tools,
)
from .integration_tools import make_integration_tools
from .tools import make_send_file_tool, make_send_message_tool
from .tool_defs import connector_for_tool
__all__ = [
"BasePlatformAdapter",
"MessageEvent",
"MessageSource",
"MessageType",
"SendResult",
"SessionSource",
"format_target",
"parse_target",
"ConnectorSettings",
"TeamAuth",
"is_authorized",
"load_settings",
"ConnectorDescriptor",
"get_descriptor",
"list_descriptors",
"FakeAdapter",
"Gateway",
"DEFAULT_SENDERS",
"connect_connector",
"connector_list",
"disconnect_connector",
"experimental_enabled",
"set_experimental_enabled",
"update_connector_tools",
"make_integration_tools",
"make_send_file_tool",
"make_send_message_tool",
"connector_for_tool",
"SlackAdapter",
"SlackRelayAdapter",
"TelegramAdapter",
"make_adapter",
"slack_event_to_event",
"telegram_message_to_event",
"slack_qualify",
"slack_split",
]

View File

@@ -0,0 +1,184 @@
"""Generic multi-account profiles — one layer for every new connector.
Slack, Gmail, Calendar, and HubSpot each grew a bespoke accounts module;
this is the same proven shape (per-account token profiles at
`<connector>:account:<id>`, a token-free `<connector>:default` holding only
the default-account pointer + connector-wide flags, lazy migration of a
legacy token-bearing default) parameterized by connector so batch-2
connectors (notion, attio, posthog, …) — and eventually the bespoke four —
share one implementation.
A connector opts in by setting `account_field` on its descriptor: the creds
field that names an account (e.g. "project_id"), or the sentinel
`"@identity"` = the identity string its validator returned (e.g. the account
email). Everything downstream (connect path, connector_list, generic
account routes, the accounts GUI) keys off that.
"""
from __future__ import annotations
from typing import Any, Optional
from ..secrets import SecretStore
from .descriptors import ConnectorDescriptor, get_descriptor
IDENTITY = "@identity"
def prefix(connector: str) -> str:
return f"{connector}:account:"
def default_key(connector: str) -> str:
return f"{connector}:default"
def _norm(value: Any) -> str:
# Emails want case-folding; UUIDs/numeric ids are unaffected by it.
return str(value or "").strip().lower()
def is_account_connector(name: str) -> bool:
d = get_descriptor(name)
return bool(d and d.account_field)
def derive_account_id(d: ConnectorDescriptor, profile: dict[str, Any]) -> str:
"""The stable id naming this account: the designated creds field, or the
validator identity (stored as `account` at connect time). "default" only
when neither exists — never fails, so migration can't strand a profile."""
if d.account_field and d.account_field != IDENTITY:
return (
_norm(profile.get(d.account_field))
or _norm(profile.get("account"))
or "default"
)
return _norm(profile.get("account")) or "default"
def migrate_legacy_default(secrets: SecretStore, connector: str) -> None:
"""Rewrite a credential-bearing `<connector>:default` (from a build predating
the account layer) as one account profile. Idempotent."""
d = get_descriptor(connector)
if d is None:
return
default = secrets.get(default_key(connector)) or {}
cred_keys = [f.key for f in d.fields if f.key != "allowed_users"]
if not any(default.get(k) for k in cred_keys):
return
account_id = derive_account_id(d, default)
account = {k: v for k, v in default.items() if k != "default_account"}
account.setdefault("account", account_id)
secrets.put(prefix(connector) + account_id, account)
secrets.put(
default_key(connector),
{
"type": default.get("type") or "token",
"enabled": bool(default.get("enabled", True)),
"default_account": _norm(default.get("default_account")) or account_id,
},
)
def list_accounts(
secrets: SecretStore, connector: str
) -> list[tuple[str, dict[str, Any]]]:
"""(account_id, profile) for every connected account, migration included."""
migrate_legacy_default(secrets, connector)
pre = prefix(connector)
out = []
for meta in secrets.status():
key = meta.get("profile", "")
if key.startswith(pre):
out.append((key[len(pre) :], secrets.get(key) or {}))
return sorted(out, key=lambda t: t[0])
def default_account(secrets: SecretStore, connector: str) -> str:
"""The default account id: the stored pointer if it still exists, else the
first connected account, else ""."""
accounts = dict(list_accounts(secrets, connector))
pointer = _norm((secrets.get(default_key(connector)) or {}).get("default_account"))
if pointer in accounts:
return pointer
return next(iter(accounts), "")
def resolve(
secrets: SecretStore, connector: str, account: str = ""
) -> tuple[str, str, Optional[dict[str, Any]]]:
"""(account_id, profile_key, profile) for the requested — or default —
account. Profile is None when nothing matches."""
account_id = _norm(account) or default_account(secrets, connector)
if not account_id:
return "", "", None
key = prefix(connector) + account_id
return account_id, key, secrets.get(key)
def add_account(
secrets: SecretStore, connector: str, account_id: str, profile: dict[str, Any]
) -> dict[str, Any]:
"""Store one account (manual connect and managed OAuth both land here); the
first connected account becomes the default. Re-adding an id replaces its
credentials in place."""
migrate_legacy_default(secrets, connector)
account_id = _norm(account_id)
if not account_id:
return {"ok": False, "error": "account id missing"}
secrets.put(prefix(connector) + account_id, profile)
pointer = secrets.get(default_key(connector)) or {}
pointer.setdefault("default_account", account_id)
pointer.setdefault("type", profile.get("type") or "token")
pointer["enabled"] = bool(pointer.get("enabled", True))
secrets.put(default_key(connector), pointer)
return {"ok": True, "account": account_id}
def set_default(
secrets: SecretStore, connector: str, account_id: str
) -> dict[str, Any]:
account_id = _norm(account_id)
if not secrets.get(prefix(connector) + account_id):
return {"ok": False, "error": "account not connected"}
pointer = secrets.get(default_key(connector)) or {}
pointer["default_account"] = account_id
pointer.setdefault("type", "token")
pointer.setdefault("enabled", True)
secrets.put(default_key(connector), pointer)
return {"ok": True, "default_account": account_id}
def disconnect_account(
secrets: SecretStore, connector: str, account_id: str
) -> dict[str, Any]:
"""Drop one account. The default pointer moves to the next account; removing
the last account removes the pointer profile too."""
account_id = _norm(account_id)
if not secrets.get(prefix(connector) + account_id):
return {"ok": False, "error": "account not connected"}
secrets.delete(prefix(connector) + account_id)
remaining = [a for a, _ in list_accounts(secrets, connector)]
if remaining:
pointer = secrets.get(default_key(connector)) or {}
if _norm(pointer.get("default_account")) == account_id:
pointer["default_account"] = remaining[0]
secrets.put(default_key(connector), pointer)
else:
secrets.delete(default_key(connector))
return {"ok": True, "remaining_accounts": len(remaining)}
def account_rows(secrets: SecretStore, connector: str) -> list[dict[str, Any]]:
"""connector_list's `accounts` field: id, display name, default/managed
flags. Display name = the identity captured at connect (else the id)."""
default = default_account(secrets, connector)
return [
{
"account_id": account_id,
"name": str(profile.get("account") or account_id),
"default": account_id == default,
"managed": bool(profile.get("managed")),
}
for account_id, profile in list_accounts(secrets, connector)
]

View File

@@ -0,0 +1,480 @@
"""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

View File

@@ -0,0 +1,72 @@
"""Sender attribution for outbound Slack posts (P1, 2026-07-14).
Multiple people can run OpenWorker into the same channel, and every one of their
posts arrives as the same @ocw bot. The managed OAuth install already records WHO
connected each workspace — Slack's `authed_user` — so outbound text carries
"[<their name>] " per workspace: the member id rides the install form-POST into the
`slack:team:<id>` profile, and the display name is resolved once via `users.info`
(scope `users:read`, granted since wave 1) and cached on that profile.
Truthfulness rules: manual Socket-Mode installs have no authed_user, so there is
nothing to attribute and their posts stay bare; DMs skip the prefix (a 1:1 with the
bot has no ambiguity); and attribution NEVER blocks a send — any resolution failure
degrades to no prefix. P2 (chat:write.customize) replaces the text prefix with a
native username override.
"""
from __future__ import annotations
import os
from typing import Optional
from ..secrets import SecretStore
_TIMEOUT = 10.0
def _api_base() -> str:
return os.environ.get("SLACK_API_URL", "https://slack.com/api/")
def _fetch_display_name(token: str, user_id: str) -> Optional[str]:
"""users.info → the human's name (display name, else real name). None on any failure."""
import httpx
try:
resp = httpx.get(
f"{_api_base()}users.info",
params={"user": user_id},
headers={"Authorization": f"Bearer {token}"},
timeout=_TIMEOUT,
)
data = resp.json()
except Exception:
return None
if not data.get("ok"):
return None
user = data.get("user") or {}
profile = user.get("profile") or {}
name = profile.get("display_name") or profile.get("real_name") or user.get("name")
return str(name).strip() or None if name else None
def sender_prefix(secrets: SecretStore, chat_id: str) -> str:
"""'[Rohit] ' for a Slack chat_id whose workspace install knows its human, else ''."""
from .slack_addr import split
team, channel = split(chat_id)
if channel.startswith("D"): # DM with the bot — nothing to disambiguate
return ""
key = f"slack:team:{team}" if team else "slack:default"
profile = secrets.get(key) or {}
name = profile.get("sender_name")
if not name:
user_id, token = profile.get("slack_user_id"), profile.get("bot_token")
if not user_id or not token:
return ""
name = _fetch_display_name(str(token), str(user_id))
if not name:
return ""
profile["sender_name"] = name
secrets.put(key, profile)
return f"[{name}] "

184
coworker/connectors/base.py Normal file
View File

@@ -0,0 +1,184 @@
"""Messaging connector core — the platform-agnostic adapter contract + value types.
Patterns borrowed from Hermes' gateway (read-only ref). An adapter connects to a platform
(Slack/Telegram), receives inbound messages and dispatches them via `handle_message`, and
can `send` outbound. Inbound identity is carried by `SessionSource`; a `target` token
(`platform:chat_id[:thread]`) is the opaque handle the agent passes back to reply.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable, Optional
class MessageType(str, Enum):
TEXT = "text"
COMMAND = "command"
MEDIA = "media"
# -- target tokens -------------------------------------------------------------
def format_target(platform: str, chat_id: str, thread_id: Optional[str] = None) -> str:
base = f"{platform}:{chat_id}"
return f"{base}:{thread_id}" if thread_id else base
def parse_target(target: str) -> tuple[str, str, Optional[str]]:
"""`'platform:chat_id[:thread]'` -> (platform, chat_id, thread_id)."""
parts = (target or "").split(":")
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError(
f"invalid target {target!r} (expected 'platform:chat_id[:thread]')"
)
thread = ":".join(parts[2:]) if len(parts) > 2 else None
return parts[0], parts[1], (thread or None)
# -- value types ---------------------------------------------------------------
@dataclass
class SessionSource:
platform: str
chat_id: str
user_id: Optional[str] = None
user_name: Optional[str] = None
chat_name: Optional[str] = None # channel/DM display name (resolved, §2.3)
chat_type: str = "dm" # "dm" | "group" | "channel"
thread_id: Optional[str] = None
team_id: Optional[str] = None # workspace id for managed-relay multi-workspace
@property
def target(self) -> str:
return format_target(self.platform, self.chat_id, self.thread_id)
def label(self) -> str:
who = self.user_name or self.user_id or "?"
where = {"dm": "DM", "group": "group", "channel": "channel"}.get(
self.chat_type, self.chat_type
)
return f"{self.platform} {where} · {who}"
@dataclass
class MessageSource:
"""Structured sidecar for a connector inbound message (UI-REFRESH §3.1).
Attached (as a plain dict via `to_dict`) to the persisted user message for DISPLAY only —
the GUI renders a rich card from it. The model-facing `content` stays the framed text and
this sidecar is stripped before the message reaches any provider. `text` is the RAW message
(what the card shows), distinct from the framed `content`.
"""
connector: str # platform id, e.g. "slack"
kind: str # "channel" | "dm"
channel_id: str # e.g. "C0BD7KZ1AH5"
channel_name: str # resolved display name; falls back to channel_id
sender_id: str
sender_name: str # resolved display name; falls back to sender_id
ts: float # epoch seconds
text: str # the RAW message (what the card shows)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class MessageEvent:
text: str
source: SessionSource
message_id: Optional[str] = None
message_type: MessageType = MessageType.TEXT
reply_to_message_id: Optional[str] = None
raw: Any = None
# The bot itself was @-mentioned (UX-DECISIONS §31 mention router). Computed from the RAW
# platform text at mapping time — mention tokens are rewritten for display afterwards.
mentions_me: bool = False
def tagged_text(self) -> str:
"""How the message enters the super-agent thread: source + reply handle + text.
The local GUI owner ('gui') is answered with plain assistant text (no `send_message`);
messaging platforms carry a reply handle the agent passes back to `send_message`.
"""
if self.source.platform == "gui":
return f"[Owner, in the app]: {self.text}"
return f"[{self.source.label()} | reply→{self.source.target}]: {self.text}"
@dataclass
class SendResult:
ok: bool
message_id: Optional[str] = None
error: Optional[str] = None
MessageHandler = Callable[[MessageEvent], Awaitable[None]]
@dataclass
class InteractionEvent:
"""A button click on an interactive prompt.
Stable actor/workspace ids are security inputs; display names are presentation only.
`response_url` is Slack's short-lived reply capability for a private rejection notice.
"""
platform: str
chat_id: str
message_id: Optional[str] # the clicked message's id/ts (to update it)
value: str
user_id: Optional[str] = None
user_name: Optional[str] = None
team_id: Optional[str] = None
response_url: Optional[str] = None
InteractionHandler = Callable[[InteractionEvent], Awaitable[None]]
class BasePlatformAdapter(ABC):
"""One messaging platform. Subclasses implement connect/disconnect/send and call
`handle_message` for inbound events."""
platform: str = "base"
def __init__(self) -> None:
self._handler: Optional[MessageHandler] = None
self._interaction_handler: Optional[InteractionHandler] = None
def set_message_handler(self, handler: MessageHandler) -> None:
self._handler = handler
def set_interaction_handler(self, handler: InteractionHandler) -> None:
self._interaction_handler = handler
async def send_interactive(
self, chat_id: str, text: str, buttons, *, thread_id: Optional[str] = None
) -> SendResult:
"""Send a prompt with choice buttons. Default: plain text (adapters without interactive
support just show the text — the user answers in the app)."""
return await self.send(chat_id, text, thread_id=thread_id)
async def handle_interaction(self, event: InteractionEvent) -> None:
if self._interaction_handler is not None:
await self._interaction_handler(event)
@abstractmethod
async def connect(self) -> bool:
"""Connect + start the inbound listener. True on success."""
@abstractmethod
async def disconnect(self) -> None:
"""Stop the listener and close connections."""
@abstractmethod
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
"""Send an outbound message."""
async def handle_message(self, event: MessageEvent) -> None:
if self._handler is not None:
await self._handler(event)

View File

@@ -0,0 +1,620 @@
"""Playwright-backed browser automation tools for Cowork.
The dependency is optional. If Playwright or its browser binaries are not installed, the
tools return a clear setup error instead of breaking engine construction.
"""
from __future__ import annotations
import re
import tempfile
import threading
import time
import base64
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Callable, Optional
import aisuite as ai
from ..web.guard import check_url
def _meta(
name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None
):
return ai.ToolMetadata(
name=name,
category="connector",
risk_level="medium" if approval else "low",
capabilities=capabilities or ["browser"],
requires_approval=approval,
)
def _schema(
name: str, description: str, properties: dict[str, Any], required: list[str]
) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
def _attach(fn: Callable[..., Any], schema: dict[str, Any], *, approval: bool = True):
from .tool_defs import approval_for_tool
name = schema["function"]["name"]
# §36: the tool registry's read/write kind wins for registered tools — reads never gate.
approval = approval_for_tool(name, default=approval)
fn.__coworker_schema__ = schema
fn.__aisuite_tool_metadata__ = _meta(name, approval=approval)
fn.__doc__ = schema["function"]["description"]
return fn
class _BrowserController:
def __init__(self) -> None:
self._lock = threading.RLock()
self._playwright = None
self._browser = None
self._context = None
self._page = None
self._error: Optional[str] = None
self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="coworker-browser"
)
self._state: dict[str, Any] = {
"open": False,
"url": "",
"title": "",
"status": "closed",
"last_action": "",
"last_result": "",
"last_error": "",
"screenshot_data_url": "",
"updated_at": None,
"controls": [],
}
def _touch(self, **changes: Any) -> None:
self._state.update(changes)
self._state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _refresh_page_state(self) -> None:
if self._page is None:
self._touch(open=False, status="closed", url="", title="", controls=[])
return
try:
snap = _snapshot(self._page, 2000)
self._touch(
open=True,
status="open",
url=self._page.url,
title=self._page.title(),
controls=snap.get("controls", [])[:30],
)
except Exception as exc:
self._touch(open=True, status="error", last_error=str(exc))
def _setup_error(self, exc: Exception) -> dict[str, str]:
return {
"error": (
"Interactive browser automation requires Playwright. Install it with "
"`pip install playwright` and `python -m playwright install chromium`."
),
"details": str(exc),
}
def page(self):
with self._lock:
if self._error:
return None, {"error": self._error}
if self._page is not None:
return self._page, None
try:
from playwright.sync_api import sync_playwright
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(headless=False)
self._context = self._browser.new_context(
viewport={"width": 1280, "height": 900}
)
self._page = self._context.new_page()
self._touch(
open=True, status="open", last_action="open browser", last_error=""
)
return self._page, None
except Exception as exc:
self._touch(open=False, status="error", last_error=str(exc))
return None, self._setup_error(exc)
def _submit(self, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
return self._executor.submit(fn).result()
def close(self) -> dict[str, Any]:
return self._submit(self._close_locked)
def _close_locked(self) -> dict[str, Any]:
with self._lock:
try:
if self._context is not None:
self._context.close()
if self._browser is not None:
self._browser.close()
if self._playwright is not None:
self._playwright.stop()
except Exception as exc:
return {"error": str(exc)}
finally:
self._playwright = None
self._browser = None
self._context = None
self._page = None
self._touch(open=False, status="closed", url="", title="", controls=[])
return {"ok": True}
def state(self) -> dict[str, Any]:
return self._submit(self._state_locked)
def _state_locked(self) -> dict[str, Any]:
with self._lock:
self._refresh_page_state()
return dict(self._state)
def screenshot(self) -> dict[str, Any]:
return self._submit(self._screenshot_locked)
def _screenshot_locked(self) -> dict[str, Any]:
with self._lock:
page, err = self.page()
if err:
return err
try:
png = page.screenshot(full_page=False)
data_url = "data:image/png;base64," + base64.b64encode(png).decode(
"ascii"
)
self._touch(
screenshot_data_url=data_url,
last_action="screenshot",
last_result="ok",
last_error="",
)
self._refresh_page_state()
return {"ok": True, **dict(self._state)}
except Exception as exc:
self._touch(
last_action="screenshot", last_result="error", last_error=str(exc)
)
return {"error": str(exc)}
def call(self, action: str, fn: Callable[[Any], dict[str, Any]]) -> dict[str, Any]:
def run() -> dict[str, Any]:
with self._lock:
page, err = self.page()
if err:
return err
self._touch(last_action=action, last_result="running", last_error="")
try:
out = fn(page)
except Exception as exc:
out = {"error": str(exc)}
if "error" in out:
self._touch(
last_action=action,
last_result="error",
last_error=str(out["error"]),
)
else:
self._refresh_page_state()
self._touch(last_action=action, last_result="ok", last_error="")
return out
return self._submit(run)
_BROWSER = _BrowserController()
def browser_state() -> dict[str, Any]:
return _BROWSER.state()
def browser_take_screenshot() -> dict[str, Any]:
return _BROWSER.screenshot()
def browser_close_session() -> dict[str, Any]:
return _BROWSER.close()
def _cap(value: int, default: int = 20000, upper: int = 100000) -> int:
try:
return max(1, min(int(value or default), upper))
except Exception:
return default
def _target_locator(page, target: str):
target = target.strip()
if target.startswith("text="):
return page.get_by_text(target[5:], exact=False).first
if target.startswith("role="):
role_name = target[5:]
role, _, name = role_name.partition(":")
return page.get_by_role(role.strip(), name=name.strip() or None).first
try:
return page.locator(target).first
except Exception:
return page.get_by_text(target, exact=False).first
def _safe_call(fn: Callable[[], Any]) -> dict[str, Any]:
try:
return fn()
except Exception as exc:
return {"error": str(exc)}
def _browser_call(action: str, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
return _BROWSER.call(action, lambda _page: fn())
_SNAPSHOT_JS = """
() => {
const visible = (el) => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style && style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
};
const labelFor = (el) => {
if (el.labels && el.labels.length) return Array.from(el.labels).map(l => l.innerText.trim()).filter(Boolean).join(' ');
const id = el.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
if (label) return label.innerText.trim();
}
return '';
};
const describe = (el, i) => ({
index: i,
tag: el.tagName.toLowerCase(),
type: el.getAttribute('type') || '',
id: el.getAttribute('id') || '',
name: el.getAttribute('name') || '',
role: el.getAttribute('role') || '',
aria: el.getAttribute('aria-label') || '',
label: labelFor(el),
placeholder: el.getAttribute('placeholder') || '',
text: (el.innerText || el.value || '').trim().slice(0, 200),
href: el.getAttribute('href') || '',
selectorHint: el.getAttribute('id') ? `#${CSS.escape(el.getAttribute('id'))}` : (el.getAttribute('name') ? `[name="${el.getAttribute('name')}"]` : '')
});
const controls = Array.from(document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]'))
.filter(visible)
.slice(0, 120)
.map(describe);
return {
title: document.title,
url: location.href,
text: document.body ? document.body.innerText : '',
controls
};
}
"""
def _snapshot(page, max_chars: int) -> dict[str, Any]:
data = page.evaluate(_SNAPSHOT_JS)
text = re.sub(r"\n{3,}", "\n\n", str(data.get("text") or ""))
cap = _cap(max_chars)
return {
"title": data.get("title"),
"url": data.get("url"),
"text": text[:cap],
"truncated": len(text) > cap,
"controls": data.get("controls") or [],
}
def redirect_refusal(requested: str, final: str) -> Optional[str]:
"""A refusal reason if navigation LANDED somewhere the address guard would refuse.
`check_url` vets the URL the model supplied; Playwright then follows redirects, and the
hop that actually loads is a different address the guard never saw (OPE-124). A public
shortener can land on the cloud metadata endpoint or a router admin page, and the
approval the user gave was for the first URL, not this one.
The request has already gone out by the time this runs — it cannot be prevented here.
What it prevents is the agent READING the page or interacting with it. Later
JavaScript- or meta-refresh-driven navigation is still unchecked; only a proxy that
vets every hop closes that, which is the larger design this defers."""
if not final or final == requested:
return None
return check_url(final)
def make_browser_automation_tools(
*, roots: Optional[list[Any]] = None
) -> list[Callable[..., Any]]:
tools: list[Callable[..., Any]] = []
def _readable_source(raw: str) -> tuple[Any, dict[str, Any] | None]:
"""A local file to upload, resolved inside a granted root (OPE-122).
These tools touch the filesystem but classify EXTERNAL, so the permission engine's
root scoping — which only runs for WRITE_LOCAL — never sees them. Without this
check the only thing between `~/.ssh/id_rsa` and a web form is someone reading the
approval card. Mirrors `email_send`'s attachment rule, which solves the same
problem for outgoing mail."""
allowed = [r.path for r in (roots or [])]
if not allowed:
return None, {"error": "no session directory is available to upload from"}
path = Path(str(raw)).expanduser().resolve()
if not any(path.is_relative_to(root) for root in allowed):
return None, {"error": f"{path} is outside the session's directories"}
return path, None
def _writable_target(raw: str) -> tuple[Any, dict[str, Any] | None]:
"""Where a screenshot may land: inside a WRITABLE granted root. An unnamed target
keeps the temp-file default, which is not a place the user asked us to protect."""
writable = [r.path for r in (roots or []) if r.writable]
if not writable:
return None, {"error": "no writable session directory for the screenshot"}
path = Path(str(raw)).expanduser().resolve()
if not any(path.is_relative_to(root) for root in writable):
return None, {
"error": f"{path} is outside the session's writable directories"
}
return path, None
def browser_open_url(
url: str, wait_until: str = "domcontentloaded"
) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
# Same address guard as web_fetch. This is approval gated, so it is defense in
# depth, not the primary control. It checks the initial model supplied URL only;
# redirects that the browser follows internally are not hop checked here.
blocked = check_url(url)
if blocked:
return {"error": blocked}
def _open(page):
page.goto(url, wait_until=wait_until, timeout=30000)
landed = redirect_refusal(url, page.url)
if landed:
# Leave nothing readable behind: the next snapshot/get_text must not be
# able to lift content off a page we just refused.
final = page.url
page.goto("about:blank")
return {"error": f"redirected to {final}{landed}"}
return {"ok": True, "url": page.url}
return _BROWSER.call("open_url", _open)
browser_open_url.__name__ = "browser_open_url"
tools.append(
_attach(
browser_open_url,
_schema(
"browser_open_url",
"Open a URL in the local Playwright browser session.",
{"url": {"type": "string"}, "wait_until": {"type": "string"}},
["url"],
),
approval=True,
)
)
def browser_read_page(max_chars: int = 20000) -> dict[str, Any]:
return _BROWSER.call("snapshot", lambda page: _snapshot(page, max_chars))
browser_read_page.__name__ = "browser_read_page"
tools.append(
_attach(
browser_read_page,
_schema(
"browser_read_page",
"Read the current page: its text plus visible controls and selector "
"hints (for browser_click/browser_type). Not an image — use "
"browser_screenshot for pixels.",
{"max_chars": {"type": "integer"}},
[],
),
approval=True,
)
)
def browser_click(target: str) -> dict[str, Any]:
return _BROWSER.call(
"click",
lambda page: (
_target_locator(page, target).click(timeout=10000),
{"ok": True, "url": page.url},
)[1],
)
browser_click.__name__ = "browser_click"
tools.append(
_attach(
browser_click,
_schema(
"browser_click",
"Click a visible page element by CSS selector, text=label, role=button:Name, or text fallback. Requires approval.",
{"target": {"type": "string"}},
["target"],
),
approval=True,
)
)
def browser_type(target: str, text: str, clear: bool = True) -> dict[str, Any]:
def run(page):
loc = _target_locator(page, target)
if clear:
loc.fill(text, timeout=10000)
else:
loc.type(text, timeout=10000)
return {"ok": True, "url": page.url}
return _BROWSER.call("type", run)
browser_type.__name__ = "browser_type"
tools.append(
_attach(
browser_type,
_schema(
"browser_type",
"Fill or type into an input, textarea, or editable element. Requires approval.",
{
"target": {"type": "string"},
"text": {"type": "string"},
"clear": {"type": "boolean"},
},
["target", "text"],
),
approval=True,
)
)
def browser_select(target: str, value: str) -> dict[str, Any]:
return _BROWSER.call(
"select",
lambda page: (
_target_locator(page, target).select_option(value, timeout=10000),
{"ok": True, "url": page.url},
)[1],
)
browser_select.__name__ = "browser_select"
tools.append(
_attach(
browser_select,
_schema(
"browser_select",
"Select an option in a dropdown by selector and option value/label. Requires approval.",
{"target": {"type": "string"}, "value": {"type": "string"}},
["target", "value"],
),
approval=True,
)
)
def browser_upload_file(target: str, path: str) -> dict[str, Any]:
file_path, err = _readable_source(path)
if err:
return err
if not file_path.exists():
return {"error": f"file not found: {file_path}"}
return _BROWSER.call(
"upload_file",
lambda page: (
_target_locator(page, target).set_input_files(
str(file_path), timeout=10000
),
{"ok": True, "path": str(file_path)},
)[1],
)
browser_upload_file.__name__ = "browser_upload_file"
tools.append(
_attach(
browser_upload_file,
_schema(
"browser_upload_file",
"Upload a local file through a file input. Requires approval.",
{"target": {"type": "string"}, "path": {"type": "string"}},
["target", "path"],
),
approval=True,
)
)
def browser_wait(milliseconds: int = 1000, target: str = "") -> dict[str, Any]:
def run(page):
if target:
_target_locator(page, target).wait_for(
timeout=max(1, int(milliseconds or 1000))
)
else:
page.wait_for_timeout(max(1, min(int(milliseconds or 1000), 30000)))
return {"ok": True, "url": page.url}
return _BROWSER.call("wait", run)
browser_wait.__name__ = "browser_wait"
tools.append(
_attach(
browser_wait,
_schema(
"browser_wait",
"Wait for a duration or for a target element to appear.",
{"milliseconds": {"type": "integer"}, "target": {"type": "string"}},
[],
),
approval=True,
)
)
def browser_screenshot(path: str = "") -> dict[str, Any]:
if path:
_target, target_err = _writable_target(path)
if target_err:
return target_err
def run(page):
out = (
_target
if path
else (
Path(tempfile.gettempdir()) / "coworker-browser-screenshot.png"
).resolve()
)
out.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(out), full_page=True)
return {"ok": True, "path": str(out), "url": page.url}
return _BROWSER.call("screenshot", run)
browser_screenshot.__name__ = "browser_screenshot"
tools.append(
_attach(
browser_screenshot,
_schema(
"browser_screenshot",
"Save a full-page screenshot of the current browser page and return the local path.",
{"path": {"type": "string"}},
[],
),
approval=True,
)
)
def browser_close() -> dict[str, Any]:
return browser_close_session()
browser_close.__name__ = "browser_close"
tools.append(
_attach(
browser_close,
_schema(
"browser_close",
"Close the local Playwright browser session.",
{},
[],
),
approval=True,
)
)
return tools

View File

@@ -0,0 +1,219 @@
"""Pre-connect catalog copy: what each connector is for and what access it gets.
Served with every /v1/connectors entry so the GUI's pre-connect detail page
(UX-DECISIONS §38) can show About / Access before any credentials exist. Plain
statements of behavior, not marketing: every bullet must stay true to the
connector's actual tools (tool_defs.py) and, for managed connectors, the scopes
the OpenWorker Cloud app requests. Overclaiming here is a product bug.
ABOUT is optional (the list blurb is the fallback subtitle); ACCESS is required
for every available connector — tests/test_connectors.py enforces it.
"""
from __future__ import annotations
ABOUT: dict[str, str] = {
"telegram": "Chat with your coworker from Telegram. Messages to your bot "
"reach the agent and replies come back to the same chat — only senders on "
"your allow-list get through.",
"slack": "Bring your coworker into Slack: mention it in a channel or DM it, "
"and replies land in-thread. Any number of workspaces can be connected, "
"each with its own allow-list of who may talk to the agent.",
"email": "Read, search, and send mail on any IMAP account — Gmail, iCloud, "
"Fastmail, or your own server — using an app password instead of your "
"account password.",
"gmail": "Search, summarize, and send over your Gmail. Multiple accounts "
"connect side by side, and privacy filters can hide chosen senders or "
"labels from agents entirely.",
"google_calendar": "Check availability, summarize your week, and manage "
"events. Multiple Google accounts connect side by side.",
"browser": "A built-in browser agents drive to read pages and act on "
"websites — separate from your personal browser, with actions subject to "
"approval.",
"github": "Work with issues, pull requests, repository files, and CI "
"status. One click installs the OpenWorker GitHub App on the repositories "
"you pick; mention the agent on an issue or PR and it answers from your "
"desktop.",
"outlook": "Search, summarize, and send Microsoft 365 mail, and run your "
"calendar — create and move meetings, respond to invites. Multiple "
"mailboxes connect side by side.",
"hubspot": "Search and read your CRM; optionally log notes and tasks and "
"update records. Read-only vs read & write is chosen at consent time, and "
"chosen properties can be hidden from agents entirely.",
"notion": "Search and read the pages and databases you share with the "
"connection, and create new pages. You choose exactly which pages it can "
"see.",
"attio": "Read your Attio CRM — objects, records, and lists — to prep "
"meetings and answer pipeline questions, and log notes as you work.",
"google_drive": "Search, browse, and read files across your Drive. "
"Multiple accounts connect side by side.",
"monday": "Work with your monday.com boards — read items, summarize and "
"aggregate board data, create items, and post updates. One-click sign-in "
"runs entirely on this computer against monday.com's own agent service; agents "
"get a small curated set of its tools, never the full catalog.",
"asana": "Keep up with your Asana work — search and read tasks and "
"projects, create tasks, and comment. Connects with a personal access "
"token from the Asana developer console.",
}
# What connecting actually grants, as short honest bullets. Write powers always
# name themselves; reads state their boundary ("…your account can see").
ACCESS: dict[str, list[str]] = {
"telegram": [
"Reads messages sent to your bot — never your personal chats.",
"Sends messages as the bot.",
"Only senders on your allow-list are answered.",
],
"slack": [
"Reads channels the bot is invited to, and its DMs.",
"Posts messages and uploads files as the bot.",
"Reads files shared in those channels.",
"Reads member and channel names to resolve who's talking.",
],
"email": [
"Reads and searches mail over IMAP.",
"Sends mail as your address, and saves attachments locally.",
"Signs in with an app password — never your account password.",
],
"gmail": [
"Reads and searches your mail.",
"Sends email as you.",
"Never deletes mail or changes account settings.",
],
"google_calendar": [
"Reads events and availability across your calendars.",
"Creates, updates, and deletes events.",
],
"browser": [
"Opens and reads web pages in its own browser session.",
"Clicks, types, and uploads files only inside that session.",
"Never touches your personal browser or its logins.",
],
"github": [
"Reads code, issues, pull requests, and CI on repositories you grant.",
"Creates issues, replies, and reviews pull requests.",
"You pick the repositories on GitHub — one, several, or all.",
],
"outlook": [
"Reads and searches your mail.",
"Sends mail as you.",
"Reads your calendar.",
"Creates, changes, and cancels events; responds to invites as you.",
],
"jira": [
"Reads and searches issues your account can see.",
"Creates, updates, and transitions issues; comments as you.",
],
"monday": [
"Reads boards, items, and updates your account can see.",
"Creates items, changes item values, and posts updates as you.",
],
"asana": [
"Reads and searches tasks your account can see.",
"Creates tasks as you.",
],
"confluence": [
"Reads and searches spaces and pages your account can see.",
"Creates pages as you.",
],
"zendesk": [
"Reads and searches tickets your agent account can see.",
"Creates tickets as you.",
],
"linear": [
"Reads and searches issues your account can see.",
"Creates issues as you.",
],
"gitlab": [
"Reads issues and merge requests within your token's scope.",
"Creates issues (needs the api scope; read_api stays read-only).",
],
"discord": [
"Reads channels the bot can see.",
"Sends messages as the bot.",
],
"stripe": [
"Reads customers, charges, and invoices — read-only.",
"A restricted read-only key means write access isn't even possible.",
],
"hubspot": [
"Reads contacts, companies, deals, and tickets.",
"Read & write adds: log notes and tasks, update records, create "
"contacts — never delete.",
"Properties you hide are stripped before an agent ever sees a record.",
],
"dropbox": [
"Reads file names and contents — read-only.",
],
"box": [
"Reads file names and contents — read-only.",
],
"whatsapp": [
"Sends messages from your Cloud API number.",
"Outbound only — it cannot read your chats.",
],
"quickbooks": [
"Reads customers, invoices, and reports — read-only.",
],
"docusign": [
"Reads envelopes and their signing status.",
"Sends documents for signature as you.",
],
"clickup": [
"Reads and searches tasks and docs your account can see.",
"Creates and updates tasks, and comments, as you.",
],
"google_drive": [
"Reads and searches your files — read-only.",
"Never edits or deletes anything in your Drive.",
],
"canva": [
"Browses your designs and exports them — read-only.",
],
"figma": [
"Reads design files and comments; exports assets.",
"Comments as you — never edits a design.",
],
"close": [
"Reads leads, contacts, and opportunities.",
"Creates leads, updates opportunities, and logs notes as you.",
],
"notion": [
"Reads only the pages and databases shared with the connection.",
"Creates pages — never edits or deletes existing ones.",
],
"attio": [
"Reads objects, records, lists, and notes.",
"Logs notes — records are never created or changed.",
],
"posthog": [
"Runs read-only queries on the connected project: events, funnels, "
"insights.",
],
"mixpanel": [
"Runs read-only queries on the connected project.",
],
"amplitude": [
"Runs read-only chart queries: active users, event totals.",
],
"apollo": [
"Searches and enriches people and companies, using your Apollo " "credits.",
],
"hunter": [
"Finds and verifies email addresses, using your Hunter quota.",
],
}
# Experimental / future connectors fall back to this rather than shipping
# without an access statement.
_DEFAULT_ACCESS = [
"Access is limited to what the credentials you provide allow.",
]
def about_for(name: str) -> str:
return ABOUT.get(name, "")
def access_for(name: str) -> list[str]:
return list(ACCESS.get(name) or _DEFAULT_ACCESS)

110
coworker/connectors/cli.py Normal file
View File

@@ -0,0 +1,110 @@
"""Small CLI to exercise connectors independently.
python -m coworker.connectors.cli status
Show which platforms are configured (token present) + allowlist size.
python -m coworker.connectors.cli fake [--user U1] [--allow U1]
Offline REPL: type messages as if they arrived from a platform; a built-in echo
handler replies through the gateway. Exercises auth + inbound dispatch + outbound
with no network. Try --user with someone NOT in --allow to see it dropped.
python -m coworker.connectors.cli send --target telegram:12345 --text "hi"
Live outbound via the send_message tool (needs a bot token in the SecretStore).
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from ..secrets import SecretStore
from .base import MessageEvent
from .config import ConnectorSettings, load_settings
from .fake import FakeAdapter
from .gateway import Gateway
from .tools import make_send_message_tool
def _cmd_status() -> int:
settings = load_settings(SecretStore())
print("Connector status:")
for platform, s in settings.items():
print(
f" {platform:10s} enabled={s.enabled} allow_all={s.allow_all} "
f"allowed_users={len(s.allowed_users)}"
)
return 0
async def _run_fake(user: str, allow: list[str]) -> int:
fake = FakeAdapter()
settings = {
"fake": ConnectorSettings(
platform="fake", enabled=True, allowed_users=set(allow), allow_all=not allow
)
}
gateway = Gateway(settings=settings)
async def echo_handler(event: MessageEvent) -> None:
reply = f"echo: {event.text}"
await gateway.deliver(event.source.target, reply)
print(f" ↩ sent to {event.source.target}: {reply!r}")
gateway.set_handler(echo_handler)
gateway.register(fake)
await gateway.start()
print(f"fake gateway up (user={user}, allow={allow or ' all'}). Ctrl-D to quit.\n")
while True:
try:
text = await asyncio.to_thread(input, "you> ")
except (EOFError, KeyboardInterrupt):
print()
break
text = text.strip()
if not text:
continue
before = len(fake.outbox)
await fake.inject(text, user_id=user, user_name=user)
if len(fake.outbox) == before:
print(" dropped (not authorized)")
await gateway.stop()
return 0
def _cmd_send(target: str, text: str) -> int:
tool = make_send_message_tool(SecretStore())
result = tool(target=target, text=text)
print(result)
return 0 if result.get("ok") else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="openworker-connectors")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
p_fake = sub.add_parser("fake")
p_fake.add_argument("--user", default="u1")
p_fake.add_argument(
"--allow", action="append", default=[], help="authorized user id (repeatable)"
)
p_send = sub.add_parser("send")
p_send.add_argument("--target", required=True)
p_send.add_argument("--text", required=True)
args = parser.parse_args(argv)
if args.cmd == "status":
return _cmd_status()
if args.cmd == "fake":
return asyncio.run(_run_fake(args.user, args.allow))
if args.cmd == "send":
return _cmd_send(args.target, args.text)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,137 @@
"""Connector settings — which platforms are enabled + the inbound allowlist.
Tokens live in the SecretStore (profile `<platform>:default`); this module only carries
enablement + authorization. The allowlist is the inbound security guard: **empty = nobody**
(you must add your own user id), `allow_all` opens it.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Optional
from ..secrets import SecretStore
from .base import SessionSource
PLATFORMS = ("telegram", "slack", "github")
@dataclass
class TeamAuth:
"""One workspace's inbound authorization (managed multi-workspace Slack).
User/channel ids are workspace-scoped — a U… only means something inside its
team — so each connected workspace carries its own allow-list.
"""
allowed_users: set[str] = field(default_factory=set)
allow_all: bool = False
@dataclass
class ConnectorSettings:
platform: str
enabled: bool = False
allowed_users: set[str] = field(default_factory=set)
allow_all: bool = False
# Per-workspace auth, keyed by team_id (populated from `slack:team:*` profiles).
# Only relay-mode Slack fills this; manual Socket Mode uses the flat fields above.
teams: dict[str, TeamAuth] = field(default_factory=dict)
def is_authorized(settings: ConnectorSettings, source: SessionSource) -> bool:
team_id = getattr(source, "team_id", None)
if team_id:
# Relay events carry their workspace; authorization is that team's list
# alone. An unknown team means no install we know of — deny (park).
team = settings.teams.get(team_id)
if team is None:
return False
if team.allow_all:
return True
uid = source.user_id
return bool(uid) and uid in team.allowed_users
if settings.allow_all:
return True
uid = source.user_id
return bool(uid) and uid in settings.allowed_users
def _csv(value: Optional[str]) -> set[str]:
return {p.strip() for p in (value or "").split(",") if p.strip()}
def load_settings(
secrets: Optional[SecretStore] = None,
) -> dict[str, ConnectorSettings]:
"""Per-platform settings from the SecretStore profile + env overrides.
A platform is enabled when its token profile exists (and isn't explicitly disabled).
Allowlist/allow-all come from the profile or `<PLATFORM>_ALLOWED_USERS` /
`<PLATFORM>_ALLOW_ALL_USERS` env vars (env wins).
"""
secrets = secrets or SecretStore()
out: dict[str, ConnectorSettings] = {}
for platform in PLATFORMS:
profile = secrets.get(f"{platform}:default") or {}
token = profile.get("bot_token")
allowed = set(profile.get("allowed_users") or [])
allowed |= _csv(os.environ.get(f"{platform.upper()}_ALLOWED_USERS"))
allow_all = bool(profile.get("allow_all")) or os.environ.get(
f"{platform.upper()}_ALLOW_ALL_USERS", ""
).lower() in ("1", "true", "yes")
# Managed relays carry no bot_token in the default profile (Slack tokens
# are per-team; GitHub tokens are minted, never stored); they enable on
# `mode == "relay"` instead of on a token. GitHub's manual PAT profile
# is a request/response connector, not a listener — never gateway-enabled.
if profile.get("mode") == "relay":
enabled = bool(profile.get("enabled", True))
elif platform == "github":
enabled = False
else:
enabled = bool(token) and profile.get("enabled", True)
teams: dict[str, TeamAuth] = {}
if platform == "slack":
for team_id, team_profile in _slack_team_profiles(secrets):
teams[team_id] = TeamAuth(
allowed_users=set(team_profile.get("allowed_users") or []),
allow_all=bool(team_profile.get("allow_all")),
)
if platform == "github":
# Per-installation allow-lists: sender logins are global on GitHub,
# but WHO may trigger work is still scoped per installation.
for installation_id, install_profile in _github_install_profiles(secrets):
teams[installation_id] = TeamAuth(
allowed_users=set(install_profile.get("allowed_users") or []),
allow_all=bool(install_profile.get("allow_all")),
)
out[platform] = ConnectorSettings(
platform=platform,
enabled=enabled,
allowed_users=allowed,
allow_all=allow_all,
teams=teams,
)
return out
def _slack_team_profiles(secrets: SecretStore) -> list[tuple[str, dict]]:
"""(team_id, profile) for every managed-install workspace (`slack:team:*`)."""
out: list[tuple[str, dict]] = []
for meta in secrets.status():
name = meta.get("profile", "")
if not name.startswith("slack:team:"):
continue
team_id = name[len("slack:team:") :]
profile = secrets.get(name)
if team_id and profile:
out.append((team_id, profile))
return out
def _github_install_profiles(secrets: SecretStore) -> list[tuple[str, dict]]:
"""(installation_id, profile) for every managed GitHub App installation."""
from .github_installs import list_installs
return [(iid, profile) for iid, profile in list_installs(secrets) if profile]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,843 @@
"""Email (IMAP/SMTP) connector tools — app-password auth, stdlib only.
One connector covers Gmail, iCloud, Fastmail, and custom IMAP servers: the user enters
an address + app password and servers are inferred from the address domain (advanced
fields override). Credentials are read from the SecretStore at execution time and never
enter prompts. All mailbox reads are non-destructive (read-only SELECT / PEEK fetches,
so the user's unread flags never flip) and v1 ships no delete/move/flag tools. Sending
and attachment download require approval. Sending is deliberately single-shot — SMTP
only, no APPEND-to-Sent afterwards — so a failure can never leave "delivered but looks
failed" state that tempts a retry into double-sending (Gmail saves to Sent server-side).
"""
from __future__ import annotations
import email as email_lib
import imaplib
import re
import smtplib
import ssl
from dataclasses import dataclass
from email.header import decode_header
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
from pathlib import Path
from typing import Any, Callable, Optional
import aisuite as ai
from ..roots import RootDir
from ..secrets import SecretStore
_TIMEOUT = 30.0
_BODY_CHAR_LIMIT = 20_000
_MAX_SEARCH_RESULTS = 25
_MAX_FOLDERS = 50
# -- presets -------------------------------------------------------------------
@dataclass(frozen=True)
class EmailServers:
imap_host: str
imap_port: int = 993
smtp_host: str = ""
smtp_port: int = 587 # 587 → STARTTLS, 465 → implicit TLS
_PRESETS: dict[str, EmailServers] = {
"gmail.com": EmailServers("imap.gmail.com", 993, "smtp.gmail.com", 587),
"googlemail.com": EmailServers("imap.gmail.com", 993, "smtp.gmail.com", 587),
"icloud.com": EmailServers("imap.mail.me.com", 993, "smtp.mail.me.com", 587),
"me.com": EmailServers("imap.mail.me.com", 993, "smtp.mail.me.com", 587),
"mac.com": EmailServers("imap.mail.me.com", 993, "smtp.mail.me.com", 587),
"fastmail.com": EmailServers("imap.fastmail.com", 993, "smtp.fastmail.com", 465),
}
def resolve_servers(profile: dict[str, Any]) -> tuple[Optional[EmailServers], str]:
"""Servers for a profile: explicit advanced fields win, then the domain preset."""
address = str(profile.get("address") or "").strip()
domain = address.rsplit("@", 1)[-1].lower() if "@" in address else ""
preset = _PRESETS.get(domain)
def _port(key: str, fallback: int) -> int:
raw = str(profile.get(key) or "").strip()
try:
return int(raw) if raw else fallback
except ValueError:
return fallback
imap_host = str(profile.get("imap_host") or "").strip() or (
preset.imap_host if preset else ""
)
smtp_host = str(profile.get("smtp_host") or "").strip() or (
preset.smtp_host if preset else ""
)
if not imap_host or not smtp_host:
return None, (
f"no server preset for '{domain or address}' — fill in the IMAP and SMTP "
"host fields in the connector settings"
)
return (
EmailServers(
imap_host=imap_host,
imap_port=_port("imap_port", preset.imap_port if preset else 993),
smtp_host=smtp_host,
smtp_port=_port("smtp_port", preset.smtp_port if preset else 587),
),
"",
)
def _is_gmail(servers: EmailServers) -> bool:
return servers.imap_host.endswith(".gmail.com")
def _auth_hint(servers: EmailServers) -> str:
if _is_gmail(servers):
return (
" For Gmail, check that 2-Step Verification is on and that this is an app "
"password from myaccount.google.com/apppasswords — not your account password."
)
return " Check the address and app password in the connector settings."
# -- connections ----------------------------------------------------------------
def _default_imap_factory(host: str, port: int) -> imaplib.IMAP4_SSL:
return imaplib.IMAP4_SSL(host, port, timeout=_TIMEOUT)
def _default_smtp_factory(host: str, port: int) -> smtplib.SMTP:
if port == 465:
return smtplib.SMTP_SSL(
host, port, timeout=_TIMEOUT, context=ssl.create_default_context()
)
smtp = smtplib.SMTP(host, port, timeout=_TIMEOUT)
smtp.starttls(context=ssl.create_default_context())
return smtp
def _imap_login(profile, servers, factory) -> imaplib.IMAP4:
imap = factory(servers.imap_host, servers.imap_port)
imap.login(profile["address"], profile["app_password"])
return imap
def _smtp_login(profile, servers, factory) -> smtplib.SMTP:
smtp = factory(servers.smtp_host, servers.smtp_port)
smtp.login(profile["address"], profile["app_password"])
return smtp
# -- MIME helpers ----------------------------------------------------------------
def decode_mime_header(raw: Any) -> str:
if not raw:
return ""
parts = []
for part, charset in decode_header(str(raw)):
if isinstance(part, bytes):
try:
parts.append(part.decode(charset or "utf-8", errors="replace"))
except LookupError: # bogus charset label in the wild
parts.append(part.decode("utf-8", errors="replace"))
else:
parts.append(part)
return "".join(parts)
def _strip_html(html: str) -> str:
text = re.sub(r"<(br|/p|/div|/tr)\s*/?>", "\n", html, flags=re.IGNORECASE)
text = re.sub(
r"<(script|style)[^>]*>.*?</\1>", "", text, flags=re.IGNORECASE | re.DOTALL
)
text = re.sub(r"<[^>]+>", "", text)
for entity, char in (
("&nbsp;", " "),
("&amp;", "&"),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", '"'),
("&#39;", "'"),
):
text = text.replace(entity, char)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def _decode_payload(part: email_lib.message.Message) -> str:
payload = part.get_payload(decode=True)
if not payload:
return ""
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except LookupError:
return payload.decode("utf-8", errors="replace")
def extract_text_body(msg: email_lib.message.Message) -> str:
"""Best text rendering of a message: prefer text/plain, fall back to stripped HTML."""
candidates = msg.walk() if msg.is_multipart() else [msg]
plain, html = "", ""
for part in candidates:
if "attachment" in str(part.get("Content-Disposition", "")):
continue
ctype = part.get_content_type()
if ctype == "text/plain" and not plain:
plain = _decode_payload(part)
elif ctype == "text/html" and not html:
html = _decode_payload(part)
text = plain or _strip_html(html)
if len(text) > _BODY_CHAR_LIMIT:
text = text[:_BODY_CHAR_LIMIT] + "\n…[truncated]"
return text
def list_attachment_parts(
msg: email_lib.message.Message,
) -> list[tuple[str, email_lib.message.Message]]:
out = []
if not msg.is_multipart():
return out
for part in msg.walk():
disposition = str(part.get("Content-Disposition", ""))
filename = part.get_filename()
if "attachment" not in disposition and not (
filename and "inline" in disposition
):
continue
if filename:
out.append((decode_mime_header(filename), part))
return out
# -- IMAP query building -----------------------------------------------------------
def _quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
_MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()
def _imap_date(value: str) -> Optional[str]:
m = _DATE_RE.match(value.strip())
if not m:
return None
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
if not 1 <= month <= 12:
return None
return f"{day:02d}-{_MONTHS[month - 1]}-{year}"
def build_search_criteria(
*,
from_address: str = "",
to_address: str = "",
subject: str = "",
text: str = "",
since: str = "",
before: str = "",
unread_only: bool = False,
) -> tuple[Optional[bytes], str]:
"""An IMAP SEARCH criteria string (as bytes, UTF-8) or an error message."""
parts: list[str] = []
for key, value in (
("FROM", from_address),
("TO", to_address),
("SUBJECT", subject),
("TEXT", text),
):
if value and value.strip():
parts.append(f"{key} {_quote(value.strip())}")
for key, value in (("SINCE", since), ("BEFORE", before)):
if value and value.strip():
date = _imap_date(value)
if date is None:
return None, f"invalid {key.lower()} date {value!r}; use YYYY-MM-DD"
parts.append(f"{key} {date}")
if unread_only:
parts.append("UNSEEN")
criteria = " ".join(parts) if parts else "ALL"
if criteria.isascii():
return criteria.encode("ascii"), ""
# Non-ASCII terms ride as UTF-8 with an explicit CHARSET (Gmail/iCloud accept this).
return b"CHARSET UTF-8 " + criteria.encode("utf-8"), ""
_LIST_RE = re.compile(rb'\((?P<flags>[^)]*)\)\s+"(?P<delim>[^"]*)"\s+(?P<name>.+)$')
def _parse_list_line(line: bytes) -> Optional[str]:
m = _LIST_RE.match(line)
if not m:
return None
name = m.group("name").strip()
if name.startswith(b'"') and name.endswith(b'"'):
name = name[1:-1].replace(b'\\"', b'"')
if rb"\Noselect" in m.group("flags"):
return None
try:
return name.decode("utf-8")
except UnicodeDecodeError:
return name.decode("latin-1")
def _select_readonly(imap: imaplib.IMAP4, folder: str) -> Optional[str]:
status, _ = imap.select(_quote(folder), readonly=True)
if status != "OK":
return f"cannot open folder {folder!r}"
return None
def _fetch_message(
imap: imaplib.IMAP4, uid: str
) -> Optional[email_lib.message.Message]:
status, data = imap.uid("FETCH", uid, "(BODY.PEEK[])")
if status != "OK" or not data or not isinstance(data[0], tuple):
return None
return email_lib.message_from_bytes(data[0][1])
def _safe_filename(name: str) -> str:
name = Path(name.replace("\\", "/")).name # strip any path components
name = re.sub(r'[\x00-\x1f<>:"|?*]', "_", name).strip(". ")
return name or "attachment"
# -- tool metadata plumbing (same shape as the sibling connector modules) -----------
def _meta(name: str, *, approval: bool, capabilities: list[str]):
return ai.ToolMetadata(
name=name,
category="connector",
risk_level="medium" if approval else "low",
capabilities=capabilities,
requires_approval=approval,
)
def _schema(
name: str, description: str, properties: dict[str, Any], required: list[str]
) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
def _attach(
fn: Callable[..., Any],
schema: dict[str, Any],
*,
approval: bool,
caps: list[str],
):
from .tool_defs import approval_for_tool
name = schema["function"]["name"]
# §36: the tool registry's read/write kind wins for registered tools — reads never gate.
approval = approval_for_tool(name, default=approval)
fn.__name__ = name
fn.__coworker_schema__ = schema
fn.__aisuite_tool_metadata__ = _meta(name, approval=approval, capabilities=caps)
fn.__doc__ = schema["function"]["description"]
return fn
# -- the tools ----------------------------------------------------------------------
def make_email_tools(
secrets: SecretStore,
*,
roots: Optional[list[RootDir]] = None,
imap_factory: Callable[[str, int], imaplib.IMAP4] = _default_imap_factory,
smtp_factory: Callable[[str, int], smtplib.SMTP] = _default_smtp_factory,
) -> list[Callable[..., Any]]:
def _connect_imap():
"""(imap, profile, servers, error) — error is a tool-result dict."""
profile = secrets.get("email:default") or {}
if not profile.get("address") or not profile.get("app_password"):
return (
None,
None,
None,
{"error": "email is not connected; add it in Manage → Integrations"},
)
servers, err = resolve_servers(profile)
if servers is None:
return None, None, None, {"error": err}
try:
imap = _imap_login(profile, servers, imap_factory)
except Exception as exc:
return (
None,
None,
None,
{"error": f"IMAP login failed: {exc}.{_auth_hint(servers)}"},
)
return imap, profile, servers, None
def _logout(imap) -> None:
try:
imap.logout()
except Exception:
pass
def email_list_folders() -> dict[str, Any]:
imap, _, _, err = _connect_imap()
if err:
return err
try:
status, lines = imap.list()
if status != "OK":
return {"error": "could not list folders"}
folders = []
for line in lines[:_MAX_FOLDERS]:
name = _parse_list_line(line) if isinstance(line, bytes) else None
if name is None:
continue
entry: dict[str, Any] = {"name": name}
try:
st, data = imap.status(_quote(name), "(MESSAGES)")
if st == "OK" and data and data[0]:
m = re.search(rb"MESSAGES\s+(\d+)", data[0])
if m:
entry["messages"] = int(m.group(1))
except Exception:
pass
folders.append(entry)
return {"ok": True, "folders": folders}
except Exception as exc:
return {"error": str(exc)}
finally:
_logout(imap)
def email_search(
folder: str = "INBOX",
from_address: str = "",
to_address: str = "",
subject: str = "",
text: str = "",
since: str = "",
before: str = "",
unread_only: bool = False,
max_results: int = 10,
) -> dict[str, Any]:
criteria, crit_err = build_search_criteria(
from_address=from_address,
to_address=to_address,
subject=subject,
text=text,
since=since,
before=before,
unread_only=bool(unread_only),
)
if criteria is None:
return {"error": crit_err}
imap, _, _, err = _connect_imap()
if err:
return err
try:
sel_err = _select_readonly(imap, folder)
if sel_err:
return {"error": sel_err}
status, data = imap.uid("SEARCH", criteria)
if status != "OK":
return {"error": "search failed"}
uids = (data[0] or b"").split()
limit = max(1, min(int(max_results or 10), _MAX_SEARCH_RESULTS))
newest = list(reversed(uids[-limit:])) # UIDs ascend → newest last
messages = []
for uid in newest:
status, fetched = imap.uid(
"FETCH",
uid.decode(),
"(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)] FLAGS BODYSTRUCTURE)",
)
if status != "OK" or not fetched:
continue
header_bytes = b""
meta_bytes = b""
for item in fetched:
if isinstance(item, tuple):
meta_bytes += item[0]
header_bytes += item[1]
elif isinstance(item, bytes):
meta_bytes += item
headers = email_lib.message_from_bytes(header_bytes)
messages.append(
{
"uid": uid.decode(),
"date": decode_mime_header(headers.get("Date", "")),
"from": decode_mime_header(headers.get("From", "")),
"to": decode_mime_header(headers.get("To", "")),
"subject": decode_mime_header(headers.get("Subject", "")),
"unread": b"\\Seen" not in meta_bytes,
"has_attachments": b'"ATTACHMENT"' in meta_bytes.upper(),
}
)
return {
"ok": True,
"folder": folder,
"total_matches": len(uids),
"messages": messages,
}
except Exception as exc:
return {"error": str(exc)}
finally:
_logout(imap)
def email_read(uid: str, folder: str = "INBOX") -> dict[str, Any]:
imap, _, _, err = _connect_imap()
if err:
return err
try:
sel_err = _select_readonly(imap, folder)
if sel_err:
return {"error": sel_err}
msg = _fetch_message(imap, str(uid))
if msg is None:
return {"error": f"message {uid} not found in {folder}"}
attachments = [
{
"filename": name,
"content_type": part.get_content_type(),
"size": len(part.get_payload(decode=True) or b""),
}
for name, part in list_attachment_parts(msg)
]
return {
"ok": True,
"uid": str(uid),
"folder": folder,
"from": decode_mime_header(msg.get("From", "")),
"to": decode_mime_header(msg.get("To", "")),
"cc": decode_mime_header(msg.get("Cc", "")),
"date": decode_mime_header(msg.get("Date", "")),
"subject": decode_mime_header(msg.get("Subject", "")),
"body": extract_text_body(msg),
"attachments": attachments,
}
except Exception as exc:
return {"error": str(exc)}
finally:
_logout(imap)
def email_download_attachment(
uid: str, filename: str, folder: str = "INBOX"
) -> dict[str, Any]:
scratch = roots[0] if roots else None
if scratch is None or not scratch.writable:
return {
"error": "no writable session directory to save the attachment into"
}
imap, _, _, err = _connect_imap()
if err:
return err
try:
sel_err = _select_readonly(imap, folder)
if sel_err:
return {"error": sel_err}
msg = _fetch_message(imap, str(uid))
if msg is None:
return {"error": f"message {uid} not found in {folder}"}
for name, part in list_attachment_parts(msg):
if name == filename:
payload = part.get_payload(decode=True) or b""
target = scratch.path / _safe_filename(name)
counter = 1
while target.exists():
target = (
scratch.path
/ f"{re.sub(r'-[0-9]+$', '', target.stem) or 'attachment'}-{counter}{target.suffix}"
)
counter += 1
target.write_bytes(payload)
return {"ok": True, "path": str(target), "size": len(payload)}
available = [n for n, _ in list_attachment_parts(msg)]
return {
"error": f"no attachment named {filename!r}; message has {available}"
}
except Exception as exc:
return {"error": str(exc)}
finally:
_logout(imap)
def email_send(
to: str,
subject: str,
body: str,
cc: str = "",
bcc: str = "",
reply_to_uid: str = "",
reply_to_folder: str = "INBOX",
attachments: Optional[list[str]] = None,
) -> dict[str, Any]:
profile = secrets.get("email:default") or {}
if not profile.get("address") or not profile.get("app_password"):
return {"error": "email is not connected; add it in Manage → Integrations"}
servers, res_err = resolve_servers(profile)
if servers is None:
return {"error": res_err}
msg = EmailMessage()
display = str(profile.get("display_name") or "").strip()
msg["From"] = (
formataddr((display, profile["address"])) if display else profile["address"]
)
msg["To"] = to
if cc:
msg["Cc"] = cc
if bcc:
msg["Bcc"] = bcc
msg["Message-ID"] = make_msgid(domain=profile["address"].rsplit("@", 1)[-1])
# Reply threading: pull Message-ID/References/Subject from the original first.
final_subject = subject
if reply_to_uid:
imap, _, _, err = _connect_imap()
if err:
return err
try:
sel_err = _select_readonly(imap, reply_to_folder)
if sel_err:
return {"error": sel_err}
status, data = imap.uid(
"FETCH",
str(reply_to_uid),
"(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID REFERENCES SUBJECT)])",
)
if status != "OK" or not data or not isinstance(data[0], tuple):
return {
"error": f"reply target {reply_to_uid} not found in {reply_to_folder}"
}
orig = email_lib.message_from_bytes(data[0][1])
orig_id = str(orig.get("Message-ID", "")).strip()
if orig_id:
msg["In-Reply-To"] = orig_id
refs = str(orig.get("References", "")).strip()
msg["References"] = f"{refs} {orig_id}".strip()
if not subject:
orig_subject = decode_mime_header(orig.get("Subject", ""))
final_subject = (
orig_subject
if orig_subject.lower().startswith("re:")
else f"Re: {orig_subject}"
)
except Exception as exc:
return {"error": str(exc)}
finally:
_logout(imap)
msg["Subject"] = final_subject
msg.set_content(body)
allowed_roots = [r.path for r in (roots or [])]
for raw_path in attachments or []:
path = Path(str(raw_path)).expanduser().resolve()
if not any(path.is_relative_to(root) for root in allowed_roots):
return {
"error": f"attachment {raw_path} is outside the session's directories"
}
if not path.is_file():
return {"error": f"attachment not found: {raw_path}"}
import mimetypes
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
msg.add_attachment(
path.read_bytes(),
maintype=maintype,
subtype=subtype,
filename=path.name,
)
try:
smtp = _smtp_login(profile, servers, smtp_factory)
except Exception as exc:
return {"error": f"SMTP login failed: {exc}.{_auth_hint(servers)}"}
try:
smtp.send_message(msg)
except Exception as exc:
return {"error": f"send failed: {exc}"}
finally:
try:
smtp.quit()
except Exception:
pass
return {"ok": True, "message_id": msg["Message-ID"], "subject": final_subject}
return [
_attach(
email_list_folders,
_schema(
"email_list_folders",
"List the connected mailbox's folders and message counts.",
{},
[],
),
approval=False,
caps=["email", "read"],
),
_attach(
email_search,
_schema(
"email_search",
"Search the connected mailbox. Returns newest-first envelopes (uid, date, "
"from, to, subject, unread, has_attachments). Never marks messages read.",
{
"folder": {
"type": "string",
"description": "Mailbox folder, default INBOX.",
},
"from_address": {"type": "string", "description": "Match sender."},
"to_address": {"type": "string", "description": "Match recipient."},
"subject": {
"type": "string",
"description": "Match subject substring.",
},
"text": {
"type": "string",
"description": "Match anywhere in the message.",
},
"since": {
"type": "string",
"description": "On/after this date, YYYY-MM-DD.",
},
"before": {
"type": "string",
"description": "Before this date, YYYY-MM-DD.",
},
"unread_only": {"type": "boolean"},
"max_results": {
"type": "integer",
"description": "Default 10, max 25.",
},
},
[],
),
approval=False,
caps=["email", "read"],
),
_attach(
email_read,
_schema(
"email_read",
"Read one email by uid: headers, text body, and attachment names/sizes "
"(use email_download_attachment to save one). Never marks messages read.",
{
"uid": {"type": "string", "description": "UID from email_search."},
"folder": {
"type": "string",
"description": "Folder the uid lives in, default INBOX.",
},
},
["uid"],
),
approval=False,
caps=["email", "read"],
),
_attach(
email_download_attachment,
_schema(
"email_download_attachment",
"Save one attachment from an email into the session's primary directory "
"and return the saved path. Requires user approval.",
{
"uid": {"type": "string", "description": "UID from email_search."},
"filename": {
"type": "string",
"description": "Attachment filename as listed by email_read.",
},
"folder": {
"type": "string",
"description": "Folder the uid lives in, default INBOX.",
},
},
["uid", "filename"],
),
approval=True,
caps=["email", "read"],
),
_attach(
email_send,
_schema(
"email_send",
"Send an email from the connected account. Requires user approval. To reply "
"to a message pass reply_to_uid (threading headers and Re: subject are set "
"automatically; leave subject empty to reuse the original).",
{
"to": {
"type": "string",
"description": "Recipient address(es), comma-separated.",
},
"subject": {"type": "string"},
"body": {"type": "string", "description": "Plain-text body."},
"cc": {"type": "string"},
"bcc": {"type": "string"},
"reply_to_uid": {
"type": "string",
"description": "UID of the message being replied to.",
},
"reply_to_folder": {
"type": "string",
"description": "Folder of reply_to_uid, default INBOX.",
},
"attachments": {
"type": "array",
"items": {"type": "string"},
"description": "Paths within the session's directories to attach.",
},
},
["to", "subject", "body"],
),
approval=True,
caps=["email", "write"],
),
]
def validate_email_account(creds: dict[str, Any]) -> tuple[bool, str, str]:
"""Connect-time check: IMAP login + INBOX open and SMTP login must both pass.
Returns (ok, identity, error). Used by the connector descriptor so a mailbox with
IMAP disabled (common on org-managed accounts) fails in the wizard with an
actionable message instead of at first tool call.
"""
servers, err = resolve_servers(creds)
if servers is None:
return False, "", err
address = str(creds.get("address") or "")
inbox_count = ""
try:
imap = _default_imap_factory(servers.imap_host, servers.imap_port)
try:
imap.login(address, creds.get("app_password", ""))
status, data = imap.select('"INBOX"', readonly=True)
if status == "OK" and data and data[0]:
inbox_count = data[0].decode(errors="replace")
finally:
try:
imap.logout()
except Exception:
pass
except Exception as exc:
return False, "", f"IMAP check failed: {exc}.{_auth_hint(servers)}"
try:
smtp = _default_smtp_factory(servers.smtp_host, servers.smtp_port)
try:
smtp.login(address, creds.get("app_password", ""))
finally:
try:
smtp.quit()
except Exception:
pass
except Exception as exc:
return False, "", f"SMTP check failed: {exc}.{_auth_hint(servers)}"
identity = address + (f" · INBOX: {inbox_count} messages" if inbox_count else "")
return True, identity, ""

View File

@@ -0,0 +1,18 @@
"""Experimental connectors — use-at-your-own-risk integrations, excluded from release builds.
Connectors in this package are hidden behind the experimental-connectors setting, require an
explicit per-connector risk acknowledgment to connect, and are stripped from official desktop
builds by packaging/openworker-server.spec (set COWORKER_EXPERIMENTAL=1 at build time to include
them in a self-built binary).
To add one: define a `ConnectorDescriptor` with a `risk_notice` that states the concrete
downside in plain language, append it to `EXPERIMENTAL_DESCRIPTORS`, and register its tools or
adapter the same way first-party connectors do. The `experimental` flag is forced on by the
loader in descriptors.py regardless of what the descriptor sets.
"""
from __future__ import annotations
from ..descriptors import ConnectorDescriptor
EXPERIMENTAL_DESCRIPTORS: list[ConnectorDescriptor] = []

View File

@@ -0,0 +1,57 @@
"""FakeAdapter — an in-memory platform for tests and the `cli fake` REPL.
Lets you inject inbound messages programmatically and inspect what was sent, so the gateway
and handler loop can be exercised end-to-end with no network or real tokens.
"""
from __future__ import annotations
from typing import Optional
from .base import BasePlatformAdapter, MessageEvent, SendResult, SessionSource
class FakeAdapter(BasePlatformAdapter):
platform = "fake"
def __init__(self) -> None:
super().__init__()
self.connected = False
self.outbox: list[dict] = [] # {chat_id, text, thread_id}
async def connect(self) -> bool:
self.connected = True
return True
async def disconnect(self) -> None:
self.connected = False
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
self.outbox.append({"chat_id": chat_id, "text": text, "thread_id": thread_id})
return SendResult(True, message_id=str(len(self.outbox)))
# -- test/dev helpers -------------------------------------------------------
async def inject(
self,
text: str,
*,
chat_id: str = "c1",
user_id: str = "u1",
user_name: str = "tester",
chat_type: str = "dm",
thread_id: Optional[str] = None,
) -> None:
"""Simulate an inbound message arriving from the platform."""
source = SessionSource(
platform=self.platform,
chat_id=chat_id,
user_id=user_id,
user_name=user_name,
chat_type=chat_type,
thread_id=thread_id,
)
await self.handle_message(
MessageEvent(text=text, source=source, message_id=f"m{user_id}")
)

View File

@@ -0,0 +1,231 @@
"""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

View File

@@ -0,0 +1,127 @@
"""Multi-account Google Calendar: per-account token profiles.
`google_calendar:account:<email>` holds ONE signed-in Google account's tokens
(managed OAuth and manual paste are field-compatible, mirroring the
single-account era). Once accounts exist, `google_calendar:default` carries no
tokens — just the default-account pointer and the enabled flag.
A legacy token-bearing `google_calendar:default` (pre-multi-account) is
migrated lazily into an account profile on first list/tool use — no user
action. Same shape as gmail_accounts, minus the privacy filters (calendar has
no "Never show agents" policy yet).
"""
from __future__ import annotations
from typing import Any, Optional
from ..secrets import SecretStore
PREFIX = "google_calendar:account:"
DEFAULT_KEY = "google_calendar:default"
def _norm(value: Any) -> str:
return str(value or "").strip().lower()
def migrate_legacy_default(secrets: SecretStore) -> None:
"""Rewrite a token-bearing `google_calendar:default` as one account profile.
Idempotent; keyed by the account email captured at connect time ("default"
if unknown)."""
default = secrets.get(DEFAULT_KEY) or {}
if not default.get("access_token"):
return
email = _norm(default.get("account")) or "default"
account = {k: v for k, v in default.items() if k != "default_account"}
account.setdefault("account", email)
secrets.put(PREFIX + email, account)
secrets.put(
DEFAULT_KEY,
{
"type": "oauth",
"enabled": bool(default.get("enabled", True)),
"default_account": _norm(default.get("default_account")) or email,
},
)
def list_accounts(secrets: SecretStore) -> list[tuple[str, dict[str, Any]]]:
"""(email, profile) for every connected account, migration included."""
migrate_legacy_default(secrets)
out = []
for meta in secrets.status():
key = meta.get("profile", "")
if key.startswith(PREFIX):
out.append((key[len(PREFIX) :], secrets.get(key) or {}))
return sorted(out, key=lambda t: t[0])
def default_account(secrets: SecretStore) -> str:
"""The default account email: the stored pointer if it still exists, else
the first connected account, else ""."""
accounts = dict(list_accounts(secrets))
pointer = _norm((secrets.get(DEFAULT_KEY) or {}).get("default_account"))
if pointer in accounts:
return pointer
return next(iter(accounts), "")
def resolve(
secrets: SecretStore, account: str = ""
) -> tuple[str, str, Optional[dict[str, Any]]]:
"""(email, profile_key, profile) for the requested — or default — account.
Profile is None when nothing matches (not connected / unknown account)."""
email = _norm(account) or default_account(secrets)
if not email:
return "", "", None
key = PREFIX + email
return email, key, secrets.get(key)
def managed_connect_account(
secrets: SecretStore, profile: dict[str, Any]
) -> dict[str, Any]:
"""Store one managed-OAuth account; the first connected account becomes the
default. Reconnecting an email replaces its tokens in place."""
migrate_legacy_default(secrets)
email = _norm(profile.get("account"))
if not email:
return {"ok": False, "error": "google account email missing from callback"}
secrets.put(PREFIX + email, profile)
pointer = secrets.get(DEFAULT_KEY) or {}
pointer.setdefault("default_account", email)
pointer.update({"type": "oauth", "enabled": True})
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "account": email}
def set_default(secrets: SecretStore, email: str) -> dict[str, Any]:
email = _norm(email)
if not secrets.get(PREFIX + email):
return {"ok": False, "error": "account not connected"}
pointer = secrets.get(DEFAULT_KEY) or {}
pointer["default_account"] = email
pointer.setdefault("type", "oauth")
pointer.setdefault("enabled", True)
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "default_account": email}
def disconnect_account(secrets: SecretStore, email: str) -> dict[str, Any]:
"""Drop one account. The default pointer moves to the next account; removing
the last account removes the pointer profile too (no account-wide policy to
preserve, unlike gmail's filters)."""
email = _norm(email)
if not secrets.get(PREFIX + email):
return {"ok": False, "error": "account not connected"}
secrets.delete(PREFIX + email)
remaining = [e for e, _ in list_accounts(secrets)]
if remaining:
pointer = secrets.get(DEFAULT_KEY) or {}
if _norm(pointer.get("default_account")) == email:
pointer["default_account"] = remaining[0]
secrets.put(DEFAULT_KEY, pointer)
else:
secrets.delete(DEFAULT_KEY)
return {"ok": True, "remaining_accounts": len(remaining)}

View File

@@ -0,0 +1,124 @@
"""Managed GitHub App installations: per-installation profiles + allow-lists.
`github:install:<installation_id>` holds ONE installation's routing metadata —
account_login (org/user the App is installed on), the connecting user's own
github_login, repo_selection, and that installation's inbound allow-list.
There is deliberately NO token field: API access runs on short-lived
installation tokens minted from the broker and cached in memory only
(github-relay-spec §4); the manual PAT path keeps living in `github:default`.
`github:default` doubles as the manual connector profile (token=PAT) and the
managed-relay switch (`mode="relay"`), exactly like Slack's default profile
carries Socket-Mode creds alongside the relay flag.
"""
from __future__ import annotations
from typing import Any
from ..secrets import SecretStore
PREFIX = "github:install:"
DEFAULT_KEY = "github:default"
def _norm(value: Any) -> str:
return str(value or "").strip()
def list_installs(secrets: SecretStore) -> list[tuple[str, dict[str, Any]]]:
"""(installation_id, profile) for every connected installation."""
out = []
for meta in secrets.status():
key = meta.get("profile", "")
if key.startswith(PREFIX):
out.append((key[len(PREFIX) :], secrets.get(key) or {}))
return sorted(out, key=lambda t: t[0])
def default_install(secrets: SecretStore) -> str:
installs = dict(list_installs(secrets))
pointer = _norm((secrets.get(DEFAULT_KEY) or {}).get("default_install"))
if pointer in installs:
return pointer
return next(iter(installs), "")
def resolve(
secrets: SecretStore, install: str = ""
) -> tuple[str, dict[str, Any] | None]:
"""(installation_id, profile) for the requested — or default — installation.
Accepts the id or the account login (what agents see in results)."""
installs = list_installs(secrets)
wanted = _norm(install) or default_install(secrets)
for installation_id, profile in installs:
if wanted and (
installation_id == wanted or _norm(profile.get("account_login")) == wanted
):
return installation_id, profile
return "", None
def managed_connect_install(
secrets: SecretStore, form: dict[str, Any]
) -> dict[str, Any]:
"""Store a managed GitHub App install from the broker's form-POST.
Writes `github:install:<id>` (metadata only — the loopback POST carries no
token by design) and flips `github:default` to relay mode so the gateway
builds the GitHubRelayAdapter. A manual PAT in the default profile stays
untouched. Re-install refreshes metadata, keeps the allow-list.
"""
installation_id = _norm(form.get("installation_id"))
if not installation_id:
return {"ok": False, "error": "installation_id missing from callback"}
existing = secrets.get(PREFIX + installation_id) or {}
profile = {
"type": "oauth",
"managed": True,
"installation_id": installation_id,
"account_login": form.get("account_login", ""),
"account_type": form.get("account_type", ""),
"github_login": form.get("github_login", ""),
"repo_selection": form.get("repo_selection", ""),
"connection_id": form.get("connection_id", ""),
}
if existing.get("allowed_users"):
profile["allowed_users"] = list(existing["allowed_users"])
if existing.get("allow_all"):
profile["allow_all"] = True
secrets.put(PREFIX + installation_id, profile)
default = secrets.get(DEFAULT_KEY) or {}
default.update({"type": "oauth", "managed": True, "mode": "relay", "enabled": True})
default.setdefault("default_install", installation_id)
secrets.put(DEFAULT_KEY, default)
return {
"ok": True,
"account": form.get("account_login") or installation_id,
"installation_id": installation_id,
}
def disconnect_install(secrets: SecretStore, installation_id: str) -> dict[str, Any]:
"""Drop one installation. The LAST removal turns relay mode off without
resurrecting a stored manual PAT (the Slack last-workspace rule)."""
installation_id = _norm(installation_id)
if not secrets.get(PREFIX + installation_id):
return {"ok": False, "error": "installation not connected"}
secrets.delete(PREFIX + installation_id)
remaining = [i for i, _ in list_installs(secrets)]
default = secrets.get(DEFAULT_KEY) or {}
if _norm(default.get("default_install")) == installation_id:
default.pop("default_install", None)
if remaining:
default["default_install"] = remaining[0]
if not remaining:
# Relay off; a manual PAT (token) stays stored but disabled — the user
# re-enables it explicitly, it never starts listening on its own.
default.pop("mode", None)
default["enabled"] = False
if not any(default.get(k) for k in ("token", "access_token")):
secrets.delete(DEFAULT_KEY)
return {"ok": True, "remaining_installs": 0}
secrets.put(DEFAULT_KEY, default)
return {"ok": True, "remaining_installs": len(remaining)}

View File

@@ -0,0 +1,202 @@
"""Managed GitHub relay adapter — the second consumer of the shared relay WS.
Inbound `@ocw` mentions / `ocw`-label events arrive as relay frames tagged
`provider: github` (github-relay-spec §7); the RelayHub fans them here. The
adapter maps them to MessageEvents with `github:owner/repo#N` addressing —
`installation_id` rides in `source.team_id`, so the gateway's per-team
allow-list machinery (park → allow & deliver) works unchanged, keyed by
installation instead of workspace.
Outbound (`send`) posts an issue/PR comment via the GitHub REST API with a
short-lived installation token from the token client — the reply path of the
`send_message` tool. Richer writes (reviews) are dedicated tools.
Sender identity is simpler than Slack: logins are human-readable and ride in
the payload, so there are no name-resolution calls at all.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any, Awaitable, Callable, Optional
from .base import BasePlatformAdapter, MessageEvent, SendResult, SessionSource
from .relay_client import RelayHub
logger = logging.getLogger("coworker.connectors")
# installation_id -> a fresh installation token (memory-only, never at rest).
TokenClient = Callable[[str], Awaitable[str]]
def split_thread(chat_id: str) -> tuple[str, Optional[int]]:
"""`owner/repo#N` → ("owner/repo", N); a bare repo has no thread number."""
repo, _, num = chat_id.partition("#")
try:
return repo, int(num) if num else None
except ValueError:
return repo, None
class GitHubRelayAdapter(BasePlatformAdapter):
platform = "github"
def __init__(
self,
hub: RelayHub,
*,
installs: Optional[dict[str, dict[str, Any]]] = None,
token_client: Optional[TokenClient] = None,
) -> None:
super().__init__()
self._hub = hub
# installation_id -> {account_login, github_login, repo_selection}.
# Mutable: a `revoked` frame drops one, an install hot-reload adds one.
self._installs: dict[str, dict[str, Any]] = dict(installs or {})
self._token_client = token_client
# owner/repo -> installation_id, learned from inbound events so replies
# to a repo mint the right installation's token.
self._repo_installs: dict[str, str] = {}
self.last_event_at: Optional[float] = None
# owner/repo -> events the cloud dropped (offline > TTL / overflow);
# surfaced via status() — GitHub has no cheap "what did I miss" pull.
self.missed: dict[str, int] = {}
# -- lifecycle -----------------------------------------------------------
async def connect(self) -> bool:
self._hub.register(self.platform, self._dispatch)
ok = await self._hub.start()
if ok:
logger.info(
"github adapter connected (managed relay), %d installation(s)",
len(self._installs),
)
return ok
async def disconnect(self) -> None:
await self._hub.release(self.platform)
def status(self) -> dict[str, Any]:
"""Health snapshot for the GUI: shared-socket state + per-installation
token health (an installation revoked upstream fails its mints)."""
return {
"state": self._hub.state(),
"reconnects": self._hub.reconnects,
"last_event_at": self.last_event_at,
"last_error": self._hub.last_error,
"installs": {
iid: {"token_ok": bool(info.get("token_ok", True))}
for iid, info in self._installs.items()
},
"missed": dict(self.missed),
}
# -- installation registry ------------------------------------------------
def set_install(self, installation_id: str, info: dict[str, Any]) -> None:
self._installs[installation_id] = dict(info)
def _note_token_health(self, installation_id: str, ok: bool) -> None:
info = self._installs.get(installation_id)
if info is not None:
info["token_ok"] = ok
# -- frame dispatch --------------------------------------------------------
async def _dispatch(self, frame: dict) -> None:
kind = frame.get("kind")
if kind == "missed":
repo = frame.get("channel", "")
self.missed[repo] = self.missed.get(repo, 0) + int(
frame.get("count", 0) or 1
)
logger.info(
"github relay: %s event(s) missed in %s", frame.get("count"), repo
)
return
if kind == "revoked":
self._installs.pop(str(frame.get("installation_id", "")), None)
logger.info(
"github relay installation %s revoked — dropped",
frame.get("installation_id"),
)
return
await self._on_event(frame)
async def _on_event(self, frame: dict) -> None:
"""A routed trigger (mention / label). Senders are logins — readable as
they are, no resolution round-trips."""
self.last_event_at = time.time()
installation_id = str(frame.get("installation_id", ""))
owner_repo = frame.get("owner_repo", "")
number = frame.get("number", "")
if not owner_repo:
return
if installation_id:
self._repo_installs[owner_repo] = installation_id
chat_id = f"{owner_repo}#{number}" if number else owner_repo
title = frame.get("title", "")
body = frame.get("body", "")
kind = frame.get("kind", "mention")
header = f"[{kind} in {owner_repo}#{number}" + (f": {title}]" if title else "]")
event = MessageEvent(
text=f"{header} {body}".strip(),
source=SessionSource(
platform=self.platform,
chat_id=chat_id,
user_id=frame.get("sender", ""),
user_name=frame.get("sender", ""),
chat_name=chat_id,
chat_type="channel", # a repo thread is a channel, not a DM
team_id=installation_id, # the allow-list scope (≙ Slack team)
),
raw=frame,
)
await self.handle_message(event)
# -- outbound --------------------------------------------------------------
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
"""Comment on the issue/PR the event came from, as `ocw[bot]`."""
owner_repo, number = split_thread(chat_id)
if number is None:
return SendResult(False, error=f"no issue/PR number in {chat_id!r}")
installation_id = self._repo_installs.get(owner_repo) or next(
iter(self._installs), ""
)
if not (self._token_client and installation_id):
return SendResult(False, error="no installation token available")
try:
token = await self._token_client(installation_id)
except Exception as exc:
self._note_token_health(installation_id, False)
return SendResult(False, error=f"token mint failed: {exc}")
if not token:
self._note_token_health(installation_id, False)
return SendResult(False, error="token mint failed")
import httpx
base = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
try:
async with httpx.AsyncClient(timeout=20) as http:
resp = await http.post(
f"{base}/repos/{owner_repo}/issues/{number}/comments",
json={"body": text},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
)
except httpx.HTTPError as exc:
return SendResult(False, error=f"github unreachable: {type(exc).__name__}")
if resp.status_code == 401:
self._note_token_health(installation_id, False)
return SendResult(False, error="installation token rejected")
if resp.status_code not in (200, 201):
return SendResult(
False, error=f"github comment failed ({resp.status_code})"
)
self._note_token_health(installation_id, True)
return SendResult(True, message_id=str((resp.json() or {}).get("id", "")))

View File

@@ -0,0 +1,185 @@
"""Multi-account Gmail: per-mailbox profiles + the "Never show agents" filters.
`gmail:account:<email>` holds ONE signed-in mailbox's tokens (managed OAuth and
manual paste are field-compatible, mirroring the single-account era). Once
accounts exist, `gmail:default` carries no tokens — just the default-account
pointer, the enabled flag, and the privacy filters (which are account-wide).
A legacy token-bearing `gmail:default` (pre-multi-account) is migrated lazily
into an account profile on first list/tool use — no user action.
Filters are enforced in the gmail TOOL layer on this desktop ("cloud knows
routing; the desktop knows content and policy"): matching messages are
silently omitted from agent-visible results — no tombstone the agent could
reason about — while the user sees the hidden count on the tool card and an
audit row (rule + count, never content).
"""
from __future__ import annotations
from typing import Any, Optional
from ..secrets import SecretStore
PREFIX = "gmail:account:"
DEFAULT_KEY = "gmail:default"
def _norm(value: Any) -> str:
return str(value or "").strip().lower()
def migrate_legacy_default(secrets: SecretStore) -> None:
"""Rewrite a token-bearing `gmail:default` as one account profile. Idempotent;
keyed by the account email captured at connect time ("default" if unknown)."""
default = secrets.get(DEFAULT_KEY) or {}
if not default.get("access_token"):
return
email = _norm(default.get("account")) or "default"
account = {
k: v for k, v in default.items() if k not in ("default_account", "filters")
}
account.setdefault("account", email)
secrets.put(PREFIX + email, account)
pointer: dict[str, Any] = {
"type": "oauth",
"enabled": bool(default.get("enabled", True)),
"default_account": _norm(default.get("default_account")) or email,
}
if default.get("filters"):
pointer["filters"] = default["filters"]
secrets.put(DEFAULT_KEY, pointer)
def list_accounts(secrets: SecretStore) -> list[tuple[str, dict[str, Any]]]:
"""(email, profile) for every connected mailbox, migration included."""
migrate_legacy_default(secrets)
out = []
for meta in secrets.status():
key = meta.get("profile", "")
if key.startswith(PREFIX):
out.append((key[len(PREFIX) :], secrets.get(key) or {}))
return sorted(out, key=lambda t: t[0])
def default_account(secrets: SecretStore) -> str:
"""The default mailbox email: the stored pointer if it still exists, else the
first connected account, else ""."""
accounts = dict(list_accounts(secrets))
pointer = _norm((secrets.get(DEFAULT_KEY) or {}).get("default_account"))
if pointer in accounts:
return pointer
return next(iter(accounts), "")
def resolve(
secrets: SecretStore, account: str = ""
) -> tuple[str, str, Optional[dict[str, Any]]]:
"""(email, profile_key, profile) for the requested — or default — mailbox.
Profile is None when nothing matches (not connected / unknown account)."""
email = _norm(account) or default_account(secrets)
if not email:
return "", "", None
key = PREFIX + email
return email, key, secrets.get(key)
def managed_connect_account(
secrets: SecretStore, profile: dict[str, Any]
) -> dict[str, Any]:
"""Store one managed-OAuth mailbox; the first connected account becomes the
default. Reconnecting an email replaces its tokens in place."""
migrate_legacy_default(secrets)
email = _norm(profile.get("account"))
if not email:
return {"ok": False, "error": "google account email missing from callback"}
secrets.put(PREFIX + email, profile)
pointer = secrets.get(DEFAULT_KEY) or {}
pointer.setdefault("default_account", email)
pointer.update({"type": "oauth", "enabled": True})
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "account": email}
def set_default(secrets: SecretStore, email: str) -> dict[str, Any]:
email = _norm(email)
if not secrets.get(PREFIX + email):
return {"ok": False, "error": "account not connected"}
pointer = secrets.get(DEFAULT_KEY) or {}
pointer["default_account"] = email
pointer.setdefault("type", "oauth")
pointer.setdefault("enabled", True)
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "default_account": email}
def disconnect_account(secrets: SecretStore, email: str) -> dict[str, Any]:
"""Drop one mailbox. The default pointer moves to the next account; removing
the last account keeps the filters (they're policy, not credentials) unless
there are none, in which case the pointer profile goes too."""
email = _norm(email)
if not secrets.get(PREFIX + email):
return {"ok": False, "error": "account not connected"}
secrets.delete(PREFIX + email)
remaining = [e for e, _ in list_accounts(secrets)]
pointer = secrets.get(DEFAULT_KEY) or {}
if _norm(pointer.get("default_account")) == email:
if remaining:
pointer["default_account"] = remaining[0]
secrets.put(DEFAULT_KEY, pointer)
else:
pointer.pop("default_account", None)
pointer.pop("managed", None)
if pointer.get("filters"):
secrets.put(DEFAULT_KEY, pointer)
else:
secrets.delete(DEFAULT_KEY)
return {"ok": True, "remaining_accounts": len(remaining)}
# --- "Never show agents" filters ---------------------------------------------
def get_filters(secrets: SecretStore) -> dict[str, list[str]]:
f = (secrets.get(DEFAULT_KEY) or {}).get("filters") or {}
return {
"senders": list(f.get("senders") or []),
"labels": list(f.get("labels") or []),
}
def set_filters(
secrets: SecretStore,
senders: Optional[list[str]] = None,
labels: Optional[list[str]] = None,
) -> dict[str, Any]:
"""Replace either list (None = leave unchanged). Senders are `addr@x` or
`@domain`; labels are Gmail label names (matched case-insensitively)."""
current = get_filters(secrets)
if senders is not None:
current["senders"] = sorted({_norm(s) for s in senders if _norm(s)})
if labels is not None:
current["labels"] = sorted({str(l).strip() for l in labels if str(l).strip()})
pointer = secrets.get(DEFAULT_KEY) or {}
pointer["filters"] = current
pointer.setdefault("type", "oauth")
pointer.setdefault("enabled", True)
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "filters": current}
def sender_matches(address: str, rules: list[str]) -> bool:
"""`addr@x.com` = exact; `@domain.com` = that domain (suffix on the addr)."""
address = _norm(address)
if not address:
return False
for rule in rules:
rule = _norm(rule)
if not rule:
continue
if rule.startswith("@"):
if address.endswith(rule):
return True
elif address == rule:
return True
return False

View File

@@ -0,0 +1,190 @@
"""Multi-portal HubSpot: per-portal profiles + the hidden-fields denylist.
`hubspot:portal:<hub_id>` holds ONE portal's credentials — managed OAuth and a
manual private-app token are field-compatible (both carry `token`). Once
portals exist, `hubspot:default` carries no tokens: just the default-portal
pointer, the enabled flag, and `hidden_fields` (portal-wide policy).
A legacy token-bearing `hubspot:default` (single-portal era) is migrated
lazily; its hub_id is parsed from the "portal <id>" identity captured at
connect time.
Hidden fields are enforced in the hubspot TOOL layer on this desktop: the
named properties are stripped from every record an agent reads. This hides
data from the MODEL — it is not an ACL against humans (HubSpot permission
sets are; UX-DECISIONS §21). Stripped-field counts go to the audit log.
"""
from __future__ import annotations
import re
from typing import Any, Optional
from ..secrets import SecretStore
PREFIX = "hubspot:portal:"
DEFAULT_KEY = "hubspot:default"
def _norm(value: Any) -> str:
return str(value or "").strip()
def migrate_legacy_default(secrets: SecretStore) -> None:
"""Rewrite a token-bearing `hubspot:default` as one portal profile.
Idempotent; keyed by the hub id when the stored identity reveals it."""
default = secrets.get(DEFAULT_KEY) or {}
if not (default.get("token") or default.get("access_token")):
return
match = re.search(r"\d+", str(default.get("account") or ""))
hub_id = match.group(0) if match else "default"
portal = {
k: v for k, v in default.items() if k not in ("default_portal", "hidden_fields")
}
portal.setdefault("hub_id", hub_id)
secrets.put(PREFIX + hub_id, portal)
pointer: dict[str, Any] = {
"type": "oauth",
"enabled": bool(default.get("enabled", True)),
"default_portal": _norm(default.get("default_portal")) or hub_id,
}
if default.get("hidden_fields"):
pointer["hidden_fields"] = default["hidden_fields"]
secrets.put(DEFAULT_KEY, pointer)
def list_portals(secrets: SecretStore) -> list[tuple[str, dict[str, Any]]]:
"""(hub_id, profile) for every connected portal, migration included."""
migrate_legacy_default(secrets)
out = []
for meta in secrets.status():
key = meta.get("profile", "")
if key.startswith(PREFIX):
out.append((key[len(PREFIX) :], secrets.get(key) or {}))
return sorted(out, key=lambda t: t[0])
def default_portal(secrets: SecretStore) -> str:
portals = dict(list_portals(secrets))
pointer = _norm((secrets.get(DEFAULT_KEY) or {}).get("default_portal"))
if pointer in portals:
return pointer
return next(iter(portals), "")
def resolve(
secrets: SecretStore, portal: str = ""
) -> tuple[str, str, Optional[dict[str, Any]]]:
"""(hub_id, profile_key, profile) for the requested — or default — portal.
`portal` may be a hub id or a portal name (account) — names are what agents
see in results, so accept both."""
portals = list_portals(secrets)
wanted = _norm(portal)
if not wanted:
wanted = default_portal(secrets)
for hub_id, profile in portals:
if wanted and (hub_id == wanted or _norm(profile.get("account")) == wanted):
return hub_id, PREFIX + hub_id, profile
return "", "", None
def managed_connect_portal(
secrets: SecretStore, profile: dict[str, Any]
) -> dict[str, Any]:
"""Store one managed-OAuth portal; the first becomes the default.
Reconnecting the same hub_id replaces its tokens (e.g. a read → write
re-consent lands in place)."""
migrate_legacy_default(secrets)
hub_id = _norm(profile.get("hub_id"))
if not hub_id:
return {"ok": False, "error": "hub_id missing from callback"}
secrets.put(PREFIX + hub_id, profile)
pointer = secrets.get(DEFAULT_KEY) or {}
pointer.setdefault("default_portal", hub_id)
pointer.update({"type": "oauth", "enabled": True})
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "account": profile.get("account") or hub_id, "hub_id": hub_id}
def set_default(secrets: SecretStore, hub_id: str) -> dict[str, Any]:
hub_id = _norm(hub_id)
if not secrets.get(PREFIX + hub_id):
return {"ok": False, "error": "portal not connected"}
pointer = secrets.get(DEFAULT_KEY) or {}
pointer["default_portal"] = hub_id
pointer.setdefault("type", "oauth")
pointer.setdefault("enabled", True)
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "default_portal": hub_id}
def disconnect_portal(secrets: SecretStore, hub_id: str) -> dict[str, Any]:
"""Drop one portal; the default pointer moves on. Removing the last portal
keeps hidden_fields (policy, not credentials) unless there are none."""
hub_id = _norm(hub_id)
if not secrets.get(PREFIX + hub_id):
return {"ok": False, "error": "portal not connected"}
secrets.delete(PREFIX + hub_id)
remaining = [h for h, _ in list_portals(secrets)]
pointer = secrets.get(DEFAULT_KEY) or {}
if _norm(pointer.get("default_portal")) == hub_id:
if remaining:
pointer["default_portal"] = remaining[0]
secrets.put(DEFAULT_KEY, pointer)
else:
pointer.pop("default_portal", None)
if pointer.get("hidden_fields"):
secrets.put(DEFAULT_KEY, pointer)
else:
secrets.delete(DEFAULT_KEY)
return {"ok": True, "remaining_portals": len(remaining)}
# --- hidden fields (model-facing denylist, not a human ACL) --------------------
def get_hidden_fields(secrets: SecretStore) -> list[str]:
return list((secrets.get(DEFAULT_KEY) or {}).get("hidden_fields") or [])
def set_hidden_fields(secrets: SecretStore, fields: list[str]) -> dict[str, Any]:
cleaned = sorted({str(f).strip().lower() for f in fields if str(f).strip()})
pointer = secrets.get(DEFAULT_KEY) or {}
pointer["hidden_fields"] = cleaned
pointer.setdefault("type", "oauth")
pointer.setdefault("enabled", True)
secrets.put(DEFAULT_KEY, pointer)
return {"ok": True, "hidden_fields": cleaned}
def strip_hidden(record: Any, hidden: list[str]) -> tuple[Any, int]:
"""Remove denylisted property keys from a CRM record (or a search page of
records), case-insensitively. Returns (cleaned, number of values removed)."""
if not hidden:
return record, 0
wanted = {h.lower() for h in hidden}
removed = 0
def _clean_obj(obj: dict[str, Any]) -> dict[str, Any]:
nonlocal removed
out = dict(obj)
props = out.get("properties")
if isinstance(props, dict):
kept = {}
for k, v in props.items():
if k.lower() in wanted:
removed += 1
else:
kept[k] = v
out["properties"] = kept
return out
if isinstance(record, dict):
if isinstance(record.get("results"), list): # a search page
out = dict(record)
out["results"] = [
_clean_obj(r) if isinstance(r, dict) else r for r in record["results"]
]
return out, removed
return _clean_obj(record), removed
return record, 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
"""Parked unauthorized messages — what an unallowed sender said, kept instead of lost.
The gateway drops inbound messages from senders not on the allow-list (closed by default).
Dropping silently made the first-contact flow clumsy: the sender had to message once just to
appear under "Recent senders", get allowed, then message AGAIN. Parking the dropped message
lets the owner see it on the connector page and resolve it in one step — dismiss it, allow
the sender, or allow AND deliver the original message (no re-send needed).
JSON-backed and capped like UnroutedStore. This IS a queue (unlike Unrouted): allow-and-deliver
re-injects the parked message through the normal inbound path.
"""
from __future__ import annotations
import json
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class ParkedMessage:
platform: str # "slack" | "telegram" | …
chat_id: str # channel/DM id, e.g. "C0BD7KZ1AH5"
user_id: str # sender id, e.g. "U07JK68S4BH"
text: str
chat_name: Optional[str] = None # resolved display name (falls back to chat_id)
user_name: Optional[str] = None # resolved display name (falls back to user_id)
chat_type: str = "channel" # "channel" | "group" | "dm"
thread_id: Optional[str] = None
team_id: Optional[str] = None # workspace id (managed relay); None for socket mode
ts: float = field(default_factory=time.time)
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
class ParkedStore:
def __init__(self, path: Optional[str | Path] = None, *, cap: int = 100) -> None:
self.path = Path(path) if path else None
self._cap = cap
self._lock = threading.Lock()
self._items: list[ParkedMessage] = []
self._load()
def _load(self) -> None:
if self.path and self.path.is_file():
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
self._items = [ParkedMessage(**raw) for raw in data.get("items", [])]
except (OSError, ValueError, TypeError):
self._items = [] # a corrupt file must never block startup
def _save(self) -> None:
if not self.path:
return
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps({"items": [asdict(i) for i in self._items]}, indent=2),
encoding="utf-8",
)
except OSError:
pass # persistence is best-effort; memory stays authoritative
def park(self, **fields) -> ParkedMessage:
item = ParkedMessage(**fields)
with self._lock:
self._items.append(item)
if len(self._items) > self._cap:
self._items = self._items[-self._cap :]
self._save()
return item
def list(self, platform: Optional[str] = None) -> list[dict]:
with self._lock:
return [
asdict(i)
for i in reversed(self._items) # newest first
if platform is None or i.platform == platform
]
def pop(self, item_id: str) -> Optional[ParkedMessage]:
with self._lock:
for i, item in enumerate(self._items):
if item.id == item_id:
del self._items[i]
self._save()
return item
return None

View File

@@ -0,0 +1,531 @@
"""Managed-relay inbound adapter — the cloud-relay alternative to Socket Mode.
The desktop offers the user two ways to receive Slack:
- **Socket Mode** (`SlackAdapter`): manual bot + app tokens, one workspace, a
direct WebSocket to Slack. No cloud involved.
- **Managed relay** (`SlackRelayAdapter`, here): "Add to Slack" OAuth, no tokens
typed, *many* workspaces, events pushed from OpenWorker Cloud over one
authenticated WebSocket. Replies still go desktop → Slack Web API directly
with the per-team bot token (the relay is inbound-only).
Both register on the gateway as platform ``slack`` and produce the same
``MessageEvent``/``InteractionEvent`` — downstream code doesn't care which mode
delivered a message. Managed-relay reply handles are **team-qualified**
(``slack:T…/C…``) so multi-workspace replies pick the right token (see
``slack_addr``).
The socket transport is injectable so the frame-handling logic is tested with a
fake relay (no live WebSocket); the default transport is a thin ``websockets``
client, lazy-imported like the Socket-Mode SDK.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from typing import Any, Awaitable, Callable, Optional, Protocol
from .adapters import _SLACK_MENTION_RE, slack_event_to_event
from .base import BasePlatformAdapter, InteractionEvent, SendResult, SessionSource
from .senders import _send_slack, _send_slack_interactive
from .slack_addr import qualify
logger = logging.getLogger("coworker.connectors")
class RelayTransport(Protocol):
"""One live connection to the cloud relay. Implementations lazy-import their
WebSocket library; the frame contract is decoded JSON dicts."""
async def open(self) -> None: ...
async def recv(self) -> Optional[dict]:
"""Next frame, or None when the connection has closed."""
...
async def close(self) -> None: ...
TransportFactory = Callable[[], RelayTransport]
# Slack errors that mean the BOT TOKEN is dead (uninstalled/revoked/suspended) —
# distinct from transient network or method errors, which say nothing about it.
_TOKEN_ERRORS = frozenset({"invalid_auth", "account_inactive", "token_revoked"})
TokenProvider = Callable[[], str] # returns the current cloud sign-in JWT
# team_id, channel, count -> list of raw Slack message dicts (newest last)
HistoryFetcher = Callable[[str, str, int], Awaitable[list[dict]]]
class RelayHub:
"""The ONE desktop↔cloud relay socket, shared by every provider adapter.
The cloud pushes all of a user's events down a single authenticated WS;
frames fan out here by their `provider` tag (slack / github / …). Owns the
transport, the read loop, and the reconnect watchdog — adapters own only
their provider's frame handling. Extracted from SlackRelayAdapter when
GitHub became the second relay provider (github-relay-spec §8)."""
_RECONNECT_DELAY = 2.0
def __init__(
self,
relay_url: str,
token_provider: TokenProvider,
*,
transport_factory: Optional[TransportFactory] = None,
reconnect_delay: Optional[float] = None,
) -> None:
self.relay_url = relay_url
self._token_provider = token_provider
self._transport_factory = transport_factory or self._default_transport_factory
self._reconnect_delay = (
reconnect_delay if reconnect_delay is not None else self._RECONNECT_DELAY
)
self._handlers: dict[str, Callable[[dict], Awaitable[None]]] = {}
self._transport: Optional[RelayTransport] = None
self._task: Optional[asyncio.Task] = None
self._closing = False
self._connections = 0 # total successful opens; reconnects == connections-1
self._connected = False # the desktop↔relay socket is open RIGHT NOW
self._dispatched = 0 # frames dispatched (observable for tests)
self.last_error: str = "" # last connect/reconnect failure ("" once healthy)
self._progress = asyncio.Event()
def register(
self, provider: str, handler: Callable[[dict], Awaitable[None]]
) -> None:
self._handlers[provider] = handler
async def release(self, provider: str) -> None:
"""An adapter is done; the socket closes when the last one leaves."""
self._handlers.pop(provider, None)
if not self._handlers:
await self.stop()
# -- lifecycle -----------------------------------------------------------
async def start(self) -> bool:
"""Open the socket (idempotent — the second adapter joins the running
loop). True when the socket is up or already running."""
if self._task is not None and not self._task.done():
return True
self._closing = False
self._transport = self._transport_factory()
try:
await self._transport.open()
except Exception as exc:
logger.exception("relay connect failed")
self.last_error = str(exc) or type(exc).__name__
return False
self._connections = 1
self._connected = True
self.last_error = ""
self._task = asyncio.create_task(self._run())
return True
async def _run(self) -> None:
"""Read frames; on a dropped connection, reconnect (fresh transport) —
the relay's own watchdog analogue on the desktop side."""
while not self._closing:
try:
frame = await self._transport.recv() if self._transport else None
except Exception:
logger.exception("relay recv error")
frame = None
if frame is not None:
handler = self._handlers.get(frame.get("provider") or "slack")
if handler is not None:
try:
await handler(frame)
except Exception:
logger.exception("relay frame dispatch failed")
self._dispatched += 1
self._progress.set()
continue
# Connection closed → reconnect unless we're shutting down.
self._connected = False
if self._closing:
break
await self._reconnect()
async def _reconnect(self) -> None:
try:
await asyncio.sleep(self._reconnect_delay)
except asyncio.CancelledError:
return
if self._closing:
return
self._transport = self._transport_factory()
try:
await self._transport.open()
self._connections += 1
self._connected = True
self.last_error = ""
logger.info("relay reconnected (#%d)", self._connections - 1)
except Exception as exc:
self.last_error = str(exc) or type(exc).__name__
logger.exception("relay reconnect failed — will retry")
async def stop(self) -> None:
self._closing = True
self._connected = False
if self._transport is not None:
try:
await self._transport.close()
except Exception:
pass
if self._task is not None:
self._task.cancel()
self._task = None
@property
def reconnects(self) -> int:
return max(0, self._connections - 1)
def state(self) -> str:
if self._connected:
return "live"
if self._task is not None and not self._closing:
return "reconnecting"
return "offline"
async def wait_dispatched(self, at_least: int, timeout: float = 2.0) -> None:
"""Test helper: wait until at least N frames have been dispatched."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while self._dispatched < at_least:
self._progress.clear()
remaining = deadline - loop.time()
if remaining <= 0:
raise TimeoutError(
f"only {self._dispatched} frames dispatched (< {at_least})"
)
try:
await asyncio.wait_for(self._progress.wait(), timeout=remaining)
except asyncio.TimeoutError:
raise TimeoutError(
f"only {self._dispatched} frames dispatched (< {at_least})"
)
# -- default transport ---------------------------------------------------
def _default_transport_factory(self) -> RelayTransport:
return _WebSocketsTransport(self.relay_url, self._token_provider)
class SlackRelayAdapter(BasePlatformAdapter):
platform = "slack"
def __init__(
self,
relay_url: str,
token_provider: TokenProvider,
*,
teams: Optional[dict[str, dict[str, Any]]] = None,
transport_factory: Optional[TransportFactory] = None,
history_fetcher: Optional[HistoryFetcher] = None,
reconnect_delay: Optional[float] = None,
hub: Optional[RelayHub] = None,
) -> None:
super().__init__()
self.relay_url = relay_url
# A shared hub arrives when several relay providers coexist; standalone
# construction (tests, single-provider setups) builds its own.
self._hub = hub or RelayHub(
relay_url,
token_provider,
transport_factory=transport_factory,
reconnect_delay=reconnect_delay,
)
# team_id -> {"bot_token", "bot_user_id"}. Mutable: a `revoked` frame or a
# new install updates it.
self._teams: dict[str, dict[str, Any]] = dict(teams or {})
self._history_fetcher = history_fetcher
self.last_event_at: Optional[float] = None # last Slack event delivered
# Name resolution caches, keyed PER WORKSPACE — a U…/C… id only means
# something inside its team, and resolution uses that team's bot token.
self._names: dict[str, dict[str, str]] = {} # team_id -> {uid: name}
self._channels: dict[str, dict[str, str]] = {} # team_id -> {cid: name}
# -- lifecycle -----------------------------------------------------------
async def connect(self) -> bool:
self._hub.register(self.platform, self._dispatch)
ok = await self._hub.start()
if ok:
logger.info(
"slack adapter connected (managed relay), %d team(s)", len(self._teams)
)
return ok
async def disconnect(self) -> None:
await self._hub.release(self.platform)
@property
def reconnects(self) -> int:
return self._hub.reconnects
@property
def last_error(self) -> str:
return self._hub.last_error
def status(self) -> dict[str, Any]:
"""Health snapshot for the GUI: the desktop↔relay socket state plus each
workspace's bot-token health. Says nothing about Slack↔cloud — the desktop
can't observe that leg, and event silence is not an outage."""
return {
"state": self._hub.state(),
"reconnects": self._hub.reconnects,
"last_event_at": self.last_event_at,
"last_error": self._hub.last_error,
"teams": {
tid: {"token_ok": bool(info.get("token_ok", True))}
for tid, info in self._teams.items()
},
}
async def wait_dispatched(self, at_least: int, timeout: float = 2.0) -> None:
await self._hub.wait_dispatched(at_least, timeout)
# -- team registry -------------------------------------------------------
def set_team(
self, team_id: str, bot_token: str, bot_user_id: Optional[str] = None
) -> None:
self._teams[team_id] = {"bot_token": bot_token, "bot_user_id": bot_user_id}
def _bot_user_id(self, team_id: str) -> Optional[str]:
return (self._teams.get(team_id) or {}).get("bot_user_id")
def _bot_token(self, team_id: str) -> Optional[str]:
return (self._teams.get(team_id) or {}).get("bot_token")
# -- frame dispatch ------------------------------------------------------
async def _dispatch(self, frame: dict) -> None:
kind = frame.get("kind")
if kind == "missed":
await self._on_missed(frame)
return
if kind == "revoked":
self._teams.pop(frame.get("team_id", ""), None)
logger.info("slack relay team %s revoked — dropped", frame.get("team_id"))
return
if kind == "interactivity":
await self._on_interactivity(frame)
return
# A routed Slack event.
await self._on_event(frame)
async def _on_event(self, frame: dict) -> None:
await self._dispatch_slack_event(
frame.get("team_id", ""), frame.get("event") or {}
)
async def _dispatch_slack_event(self, team_id: str, event: dict) -> None:
"""Map a raw Slack event → MessageEvent, resolve display names via the
per-team bot token, team-qualify the reply handle, and dispatch."""
self.last_event_at = time.time()
mapped = slack_event_to_event(event, self._bot_user_id(team_id))
if mapped is None:
return
channel = mapped.source.chat_id # bare channel id before qualification
# Resolve friendly names with THIS workspace's bot token (cached per team),
# mirroring the Socket-Mode adapter — so cards read "@OpenWorker"/"Rohit"/"#ocw-test"
# not raw U…/C… ids. Best-effort: ids fall through on failure.
if not mapped.source.user_name:
mapped.source.user_name = await self._display_name(
team_id, mapped.source.user_id
)
if not mapped.source.chat_name:
mapped.source.chat_name = await self._channel_name(team_id, channel)
mapped.text = await self._resolve_mentions(team_id, mapped.text)
# Team-qualify the reply handle so multi-workspace replies pick the right
# per-team token.
mapped.source.chat_id = qualify(team_id, channel)
mapped.source.team_id = team_id
await self.handle_message(mapped)
async def _on_interactivity(self, frame: dict) -> None:
interaction = frame.get("interaction") or {}
actions = interaction.get("actions") or [{}]
value = actions[0].get("value", "")
user = interaction.get("user") or {}
team_id = frame.get("team_id", "")
channel = (interaction.get("channel") or {}).get("id", "")
ts = (interaction.get("message") or {}).get("ts")
await self.handle_interaction(
InteractionEvent(
platform="slack",
chat_id=qualify(team_id, channel),
message_id=ts,
value=str(value),
user_id=user.get("id"),
user_name=user.get("username") or user.get("name"),
team_id=team_id,
response_url=interaction.get("response_url"),
)
)
async def _on_missed(self, frame: dict) -> None:
"""A nudge: content was dropped (offline > TTL / overflow). Pull the
recent channel history ourselves via the per-team bot token and replay
the missed messages (spec §7 channel-context / nudge)."""
team_id = frame.get("team_id", "")
channel = frame.get("channel", "")
count = int(frame.get("count", 0)) or 1
if self._history_fetcher is None or not channel:
return
try:
messages = await self._history_fetcher(team_id, channel, count)
except Exception:
logger.exception("relay nudge history fetch failed")
return
for raw in messages:
await self._dispatch_slack_event(team_id, {**raw, "channel": channel})
def _note_token_health(self, team_id: str, error: Optional[str]) -> None:
"""Record what a Web API call said about the team's bot token: success
proves it live; a token-class error marks it dead; anything else —
network trouble, channel_not_found — says nothing, so changes nothing."""
info = self._teams.get(team_id)
if info is None:
return
if error is None:
info["token_ok"] = True
elif error in _TOKEN_ERRORS:
info["token_ok"] = False
# -- name resolution (per workspace, via that team's bot token) ----------
async def _slack_get(
self, team_id: str, method: str, params: dict
) -> Optional[dict]:
"""Call a Slack Web API read method with the team's bot token. Best-effort
(None on any failure). `SLACK_API_URL` redirects to the fake in tests."""
import httpx
token = self._bot_token(team_id)
if not token:
return None
base = os.environ.get("SLACK_API_URL", "https://slack.com/api/")
try:
async with httpx.AsyncClient(timeout=15) as http:
resp = await http.get(
base + method,
params=params,
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
except Exception:
return None
self._note_token_health(team_id, None if data.get("ok") else data.get("error"))
return data if data.get("ok") else None
async def _display_name(self, team_id: str, uid: Optional[str]) -> Optional[str]:
if not uid:
return None
cache = self._names.setdefault(team_id, {})
if uid in cache:
return cache[uid]
data = await self._slack_get(team_id, "users.info", {"user": uid})
u = (data or {}).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")
)
if name:
cache[uid] = name
return name
async def _channel_name(self, team_id: str, cid: Optional[str]) -> Optional[str]:
if not cid:
return None
cache = self._channels.setdefault(team_id, {})
if cid in cache:
return cache[cid]
data = await self._slack_get(team_id, "conversations.info", {"channel": cid})
chan = (data or {}).get("channel") or {}
name = chan.get("name") or chan.get("name_normalized")
if name:
cache[cid] = name
return name
async def _resolve_mentions(self, team_id: str, text: str) -> str:
"""Rewrite `<@U…>` tokens to `@display-name` (cached). Best-effort."""
out = text
for uid in set(_SLACK_MENTION_RE.findall(text or "")):
name = await self._display_name(team_id, uid)
if name:
out = re.sub(rf"<@{re.escape(uid)}(?:\|[^>]*)?>", f"@{name}", out)
return out
# -- outbound ------------------------------------------------------------
async def send(
self, chat_id: str, text: str, *, thread_id: Optional[str] = None
) -> SendResult:
"""Reply directly via the Slack Web API with the per-team bot token."""
from .slack_addr import split
team_id, _channel = split(chat_id)
token = self._bot_token(team_id or "")
if not token:
return SendResult(False, error=f"no bot token for team {team_id}")
result = await asyncio.to_thread(_send_slack, token, chat_id, text, thread_id)
self._note_token_health(team_id or "", None if result.ok else result.error)
return result
async def send_interactive(
self, chat_id: str, text: str, buttons, *, thread_id: Optional[str] = None
) -> SendResult:
from .slack_addr import split
team_id, _channel = split(chat_id)
token = self._bot_token(team_id or "")
if not token:
return SendResult(False, error=f"no bot token for team {team_id}")
result = await asyncio.to_thread(
_send_slack_interactive, token, chat_id, text, buttons, thread_id
)
self._note_token_health(team_id or "", None if result.ok else result.error)
return result
class _WebSocketsTransport:
"""Real transport: an authenticated `websockets` client. Sends the cloud
sign-in JWT in the Authorization header (the relay's $connect authorizer)."""
def __init__(self, url: str, token_provider: TokenProvider) -> None:
self._url = url
self._token_provider = token_provider
self._ws = None
async def open(self) -> None:
import websockets # lazy: optional extra
token = self._token_provider()
self._ws = await websockets.connect(
self._url, additional_headers={"Authorization": f"Bearer {token}"}
)
async def recv(self) -> Optional[dict]:
import websockets
if self._ws is None:
return None
try:
raw = await self._ws.recv()
except websockets.ConnectionClosed:
return None
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
async def close(self) -> None:
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
self._ws = None

View File

@@ -0,0 +1,216 @@
"""Stateless outbound senders — one-shot HTTP POSTs, no SDK, no live connection.
These power the `send_message` tool (and the super-agent's replies). Both Telegram and
Slack outbound are simple HTTP calls, so we use a synchronous `httpx` client and avoid the
heavy SDKs (those are only needed for the inbound listeners). Sync fits the ToolRegistry's
`execute` contract (the engine runs it in a thread).
A `Sender` is `(token, chat_id, text, thread_id) -> SendResult`. The registry is swappable so
tests inject fakes — no network.
"""
from __future__ import annotations
import os
from typing import Callable, Optional
from .base import SendResult
Sender = Callable[[str, str, str, Optional[str]], SendResult]
_TIMEOUT = 30.0
def _slack_api_base() -> str:
"""Web API base URL. `SLACK_API_URL` (trailing slash) lets tests / the FakeSlack harness
redirect outbound sends to a local fake. See platform/docs/FAKE-SLACK-SPEC.md."""
return os.environ.get("SLACK_API_URL", "https://slack.com/api/")
def _send_telegram(
token: str, chat_id: str, text: str, thread_id: Optional[str] = None
) -> SendResult:
import httpx
payload: dict = {"chat_id": chat_id, "text": text}
# Telegram's General forum topic is thread_id "1", which sendMessage rejects → omit it.
if thread_id and thread_id != "1":
try:
payload["message_thread_id"] = int(thread_id)
except ValueError:
pass
try:
resp = httpx.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json=payload,
timeout=_TIMEOUT,
)
data = resp.json()
except Exception as exc: # network / decode
return SendResult(False, error=str(exc))
if data.get("ok"):
return SendResult(
True, message_id=str(data.get("result", {}).get("message_id"))
)
return SendResult(False, error=data.get("description") or "telegram send failed")
def _send_slack(
token: str, chat_id: str, text: str, thread_id: Optional[str] = None
) -> SendResult:
import httpx
from .slack_addr import split
# A managed-relay chat_id is team-qualified ("T…/C…"); Slack's API wants the
# bare channel. The per-team token is selected by the caller (send_message).
_team, chat_id = split(chat_id)
payload: dict = {"channel": chat_id, "text": text}
if thread_id:
payload["thread_ts"] = thread_id
try:
resp = httpx.post(
f"{_slack_api_base()}chat.postMessage",
headers={"Authorization": f"Bearer {token}"},
json=payload,
timeout=_TIMEOUT,
)
data = resp.json()
except Exception as exc:
return SendResult(False, error=str(exc))
if data.get("ok"):
return SendResult(True, message_id=data.get("ts"))
err = data.get("error") or "slack send failed"
if err == "not_in_channel":
err = "not_in_channel — invite @OpenWorker to the channel in Slack, then retry"
return SendResult(False, error=err)
def _slack_blocks(text: str, buttons) -> list[dict]:
"""A Block Kit message: a text section + a row of action buttons (action_id `ocw_<i>`,
value = the encoded item id + resolution)."""
blocks: list[dict] = [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
if buttons:
blocks.append(
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": b.label[:75]},
"value": b.value,
"action_id": f"ocw_{i}",
}
for i, b in enumerate(buttons)
],
}
)
return blocks
def _send_slack_interactive(
token: str, chat_id: str, text: str, buttons, thread_id: Optional[str] = None
) -> SendResult:
import httpx
from .slack_addr import split
_team, chat_id = split(chat_id)
payload: dict = {
"channel": chat_id,
"text": text,
"blocks": _slack_blocks(text, buttons),
}
if thread_id:
payload["thread_ts"] = thread_id
try:
resp = httpx.post(
f"{_slack_api_base()}chat.postMessage",
headers={"Authorization": f"Bearer {token}"},
json=payload,
timeout=_TIMEOUT,
)
data = resp.json()
except Exception as exc:
return SendResult(False, error=str(exc))
if data.get("ok"):
return SendResult(True, message_id=data.get("ts"))
return SendResult(False, error=data.get("error") or "slack send failed")
DEFAULT_SENDERS: dict[str, Sender] = {
"telegram": _send_telegram,
"slack": _send_slack,
}
# -- file upload (§34 / UX-016) --------------------------------------------------------
# A FileSender is (token, chat_id, thread_id, filename, data, title, comment) -> SendResult.
FileSender = Callable[
[str, str, Optional[str], str, bytes, Optional[str], Optional[str]], SendResult
]
def _send_slack_file(
token: str,
chat_id: str,
thread_id: Optional[str],
filename: str,
data: bytes,
title: Optional[str] = None,
comment: Optional[str] = None,
) -> SendResult:
"""files_upload_v2 (the only non-deprecated path): reserve an upload URL, PUT the
bytes, then complete into the channel/thread. Slack renders its own previews for
pdf/csv/images — that's the whole point of sending the file instead of a thumbnail.
"""
import httpx
from .slack_addr import split
_team, chat_id = split(chat_id)
headers = {"Authorization": f"Bearer {token}"}
try:
resp = httpx.post(
f"{_slack_api_base()}files.getUploadURLExternal",
headers=headers,
data={"filename": filename, "length": str(len(data))},
timeout=_TIMEOUT,
)
got = resp.json()
if not got.get("ok"):
return SendResult(
False, error=got.get("error") or "slack upload-url failed"
)
up = httpx.post(
got["upload_url"],
files={"file": (filename, data)},
timeout=max(_TIMEOUT, 120.0),
)
if up.status_code != 200:
return SendResult(False, error=f"slack upload failed ({up.status_code})")
complete: dict = {
"files": [{"id": got["file_id"], "title": title or filename}],
"channel_id": chat_id,
}
if thread_id:
complete["thread_ts"] = thread_id
if comment:
complete["initial_comment"] = comment
resp = httpx.post(
f"{_slack_api_base()}files.completeUploadExternal",
headers=headers,
json=complete,
timeout=_TIMEOUT,
)
data_out = resp.json()
except Exception as exc: # network / decode
return SendResult(False, error=str(exc))
if data_out.get("ok"):
return SendResult(True, message_id=got["file_id"])
return SendResult(False, error=data_out.get("error") or "slack file send failed")
DEFAULT_FILE_SENDERS: dict[str, FileSender] = {
"slack": _send_slack_file,
}

View File

@@ -0,0 +1,524 @@
"""Connect / disconnect / list connectors — writes tokens to the SecretStore.
Pure functions over a SecretStore so they're testable without the server. `validate=False`
skips the network check (used by tests). Secrets are never returned — only status + the
public bot identity captured at connect time.
"""
from __future__ import annotations
from typing import Any
from ..secrets import SecretStore
from .catalog_copy import about_for, access_for
from .descriptors import get_descriptor, list_descriptors
from .tool_defs import patch_tool_settings, tool_dicts
_EXPERIMENTAL_KEY = "experimental:settings"
def experimental_enabled(secrets: SecretStore) -> bool:
"""Whether the user has opted in to experimental (use-at-your-own-risk) connectors."""
return bool((secrets.get(_EXPERIMENTAL_KEY) or {}).get("enabled"))
def set_experimental_enabled(secrets: SecretStore, value: bool) -> dict[str, Any]:
secrets.put(_EXPERIMENTAL_KEY, {"enabled": bool(value)})
return {"ok": True, "enabled": bool(value)}
def _profile_connected(descriptor, profile: dict[str, Any]) -> bool:
if not descriptor.available:
return False
if descriptor.auth == "none":
return True
# Managed relay (e.g. Slack cloud relay) carries no manual credential in the
# :default profile — the tokens live per-team (slack:team:*). The relay-mode
# flag is what marks it connected, so don't require the manual fields.
if profile.get("mode") == "relay":
return True
required = [
f.key for f in descriptor.fields if f.required and f.key != "allowed_users"
]
return bool(profile) and all(bool(profile.get(k)) for k in required)
def _mcp_tokens_present(secrets: SecretStore, name: str) -> bool:
# Lazy import: the mcp package pulls in the MCP SDK, which connector listing
# shouldn't pay for unless an MCP-backed profile actually exists.
from ..mcp.oauth import has_tokens
return has_tokens(name, secrets)
def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
show_experimental = experimental_enabled(secrets)
out: list[dict[str, Any]] = []
for d in list_descriptors():
# Experimental connectors are invisible (not just disabled) until the user opts in;
# hiding them here also drops their tools from engine builds via
# _enabled_connector_tools, so flipping the setting off cuts access immediately.
if d.experimental and not show_experimental:
continue
profile = secrets.get(f"{d.name}:default") or {}
if d.mcp_url and profile.get("mode") == "mcp":
# MCP-backed connect: the profile is just a marker — connected-ness
# lives with the OAuth tokens (mcp-oauth:<name> in the SecretStore).
connected = _mcp_tokens_present(secrets, d.name)
else:
connected = _profile_connected(d, profile)
entry = {
"name": d.name,
"title": d.title,
"icon": d.icon,
"blurb": d.blurb,
# Pre-connect detail page copy (UX-DECISIONS §38): About paragraph
# (may be empty → GUI omits the group) + honest Access bullets.
"about": about_for(d.name),
"access": access_for(d.name),
"auth": d.auth,
"two_way": d.two_way,
"channels": d.channels,
"available": d.available,
"brand_color": d.brand_color,
"logo": d.logo,
"aliases": list(d.aliases),
# MCP-backed one-click (vendor-hosted MCP server + local OAuth) —
# distinct from `managed` (broker OAuth): no cloud sign-in needed.
"mcp": bool(d.mcp_url),
"fields": [f.to_dict() for f in d.fields],
"instructions": d.instructions,
"connected": connected,
"account": profile.get("account"),
"enabled": bool(profile.get("enabled", True)) and connected,
# The actual allow-list (the GUI manages it inline); was a bare count.
"allowed_users": list(profile.get("allowed_users") or []),
# Manual Socket Mode only: explicitly selected humans who may resolve
# consequential Inbox prompts. Relay uses its OAuth installer instead.
"approval_owner_ids": list(profile.get("approval_owner_ids") or []),
"tools": tool_dicts(secrets, d.name),
"experimental": d.experimental,
"risk_notice": d.risk_notice,
"managed": d.managed,
"managed_paused": d.managed_paused,
# Whether THIS profile came from managed OAuth (vs manual paste).
"managed_profile": bool(profile.get("managed")),
# "relay" for the managed cloud path; empty for manual/token connect.
"mode": profile.get("mode") or "",
}
if d.name == "slack":
# Managed relay is multi-workspace: each `slack:team:*` profile is one
# connected workspace with its OWN allow-list (ids are workspace-scoped).
entry["workspaces"] = _slack_workspaces(secrets)
if profile.get("mode") == "relay":
# Dormant Manual-mode owners may remain beside preserved Socket
# Mode credentials; they never authorize a bare Relay target.
entry["approval_owner_ids"] = []
if d.name == "gmail":
# Multi-account: each `gmail:account:*` profile is one mailbox; the
# :default profile is just the default pointer + privacy filters.
from . import gmail_accounts
accounts = _gmail_account_list(secrets)
default_email = gmail_accounts.default_account(secrets)
entry["accounts"] = accounts
entry["connected"] = bool(accounts)
entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
entry["account"] = default_email or None
entry["managed_profile"] = any(
a["email"] == default_email and a["managed"] for a in accounts
)
entry["filters"] = gmail_accounts.get_filters(secrets)
if d.name == "google_calendar":
# Multi-account, same shape as gmail: each `google_calendar:account:*`
# profile is one Google account; :default is just the default pointer.
from . import gcal_accounts
accounts = _gcal_account_list(secrets)
default_email = gcal_accounts.default_account(secrets)
entry["accounts"] = accounts
entry["connected"] = bool(accounts)
entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
entry["account"] = default_email or None
entry["managed_profile"] = any(
a["email"] == default_email and a["managed"] for a in accounts
)
if d.name == "github":
# Managed relay is multi-installation: each `github:install:*`
# profile is one App installation with its OWN allow-list of
# sender logins. The manual PAT path stays on the default profile.
entry["installations"] = _github_installations(secrets)
if entry["installations"] and profile.get("mode") == "relay":
first = entry["installations"][0]
entry["account"] = entry["account"] or first["account_login"]
if d.account_field:
# Generic multi-account (batch-2 connectors): each
# `<name>:account:*` profile is one account; :default is pointer-only.
from . import accounts as _accounts
rows = _accounts.account_rows(secrets, d.name)
default_id = _accounts.default_account(secrets, d.name)
entry["accounts"] = rows
entry["connected"] = bool(rows)
entry["enabled"] = bool(profile.get("enabled", True)) and bool(rows)
default_row = next((r for r in rows if r["account_id"] == default_id), None)
entry["account"] = (default_row or {}).get("name") or None
entry["managed_profile"] = bool((default_row or {}).get("managed"))
if d.name == "hubspot":
# Multi-portal: each `hubspot:portal:*` profile is one portal; the
# :default profile is the default pointer + hidden-fields policy.
from . import hubspot_portals
portals = _hubspot_portal_list(secrets)
default_hub = hubspot_portals.default_portal(secrets)
entry["portals"] = portals
entry["connected"] = bool(portals)
entry["enabled"] = bool(profile.get("enabled", True)) and bool(portals)
default_row = next((p for p in portals if p["hub_id"] == default_hub), None)
entry["account"] = (default_row or {}).get("name") or None
entry["managed_profile"] = bool((default_row or {}).get("managed"))
entry["hidden_fields"] = hubspot_portals.get_hidden_fields(secrets)
out.append(entry)
return out
def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]:
from .config import _slack_team_profiles
return [
{
"team_id": team_id,
"account": profile.get("account") or team_id,
"domain": profile.get("domain") or "",
"allowed_users": list(profile.get("allowed_users") or []),
"allow_all": bool(profile.get("allow_all")),
# Relay approvals are installer-only. Keep the list-shaped API aligned
# with Manual mode without creating a second editable relay role.
"approval_owner_ids": (
[profile["slack_user_id"]] if profile.get("slack_user_id") else []
),
# Who installed (authed_user) — the GUI marks their chip "you" and
# keys the post-connect card's "your mentions get through" line.
"installer_user_id": profile.get("slack_user_id") or "",
"installer_name": profile.get("sender_name") or "",
}
for team_id, profile in sorted(
_slack_team_profiles(secrets), key=lambda t: t[0]
)
]
def _github_installations(secrets: SecretStore) -> list[dict[str, Any]]:
from .github_installs import list_installs
return [
{
"installation_id": installation_id,
"account_login": profile.get("account_login") or installation_id,
"account_type": profile.get("account_type") or "",
"repo_selection": profile.get("repo_selection") or "",
"github_login": profile.get("github_login") or "",
"allowed_users": list(profile.get("allowed_users") or []),
"allow_all": bool(profile.get("allow_all")),
}
for installation_id, profile in list_installs(secrets)
]
def _gmail_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
from time import time
from . import gmail_accounts
default = gmail_accounts.default_account(secrets)
out = []
for email, profile in gmail_accounts.list_accounts(secrets):
expires = float(profile.get("expires") or 0)
out.append(
{
"email": email,
"default": email == default,
"managed": bool(profile.get("managed")),
"scopes": profile.get("scope") or "",
# Expired with no way to renew silently → the GUI offers Reauthorize.
"needs_reauth": bool(
expires and expires < time() and not profile.get("refresh_token")
),
}
)
return out
def _gcal_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
from time import time
from . import gcal_accounts
default = gcal_accounts.default_account(secrets)
out = []
for email, profile in gcal_accounts.list_accounts(secrets):
expires = float(profile.get("expires") or 0)
out.append(
{
"email": email,
"default": email == default,
"managed": bool(profile.get("managed")),
"scopes": profile.get("scope") or "",
# Expired with no way to renew silently → the GUI offers Reauthorize.
"needs_reauth": bool(
expires and expires < time() and not profile.get("refresh_token")
),
}
)
return out
def _hubspot_portal_list(secrets: SecretStore) -> list[dict[str, Any]]:
from . import hubspot_portals
default = hubspot_portals.default_portal(secrets)
out = []
for hub_id, profile in hubspot_portals.list_portals(secrets):
scope = str(profile.get("scope") or "")
out.append(
{
"hub_id": hub_id,
"name": profile.get("account") or f"portal {hub_id}",
"sandbox": bool(profile.get("sandbox")),
"default": hub_id == default,
"managed": bool(profile.get("managed")),
# Consent tier granted at connect: managed profiles reveal it in
# their scope grant; a manual private-app token doesn't say.
"access": (".write" in scope and "write") or (scope and "read") or "",
}
)
return out
def update_connector_tools(
secrets: SecretStore, name: str, enabled: dict[str, Any]
) -> dict[str, Any]:
if get_descriptor(name) is None:
return {"ok": False, "error": "unknown connector"}
return patch_tool_settings(secrets, name, enabled)
def connect_connector(
secrets: SecretStore,
name: str,
fields: dict[str, Any],
*,
validate: bool = True,
acknowledged: bool = False,
) -> dict[str, Any]:
d = get_descriptor(name)
if d is None or not d.available:
return {"ok": False, "error": "unknown or unavailable connector"}
if d.experimental:
if not experimental_enabled(secrets):
return {"ok": False, "error": "experimental connectors are disabled"}
if not acknowledged:
return {
"ok": False,
"error": "risk acknowledgment required",
"risk_notice": d.risk_notice,
}
# Reconnect-safe: never let a re-submit clobber a stored secret. The GUI masks a connected
# connector's secret fields (it shows the placeholder, e.g. `xoxb-…`), so a blank — or
# mask-equal — submission means "keep what's stored", not "overwrite with the mask". (This is
# the bug that reset a real token down to its 6-char placeholder.)
existing = secrets.get(f"{name}:default") or {}
def _resolved(f) -> str:
v = str(fields.get(f.key) or "").strip()
if f.key == "allowed_users":
return v # a list in storage / CSV in the form — handled separately below
if not v or (f.secret and v == (f.placeholder or "").strip()):
return str(existing.get(f.key) or "").strip()
return v
raw = {f.key: _resolved(f) for f in d.fields}
missing = [f.label for f in d.fields if f.required and not raw.get(f.key)]
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
allowed = sorted(
{u.strip() for u in raw.get("allowed_users", "").split(",") if u.strip()}
)
if not allowed and existing.get("allowed_users"):
allowed = list(
existing["allowed_users"]
) # don't wipe the live allow-list on reconnect
token_creds = {k: v for k, v in raw.items() if k != "allowed_users" and v}
identity = None
if validate and d.validate is not None:
result = d.validate(token_creds)
if not result.ok:
return {"ok": False, "error": result.error or "validation failed"}
identity = result.identity
profile_type = (
"oauth" if d.auth == "oauth" else "none" if d.auth == "none" else "token"
)
profile: dict[str, Any] = {"type": profile_type, "enabled": True, **token_creds}
if any(f.key == "allowed_users" for f in d.fields):
profile["allowed_users"] = allowed
if name == "slack" and existing.get("approval_owner_ids"):
# Re-pasting manual Socket Mode tokens must not erase the locally selected
# approval owners.
profile["approval_owner_ids"] = list(existing["approval_owner_ids"])
if identity:
profile["account"] = identity
if d.account_field:
# Account-patterned connector: connecting ADDS an account (a second
# submit with different creds is a second account, not an overwrite).
from . import accounts as _accounts
account_id = _accounts.derive_account_id(d, profile)
result = _accounts.add_account(secrets, name, account_id, profile)
if not result.get("ok"):
return result
return {"ok": True, "account": identity or account_id, "account_id": account_id}
secrets.put(f"{name}:default", profile)
return {"ok": True, "account": identity}
def managed_connect_connector(
secrets: SecretStore, name: str, profile: dict[str, Any]
) -> dict[str, Any]:
"""Store a profile produced by managed OAuth (cloud.managed_profile_from_callback).
Field-compatible with a manual connect for the same connector, so tools and
session gating can't tell the paths apart; preserves an existing allow-list
on reconnect just like the manual path does.
"""
d = get_descriptor(name)
if d is None or not d.available:
return {"ok": False, "error": "unknown or unavailable connector"}
if not d.managed:
return {"ok": False, "error": f"{name} does not support managed connect"}
if d.account_field:
from . import accounts as _accounts
account_id = _accounts.derive_account_id(d, profile)
result = _accounts.add_account(secrets, name, account_id, profile)
if not result.get("ok"):
return result
return {
"ok": True,
"account": profile.get("account") or account_id,
"account_id": account_id,
}
existing = secrets.get(f"{name}:default") or {}
if existing.get("allowed_users"):
profile = {**profile, "allowed_users": list(existing["allowed_users"])}
secrets.put(f"{name}:default", profile)
return {"ok": True, "account": profile.get("account") or None}
def managed_connect_slack_install(
secrets: SecretStore, form: dict[str, Any]
) -> dict[str, Any]:
"""Store a managed Slack install (relay mode) from the broker's form-POST.
Slack managed install is multi-workspace and inbound-via-relay, so unlike a
single-token connector it writes:
- `slack:team:<team_id>` — that workspace's bot token + bot_user_id (used for
replies and to ignore the bot's own posts);
- `slack:default` flipped to `mode="relay"` so the gateway builds the
`SlackRelayAdapter` (Socket Mode's manual bot_token/app_token untouched if
the user later switches back). Existing allow-list preserved.
"""
team_id = form.get("team_id", "")
bot_token = form.get("access_token", "")
if not team_id or not bot_token:
return {"ok": False, "error": "missing team_id or bot token"}
# A reinstall replaces the token but must not reset authorization state.
existing = secrets.get(f"slack:team:{team_id}") or {}
allowed = set(existing.get("allowed_users") or [])
installer = form.get("slack_user_id", "")
if installer:
# Pre-add the installer (UX-027): connecting the workspace is consent to
# talk to your own bot — without this, the connector's very first mention
# comes from the installer and parks.
allowed.add(installer)
secrets.put(
f"slack:team:{team_id}",
{
"type": "oauth",
"managed": True,
"bot_token": bot_token,
"bot_user_id": form.get("bot_user_id", ""),
# The INSTALLER's Slack member id (authed_user) — who this workspace's
# outbound posts speak for (attribution.py resolves + caches the name).
"slack_user_id": installer,
"team_id": team_id,
"account": form.get("account", ""),
# The workspace's slack.com subdomain (broker resolves it via auth.test)
# — the unique human handle when two workspaces share a display name.
"domain": form.get("team_domain", ""),
"scope": form.get("scope", ""),
"connection_id": form.get("connection_id", ""),
"allowed_users": sorted(allowed),
"allow_all": bool(existing.get("allow_all")),
"sender_name": existing.get("sender_name", ""),
},
)
default = secrets.get("slack:default") or {}
default.update({"type": "oauth", "managed": True, "mode": "relay", "enabled": True})
secrets.put("slack:default", default)
return {"ok": True, "account": form.get("account") or team_id}
def disconnect_connector(secrets: SecretStore, name: str) -> dict[str, Any]:
dropped_accounts = False
from . import accounts as _accounts
if _accounts.is_account_connector(name):
for account_id, _profile in _accounts.list_accounts(secrets, name):
dropped_accounts = (
secrets.delete(_accounts.prefix(name) + account_id) or dropped_accounts
)
if name == "gmail":
# Whole-connector disconnect drops every mailbox (per-account removal
# lives on the Gmail page); filters go too — an explicit full reset.
from . import gmail_accounts
for email, _profile in gmail_accounts.list_accounts(secrets):
dropped_accounts = (
secrets.delete(gmail_accounts.PREFIX + email) or dropped_accounts
)
if name == "google_calendar":
from . import gcal_accounts
for email, _profile in gcal_accounts.list_accounts(secrets):
dropped_accounts = (
secrets.delete(gcal_accounts.PREFIX + email) or dropped_accounts
)
if name == "hubspot":
from . import hubspot_portals
for hub_id, _profile in hubspot_portals.list_portals(secrets):
dropped_accounts = (
secrets.delete(hubspot_portals.PREFIX + hub_id) or dropped_accounts
)
if name == "github":
from . import github_installs
for installation_id, _profile in github_installs.list_installs(secrets):
dropped_accounts = (
secrets.delete(github_installs.PREFIX + installation_id)
or dropped_accounts
)
profile = secrets.get(f"{name}:default") or {}
if profile.get("mode") == "mcp":
# MCP-backed connect: forget the OAuth tokens + DCR registration and remove
# the seeded server entry, so a reconnect runs a fresh flow.
from ..mcp import config as mcp_config
from ..mcp import oauth as mcp_oauth
dropped_accounts = mcp_oauth.sign_out(name, secrets) or dropped_accounts
mcp_config.delete_global_server(name)
return {"ok": secrets.delete(f"{name}:default") or dropped_accounts}

View File

@@ -0,0 +1,34 @@
"""Slack team-qualified addressing for managed relay (slack-relay-spec §8/§9).
A single owner can be in several Slack workspaces at once, so a bare channel id
(`C…`) is ambiguous — a `U…`/`C…` only means something inside its `team_id`.
Managed-relay targets therefore carry the team: the reply handle's chat_id is
`"{team_id}/{channel}"`.
Encoding note: the reply-target grammar is colon-delimited
(`platform:chat_id[:thread]`, see base.parse_target), so we join team+channel
with `/` — colon-free — to stay inside that grammar unchanged. `slack:T012345/C0123`
is the wire form of the spec's conceptual `slack:T012345:C0123`. Manual
Socket-Mode targets (single workspace) keep the bare `slack:C0123` form.
"""
from __future__ import annotations
from typing import Optional
def qualify(team_id: Optional[str], channel: str) -> str:
"""Build a team-qualified chat_id, or the bare channel when no team."""
return f"{team_id}/{channel}" if team_id else channel
def split(chat_id: str) -> tuple[Optional[str], str]:
"""`'T…/C…' -> ('T…', 'C…')`; a bare `'C…' -> (None, 'C…')`.
Only the first `/` splits (channel ids never contain one), so this is
lossless both ways.
"""
if chat_id and "/" in chat_id:
team, _, channel = chat_id.partition("/")
return (team or None), channel
return None, chat_id

View File

@@ -0,0 +1,197 @@
"""Workspace rosters for the Slack pickers (people + channels).
Backs "find your name in a list" instead of the park→approve-only flow, and
channel-by-name instead of pasted IDs. Pure reads on scopes every install
already granted (`users:read`, `channels:read`, `groups:read`) — no consent
bump, and the roster never leaves this machine (in-memory cache, not the
SecretStore; names/ids are routing metadata, not content).
Slack API notes: `users.list` is Tier-2 (~20 req/min) and Slack's own guidance
is to cache it — one paginated sweep per workspace per TTL, filtered locally.
Private channels only appear where the bot is a MEMBER (API constraint — the
GUI words it honestly); public channels carry `is_member` so the picker can
hint "invite @OpenWorker in Slack" instead of silently failing to listen.
"""
from __future__ import annotations
import os
import time
from typing import Any, Optional
from ..secrets import SecretStore
_TTL = 900.0 # 15 min — rosters drift slowly; a Refresh affordance can force it
# users.list: Slack recommends ≤200/page. conversations.list allows 1000 — use it:
# the cold sweep is user-visible latency (a big workspace took ~11 s at 200/page).
_PAGE_LIMIT = 200
_CHANNEL_PAGE_LIMIT = 999
_MAX_PAGES = 25 # caps both sweeps — beyond that, type more letters
# (team_id, kind) → (fetched_at, rows). Module-level on purpose: survives
# request handlers but not the process — nothing roster-shaped is persisted.
_CACHE: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {}
def _api_base() -> str:
return os.environ.get("SLACK_API_URL", "https://slack.com/api/")
def _bot_token(secrets: SecretStore, team_id: str) -> str:
"""The workspace's bot token: per-team profile (managed relay) or the flat
default profile (manual Socket Mode — team_id "default")."""
if team_id and team_id != "default":
profile = secrets.get(f"slack:team:{team_id}") or {}
if profile.get("bot_token"):
return str(profile["bot_token"])
return str((secrets.get("slack:default") or {}).get("bot_token") or "")
def _get_pages(
token: str,
method: str,
params: dict[str, Any],
key: str,
page_limit: int = _PAGE_LIMIT,
) -> list[dict]:
"""Cursor-paginated GET; raises RuntimeError with Slack's error string."""
import httpx
rows: list[dict] = []
cursor = ""
for _ in range(_MAX_PAGES):
q = {**params, "limit": page_limit}
if cursor:
q["cursor"] = cursor
resp = httpx.get(
_api_base() + method,
params=q,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
data = resp.json()
if not data.get("ok"):
raise RuntimeError(str(data.get("error") or f"{method} failed"))
rows.extend(data.get(key) or [])
cursor = (data.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
break
return rows
def _cached(team_id: str, kind: str, fetch, refresh: bool) -> list[dict[str, Any]]:
now = time.time()
hit = _CACHE.get((team_id, kind))
if hit and not refresh and now - hit[0] < _TTL:
return hit[1]
rows = fetch()
_CACHE[(team_id, kind)] = (now, rows)
return rows
def _rank(rows: list[dict], query: str, key: str, limit: int) -> list[dict]:
"""Case-insensitive substring filter; prefix matches first, then alpha."""
q = query.strip().lower()
if q:
rows = [
r for r in rows if q in r[key].lower() or q in r.get("handle", "").lower()
]
rows = sorted(
rows, key=lambda r: (not r[key].lower().startswith(q), r[key].lower())
)
return rows[: max(1, min(int(limit or 25), 100))]
def list_members(
secrets: SecretStore,
team_id: str,
query: str = "",
limit: int = 25,
*,
refresh: bool = False,
) -> dict[str, Any]:
"""Human members of the workspace: id, display name, @handle, guest flag.
Bots, deleted users, and Slackbot are filtered — they can't need allowing."""
token = _bot_token(secrets, team_id)
if not token:
return {"ok": False, "error": "workspace not connected"}
def fetch() -> list[dict[str, Any]]:
members = _get_pages(token, "users.list", {}, "members")
out = []
for m in members:
if m.get("deleted") or m.get("is_bot") or m.get("id") == "USLACKBOT":
continue
profile = m.get("profile") or {}
name = (
profile.get("display_name")
or profile.get("real_name")
or m.get("name")
or ""
)
out.append(
{
"id": m.get("id", ""),
"name": name,
"handle": m.get("name") or "",
"guest": bool(
m.get("is_restricted") or m.get("is_ultra_restricted")
),
}
)
return out
try:
rows = _cached(team_id, "members", fetch, refresh)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "members": _rank(rows, query, "name", limit)}
def list_channels(
secrets: SecretStore,
team_id: str,
query: str = "",
limit: int = 25,
*,
refresh: bool = False,
) -> dict[str, Any]:
"""Channels the token can see: all public ones, private only where the bot
is a member. `is_member` lets the GUI hint "invite @OpenWorker" for the rest."""
token = _bot_token(secrets, team_id)
if not token:
return {"ok": False, "error": "workspace not connected"}
def fetch() -> list[dict[str, Any]]:
chans = _get_pages(
token,
"conversations.list",
{"types": "public_channel,private_channel", "exclude_archived": "true"},
"channels",
page_limit=_CHANNEL_PAGE_LIMIT,
)
return [
{
"id": c.get("id", ""),
"name": c.get("name", ""),
"is_private": bool(c.get("is_private")),
"is_member": bool(c.get("is_member")),
}
for c in chans
if c.get("id") and c.get("name")
]
try:
rows = _cached(team_id, "channels", fetch, refresh)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "channels": _rank(rows, query, "name", limit)}
def clear_cache(team_id: Optional[str] = None) -> None:
"""Drop cached rosters (all teams, or one) — disconnect/reconnect hygiene."""
if team_id is None:
_CACHE.clear()
return
for key in [k for k in _CACHE if k[0] == team_id]:
del _CACHE[key]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,348 @@
"""The `send_message` outbound tool — available to every agent.
Stateless: parses the `target` token, pulls the bot token from the SecretStore at call time
(never in the model's context), and dispatches via a swappable sender registry. Permission-
gated (`requires_approval=True` → asks outside Auto mode).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Callable, Optional
import aisuite as ai
from ..secrets import SecretStore
from .base import parse_target
from .senders import DEFAULT_FILE_SENDERS, DEFAULT_SENDERS, FileSender, Sender
_SCHEMA = {
"type": "function",
"function": {
"name": "send_message",
"description": (
"Send a message to a connected chat (Slack or Telegram). `target` is the "
"reply handle from an inbound message (e.g. 'telegram:12345' or 'slack:C0123', "
"optionally with a ':<thread>' suffix) — or, for Slack, just the channel NAME "
"('#general' or 'general'; resolved against the connected workspaces). Use this to "
"actually reach a person — plain assistant text is not delivered anywhere."
),
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Destination handle 'platform:chat_id[:thread]', e.g. 'telegram:12345'.",
},
"text": {"type": "string", "description": "The message text to send."},
},
"required": ["target", "text"],
},
},
}
# Slack channel NAMES are strictly lowercase (letters/digits/[-._]); ids are uppercase
# C…/D…/G…/U… tokens. That asymmetry is the discriminator: anything lowercase (or
# #-prefixed) is a name the user said, everything else keeps the raw-address path.
_SLACK_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
def _slack_channel_name_like(chat_id: str) -> bool:
return chat_id.startswith("#") or bool(_SLACK_NAME.match(chat_id))
def _parse_or_coerce(target: str) -> tuple[str, str, Optional[str]]:
"""parse_target, but a BARE channel name ('all-openworker', '#general') coerces to
Slack — models pass what the user said, and a lowercase/#-name is Slack-shaped (owner
repro 2026-07-14: the model never invented the 'slack:' prefix on its own). Telegram
targets are numeric, so the shapes never collide."""
try:
return parse_target(target)
except ValueError:
raw = (target or "").strip()
if raw and _slack_channel_name_like(raw.lstrip("#")):
return "slack", raw, None
raise
def _resolve_slack_channel(
secrets: SecretStore, name: str
) -> tuple[Optional[str], Optional[str]]:
"""'#all-openworker' (a NAME the user said) → the team-qualified chat_id, via the
same cached conversations.list roster the GUI's channel picker uses. (chat_id, error):
exactly one match wins; none/many return an actionable error instead of a guess
(§36 — 'post Hi to <channel>' must just work when Slack is connected)."""
from .config import _slack_team_profiles
from .slack_directory import list_channels
query = name.lstrip("#").strip()
teams = [team_id for team_id, _p in _slack_team_profiles(secrets)]
if not teams and (secrets.get("slack:default") or {}).get("bot_token"):
teams = ["default"]
if not teams:
return None, "no bot token for slack — connect it first"
hits: list[tuple[str, dict]] = []
for team in teams:
r = list_channels(secrets, team, query, limit=50)
if not r.get("ok"):
continue
for c in r.get("channels") or []:
if str(c.get("name", "")).lower() == query.lower():
hits.append((team, c))
if not hits:
return None, (
f"no Slack channel named #{query} in the connected workspace"
f"{'s' if len(teams) > 1 else ''} — check the name, or pass the full "
"address (slack:C… / slack:T…/C…)"
)
if len(hits) > 1:
return None, (
f"#{query} exists in more than one connected workspace — use the full "
"address (slack:TEAM_ID/CHANNEL_ID) to pick one"
)
team, c = hits[0]
chat_id = str(c["id"]) if team == "default" else f"{team}/{c['id']}"
if not c.get("is_member"):
return None, (
f"found #{query}, but the bot isn't a member — invite @OpenWorker to #{query} "
"in Slack, then retry"
)
return chat_id, None
def _resolve_token(secrets: SecretStore, platform: str, chat_id: str) -> Optional[str]:
"""Pick the outbound token for a reply.
Managed Slack relay is multi-workspace: a team-qualified chat_id ("T…/C…")
selects that team's bot token from its `slack:team:<team_id>` profile. Manual
Socket-Mode (single workspace, bare "C…") uses `slack:default`. Non-Slack
platforms always use `<platform>:default`.
"""
if platform == "slack":
from .slack_addr import split
team, _channel = split(chat_id)
if team:
per_team = secrets.get(f"slack:team:{team}") or {}
return per_team.get("bot_token")
creds = secrets.get(f"{platform}:default") or {}
return creds.get("bot_token")
def make_send_message_tool(
secrets: SecretStore,
*,
senders: Optional[dict[str, Sender]] = None,
) -> Callable[..., Any]:
"""Build the `send_message` tool bound to a SecretStore (and optional sender registry)."""
senders = senders if senders is not None else DEFAULT_SENDERS
def send_message(target: str, text: str) -> dict[str, Any]:
try:
platform, chat_id, thread_id = _parse_or_coerce(target)
except ValueError as exc:
return {"error": str(exc)}
sender = senders.get(platform)
if sender is None:
return {"error": f"unknown platform: {platform}"}
# §36: a channel NAME resolves to its address (the user says "#general", not C0123).
if platform == "slack" and _slack_channel_name_like(chat_id):
chat_id, err = _resolve_slack_channel(secrets, chat_id)
if err:
return {"error": err}
token = _resolve_token(secrets, platform, chat_id)
if not token:
return {"error": f"no bot token for {platform} — connect it first"}
if platform == "slack":
from .attribution import sender_prefix
text = sender_prefix(secrets, chat_id) + text
result = sender(token, chat_id, text, thread_id)
if result.ok:
return {"ok": True, "message_id": result.message_id, "target": target}
return {"error": result.error or "send failed"}
send_message.__name__ = "send_message"
send_message.__doc__ = _SCHEMA["function"]["description"]
send_message.__aisuite_tool_metadata__ = ai.ToolMetadata(
name="send_message",
category="messaging",
risk_level="medium",
capabilities=["messaging"],
requires_approval=True,
)
send_message.__coworker_schema__ = _SCHEMA
return send_message
# -- send_file (§34 / UX-016) ----------------------------------------------------------
_FILE_SCHEMA = {
"type": "function",
"function": {
"name": "send_file",
"description": (
"Upload a file from the session's workspace into a connected chat (Slack). "
"`target` is the same handle send_message uses. Slack shows its own previews "
"for pdf/csv/images — send the actual file, not a screenshot of it. For .html "
"artifacts (which Slack can't preview) set as_screenshot=true to send a "
"rendered PNG instead. This is a DISTINCT permission from send_message: it "
"asks for approval even in threads where text replies are pre-approved."
),
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Destination handle 'platform:chat_id[:thread]', e.g. 'slack:C0123:171234.5678'.",
},
"path": {
"type": "string",
"description": "The file to send — workspace-relative, or absolute within an allowed folder.",
},
"title": {
"type": "string",
"description": "Display title (defaults to the filename).",
},
"comment": {
"type": "string",
"description": "Short message posted with the file.",
},
"as_screenshot": {
"type": "boolean",
"description": "HTML only: render the page headless and send a PNG preview instead of the raw file.",
},
},
"required": ["target", "path"],
},
},
}
_MAX_FILE_BYTES = 50 * 1024 * 1024 # sanity cap well under Slack's limit
def _resolve_within(path: str, bases: list[Path]) -> Optional[Path]:
"""Resolve `path` (relative → tried against each base) and require the result to live
inside one of the allowed bases. None → outside every base or nonexistent."""
candidates = []
p = Path(path).expanduser()
if p.is_absolute():
candidates.append(p)
else:
candidates.extend(base / p for base in bases)
for cand in candidates:
try:
resolved = cand.resolve(strict=True)
except OSError:
continue
for base in bases:
try:
resolved.relative_to(base.resolve())
return resolved
except ValueError:
continue
return None
def _render_html_png(path: Path) -> bytes:
"""Headless render of a local HTML artifact → viewport PNG (1280×800). Uses the
Playwright chromium we already ship for the browser connector."""
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.launch()
try:
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(path.as_uri())
page.wait_for_timeout(500) # let embedded JS (charts, tables) paint
return page.screenshot(full_page=False)
finally:
browser.close()
def make_send_file_tool(
secrets: SecretStore,
*,
workspace: Optional[Path] = None,
roots: Optional[list] = None,
file_senders: Optional[dict[str, FileSender]] = None,
render_html: Optional[Callable[[Path], bytes]] = None,
) -> Callable[..., Any]:
"""Build the `send_file` tool. Same target grammar and token resolution as
send_message, but a DIFFERENT tool name — standing send_message grants (e.g. a
mention-thread's pre-approval) never cover file uploads."""
file_senders = file_senders if file_senders is not None else DEFAULT_FILE_SENDERS
render_html = render_html or _render_html_png
bases = [Path(r.path) for r in (roots or []) if getattr(r, "path", None)]
if workspace is not None:
bases.append(Path(workspace))
def send_file(
target: str,
path: str,
title: Optional[str] = None,
comment: Optional[str] = None,
as_screenshot: bool = False,
) -> dict[str, Any]:
try:
platform, chat_id, thread_id = _parse_or_coerce(target)
except ValueError as exc:
return {"error": str(exc)}
sender = file_senders.get(platform)
if sender is None:
return {"error": f"file sending is not supported on {platform} yet"}
# §36: channel names resolve here too — same rule as send_message.
if platform == "slack" and _slack_channel_name_like(chat_id):
chat_id, err = _resolve_slack_channel(secrets, chat_id)
if err:
return {"error": err}
if not bases:
return {"error": "no workspace folders available to read from"}
resolved = _resolve_within(path, bases)
if resolved is None or not resolved.is_file():
return {
"error": "path is outside the folders this session can access (or missing)"
}
token = _resolve_token(secrets, platform, chat_id)
if not token:
return {"error": f"no bot token for {platform} — connect it first"}
if as_screenshot:
if resolved.suffix.lower() not in (".html", ".htm"):
return {"error": "as_screenshot only applies to .html files"}
try:
data = render_html(resolved)
except Exception as exc:
return {"error": f"could not render the page: {exc}"}
filename = resolved.stem + ".png"
else:
if resolved.stat().st_size > _MAX_FILE_BYTES:
return {"error": "file is larger than 50 MB"}
data = resolved.read_bytes()
filename = resolved.name
if platform == "slack" and comment:
from .attribution import sender_prefix
comment = sender_prefix(secrets, chat_id) + comment
result = sender(token, chat_id, thread_id, filename, data, title, comment)
if result.ok:
return {
"ok": True,
"file_id": result.message_id,
"target": target,
"filename": filename,
}
return {"error": result.error or "file send failed"}
send_file.__name__ = "send_file"
send_file.__doc__ = _FILE_SCHEMA["function"]["description"]
send_file.__aisuite_tool_metadata__ = ai.ToolMetadata(
name="send_file",
category="messaging",
risk_level="medium",
capabilities=["messaging", "files"],
requires_approval=True,
)
send_file.__coworker_schema__ = _FILE_SCHEMA
return send_file