feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: 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:
78
coworker/connectors/__init__.py
Normal file
78
coworker/connectors/__init__.py
Normal 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",
|
||||
]
|
||||
184
coworker/connectors/accounts.py
Normal file
184
coworker/connectors/accounts.py
Normal 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)
|
||||
]
|
||||
480
coworker/connectors/adapters.py
Normal file
480
coworker/connectors/adapters.py
Normal 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
|
||||
72
coworker/connectors/attribution.py
Normal file
72
coworker/connectors/attribution.py
Normal 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
184
coworker/connectors/base.py
Normal 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)
|
||||
620
coworker/connectors/browser_automation.py
Normal file
620
coworker/connectors/browser_automation.py
Normal 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
|
||||
219
coworker/connectors/catalog_copy.py
Normal file
219
coworker/connectors/catalog_copy.py
Normal 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
110
coworker/connectors/cli.py
Normal 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())
|
||||
137
coworker/connectors/config.py
Normal file
137
coworker/connectors/config.py
Normal 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]
|
||||
1470
coworker/connectors/descriptors.py
Normal file
1470
coworker/connectors/descriptors.py
Normal file
File diff suppressed because it is too large
Load Diff
843
coworker/connectors/email_tools.py
Normal file
843
coworker/connectors/email_tools.py
Normal 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 (
|
||||
(" ", " "),
|
||||
("&", "&"),
|
||||
("<", "<"),
|
||||
(">", ">"),
|
||||
(""", '"'),
|
||||
("'", "'"),
|
||||
):
|
||||
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, ""
|
||||
18
coworker/connectors/experimental/__init__.py
Normal file
18
coworker/connectors/experimental/__init__.py
Normal 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] = []
|
||||
57
coworker/connectors/fake.py
Normal file
57
coworker/connectors/fake.py
Normal 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}")
|
||||
)
|
||||
231
coworker/connectors/gateway.py
Normal file
231
coworker/connectors/gateway.py
Normal 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
|
||||
127
coworker/connectors/gcal_accounts.py
Normal file
127
coworker/connectors/gcal_accounts.py
Normal 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)}
|
||||
124
coworker/connectors/github_installs.py
Normal file
124
coworker/connectors/github_installs.py
Normal 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)}
|
||||
202
coworker/connectors/github_relay.py
Normal file
202
coworker/connectors/github_relay.py
Normal 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", "")))
|
||||
185
coworker/connectors/gmail_accounts.py
Normal file
185
coworker/connectors/gmail_accounts.py
Normal 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
|
||||
190
coworker/connectors/hubspot_portals.py
Normal file
190
coworker/connectors/hubspot_portals.py
Normal 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
|
||||
4894
coworker/connectors/integration_tools.py
Normal file
4894
coworker/connectors/integration_tools.py
Normal file
File diff suppressed because it is too large
Load Diff
91
coworker/connectors/parked.py
Normal file
91
coworker/connectors/parked.py
Normal 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
|
||||
531
coworker/connectors/relay_client.py
Normal file
531
coworker/connectors/relay_client.py
Normal 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
|
||||
216
coworker/connectors/senders.py
Normal file
216
coworker/connectors/senders.py
Normal 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,
|
||||
}
|
||||
524
coworker/connectors/setup.py
Normal file
524
coworker/connectors/setup.py
Normal 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}
|
||||
34
coworker/connectors/slack_addr.py
Normal file
34
coworker/connectors/slack_addr.py
Normal 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
|
||||
197
coworker/connectors/slack_directory.py
Normal file
197
coworker/connectors/slack_directory.py
Normal 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]
|
||||
1195
coworker/connectors/tool_defs.py
Normal file
1195
coworker/connectors/tool_defs.py
Normal file
File diff suppressed because it is too large
Load Diff
348
coworker/connectors/tools.py
Normal file
348
coworker/connectors/tools.py
Normal 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
|
||||
Reference in New Issue
Block a user