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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ dist/
# Local secrets (live-smoke BYO keys) — never committed
.env
.claude/settings.local.json
.workbuddy/
debug_*.py
5 changes: 5 additions & 0 deletions coworker/connectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
parse_target,
)
from .adapters import (
DingTalkAdapter,
SlackAdapter,
TelegramAdapter,
make_adapter,
slack_event_to_event,
telegram_message_to_event,
)
from .config import ConnectorSettings, TeamAuth, is_authorized, load_settings
from .dingtalk import send_dingtalk, webhook_payload_to_event
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
Expand Down Expand Up @@ -67,12 +69,15 @@
"make_send_file_tool",
"make_send_message_tool",
"connector_for_tool",
"DingTalkAdapter",
"SlackAdapter",
"SlackRelayAdapter",
"TelegramAdapter",
"make_adapter",
"slack_event_to_event",
"telegram_message_to_event",
"send_dingtalk",
"webhook_payload_to_event",
"slack_qualify",
"slack_split",
]
16 changes: 16 additions & 0 deletions coworker/connectors/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SendResult,
SessionSource,
)
from .dingtalk import DingTalkAdapter
from .senders import _send_slack, _send_slack_interactive, _send_telegram

logger = logging.getLogger("coworker.connectors")
Expand Down Expand Up @@ -441,6 +442,21 @@ def make_adapter(
"""
if platform == "telegram" and profile.get("bot_token"):
return TelegramAdapter(profile["bot_token"])
if platform == "dingtalk":
if profile.get("client_id") and profile.get("client_secret"):
logger.info("dingtalk adapter created (stream mode)")
return DingTalkAdapter(
client_id=profile["client_id"],
client_secret=profile["client_secret"],
secrets=secrets,
)
if profile.get("webhook_url"):
logger.info("dingtalk adapter created (webhook mode)")
return DingTalkAdapter(
webhook_url=profile["webhook_url"],
secret=profile.get("secret"),
secrets=secrets,
)
if platform == "slack":
if profile.get("mode") == "relay":
if not (relay_url and token_provider):
Expand Down
9 changes: 9 additions & 0 deletions coworker/connectors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@

from __future__ import annotations

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


logger = logging.getLogger("coworker.connectors")


class MessageType(str, Enum):
TEXT = "text"
COMMAND = "command"
Expand Down Expand Up @@ -180,5 +184,10 @@ async def send(
"""Send an outbound message."""

async def handle_message(self, event: MessageEvent) -> None:
logger.info(
"adapter.handle_message platform=%s handler_set=%s",
self.platform,
self._handler is not None,
)
if self._handler is not None:
await self._handler(event)
8 changes: 8 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.",
"dingtalk": "Bring your coworker into DingTalk. Stream mode gives two-way "
"chat through an enterprise app robot with no public IP required; webhook mode "
"sends one-way notifications to a group bot.",
"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,11 @@
"Reads files shared in those channels.",
"Reads member and channel names to resolve who's talking.",
],
"dingtalk": [
"Reads @-mention messages sent to the robot (Stream mode) or group bot.",
"Posts messages back to the same conversation.",
"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
7 changes: 6 additions & 1 deletion coworker/connectors/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from ..secrets import SecretStore
from .base import SessionSource

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


@dataclass
Expand Down Expand Up @@ -76,6 +76,11 @@ def load_settings(
for platform in PLATFORMS:
profile = secrets.get(f"{platform}:default") or {}
token = profile.get("bot_token")
# DingTalk authenticates in Stream mode with client_id + client_secret,
# not a bot_token. Treat those as the credential for enablement so the
# adapter actually registers and connects at startup.
if platform == "dingtalk":
token = token or (profile.get("client_id") and profile.get("client_secret"))
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
91 changes: 91 additions & 0 deletions coworker/connectors/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,47 @@ def _validate_slack(creds: dict) -> ValidationResult:
return ValidationResult(False, error=data.get("error") or "invalid bot token")


def _validate_dingtalk(creds: dict) -> ValidationResult:
"""Validate either a Stream-mode enterprise app or a group-bot webhook."""
client_id = creds.get("client_id", "")
client_secret = creds.get("client_secret", "")
if client_id and client_secret:
try:
import dingtalk_stream
except ImportError:
return ValidationResult(
False,
error="dingtalk-stream SDK is not installed — run `pip install dingtalk-stream`",
)
try:
credential = dingtalk_stream.Credential(client_id, client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential)
token = client.get_access_token()
except Exception as exc:
return ValidationResult(False, error=str(exc))
if token:
return ValidationResult(True, identity="DingTalk enterprise bot (stream)")
return ValidationResult(False, error="invalid DingTalk client credentials")

# Fallback / dual-use: validate the group-bot webhook if provided.
from .dingtalk import send_dingtalk

webhook_url = creds.get("webhook_url", "")
if not webhook_url.startswith("https://oapi.dingtalk.com/robot/send"):
return ValidationResult(
False,
error="expected a DingTalk group-bot webhook URL, or Client ID + Client Secret for stream mode",
)
secret = creds.get("secret") or None
try:
result = send_dingtalk(webhook_url, "OpenWorker connection test", secret=secret)
except Exception as exc:
return ValidationResult(False, error=str(exc))
if result.ok:
return ValidationResult(True, identity="DingTalk group bot")
return ValidationResult(False, error=result.error or "invalid DingTalk webhook")


def _validate_whoami(
method: str,
url: str,
Expand Down Expand Up @@ -488,6 +529,56 @@ def _validate_outlook(creds: dict) -> ValidationResult:
],
validate=_validate_slack,
),
ConnectorDescriptor(
name="dingtalk",
title="DingTalk",
icon="💬",
blurb="Send notifications to a DingTalk group bot, or hold two-way conversations with an enterprise Stream-mode robot.",
auth="webhook",
two_way=True,
channels=True,
brand_color="#3370ff",
logo="dingtalk",
fields=[
Field(
"client_id",
"Client ID (AppKey)",
secret=True,
required=False,
help="Enterprise app Client ID for Stream mode (two-way, no public IP needed).",
placeholder="dingxxxxxxxxxxxx",
),
Field(
"client_secret",
"Client Secret (AppSecret)",
secret=True,
required=False,
help="Enterprise app Client Secret for Stream mode.",
),
Field(
"webhook_url",
"Webhook URL",
secret=True,
required=False,
help="Group-bot webhook for outbound-only notifications. Ignored when Client ID + Client Secret are provided.",
placeholder="https://oapi.dingtalk.com/robot/send?access_token=...",
),
Field(
"secret",
"Secret",
secret=True,
required=False,
help="Optional signing secret for the group-bot webhook.",
),
_ALLOWED_FIELD,
],
instructions=[
"For two-way chat (recommended): open https://open-dev.dingtalk.com → create an enterprise-internal app → add a robot → set message-receiving mode to Stream. Copy the app's Client ID and Client Secret below.",
"For outbound-only notifications: open a DingTalk group → Group Settings → Group Assistant → Add Robot → Custom, enable signing if desired, and copy the webhook URL.",
"If both sets of credentials are provided, Stream mode takes precedence.",
],
validate=_validate_dingtalk,
),
ConnectorDescriptor(
name="email",
title="Email (IMAP)",
Expand Down
Loading