mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat(slack): finalize secure Events API integration with no-skip R124/R125 gates and docs/roadmap sync
This commit is contained in:
@@ -9,7 +9,7 @@ ComfyUI-OpenClaw is a **security-first orchestration layer** for ComfyUI that co
|
||||
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
|
||||
- **Public-ready control-plane split architecture** (embedded UX + externalized high-risk control surfaces)
|
||||
- **Verification-first hardening lanes** (route drift, real-backend E2E, adversarial fuzz/mutation gates)
|
||||
- **Now supports major messaging platforms, including Discord, Telegram, WhatsApp, LINE, WeChat and KakaoTalk.**
|
||||
- **Now supports major messaging platforms, including Discord, Telegram, WhatsApp, LINE, WeChat, KakaoTalk, and Slack.**
|
||||
- **And more exciting features being added continuously**
|
||||
|
||||
---
|
||||
@@ -50,6 +50,18 @@ Deployment profiles and hardening checklists:
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes</strong></summary>
|
||||
|
||||
- Completed Slack implementation hardening chain with full SOP validation:
|
||||
- added Slack Events API adapter with signed ingress checks, replay/dedupe handling, bot-loop suppression, allowlist enforcement, and thread-aware reply delivery
|
||||
- wired Slack runtime policy into existing connector authorization boundaries so command trust behavior stays consistent with other platforms
|
||||
- added dedicated Slack verification lanes for ingress contract coverage and real-backend flow parity, both enforced by skip-policy and full-test scripts
|
||||
- synchronized verification evidence through detect-secrets, pre-commit, backend unit + real lanes, adversarial gate, and frontend E2E full pass
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Post-Wave E closeout: Hardening chain completed</strong></summary>
|
||||
|
||||
- Completed on 2026-02 with full SOP validation:
|
||||
|
||||
+20
-1
@@ -12,6 +12,7 @@ from .openclaw_client import OpenClawClient
|
||||
from .platforms.discord_gateway import DiscordGateway
|
||||
from .platforms.kakao_webhook import KakaoWebhookServer
|
||||
from .platforms.line_webhook import LINEWebhookServer
|
||||
from .platforms.slack_webhook import SlackWebhookServer
|
||||
from .platforms.telegram_polling import TelegramPolling
|
||||
from .platforms.wechat_webhook import WeChatWebhookServer
|
||||
from .platforms.whatsapp_webhook import WhatsAppWebhookServer
|
||||
@@ -42,6 +43,7 @@ def _print_security_banner(config):
|
||||
or config.whatsapp_allowed_users
|
||||
or config.wechat_allowed_users
|
||||
or config.kakao_allowed_users
|
||||
or config.slack_allowed_users
|
||||
)
|
||||
has_admins = bool(config.admin_users)
|
||||
|
||||
@@ -104,6 +106,7 @@ async def main():
|
||||
whatsapp_server = None
|
||||
wechat_server = None
|
||||
kakao_server = None
|
||||
slack_server = None
|
||||
|
||||
# 3. Platforms
|
||||
if config.telegram_bot_token:
|
||||
@@ -171,15 +174,29 @@ async def main():
|
||||
else:
|
||||
logger.info("Kakao adapter disabled.")
|
||||
|
||||
if config.slack_bot_token and config.slack_signing_secret:
|
||||
slack_server = SlackWebhookServer(config, router)
|
||||
platforms["slack"] = slack_server
|
||||
await slack_server.start()
|
||||
if not tasks:
|
||||
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
|
||||
elif config.slack_bot_token:
|
||||
logger.warning("Slack configured but Signing Secret missing. Skipping.")
|
||||
else:
|
||||
logger.info("Slack not configured (OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN missing)")
|
||||
|
||||
if (
|
||||
not tasks
|
||||
and not line_server
|
||||
and not whatsapp_server
|
||||
and not wechat_server
|
||||
and not kakao_server
|
||||
and not slack_server
|
||||
):
|
||||
logger.error(
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN or KAKAO_ENABLED."
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, "
|
||||
"LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN, "
|
||||
"KAKAO_ENABLED or SLACK_BOT_TOKEN."
|
||||
)
|
||||
await client.close()
|
||||
return
|
||||
@@ -206,6 +223,8 @@ async def main():
|
||||
await wechat_server.stop()
|
||||
if kakao_server:
|
||||
await kakao_server.stop()
|
||||
if slack_server:
|
||||
await slack_server.stop()
|
||||
if poller:
|
||||
await poller.stop()
|
||||
await client.close()
|
||||
|
||||
@@ -90,6 +90,17 @@ class ConnectorConfig:
|
||||
kakao_webhook_path: str = "/kakao/webhook"
|
||||
kakao_allowed_users: List[str] = field(default_factory=list)
|
||||
|
||||
# Slack (F56 / S67)
|
||||
slack_bot_token: Optional[str] = None
|
||||
slack_signing_secret: Optional[str] = None
|
||||
slack_allowed_users: List[str] = field(default_factory=list)
|
||||
slack_allowed_channels: List[str] = field(default_factory=list)
|
||||
slack_bind_host: str = "127.0.0.1"
|
||||
slack_bind_port: int = 8095
|
||||
slack_webhook_path: str = "/slack/events"
|
||||
slack_require_mention: bool = True
|
||||
slack_reply_in_thread: bool = True
|
||||
|
||||
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
|
||||
admin_users: List[str] = field(default_factory=list)
|
||||
|
||||
@@ -231,6 +242,31 @@ def load_config() -> ConnectorConfig:
|
||||
if ku := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"):
|
||||
cfg.kakao_allowed_users = [u.strip() for u in ku.split(",") if u.strip()]
|
||||
|
||||
# Slack (F56 / S67)
|
||||
cfg.slack_bot_token = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN")
|
||||
cfg.slack_signing_secret = os.environ.get("OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET")
|
||||
if su := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS"):
|
||||
cfg.slack_allowed_users = [u.strip() for u in su.split(",") if u.strip()]
|
||||
if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"):
|
||||
cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()]
|
||||
cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1")
|
||||
if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"):
|
||||
if sp.isdigit():
|
||||
cfg.slack_bind_port = int(sp)
|
||||
cfg.slack_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events"
|
||||
)
|
||||
if (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION", "").lower()
|
||||
== "false"
|
||||
):
|
||||
cfg.slack_require_mention = False
|
||||
if (
|
||||
os.environ.get("OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD", "").lower()
|
||||
== "false"
|
||||
):
|
||||
cfg.slack_reply_in_thread = False
|
||||
|
||||
# Admin
|
||||
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
|
||||
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Slack Events API Webhook Adapter (F56).
|
||||
|
||||
Implements:
|
||||
- Events API POST ingress with ``url_verification`` challenge response.
|
||||
- Slack request authenticity via ``X-Slack-Signature`` + ``X-Slack-Request-Timestamp``
|
||||
(``v0:{ts}:{raw_body}`` HMAC-SHA256).
|
||||
- Replay / duplicate guard (event_id + timestamp window).
|
||||
- ``message`` / ``app_mention`` event normalization with de-duplication
|
||||
(avoid double-trigger when bot is mentioned in a regular message).
|
||||
- CommandRequest conversion -> CommandRouter.
|
||||
- Slack Web API thread or channel reply.
|
||||
|
||||
S67 Safety Profile:
|
||||
- AllowlistPolicy for users and channels (fail-closed when configured).
|
||||
- Bot-loop prevention (ignore messages from bot itself).
|
||||
- Rate-limit delegation to CommandRouter (R80 authz + F32 rate limiter).
|
||||
- Require-mention policy for group conversations.
|
||||
|
||||
Setup:
|
||||
1. Create a Slack App at https://api.slack.com/apps.
|
||||
2. Enable Events API; set Request URL to ``https://<host>/slack/events``.
|
||||
3. Subscribe to ``message.channels``, ``message.groups``, ``message.im``,
|
||||
``app_mention`` bot events.
|
||||
4. Install app to workspace; copy Bot Token and Signing Secret.
|
||||
5. Set env vars:
|
||||
- ``OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN``
|
||||
- ``OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET``
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..contract import CommandRequest, CommandResponse
|
||||
from ..router import CommandRouter
|
||||
from ..security_profile import AllowlistPolicy, ReplayGuard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -- aiohttp compat layer (same pattern as kakao/whatsapp/wechat) -----------
|
||||
|
||||
|
||||
def _import_aiohttp_web():
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
except ModuleNotFoundError:
|
||||
return None, None
|
||||
return aiohttp, web
|
||||
|
||||
|
||||
class _CompatResponse:
|
||||
"""Minimal response shim for unit tests when aiohttp is unavailable."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
text: str = "",
|
||||
content_type: str = "text/plain",
|
||||
body: Optional[bytes] = None,
|
||||
):
|
||||
self.status = status
|
||||
self.text = text
|
||||
self.content_type = content_type
|
||||
self.body = body if body is not None else text.encode("utf-8")
|
||||
|
||||
|
||||
def _make_response(web_mod, *, status: int = 200, text: str = "OK"):
|
||||
if web_mod is not None:
|
||||
return web_mod.Response(status=status, text=text)
|
||||
return _CompatResponse(status=status, text=text)
|
||||
|
||||
|
||||
def _make_json_response(web_mod, data: dict, *, status: int = 200):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
if web_mod is not None:
|
||||
return web_mod.json_response(data, status=status)
|
||||
return _CompatResponse(
|
||||
status=status,
|
||||
text=body.decode("utf-8"),
|
||||
content_type="application/json",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
# -- Slack signature verification -------------------------------------------
|
||||
|
||||
# Maximum acceptable clock skew for timestamp validation (5 minutes).
|
||||
SLACK_TIMESTAMP_MAX_DRIFT_SEC = 300
|
||||
SLACK_SIGNING_VERSION = "v0"
|
||||
|
||||
|
||||
def verify_slack_signature(
|
||||
*,
|
||||
signing_secret: str,
|
||||
timestamp: str,
|
||||
body: bytes,
|
||||
signature: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify Slack ``X-Slack-Signature`` using ``v0:{ts}:{body}`` HMAC-SHA256.
|
||||
|
||||
Fail-closed: returns False on any missing/invalid input.
|
||||
"""
|
||||
if not signing_secret or not timestamp or not signature:
|
||||
return False
|
||||
|
||||
# Timestamp freshness check
|
||||
try:
|
||||
ts_int = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if abs(time.time() - ts_int) > SLACK_TIMESTAMP_MAX_DRIFT_SEC:
|
||||
return False
|
||||
|
||||
# Compute expected signature
|
||||
sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
|
||||
expected = (
|
||||
SLACK_SIGNING_VERSION
|
||||
+ "="
|
||||
+ hmac.new(
|
||||
signing_secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# -- Slack adapter ----------------------------------------------------------
|
||||
|
||||
|
||||
class SlackWebhookServer:
|
||||
"""
|
||||
F56 -- Slack Events API adapter.
|
||||
|
||||
Security invariants (S67 / R124):
|
||||
- CRITICAL: Reject unsigned or replay requests (fail-closed).
|
||||
- CRITICAL: Ignore bot's own messages (bot-loop prevention).
|
||||
- IMPORTANT: Deduplicate ``message`` + ``app_mention`` for the same event
|
||||
to prevent double command execution.
|
||||
- IMPORTANT: Respect ``require_mention`` policy for group channels.
|
||||
"""
|
||||
|
||||
REPLAY_WINDOW_SEC = 300
|
||||
NONCE_CACHE_SIZE = 5000
|
||||
|
||||
def __init__(self, config: ConnectorConfig, router: CommandRouter):
|
||||
self.config = config
|
||||
self.router = router
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
# S67: Replay / dedupe guard keyed by Slack event_id
|
||||
self._replay_guard = ReplayGuard(
|
||||
window_sec=self.REPLAY_WINDOW_SEC,
|
||||
max_entries=self.NONCE_CACHE_SIZE,
|
||||
)
|
||||
|
||||
# S67: Allowlists (fail-closed when configured)
|
||||
self._user_allowlist = AllowlistPolicy(config.slack_allowed_users, strict=False)
|
||||
self._channel_allowlist = AllowlistPolicy(
|
||||
config.slack_allowed_channels, strict=False
|
||||
)
|
||||
|
||||
# Bot user ID (resolved on first event or set from config)
|
||||
self._bot_user_id: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self):
|
||||
aiohttp, web = _import_aiohttp_web()
|
||||
if aiohttp is None or web is None:
|
||||
logger.warning("aiohttp not installed. Skipping Slack adapter.")
|
||||
return
|
||||
|
||||
if not self.config.slack_bot_token or not self.config.slack_signing_secret:
|
||||
logger.info(
|
||||
"Slack adapter disabled "
|
||||
"(OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN or "
|
||||
"OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET missing)"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting Slack Webhook on "
|
||||
f"{self.config.slack_bind_host}:{self.config.slack_bind_port}"
|
||||
f"{self.config.slack_webhook_path}"
|
||||
)
|
||||
|
||||
self.app = web.Application()
|
||||
self.app.router.add_post(self.config.slack_webhook_path, self.handle_event)
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(
|
||||
self.runner, self.config.slack_bind_host, self.config.slack_bind_port
|
||||
)
|
||||
await self.site.start()
|
||||
|
||||
async def stop(self):
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def handle_event(self, request):
|
||||
"""POST handler for Slack Events API."""
|
||||
_, web = _import_aiohttp_web()
|
||||
|
||||
try:
|
||||
body_bytes = await request.read()
|
||||
except Exception:
|
||||
return _make_response(web, status=400, text="Bad request")
|
||||
|
||||
# -- Step 1: Signature verification (fail-closed) --
|
||||
timestamp = ""
|
||||
signature = ""
|
||||
if hasattr(request, "headers"):
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||
signature = request.headers.get("X-Slack-Signature", "")
|
||||
|
||||
if not verify_slack_signature(
|
||||
signing_secret=self.config.slack_signing_secret or "",
|
||||
timestamp=timestamp,
|
||||
body=body_bytes,
|
||||
signature=signature,
|
||||
):
|
||||
logger.warning("Slack signature verification failed (rejected)")
|
||||
return _make_response(web, status=401, text="Invalid signature")
|
||||
|
||||
# -- Step 2: Parse payload --
|
||||
try:
|
||||
payload = json.loads(body_bytes)
|
||||
except json.JSONDecodeError:
|
||||
return _make_response(web, status=400, text="Bad JSON")
|
||||
|
||||
# -- Step 3: url_verification challenge --
|
||||
if payload.get("type") == "url_verification":
|
||||
challenge = payload.get("challenge", "")
|
||||
return _make_json_response(web, {"challenge": challenge})
|
||||
|
||||
# -- Step 4: event_callback processing --
|
||||
if payload.get("type") != "event_callback":
|
||||
# Ignore unknown payload types gracefully
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
event = payload.get("event", {})
|
||||
event_id = payload.get("event_id", "")
|
||||
event_type = event.get("type", "")
|
||||
|
||||
# -- Step 5: Replay / dedupe guard --
|
||||
if not event_id:
|
||||
logger.warning("Slack event missing event_id (rejected)")
|
||||
return _make_response(web, status=400, text="Missing event_id")
|
||||
|
||||
if not self._replay_guard.check_and_record(event_id):
|
||||
logger.debug(f"Slack duplicate event_id={event_id} (accepted, no-op)")
|
||||
# Return 200 to acknowledge; Slack will retry on non-2xx.
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# -- Step 6: Bot-loop prevention --
|
||||
# Resolve bot user ID from authorizations or cache
|
||||
if self._bot_user_id is None:
|
||||
auths = payload.get("authorizations", [])
|
||||
if auths and isinstance(auths, list):
|
||||
self._bot_user_id = auths[0].get("user_id", "")
|
||||
|
||||
sender_id = event.get("user", "")
|
||||
if sender_id and sender_id == self._bot_user_id:
|
||||
# Ignore bot's own messages
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# Also detect bot_id field (Slack webhook integrations)
|
||||
if event.get("bot_id"):
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# Ignore message subtypes that are not user messages
|
||||
subtype = event.get("subtype", "")
|
||||
if subtype and subtype not in ("", "file_share"):
|
||||
# e.g. message_changed, message_deleted, bot_message, etc.
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# -- Step 7: Event normalization --
|
||||
# Supported events: message, app_mention
|
||||
# De-duplication: if event is app_mention AND the same content would
|
||||
# also arrive as a message event, use event_id to dedupe (already done
|
||||
# in step 5 via replay guard). Also, for group channels with
|
||||
# require_mention, we only process app_mention events (skip plain
|
||||
# messages that don't mention the bot).
|
||||
text = event.get("text", "").strip()
|
||||
channel_id = event.get("channel", "")
|
||||
thread_ts = event.get("thread_ts", "")
|
||||
message_ts = event.get("ts", "")
|
||||
|
||||
if event_type not in ("message", "app_mention"):
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
if not text or not sender_id:
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# S67: Require-mention policy for group channels
|
||||
# Channel types: C=public, G=private group, D=DM, "im"
|
||||
is_dm = channel_id.startswith("D")
|
||||
if not is_dm and self.config.slack_require_mention:
|
||||
# In group channels, only process app_mention events
|
||||
# or messages that explicitly mention the bot
|
||||
if event_type != "app_mention":
|
||||
if self._bot_user_id and f"<@{self._bot_user_id}>" not in text:
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# Strip bot mention from text for cleaner command parsing
|
||||
if self._bot_user_id:
|
||||
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
|
||||
|
||||
# -- Step 8: Allowlist checks (S67) --
|
||||
if self._user_allowlist.entries:
|
||||
user_result = self._user_allowlist.evaluate(sender_id)
|
||||
if user_result.decision == "deny":
|
||||
logger.warning(f"Slack user {sender_id} denied by allowlist")
|
||||
# Don't leak auth info; return 200 but silently drop
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
if self._channel_allowlist.entries and channel_id:
|
||||
chan_result = self._channel_allowlist.evaluate(channel_id)
|
||||
if chan_result.decision == "deny":
|
||||
logger.warning(f"Slack channel {channel_id} denied by allowlist")
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# -- Step 9: Build CommandRequest and route --
|
||||
req = CommandRequest(
|
||||
platform="slack",
|
||||
sender_id=sender_id,
|
||||
channel_id=channel_id,
|
||||
username=sender_id, # Slack doesn't include username in events
|
||||
message_id=event_id,
|
||||
text=text,
|
||||
timestamp=float(message_ts) if message_ts else time.time(),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.router.handle(req)
|
||||
resp_text = getattr(resp, "text", "")
|
||||
if not isinstance(resp_text, str):
|
||||
resp_text = str(resp_text) if resp_text is not None else ""
|
||||
|
||||
if resp_text:
|
||||
await self._send_reply(
|
||||
channel_id=channel_id,
|
||||
text=resp_text,
|
||||
thread_ts=thread_ts
|
||||
or (message_ts if self.config.slack_reply_in_thread else ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling Slack event: {e}")
|
||||
|
||||
# Always return 200 to Slack to prevent retries
|
||||
return _make_response(web, status=200, text="OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slack Web API reply
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_reply(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
thread_ts: str = "",
|
||||
) -> None:
|
||||
"""Send a message via Slack Web API (chat.postMessage)."""
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
logger.warning("aiohttp not available; cannot send Slack reply")
|
||||
return
|
||||
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.slack_bot_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"text": text,
|
||||
}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
|
||||
try:
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=_aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
logger.warning(
|
||||
f"Slack API error: status={resp.status} body={body[:200]}"
|
||||
)
|
||||
else:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
logger.warning(
|
||||
f"Slack API error: {data.get('error', 'unknown')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack reply failed: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Platform contract: send_message / send_image
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message(self, channel_id: str, text: str):
|
||||
"""Platform contract: send text message."""
|
||||
await self._send_reply(channel_id=channel_id, text=text)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
channel_id: str,
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
):
|
||||
"""Platform contract: send image (Slack files.upload)."""
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
logger.warning("aiohttp not available; cannot upload Slack image")
|
||||
return
|
||||
|
||||
url = "https://slack.com/api/files.upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.slack_bot_token}",
|
||||
}
|
||||
data = _aiohttp.FormData()
|
||||
data.add_field("file", image_data, filename=filename, content_type="image/png")
|
||||
data.add_field("channels", channel_id)
|
||||
if caption:
|
||||
data.add_field("initial_comment", caption)
|
||||
|
||||
try:
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(f"Slack file upload error: status={resp.status}")
|
||||
else:
|
||||
resp_data = await resp.json()
|
||||
if not resp_data.get("ok"):
|
||||
logger.warning(
|
||||
f"Slack file upload error: {resp_data.get('error')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack image upload failed: {e}")
|
||||
@@ -277,6 +277,13 @@ class CommandRouter:
|
||||
return True
|
||||
return False
|
||||
|
||||
if platform == "slack":
|
||||
if sender_id in self.config.slack_allowed_users:
|
||||
return True
|
||||
if channel_id in self.config.slack_allowed_channels:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Unknown platform: trust only admins
|
||||
return False
|
||||
|
||||
|
||||
+69
-27
@@ -13,10 +13,10 @@ The connector runs alongside ComfyUI on your machine.
|
||||
|
||||
**Security**:
|
||||
|
||||
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk require inbound HTTPS webhook endpoints.
|
||||
- **Allowlist**: Only users/chats you explicitly allow can send commands.
|
||||
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints.
|
||||
- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
|
||||
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
|
||||
- **Admin Boundary**: Control-plane actions call admin endpoints on the local ComfyUI instance. See `OPENCLAW_CONNECTOR_ADMIN_TOKEN` below.
|
||||
- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
@@ -48,8 +48,9 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
|
||||
**Admin token behavior:**
|
||||
|
||||
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to the same value or admin calls will return HTTP 403.
|
||||
- If the OpenClaw server is in loopback-only convenience mode (no Admin Token configured), the connector can still call admin endpoints via `localhost` without sending a token.
|
||||
- Connector admin command paths require `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to be set in connector runtime.
|
||||
- If the OpenClaw server has `OPENCLAW_ADMIN_TOKEN` configured, `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match it or admin calls return HTTP 403.
|
||||
- Without `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, admin command flows (`/approve`, `/reject`, `/trace`, schedules) are blocked by connector policy before upstream calls.
|
||||
|
||||
**Telegram:**
|
||||
|
||||
@@ -326,40 +327,73 @@ Kakao i Open Builder sends webhook requests to your connector Skill endpoint. Yo
|
||||
|
||||
#### Slack Webhook Setup (Detailed)
|
||||
|
||||
Slack Events API pushes JSON payloads to your connector. You must expose the endpoint publicly over HTTPS.
|
||||
Slack uses the Events API webhook mode in OpenClaw. You must expose the endpoint publicly over HTTPS.
|
||||
|
||||
1. **Create Slack App**:
|
||||
1. **Create the Slack App**
|
||||
- Go to [api.slack.com/apps](https://api.slack.com/apps).
|
||||
- Create a new app (from scratch).
|
||||
- Copy **Signing Secret** from Basic Information.
|
||||
- Create a new app (From scratch) and select your workspace.
|
||||
- In **Basic Information**, copy the **Signing Secret**.
|
||||
|
||||
2. **Configure Connector**:
|
||||
2. **Configure OAuth Scopes and install**
|
||||
- Go to **OAuth & Permissions**.
|
||||
- Add bot scopes:
|
||||
- `chat:write`
|
||||
- `files:write`
|
||||
- `app_mentions:read`
|
||||
- `im:history` (DM support)
|
||||
- `channels:history` (public channel messages)
|
||||
- `groups:history` (private channel messages)
|
||||
- Click **Install to Workspace**.
|
||||
- Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
|
||||
3. **Configure connector environment variables**
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN=xoxb-your-token
|
||||
OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET=your-signing-secret
|
||||
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345
|
||||
OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS=U12345,U67890
|
||||
OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS=C12345
|
||||
OPENCLAW_CONNECTOR_SLACK_BIND=127.0.0.1
|
||||
OPENCLAW_CONNECTOR_SLACK_PORT=8095
|
||||
OPENCLAW_CONNECTOR_SLACK_PATH=/slack/events
|
||||
OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true
|
||||
OPENCLAW_CONNECTOR_SLACK_REPLY_IN_THREAD=true
|
||||
OPENCLAW_CONNECTOR_ADMIN_TOKEN=replace-with-openclaw-admin-token
|
||||
```
|
||||
|
||||
Start the connector (`python -m connector`). Expose it via tunnel (e.g. Cloudflare) to `https://<public-host>/slack/events`.
|
||||
Notes:
|
||||
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN` must match server `OPENCLAW_ADMIN_TOKEN` if server-side admin token is enabled.
|
||||
- Slack ingress is fail-closed: invalid/missing signature, stale timestamp, and replayed events are rejected.
|
||||
|
||||
3. **Enable Events**:
|
||||
- In **Event Subscriptions**:
|
||||
- Enable Events.
|
||||
- Set Request URL to `https://<public-host>/slack/events`.
|
||||
- Slack will send a `url_verification` challenge; the connector handles this automatically. Verify connection.
|
||||
- Subscribe to bot events: `message.channels`, `message.groups`, `message.im`, `app_mention`.
|
||||
4. **Start connector and expose webhook endpoint**
|
||||
- Start connector: `python -m connector`
|
||||
- Expose local endpoint to public HTTPS (Cloudflare Tunnel/ngrok/reverse proxy):
|
||||
- local upstream: `http://127.0.0.1:8095`
|
||||
- public URL: `https://<public-host>/slack/events`
|
||||
|
||||
4. **OAuth & Permissions**:
|
||||
- In **OAuth & Permissions**:
|
||||
- Add Scopes: `chat:write`, `files:write`, `channels:history` (if public), `groups:history` (if private), `im:history` (for DMs), `app_mentions:read`.
|
||||
- Install App to Workspace.
|
||||
- Copy **Bot User OAuth Token** (`xoxb-...`).
|
||||
5. **Enable Event Subscriptions**
|
||||
- Go to **Event Subscriptions** and enable events.
|
||||
- Set **Request URL** to `https://<public-host>/slack/events`.
|
||||
- Slack sends `url_verification`; connector responds automatically.
|
||||
- Add bot events:
|
||||
- `app_mention`
|
||||
- `message.channels`
|
||||
- `message.groups`
|
||||
- `message.im`
|
||||
|
||||
5. **Test**:
|
||||
- Invite bot to a channel: `/invite @BotName`.
|
||||
- Mention bot: `@BotName /status`.
|
||||
- DM bot: `/help`.
|
||||
6. **Invite and validate**
|
||||
- Invite the app to target channels: `/invite @YourBot`.
|
||||
- In channel: `@YourBot /status` (when `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true`).
|
||||
- In DM: `/help`.
|
||||
- Verify connector logs show signed ingress accepted and replies delivered.
|
||||
|
||||
7. **Security checklist before production**
|
||||
- Keep `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`/`OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` restricted.
|
||||
- Keep `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` unless intentionally running command-style channels.
|
||||
- Rotate Slack bot token/signing secret on incident response.
|
||||
- Do not expose connector without HTTPS termination.
|
||||
|
||||
Slack Socket Mode fallback is tracked separately and is not part of the current webhook setup.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -434,3 +468,11 @@ Slack Events API pushes JSON payloads to your connector. You must expose the end
|
||||
- **Kakao `/run` always goes to approval**:
|
||||
- Sender is not in `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` (or allowlist is empty).
|
||||
- Fix: capture `userRequest.user.id` from logs, add it to `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`, restart connector.
|
||||
|
||||
- **Slack Event Subscriptions verification fails**:
|
||||
- Request URL/path mismatch, connector not reachable, or `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` is wrong.
|
||||
- Fix: confirm public URL points to `/slack/events`, verify tunnel/proxy routes to `127.0.0.1:8095`, and re-check Signing Secret.
|
||||
|
||||
- **Slack commands ignored in channels**:
|
||||
- `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=true` and message does not mention the bot.
|
||||
- Fix: mention bot explicitly (`@Bot /status`) or set `OPENCLAW_CONNECTOR_SLACK_REQUIRE_MENTION=false` if policy allows.
|
||||
|
||||
@@ -110,13 +110,13 @@ fi
|
||||
|
||||
echo "[tests] Node version: $(node -v)"
|
||||
|
||||
echo "[tests] 0/7 R120 dependency preflight"
|
||||
echo "[tests] 0/8 R120 dependency preflight"
|
||||
"$VENV_PY" scripts/preflight_check.py --strict
|
||||
|
||||
echo "[tests] 1/7 detect-secrets"
|
||||
echo "[tests] 1/8 detect-secrets"
|
||||
"$VENV_PY" -m pre_commit run detect-secrets --all-files
|
||||
|
||||
echo "[tests] 2/7 pre-commit all hooks (pass 1: autofix)"
|
||||
echo "[tests] 2/8 pre-commit all hooks (pass 1: autofix)"
|
||||
if "$VENV_PY" -m pre_commit run --all-files --show-diff-on-failure; then
|
||||
:
|
||||
else
|
||||
@@ -124,29 +124,33 @@ else
|
||||
"$VENV_PY" -m pre_commit run --all-files --show-diff-on-failure
|
||||
fi
|
||||
|
||||
echo "[tests] 3/7 backend unit tests"
|
||||
echo "[tests] 3/8 backend unit tests"
|
||||
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_unit" "$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
|
||||
|
||||
if [ -n "${OPENCLAW_IMPL_RECORD_PATH:-}" ]; then
|
||||
echo "[tests] 3.5/7 implementation record lint (strict)"
|
||||
echo "[tests] 3.5/8 implementation record lint (strict)"
|
||||
# IMPORTANT: strict mode is opt-in via OPENCLAW_IMPL_RECORD_PATH to avoid retroactive legacy record failures.
|
||||
"$VENV_PY" scripts/lint_implementation_record.py --path "$OPENCLAW_IMPL_RECORD_PATH" --strict
|
||||
fi
|
||||
|
||||
echo "[tests] 4/7 backend real E2E lanes (R122/R123)"
|
||||
echo "[tests] 4/8 backend real E2E lanes (R122/R123)"
|
||||
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_backend_e2e_real" \
|
||||
"$VENV_PY" scripts/run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_backend_e2e_real" \
|
||||
"$VENV_PY" scripts/run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
|
||||
echo "[tests] 5/7 R121 retry partition contract"
|
||||
echo "[tests] 5/8 R121 retry partition contract"
|
||||
"$VENV_PY" scripts/run_unittests.py --module tests.test_r121_retry_partition_contract --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
|
||||
echo "[tests] 6/7 R118 adversarial gate (smoke)"
|
||||
echo "[tests] 6/8 Slack integration gates (R124/R125)"
|
||||
"$VENV_PY" scripts/run_unittests.py --module tests.test_r124_slack_ingress_contract --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
"$VENV_PY" scripts/run_unittests.py --module tests.test_r125_slack_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0
|
||||
|
||||
echo "[tests] 7/8 R118 adversarial gate (smoke)"
|
||||
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_adversarial" \
|
||||
"$VENV_PY" scripts/run_adversarial_gate.py --profile smoke --seed 42 --artifact-dir .tmp/adversarial
|
||||
|
||||
echo "[tests] 7/7 frontend E2E"
|
||||
echo "[tests] 8/8 frontend E2E"
|
||||
npm test
|
||||
|
||||
echo "[tests] PASS"
|
||||
|
||||
@@ -152,34 +152,34 @@ if ($nodeMajor -lt 18) {
|
||||
|
||||
Write-Host "[tests] Node version: $(node -v)"
|
||||
|
||||
Write-Host "[tests] 0/7 R120 dependency preflight"
|
||||
Write-Host "[tests] 0/8 R120 dependency preflight"
|
||||
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
|
||||
|
||||
Write-Host "[tests] 1/7 detect-secrets"
|
||||
Write-Host "[tests] 1/8 detect-secrets"
|
||||
Invoke-Checked "detect-secrets" { & $venvPython -m pre_commit run detect-secrets --all-files }
|
||||
|
||||
Write-Host "[tests] 2/7 pre-commit all hooks (pass 1: autofix)"
|
||||
Write-Host "[tests] 2/8 pre-commit all hooks (pass 1: autofix)"
|
||||
& $venvPython -m pre_commit run --all-files --show-diff-on-failure
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[tests] INFO: pre-commit reported changes/issues; running pass 2 verification..."
|
||||
Invoke-Checked "pre-commit all hooks (pass 2 verify)" { & $venvPython -m pre_commit run --all-files --show-diff-on-failure }
|
||||
}
|
||||
|
||||
Write-Host "[tests] 3/7 backend unit tests"
|
||||
Write-Host "[tests] 3/8 backend unit tests"
|
||||
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_unit"
|
||||
Invoke-Checked "backend unit tests" {
|
||||
& $venvPython scripts\run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests\skip_policy.json
|
||||
}
|
||||
|
||||
if ($env:OPENCLAW_IMPL_RECORD_PATH) {
|
||||
Write-Host "[tests] 3.5/7 implementation record lint (strict)"
|
||||
Write-Host "[tests] 3.5/8 implementation record lint (strict)"
|
||||
# IMPORTANT: strict mode is opt-in via OPENCLAW_IMPL_RECORD_PATH to avoid retroactive legacy record failures.
|
||||
Invoke-Checked "implementation record lint" {
|
||||
& $venvPython scripts\lint_implementation_record.py --path $env:OPENCLAW_IMPL_RECORD_PATH --strict
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[tests] 4/7 backend real E2E lanes (R122/R123)"
|
||||
Write-Host "[tests] 4/8 backend real E2E lanes (R122/R123)"
|
||||
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_backend_e2e_real"
|
||||
Invoke-Checked "backend real E2E lane R122" {
|
||||
& $venvPython scripts\run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
|
||||
@@ -188,18 +188,24 @@ Invoke-Checked "backend real E2E lane R123" {
|
||||
& $venvPython scripts\run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
|
||||
}
|
||||
|
||||
Write-Host "[tests] 5/7 R121 retry partition contract"
|
||||
Write-Host "[tests] 5/8 R121 retry partition contract"
|
||||
Invoke-Checked "R121 retry partition contract" {
|
||||
& $venvPython scripts\run_unittests.py --module tests.test_r121_retry_partition_contract --enforce-skip-policy tests\skip_policy.json --max-skipped 0
|
||||
}
|
||||
|
||||
Write-Host "[tests] 6/7 R118 adversarial gate (smoke)"
|
||||
Write-Host "[tests] 6/8 Slack integration gates (R124/R125)"
|
||||
Invoke-Checked "Slack integration gates" {
|
||||
& $venvPython scripts\run_unittests.py --module tests.test_r124_slack_ingress_contract --enforce-skip-policy tests\skip_policy.json --max-skipped 0
|
||||
& $venvPython scripts\run_unittests.py --module tests.test_r125_slack_real_backend_lane --enforce-skip-policy tests\skip_policy.json --max-skipped 0
|
||||
}
|
||||
|
||||
Write-Host "[tests] 7/8 R118 adversarial gate (smoke)"
|
||||
$env:MOLTBOT_STATE_DIR = "$root\moltbot_state\_local_adversarial"
|
||||
Invoke-Checked "R118 adversarial smoke" {
|
||||
& $venvPython scripts\run_adversarial_gate.py --profile smoke --seed 42 --artifact-dir .tmp\adversarial
|
||||
}
|
||||
|
||||
Write-Host "[tests] 7/7 frontend E2E"
|
||||
Write-Host "[tests] 8/8 frontend E2E"
|
||||
Invoke-Checked "frontend E2E" { npm test }
|
||||
|
||||
Write-Host "[tests] PASS"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"tests.security.test_endpoint_drift",
|
||||
"tests.test_r122_real_backend_lane",
|
||||
"tests.test_r123_real_backend_model_list_lane",
|
||||
"tests.test_r121_retry_partition_contract"
|
||||
"tests.test_r121_retry_partition_contract",
|
||||
"tests.test_r124_slack_ingress_contract",
|
||||
"tests.test_r125_slack_real_backend_lane"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"""
|
||||
R124 -- Slack Ingress Security Contract Matrix.
|
||||
|
||||
Covers:
|
||||
- Signature verification (valid, missing, invalid, expired timestamp)
|
||||
- Replay / duplicate event_id guard
|
||||
- Retry header handling
|
||||
- Conversation identity (sender_id, channel_id, thread_ts)
|
||||
- Bot-loop prevention
|
||||
- url_verification challenge
|
||||
- Mention policy for groups
|
||||
- User/channel allowlist enforcement
|
||||
- Subtype filtering
|
||||
|
||||
This test suite is marked no-skip in skip_policy.json.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
from connector.contract import CommandRequest, CommandResponse
|
||||
from connector.platforms.slack_webhook import (
|
||||
SLACK_SIGNING_VERSION,
|
||||
SLACK_TIMESTAMP_MAX_DRIFT_SEC,
|
||||
SlackWebhookServer,
|
||||
verify_slack_signature,
|
||||
)
|
||||
|
||||
# -- Test helpers -----------------------------------------------------------
|
||||
|
||||
SIGNING_SECRET = "test_signing_secret_abc123"
|
||||
BOT_TOKEN = "xoxb-test-token"
|
||||
|
||||
|
||||
def _make_signature(secret: str, timestamp: str, body: bytes) -> str:
|
||||
"""Compute a valid Slack signature for testing."""
|
||||
sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
|
||||
sig = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{SLACK_SIGNING_VERSION}={sig}"
|
||||
|
||||
|
||||
def _make_event_payload(
|
||||
event_type: str = "message",
|
||||
text: str = "/status",
|
||||
user: str = "U_SENDER",
|
||||
channel: str = "C_CHANNEL",
|
||||
event_id: str = "Ev12345",
|
||||
ts: str = "1234567.000",
|
||||
thread_ts: str = "",
|
||||
bot_id: str = "",
|
||||
subtype: str = "",
|
||||
authorizations: list = None,
|
||||
) -> dict:
|
||||
event = {
|
||||
"type": event_type,
|
||||
"text": text,
|
||||
"user": user,
|
||||
"channel": channel,
|
||||
"ts": ts,
|
||||
}
|
||||
if thread_ts:
|
||||
event["thread_ts"] = thread_ts
|
||||
if bot_id:
|
||||
event["bot_id"] = bot_id
|
||||
if subtype:
|
||||
event["subtype"] = subtype
|
||||
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": event_id,
|
||||
"event": event,
|
||||
}
|
||||
if authorizations is not None:
|
||||
payload["authorizations"] = authorizations
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeRequest:
|
||||
"""Minimal request shim for unit tests."""
|
||||
|
||||
_body: bytes
|
||||
_headers: dict
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._headers
|
||||
|
||||
async def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _build_request(
|
||||
payload: dict,
|
||||
signing_secret: str = SIGNING_SECRET,
|
||||
timestamp: Optional[str] = None,
|
||||
signature: Optional[str] = None,
|
||||
) -> FakeRequest:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
ts = timestamp or str(int(time.time()))
|
||||
sig = signature or _make_signature(signing_secret, ts, body)
|
||||
return FakeRequest(
|
||||
_body=body,
|
||||
_headers={
|
||||
"X-Slack-Request-Timestamp": ts,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_server(
|
||||
allowed_users=None,
|
||||
allowed_channels=None,
|
||||
require_mention=True,
|
||||
reply_in_thread=True,
|
||||
) -> SlackWebhookServer:
|
||||
config = ConnectorConfig()
|
||||
config.slack_bot_token = BOT_TOKEN
|
||||
config.slack_signing_secret = SIGNING_SECRET
|
||||
config.slack_allowed_users = allowed_users or []
|
||||
config.slack_allowed_channels = allowed_channels or []
|
||||
config.slack_require_mention = require_mention
|
||||
config.slack_reply_in_thread = reply_in_thread
|
||||
|
||||
router = MagicMock()
|
||||
router.handle = AsyncMock(return_value=CommandResponse(text="OK"))
|
||||
|
||||
server = SlackWebhookServer(config, router)
|
||||
return server
|
||||
|
||||
|
||||
class BaseSlackTest(unittest.IsolatedAsyncioTestCase):
|
||||
"""Base test case with patched aiohttp session for R79 compliance."""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.aiohttp_patcher = patch("aiohttp.ClientSession")
|
||||
self.mock_session_cls = self.aiohttp_patcher.start()
|
||||
self.mock_session = self.mock_session_cls.return_value
|
||||
self.mock_session.__aenter__.return_value = self.mock_session
|
||||
self.mock_session.post.return_value.__aenter__.return_value.status = 200
|
||||
self.mock_session.post.return_value.__aenter__.return_value.json = AsyncMock(
|
||||
return_value={"ok": True}
|
||||
)
|
||||
self.mock_session.post.return_value.__aenter__.return_value.text = AsyncMock(
|
||||
return_value="OK"
|
||||
)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
self.aiohttp_patcher.stop()
|
||||
|
||||
|
||||
# -- Tests ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlackSignatureVerification(unittest.TestCase):
|
||||
"""R124 Matrix Row 1: Signature verification."""
|
||||
|
||||
def test_valid_signature_accepted(self):
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback","event_id":"Ev1","event":{"type":"message"}}'
|
||||
sig = _make_signature(SIGNING_SECRET, ts, body)
|
||||
self.assertTrue(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=ts,
|
||||
body=body,
|
||||
signature=sig,
|
||||
)
|
||||
)
|
||||
|
||||
def test_invalid_signature_rejected(self):
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback"}'
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=ts,
|
||||
body=body,
|
||||
signature="v0=deadbeef",
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_signature_rejected(self):
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback"}'
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=ts,
|
||||
body=body,
|
||||
signature="",
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_timestamp_rejected(self):
|
||||
body = b'{"type":"event_callback"}'
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp="",
|
||||
body=body,
|
||||
signature="v0=abc",
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_secret_rejected(self):
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback"}'
|
||||
sig = _make_signature(SIGNING_SECRET, ts, body)
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret="",
|
||||
timestamp=ts,
|
||||
body=body,
|
||||
signature=sig,
|
||||
)
|
||||
)
|
||||
|
||||
def test_expired_timestamp_rejected(self):
|
||||
old_ts = str(int(time.time()) - SLACK_TIMESTAMP_MAX_DRIFT_SEC - 10)
|
||||
body = b'{"type":"event_callback"}'
|
||||
sig = _make_signature(SIGNING_SECRET, old_ts, body)
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=old_ts,
|
||||
body=body,
|
||||
signature=sig,
|
||||
)
|
||||
)
|
||||
|
||||
def test_future_timestamp_rejected(self):
|
||||
future_ts = str(int(time.time()) + SLACK_TIMESTAMP_MAX_DRIFT_SEC + 10)
|
||||
body = b'{"type":"event_callback"}'
|
||||
sig = _make_signature(SIGNING_SECRET, future_ts, body)
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=future_ts,
|
||||
body=body,
|
||||
signature=sig,
|
||||
)
|
||||
)
|
||||
|
||||
def test_wrong_secret_rejected(self):
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback"}'
|
||||
sig = _make_signature("wrong_secret", ts, body)
|
||||
self.assertFalse(
|
||||
verify_slack_signature(
|
||||
signing_secret=SIGNING_SECRET,
|
||||
timestamp=ts,
|
||||
body=body,
|
||||
signature=sig,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSlackReplayGuard(BaseSlackTest):
|
||||
"""R124 Matrix Row 2: Replay / dedupe."""
|
||||
|
||||
async def test_duplicate_event_id_deduped(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(event_id="Ev_dup_1")
|
||||
req1 = _build_request(payload)
|
||||
req2 = _build_request(payload)
|
||||
|
||||
resp1 = await server.handle_event(req1)
|
||||
resp2 = await server.handle_event(req2)
|
||||
|
||||
# First should route; second is silently accepted (200) but not routed
|
||||
self.assertEqual(resp1.status, 200)
|
||||
self.assertEqual(resp2.status, 200)
|
||||
# Router should have been called only once
|
||||
self.assertEqual(server.router.handle.call_count, 1)
|
||||
|
||||
async def test_different_event_ids_both_processed(self):
|
||||
server = _make_server()
|
||||
req1 = _build_request(_make_event_payload(event_id="Ev_a"))
|
||||
req2 = _build_request(_make_event_payload(event_id="Ev_b"))
|
||||
|
||||
await server.handle_event(req1)
|
||||
await server.handle_event(req2)
|
||||
|
||||
self.assertEqual(server.router.handle.call_count, 2)
|
||||
|
||||
async def test_missing_event_id_rejected(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload()
|
||||
payload["event_id"] = ""
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
|
||||
class TestSlackBotLoopPrevention(BaseSlackTest):
|
||||
"""R124 Matrix Row 3: Bot-loop prevention."""
|
||||
|
||||
async def test_bot_own_message_ignored(self):
|
||||
server = _make_server()
|
||||
server._bot_user_id = "U_BOT"
|
||||
payload = _make_event_payload(user="U_BOT")
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_bot_id_field_ignored(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(bot_id="B_INTEGRATION")
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_bot_user_id_discovered_from_authorizations(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(
|
||||
user="U_BOT_SELF",
|
||||
authorizations=[{"user_id": "U_BOT_SELF"}],
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
# Bot discovered its own user_id and ignored the message
|
||||
server.router.handle.assert_not_called()
|
||||
self.assertEqual(server._bot_user_id, "U_BOT_SELF")
|
||||
|
||||
|
||||
class TestSlackUrlVerification(BaseSlackTest):
|
||||
"""R124 Matrix Row 4: url_verification challenge."""
|
||||
|
||||
async def test_url_verification_returns_challenge(self):
|
||||
server = _make_server()
|
||||
payload = {"type": "url_verification", "challenge": "abc123xyz"}
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("abc123xyz", resp.text)
|
||||
|
||||
|
||||
class TestSlackMentionPolicy(BaseSlackTest):
|
||||
"""R124 Matrix Row 5: Mention policy for groups."""
|
||||
|
||||
async def test_group_message_without_mention_ignored_when_required(self):
|
||||
server = _make_server(require_mention=True)
|
||||
server._bot_user_id = "U_BOT"
|
||||
# Channel starting with "C" is a public channel (not DM)
|
||||
payload = _make_event_payload(
|
||||
event_type="message",
|
||||
text="hello world",
|
||||
channel="C_GROUP",
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_group_app_mention_processed_when_required(self):
|
||||
server = _make_server(require_mention=True)
|
||||
server._bot_user_id = "U_BOT"
|
||||
payload = _make_event_payload(
|
||||
event_type="app_mention",
|
||||
text="<@U_BOT> /status",
|
||||
channel="C_GROUP",
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
# Verify bot mention was stripped from text
|
||||
routed_req = server.router.handle.call_args[0][0]
|
||||
self.assertEqual(routed_req.text, "/status")
|
||||
|
||||
async def test_dm_message_always_processed(self):
|
||||
server = _make_server(require_mention=True)
|
||||
server._bot_user_id = "U_BOT"
|
||||
# Channel starting with "D" is a DM
|
||||
payload = _make_event_payload(
|
||||
event_type="message",
|
||||
text="/status",
|
||||
channel="D_DM_CHANNEL",
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
async def test_group_message_without_mention_processed_when_not_required(self):
|
||||
server = _make_server(require_mention=False)
|
||||
server._bot_user_id = "U_BOT"
|
||||
payload = _make_event_payload(
|
||||
event_type="message",
|
||||
text="/status",
|
||||
channel="C_GROUP",
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
|
||||
class TestSlackAllowlist(BaseSlackTest):
|
||||
"""R124 Matrix Row 6: User/channel allowlist enforcement."""
|
||||
|
||||
async def test_user_not_in_allowlist_silently_dropped(self):
|
||||
server = _make_server(allowed_users=["U_ALLOWED"])
|
||||
payload = _make_event_payload(user="U_DENIED")
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_user_in_allowlist_processed(self):
|
||||
server = _make_server(allowed_users=["U_ALLOWED"])
|
||||
payload = _make_event_payload(user="U_ALLOWED")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
async def test_channel_not_in_allowlist_silently_dropped(self):
|
||||
server = _make_server(allowed_channels=["C_APPROVED"])
|
||||
payload = _make_event_payload(channel="C_UNAPPROVED")
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_channel_in_allowlist_processed(self):
|
||||
server = _make_server(allowed_channels=["C_APPROVED"])
|
||||
payload = _make_event_payload(channel="C_APPROVED")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
async def test_empty_allowlist_allows_all(self):
|
||||
server = _make_server(allowed_users=[], allowed_channels=[])
|
||||
payload = _make_event_payload(user="U_ANYONE")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
|
||||
class TestSlackConversationIdentity(BaseSlackTest):
|
||||
"""R124 Matrix Row 7: Conversation identity mapping."""
|
||||
|
||||
async def test_command_request_fields_correct(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(
|
||||
user="U_USER1",
|
||||
channel="C_CH1",
|
||||
text="/help",
|
||||
event_id="Ev_id_1",
|
||||
ts="1609459200.123",
|
||||
)
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
routed = server.router.handle.call_args[0][0]
|
||||
|
||||
self.assertIsInstance(routed, CommandRequest)
|
||||
self.assertEqual(routed.platform, "slack")
|
||||
self.assertEqual(routed.sender_id, "U_USER1")
|
||||
self.assertEqual(routed.channel_id, "C_CH1")
|
||||
self.assertEqual(routed.message_id, "Ev_id_1")
|
||||
self.assertEqual(routed.text, "/help")
|
||||
|
||||
async def test_thread_ts_preserved_for_reply(self):
|
||||
"""Verify thread_ts is available for reply routing (covered in adapter)."""
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(thread_ts="1609459200.001")
|
||||
req = _build_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
|
||||
class TestSlackSubtypeFiltering(BaseSlackTest):
|
||||
"""R124 Matrix Row 8: Message subtype filtering."""
|
||||
|
||||
async def test_message_changed_ignored(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(subtype="message_changed")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_message_deleted_ignored(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(subtype="message_deleted")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_bot_message_subtype_ignored(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(subtype="bot_message")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_file_share_subtype_processed(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload(subtype="file_share", text="/run test")
|
||||
req = _build_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
|
||||
class TestSlackSignatureOnIngress(BaseSlackTest):
|
||||
"""R124: End-to-end ingress signature checks via handle_event."""
|
||||
|
||||
async def test_unsigned_request_returns_401(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload()
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = FakeRequest(
|
||||
_body=body,
|
||||
_headers={
|
||||
"X-Slack-Request-Timestamp": str(int(time.time())),
|
||||
"X-Slack-Signature": "v0=invalid",
|
||||
},
|
||||
)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 401)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_missing_headers_returns_401(self):
|
||||
server = _make_server()
|
||||
payload = _make_event_payload()
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = FakeRequest(_body=body, _headers={})
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 401)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
R125 -- Slack Real-Backend No-Skip E2E Lane.
|
||||
|
||||
Verifies the complete Slack adapter data flow:
|
||||
signed ingress -> event normalization -> command authz -> thread delivery parity.
|
||||
|
||||
Exercises the full chain without network by using mock router and mock Slack API.
|
||||
This test suite is marked no-skip in skip_policy.json.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
from connector.contract import CommandRequest, CommandResponse
|
||||
from connector.platforms.slack_webhook import SLACK_SIGNING_VERSION, SlackWebhookServer
|
||||
|
||||
# -- Test helpers -----------------------------------------------------------
|
||||
|
||||
SIGNING_SECRET = "r125_lane_secret"
|
||||
BOT_TOKEN = "xoxb-r125-token"
|
||||
|
||||
|
||||
def _make_signature(secret: str, timestamp: str, body: bytes) -> str:
|
||||
sig_basestring = f"{SLACK_SIGNING_VERSION}:{timestamp}:{body.decode('utf-8')}"
|
||||
sig = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{SLACK_SIGNING_VERSION}={sig}"
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, body: bytes, headers: dict):
|
||||
self._body = body
|
||||
self.headers = headers
|
||||
|
||||
async def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _build_signed_request(payload: dict) -> FakeRequest:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
ts = str(int(time.time()))
|
||||
sig = _make_signature(SIGNING_SECRET, ts, body)
|
||||
return FakeRequest(
|
||||
body,
|
||||
{
|
||||
"X-Slack-Request-Timestamp": ts,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_server() -> SlackWebhookServer:
|
||||
config = ConnectorConfig()
|
||||
config.slack_bot_token = BOT_TOKEN
|
||||
config.slack_signing_secret = SIGNING_SECRET
|
||||
config.slack_allowed_users = []
|
||||
config.slack_allowed_channels = []
|
||||
config.slack_require_mention = True
|
||||
config.slack_reply_in_thread = True
|
||||
|
||||
router = MagicMock()
|
||||
router.handle = AsyncMock(return_value=CommandResponse(text="Done"))
|
||||
|
||||
server = SlackWebhookServer(config, router)
|
||||
return server
|
||||
|
||||
|
||||
# -- Tests ------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestR125SlackRealBackendLane(unittest.IsolatedAsyncioTestCase):
|
||||
"""R125: Complete Slack ingress -> authz -> delivery chain."""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.aiohttp_patcher = patch("aiohttp.ClientSession")
|
||||
self.mock_session_cls = self.aiohttp_patcher.start()
|
||||
self.mock_session = self.mock_session_cls.return_value
|
||||
self.mock_session.__aenter__.return_value = self.mock_session
|
||||
self.mock_session.post.return_value.__aenter__.return_value.status = 200
|
||||
self.mock_session.post.return_value.__aenter__.return_value.json = AsyncMock(
|
||||
return_value={"ok": True}
|
||||
)
|
||||
self.mock_session.post.return_value.__aenter__.return_value.text = AsyncMock(
|
||||
return_value="OK"
|
||||
)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
self.aiohttp_patcher.stop()
|
||||
|
||||
async def test_signed_message_routed_to_command_router(self):
|
||||
"""Full chain: signed event -> router.handle() called with correct CommandRequest."""
|
||||
server = _make_server()
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_1",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": "/status",
|
||||
"user": "U_TEST_USER",
|
||||
"channel": "D_DM",
|
||||
"ts": "1609459200.111",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
|
||||
server.router.handle.assert_called_once()
|
||||
routed: CommandRequest = server.router.handle.call_args[0][0]
|
||||
self.assertEqual(routed.platform, "slack")
|
||||
self.assertEqual(routed.sender_id, "U_TEST_USER")
|
||||
self.assertEqual(routed.channel_id, "D_DM")
|
||||
self.assertEqual(routed.text, "/status")
|
||||
self.assertEqual(routed.message_id, "Ev_r125_1")
|
||||
|
||||
async def test_unsigned_event_never_reaches_router(self):
|
||||
"""R125 parity: unsigned ingress must not reach command router."""
|
||||
server = _make_server()
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_2",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": "/run exploit",
|
||||
"user": "U_ATTACKER",
|
||||
"channel": "C_CH",
|
||||
"ts": "1609459200.222",
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
fake_req = FakeRequest(
|
||||
body,
|
||||
{
|
||||
"X-Slack-Request-Timestamp": str(int(time.time())),
|
||||
"X-Slack-Signature": "v0=0000000000000000000000000000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
|
||||
resp = await server.handle_event(fake_req)
|
||||
self.assertEqual(resp.status, 401)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
async def test_app_mention_with_bot_strip_routed(self):
|
||||
"""R125 parity: app_mention events strip bot mention before routing."""
|
||||
server = _make_server()
|
||||
server._bot_user_id = "U_BOT_R125"
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_3",
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"text": "<@U_BOT_R125> /run txt2img",
|
||||
"user": "U_SENDER_A",
|
||||
"channel": "C_GROUP",
|
||||
"ts": "1609459200.333",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
|
||||
server.router.handle.assert_called_once()
|
||||
routed = server.router.handle.call_args[0][0]
|
||||
self.assertEqual(routed.text, "/run txt2img")
|
||||
self.assertEqual(routed.platform, "slack")
|
||||
|
||||
async def test_thread_reply_context_preserved(self):
|
||||
"""R125 parity: thread_ts from event is available for reply routing."""
|
||||
server = _make_server()
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_4",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": "/help",
|
||||
"user": "U_THREAD_USER",
|
||||
"channel": "D_DM",
|
||||
"ts": "1609459200.444",
|
||||
"thread_ts": "1609459200.001",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
|
||||
resp = await server.handle_event(req)
|
||||
self.assertEqual(resp.status, 200)
|
||||
server.router.handle.assert_called_once()
|
||||
|
||||
async def test_run_command_flows_through_router(self):
|
||||
"""R125: /run command reaches router with correct arguments."""
|
||||
server = _make_server()
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_5",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": "/run txt2img prompt=hello",
|
||||
"user": "U_RUNNER",
|
||||
"channel": "D_DM",
|
||||
"ts": "1609459200.555",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
|
||||
server.router.handle.assert_called_once()
|
||||
routed = server.router.handle.call_args[0][0]
|
||||
self.assertEqual(routed.text, "/run txt2img prompt=hello")
|
||||
|
||||
async def test_multiple_events_both_processed(self):
|
||||
"""R125: Multiple distinct events are independently processed."""
|
||||
server = _make_server()
|
||||
|
||||
for i in range(3):
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": f"Ev_r125_multi_{i}",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": f"/status {i}",
|
||||
"user": "U_MULTI",
|
||||
"channel": "D_DM",
|
||||
"ts": f"1609459200.{i:03d}",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
await server.handle_event(req)
|
||||
|
||||
self.assertEqual(server.router.handle.call_count, 3)
|
||||
|
||||
async def test_bot_message_filtered_before_router(self):
|
||||
"""R125: Bot messages must never reach command router."""
|
||||
server = _make_server()
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": "Ev_r125_bot",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"text": "bot output",
|
||||
"user": "U_SOMEONE",
|
||||
"channel": "C_CH",
|
||||
"ts": "1609459200.666",
|
||||
"bot_id": "B_BOT",
|
||||
},
|
||||
}
|
||||
req = _build_signed_request(payload)
|
||||
|
||||
await server.handle_event(req)
|
||||
server.router.handle.assert_not_called()
|
||||
|
||||
|
||||
class TestR125SlackRouterTrust(unittest.TestCase):
|
||||
"""R125: Verify router trust check integration for Slack platform."""
|
||||
|
||||
def test_slack_trust_check_exists_in_router(self):
|
||||
"""Verify that the router._is_trusted method handles platform='slack'."""
|
||||
from connector.router import CommandRouter
|
||||
|
||||
config = ConnectorConfig()
|
||||
config.slack_allowed_users = ["U_TRUSTED"]
|
||||
config.slack_allowed_channels = ["C_TRUSTED"]
|
||||
|
||||
client = MagicMock()
|
||||
router = CommandRouter(config, client)
|
||||
|
||||
# Trusted user
|
||||
req_trusted = CommandRequest(
|
||||
platform="slack",
|
||||
sender_id="U_TRUSTED",
|
||||
channel_id="C_ANY",
|
||||
username="trusted",
|
||||
message_id="m1",
|
||||
text="/run test",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
self.assertTrue(router._is_trusted(req_trusted))
|
||||
|
||||
# Trusted channel
|
||||
req_chan = CommandRequest(
|
||||
platform="slack",
|
||||
sender_id="U_UNKNOWN",
|
||||
channel_id="C_TRUSTED",
|
||||
username="unknown",
|
||||
message_id="m2",
|
||||
text="/run test",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
self.assertTrue(router._is_trusted(req_chan))
|
||||
|
||||
# Untrusted
|
||||
req_untrusted = CommandRequest(
|
||||
platform="slack",
|
||||
sender_id="U_RANDOM",
|
||||
channel_id="C_RANDOM",
|
||||
username="random",
|
||||
message_id="m3",
|
||||
text="/run test",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
self.assertFalse(router._is_trusted(req_untrusted))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -286,6 +286,10 @@ class TestR79EgressCompliance(unittest.TestCase):
|
||||
"connector/platforms/telegram_polling.py",
|
||||
"connector/platforms/wechat_webhook.py",
|
||||
"connector/platforms/whatsapp_webhook.py",
|
||||
# IMPORTANT: Keep connector platform adapters in parity here.
|
||||
# Missing a newly-added adapter causes false-positive R79 failures
|
||||
# in full-gate runs even when egress behavior is intentional.
|
||||
"connector/platforms/slack_webhook.py",
|
||||
# Providers
|
||||
"services/providers/anthropic.py",
|
||||
"services/providers/openai_compat.py",
|
||||
|
||||
Reference in New Issue
Block a user