Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ __pycache__/
build/
dist/
.coverage
openspec/
7 changes: 7 additions & 0 deletions coworker/connectors/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,13 @@ def make_adapter(
)
if profile.get("bot_token") and profile.get("app_token"):
return SlackAdapter(profile["bot_token"], profile["app_token"])
if platform == "matrix" and profile.get("access_token"):
from ..secrets import state_dir
from .matrix_adapter import MatrixAdapter
from .matrix_settings import MatrixSettings

settings = MatrixSettings.from_profile(profile)
return MatrixAdapter(settings, store_path=state_dir() / "matrix" / "store")
if platform == "github" and profile.get("mode") == "relay":
if not (relay_url and token_provider):
logger.warning(
Expand Down
59 changes: 55 additions & 4 deletions coworker/connectors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@

from __future__ import annotations

import base64
import re
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable, Optional

_MATRIX_TARGET_RE = re.compile(
r"^matrix/([^/]+)(?:/thread/([^/]+))?$"
)


class MessageType(str, Enum):
TEXT = "text"
Expand All @@ -21,17 +27,57 @@ class MessageType(str, Enum):


# -- target tokens -------------------------------------------------------------
def encode_matrix_target(
room_id: str, thread_id: Optional[str] = None
) -> str:
"""URL-safe base64 room (and optional thread) for Matrix reply handles."""
enc = base64.urlsafe_b64encode(room_id.encode()).decode().rstrip("=")
if not thread_id:
return f"matrix/{enc}"
tenc = base64.urlsafe_b64encode(thread_id.encode()).decode().rstrip("=")
return f"matrix/{enc}/thread/{tenc}"


def decode_matrix_target(target: str) -> tuple[str, Optional[str]]:
"""`matrix/<b64room>[/thread/<b64thread>]` -> (room_id, thread_id)."""
m = _MATRIX_TARGET_RE.match((target or "").strip())
if not m:
raise ValueError(
f"invalid matrix target {target!r} "
"(expected 'matrix/<b64room>[/thread/<b64thread>]')"
)
room_b64, thread_b64 = m.group(1), m.group(2)

def _dec(part: str) -> str:
pad = "=" * (-len(part) % 4)
try:
return base64.urlsafe_b64decode(part + pad).decode()
except Exception as exc:
raise ValueError(f"invalid matrix target encoding in {target!r}") from exc

room_id = _dec(room_b64)
thread_id = _dec(thread_b64) if thread_b64 else None
return room_id, thread_id


def format_target(platform: str, chat_id: str, thread_id: Optional[str] = None) -> str:
if platform == "matrix":
return encode_matrix_target(chat_id, thread_id)
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(":")
"""`'platform:chat_id[:thread]'` or `matrix/<b64room>[/thread/<b64thread>]` -> triple."""
raw = (target or "").strip()
if raw.startswith("matrix/"):
room_id, thread_id = decode_matrix_target(raw)
return "matrix", room_id, thread_id
parts = raw.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]')"
f"invalid target {target!r} (expected 'platform:chat_id[:thread]' "
"or 'matrix/<b64room>[/thread/<b64thread>]')"
)
thread = ":".join(parts[2:]) if len(parts) > 2 else None
return parts[0], parts[1], (thread or None)
Expand Down Expand Up @@ -95,6 +141,8 @@ class MessageEvent:
# 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
# When set, delivered to the agent instead of `tagged_text()` — e.g. multimodal parts.
agent_content: Any = None

def tagged_text(self) -> str:
"""How the message enters the super-agent thread: source + reply handle + text.
Expand All @@ -119,10 +167,11 @@ class SendResult:

@dataclass
class InteractionEvent:
"""A button click on an interactive prompt.
"""A button click or emoji reaction 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.
Matrix reactions set `interaction_kind="reaction"` and `reaction_key` to the emoji.
"""

platform: str
Expand All @@ -133,6 +182,8 @@ class InteractionEvent:
user_name: Optional[str] = None
team_id: Optional[str] = None
response_url: Optional[str] = None
interaction_kind: str = "button" # "button" | "reaction"
reaction_key: Optional[str] = None


InteractionHandler = Callable[[InteractionEvent], Awaitable[None]]
Expand Down
10 changes: 10 additions & 0 deletions coworker/connectors/catalog_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"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.",
"matrix": "Chat with your coworker over Matrix (Element) on your own "
"homeserver. End-to-end encrypted rooms are supported; approve Inbox "
"requests with emoji reactions on mirrored prompts.",
"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.",
Expand Down Expand Up @@ -70,6 +73,13 @@
"Reads files shared in those channels.",
"Reads member and channel names to resolve who's talking.",
],
"matrix": [
"Reads messages in rooms the bot has joined (E2EE when enabled).",
"Sends encrypted messages and uploads files as the bot user.",
"Downloads media you share in those rooms (size-capped).",
"Inbox approvals resolve via emoji reactions on mirrored prompts.",
"Only senders on your allow-list are answered.",
],
"email": [
"Reads and searches mail over IMAP.",
"Sends mail as your address, and saves attachments locally.",
Expand Down
18 changes: 16 additions & 2 deletions coworker/connectors/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@

import os
from dataclasses import dataclass, field
from typing import Optional
from typing import Any, Optional

from ..secrets import SecretStore
from .base import SessionSource

PLATFORMS = ("telegram", "slack", "github")
PLATFORMS = ("telegram", "slack", "matrix", "github")


@dataclass
Expand Down Expand Up @@ -62,6 +62,18 @@ def _csv(value: Optional[str]) -> set[str]:
return {p.strip() for p in (value or "").split(",") if p.strip()}


def _profile_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return [p.strip() for p in str(value).split(",") if p.strip()]


def _profile_set(value: Any) -> set[str]:
return set(_profile_list(value))


def load_settings(
secrets: Optional[SecretStore] = None,
) -> dict[str, ConnectorSettings]:
Expand All @@ -76,6 +88,8 @@ def load_settings(
for platform in PLATFORMS:
profile = secrets.get(f"{platform}:default") or {}
token = profile.get("bot_token")
if platform == "matrix":
token = profile.get("access_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(
Expand Down
80 changes: 80 additions & 0 deletions coworker/connectors/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ def _validate_email(creds: dict) -> ValidationResult:
return ValidationResult(ok, identity=identity or None, error=error or None)


def _validate_matrix(creds: dict) -> ValidationResult:
import httpx

base = str(creds.get("homeserver_url") or "").rstrip("/")
token = creds.get("access_token", "")
if not base or not token:
return ValidationResult(False, error="homeserver_url and access_token required")
try:
resp = httpx.get(
f"{base}/_matrix/client/v3/account/whoami",
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
data = resp.json()
except Exception as exc:
return ValidationResult(False, error=str(exc))
if resp.status_code >= 400:
detail = (
(data.get("errcode") or data.get("error")) if isinstance(data, dict) else None
)
return ValidationResult(False, error=str(detail or f"HTTP {resp.status_code}"))
user_id = data.get("user_id") if isinstance(data, dict) else None
return ValidationResult(True, identity=str(user_id or "matrix user"))


def _validate_slack(creds: dict) -> ValidationResult:
import httpx

Expand Down Expand Up @@ -488,6 +513,61 @@ def _validate_outlook(creds: dict) -> ValidationResult:
],
validate=_validate_slack,
),
ConnectorDescriptor(
name="matrix",
title="Matrix",
icon="⬡",
blurb="Two-way encrypted messaging on your Synapse or Element Server Suite homeserver.",
auth="token",
two_way=True,
channels=True,
brand_color="#0dbd8b",
logo="matrix",
fields=[
Field(
"homeserver_url",
"Homeserver URL",
help="Your Synapse or ESS base URL, e.g. https://matrix.example.org",
placeholder="https://matrix.example.org",
),
Field(
"access_token",
"Access token",
secret=True,
help="Bot user access token (from Element → Settings → Help & About).",
),
Field(
"recovery_key",
"Recovery key",
secret=True,
help="Required for E2EE cross-signing bootstrap on encrypted rooms.",
),
Field(
"user_id",
"User ID",
required=False,
help="Optional @user:server — filled automatically on connect.",
placeholder="@bot:example.org",
),
_ALLOWED_FIELD,
Field(
"allowed_rooms",
"Allowed room IDs",
required=False,
help="Comma-separated room IDs. Empty = all joined rooms.",
placeholder="!abc:example.org",
),
],
instructions=[
"Create a dedicated bot user on your Synapse or ESS homeserver.",
"Sign in with Element, open Settings → Help & About → Access Token.",
"Export your security recovery key (Settings → Security & Privacy) — required for E2EE.",
"Install libolm on this machine: brew install libolm (macOS) or apt install libolm-dev (Linux).",
"Invite the bot to encrypted rooms; it auto-accepts invites.",
"After connecting, message the bot once, then use Capture to grab your Matrix user ID.",
],
validate=_validate_matrix,
),
ConnectorDescriptor(
name="email",
title="Email (IMAP)",
Expand Down
Loading