Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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