4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
376 lines
13 KiB
Python
376 lines
13 KiB
Python
"""Slack delivery helper - posts directly to Slack API or delegates to NextJS."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from config.config import SLACK_CHANNEL
|
|
from platform.notifications.delivery_errors import extract_http_error
|
|
from platform.notifications.delivery_transport import post_json
|
|
from platform.notifications.redaction import redact_slack_token
|
|
from platform.observability import debug_print
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Max length for response body excerpts in log messages
|
|
_LOG_BODY_MAX_LEN = 200
|
|
|
|
|
|
def _slack_bearer_headers(token: str) -> dict[str, str]:
|
|
# Slack explicitly recommends ``charset=utf-8`` on JSON POSTs — without
|
|
# it the API replies with a ``missing_charset`` warning in
|
|
# ``response_metadata.warnings``. httpx only auto-sets the bare
|
|
# ``application/json`` (no charset) for ``json=`` kwargs, so we keep
|
|
# the explicit charset header here.
|
|
# See https://api.slack.com/web#posting_json
|
|
return {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
}
|
|
|
|
|
|
def _call_reactions_api(method: str, token: str, channel: str, timestamp: str, emoji: str) -> bool:
|
|
"""Call Slack reactions.add or reactions.remove.
|
|
|
|
Returns True on success, False on expected failures (already_reacted, no_reaction, etc.).
|
|
"""
|
|
response = post_json(
|
|
url=f"https://slack.com/api/{method}",
|
|
payload={"channel": channel, "timestamp": timestamp, "name": emoji},
|
|
headers=_slack_bearer_headers(token),
|
|
timeout=8.0,
|
|
)
|
|
if not response.ok:
|
|
safe_error = redact_slack_token(response.error, token)
|
|
logger.warning("[slack] %s(%s) exception: %s", method, emoji, safe_error)
|
|
return False
|
|
if not response.data.get("ok"):
|
|
error = response.data.get("error", "unknown")
|
|
if error not in ("already_reacted", "no_reaction", "message_not_found"):
|
|
logger.warning("[slack] %s(%s) failed: %s", method, emoji, error)
|
|
return bool(response.data.get("ok", False))
|
|
|
|
|
|
def add_reaction(
|
|
emoji: str,
|
|
channel: str,
|
|
timestamp: str,
|
|
token: str,
|
|
) -> None:
|
|
"""Add a reaction emoji to a Slack message."""
|
|
_call_reactions_api("reactions.add", token, channel, timestamp, emoji)
|
|
|
|
|
|
def remove_reaction(
|
|
emoji: str,
|
|
channel: str,
|
|
timestamp: str,
|
|
token: str,
|
|
) -> None:
|
|
"""Remove a reaction emoji from a Slack message (silently ignores if not present)."""
|
|
_call_reactions_api("reactions.remove", token, channel, timestamp, emoji)
|
|
|
|
|
|
def swap_reaction(
|
|
remove_emoji: str,
|
|
add_emoji: str,
|
|
channel: str,
|
|
timestamp: str,
|
|
token: str,
|
|
) -> None:
|
|
"""Remove one emoji reaction and add another atomically (best-effort)."""
|
|
remove_reaction(remove_emoji, channel, timestamp, token)
|
|
add_reaction(add_emoji, channel, timestamp, token)
|
|
|
|
|
|
def build_action_blocks(
|
|
investigation_url: str, investigation_id: str | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Build Slack Block Kit action blocks with interactive buttons.
|
|
|
|
Args:
|
|
investigation_url: URL to the investigation details page in Tracer.
|
|
investigation_id: Investigation ID embedded in feedback option values so the
|
|
interactivity handler can update the correct record.
|
|
|
|
Returns:
|
|
List of Block Kit block dicts ready for the blocks parameter.
|
|
"""
|
|
feedback_options = [
|
|
{
|
|
"text": {"type": "plain_text", "text": "\U0001f44d Accurate"},
|
|
"value": f"accurate|{investigation_id or ''}",
|
|
},
|
|
{
|
|
"text": {"type": "plain_text", "text": "\U0001f914 Partially accurate"},
|
|
"value": f"partial|{investigation_id or ''}",
|
|
},
|
|
{
|
|
"text": {"type": "plain_text", "text": "\U0001f44e Inaccurate"},
|
|
"value": f"inaccurate|{investigation_id or ''}",
|
|
},
|
|
]
|
|
elements: list[dict[str, Any]] = [
|
|
{
|
|
"type": "button",
|
|
"text": {"type": "plain_text", "text": "View Details in Tracer"},
|
|
"url": investigation_url,
|
|
"style": "primary",
|
|
"action_id": "view_investigation",
|
|
},
|
|
{
|
|
"type": "static_select",
|
|
"placeholder": {"type": "plain_text", "text": "\U0001f4dd Give Feedback"},
|
|
"action_id": "give_feedback",
|
|
"options": feedback_options,
|
|
},
|
|
]
|
|
return [{"type": "actions", "elements": elements}]
|
|
|
|
|
|
def _merge_payload(
|
|
channel: str,
|
|
text: str,
|
|
thread_ts: str,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> dict[str, Any]:
|
|
"""Build Slack payload by merging base config with optional blocks and any extra keys."""
|
|
payload: dict[str, Any] = {
|
|
"channel": channel,
|
|
"text": text,
|
|
"thread_ts": thread_ts,
|
|
}
|
|
if blocks:
|
|
payload["blocks"] = blocks
|
|
if extra:
|
|
payload.update(extra)
|
|
return payload
|
|
|
|
|
|
def _configured_webhook_url() -> str:
|
|
"""Return the standalone Slack webhook from env or the local integration store."""
|
|
env_webhook_url = os.getenv("SLACK_WEBHOOK_URL", "").strip()
|
|
if env_webhook_url:
|
|
return env_webhook_url
|
|
|
|
try:
|
|
from integrations.catalog import resolve_effective_integrations
|
|
|
|
slack_integration = resolve_effective_integrations().get("slack") or {}
|
|
config = slack_integration.get("config") if isinstance(slack_integration, dict) else {}
|
|
return str(config.get("webhook_url", "") if isinstance(config, dict) else "").strip()
|
|
except Exception:
|
|
logger.debug("Failed to resolve Slack webhook from integration store", exc_info=True)
|
|
return ""
|
|
|
|
|
|
def send_slack_webhook_message(
|
|
text: str,
|
|
*,
|
|
webhook_url: str | None = None,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> tuple[bool, str]:
|
|
"""Post a standalone message to a Slack incoming webhook.
|
|
|
|
General-purpose entry point that is **not** tied to the investigation/RCA
|
|
flow: it posts ``text`` (and optional Block Kit ``blocks``) to a Slack
|
|
incoming webhook so any surface can send a Slack notification on demand.
|
|
|
|
Args:
|
|
text: Plain-text message body.
|
|
webhook_url: Optional webhook URL. When omitted, resolves from
|
|
``SLACK_WEBHOOK_URL`` or the local Slack integration store.
|
|
blocks: Optional Slack Block Kit blocks.
|
|
**extra: Any additional Slack payload params merged into the body.
|
|
|
|
Returns:
|
|
``(success, error_detail)``. ``error_detail`` is ``"no_webhook"`` when
|
|
no webhook is configured and ``"webhook=failed"`` when delivery failed.
|
|
"""
|
|
url = (webhook_url or _configured_webhook_url()).strip()
|
|
if not url:
|
|
return False, "no_webhook"
|
|
if _post_via_incoming_webhook(text, url, blocks=blocks, **extra):
|
|
return True, ""
|
|
return False, "webhook=failed"
|
|
|
|
|
|
def send_slack_report(
|
|
slack_message: str,
|
|
channel: str | None = None,
|
|
thread_ts: str | None = None,
|
|
access_token: str | None = None,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Post the RCA report as a thread reply in Slack.
|
|
|
|
When thread context is available, prefers a thread reply to avoid creating
|
|
loops for inbound Slack-triggered investigations. For standalone CLI or
|
|
local investigations, falls back to SLACK_WEBHOOK_URL or the local Slack
|
|
integration store if configured.
|
|
|
|
Args:
|
|
slack_message: The formatted RCA report text.
|
|
channel: Slack channel ID to post to.
|
|
thread_ts: The parent message ts to reply under. Required.
|
|
access_token: Slack bot/user OAuth token for direct posting.
|
|
blocks: Optional Slack Block Kit blocks for interactive elements.
|
|
**extra: Any additional Slack API params (e.g. unfurl_links, mrkdwn) merged into the payload.
|
|
|
|
Returns:
|
|
(success, error_detail) — success is True if posted, error_detail is non-empty on failure.
|
|
"""
|
|
if not thread_ts:
|
|
webhook_url = _configured_webhook_url()
|
|
if webhook_url:
|
|
webhook_ok = _post_via_incoming_webhook(
|
|
slack_message,
|
|
webhook_url,
|
|
blocks=blocks,
|
|
**extra,
|
|
)
|
|
return (True, "") if webhook_ok else (False, "webhook=failed")
|
|
logger.debug("[slack] Delivery skipped: no thread_ts (channel=%s)", channel)
|
|
debug_print("Slack delivery skipped: no thread_ts and no Slack webhook configured.")
|
|
return False, "no_thread_ts"
|
|
|
|
if access_token and channel:
|
|
success, direct_error = _post_direct(
|
|
slack_message, channel, thread_ts, access_token, blocks=blocks, **extra
|
|
)
|
|
if not success:
|
|
safe_error = redact_slack_token(direct_error, access_token)
|
|
logger.info(
|
|
"[slack] Direct post failed (%s), falling back to webapp delivery", safe_error
|
|
)
|
|
webapp_ok = _post_via_webapp(slack_message, channel, thread_ts, blocks=blocks, **extra)
|
|
if not webapp_ok:
|
|
return False, f"direct={safe_error}, webapp=failed"
|
|
return True, ""
|
|
return True, ""
|
|
else:
|
|
webapp_ok = _post_via_webapp(slack_message, channel, thread_ts, blocks=blocks, **extra)
|
|
return (True, "") if webapp_ok else (False, "webapp=failed")
|
|
|
|
|
|
def _post_direct(
|
|
text: str,
|
|
channel: str,
|
|
thread_ts: str,
|
|
token: str,
|
|
*,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> tuple[bool, str]:
|
|
"""Post as a thread reply via Slack chat.postMessage.
|
|
|
|
Returns (success, error_detail) where error_detail is empty on success.
|
|
"""
|
|
payload = _merge_payload(channel, text, thread_ts, blocks=blocks, **extra)
|
|
response = post_json(
|
|
url="https://slack.com/api/chat.postMessage",
|
|
payload=payload,
|
|
headers=_slack_bearer_headers(token),
|
|
)
|
|
if not response.ok:
|
|
safe_error = redact_slack_token(response.error, token)
|
|
logger.error(
|
|
"[slack] Direct post exception type=%s channel=%s thread_ts=%s detail=%s "
|
|
"(caller may attempt fallback)",
|
|
response.exc_type or "Exception",
|
|
channel,
|
|
thread_ts,
|
|
safe_error,
|
|
)
|
|
return False, f"exception={safe_error}"
|
|
if response.data.get("ok") is not True:
|
|
error = response.data.get("error")
|
|
if not error:
|
|
error = extract_http_error(response.data, response.status_code, response.text)
|
|
safe_error = redact_slack_token(str(error), token)
|
|
response_meta = response.data.get("response_metadata", {})
|
|
logger.error(
|
|
"[slack] Direct post FAILED: error=%s, metadata=%s (channel=%s, thread_ts=%s)",
|
|
safe_error,
|
|
response_meta,
|
|
channel,
|
|
thread_ts,
|
|
)
|
|
return False, f"slack_error={safe_error}"
|
|
warnings = response.data.get("response_metadata", {}).get("warnings", [])
|
|
if warnings:
|
|
logger.warning("[slack] Reply posted with warnings: %s", warnings)
|
|
logger.info(
|
|
"[slack] Reply posted successfully (thread_ts=%s, ts=%s)",
|
|
thread_ts,
|
|
response.data.get("ts"),
|
|
)
|
|
return True, ""
|
|
|
|
|
|
def _post_via_webapp(
|
|
text: str,
|
|
channel: str | None,
|
|
thread_ts: str,
|
|
*,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> bool:
|
|
"""Fallback: delegate to NextJS /api/slack endpoint.
|
|
|
|
Returns True if the message was delivered successfully, False otherwise.
|
|
"""
|
|
base_url = os.getenv("TRACER_API_URL")
|
|
target_channel = channel or SLACK_CHANNEL
|
|
|
|
if not base_url:
|
|
debug_print("Slack delivery skipped: TRACER_API_URL not set.")
|
|
return False
|
|
|
|
api_url = f"{base_url.rstrip('/')}/api/slack"
|
|
payload = _merge_payload(target_channel, text, thread_ts, blocks=blocks, **extra)
|
|
response = post_json(url=api_url, payload=payload, timeout=10.0, follow_redirects=True)
|
|
if not response.ok:
|
|
debug_print(f"Slack delivery failed: {response.error}")
|
|
return False
|
|
if not 200 <= response.status_code < 300:
|
|
debug_print(
|
|
f"Slack delivery failed: HTTP {response.status_code}: {response.text[:_LOG_BODY_MAX_LEN]}"
|
|
)
|
|
return False
|
|
debug_print(f"Slack delivery triggered via NextJS /api/slack (thread_ts={thread_ts}).")
|
|
return True
|
|
|
|
|
|
def _post_via_incoming_webhook(
|
|
text: str,
|
|
webhook_url: str,
|
|
*,
|
|
blocks: list[dict[str, Any]] | None = None,
|
|
**extra: Any,
|
|
) -> bool:
|
|
"""Post a standalone RCA report via Slack incoming webhook."""
|
|
payload: dict[str, Any] = {"text": text}
|
|
if blocks:
|
|
payload["blocks"] = blocks
|
|
if extra:
|
|
payload.update(extra)
|
|
|
|
response = post_json(url=webhook_url, payload=payload, timeout=10.0, follow_redirects=True)
|
|
if not response.ok:
|
|
debug_print(f"Slack incoming webhook failed: {response.error}")
|
|
return False
|
|
if not 200 <= response.status_code < 300:
|
|
debug_print(
|
|
f"Slack incoming webhook failed: HTTP {response.status_code}: {response.text[:_LOG_BODY_MAX_LEN]}"
|
|
)
|
|
return False
|
|
debug_print("Slack report posted via incoming webhook.")
|
|
return True
|