From c146a21ef5657091f98658825768f48e738fec5c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 14:30:14 +0800 Subject: [PATCH 1/3] Add multi-channel webhook notifications (Slack/Discord/Teams/raw) --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v50_features_doc.rst | 42 ++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v50_features_doc.rst | 38 ++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 13 +++ .../utils/executor/action_executor.py | 10 ++ .../utils/mcp_server/tools/_factories.py | 22 ++++- .../utils/mcp_server/tools/_handlers.py | 8 ++ .../utils/notify_channels/__init__.py | 8 ++ .../utils/notify_channels/notify_channels.py | 90 ++++++++++++++++++ .../headless/test_notify_channels_batch.py | 95 +++++++++++++++++++ 15 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v50_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v50_features_doc.rst create mode 100644 je_auto_control/utils/notify_channels/__init__.py create mode 100644 je_auto_control/utils/notify_channels/notify_channels.py create mode 100644 test/unit_test/headless/test_notify_channels_batch.py diff --git a/README.md b/README.md index 0aee7727..1e774c8a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — Multi-Channel Webhook Notifications](#whats-new-2026-06-20--multi-channel-webhook-notifications) - [What's new (2026-06-20) — Outbound CloudEvents Emitter](#whats-new-2026-06-20--outbound-cloudevents-emitter) - [What's new (2026-06-20) — Environment-Scoped Typed Asset Store](#whats-new-2026-06-20--environment-scoped-typed-asset-store) - [What's new (2026-06-20) — Task / Process Mining (Automation-Candidate Discovery)](#whats-new-2026-06-20--task--process-mining-automation-candidate-discovery) @@ -102,6 +103,12 @@ --- +## What's new (2026-06-20) — Multi-Channel Webhook Notifications + +Alert Teams/Discord/Slack/webhook. Full reference: [`docs/source/Eng/doc/new_features/v50_features_doc.rst`](docs/source/Eng/doc/new_features/v50_features_doc.rst). + +- **`notify_webhook` / `WebhookChannel`** (`AC_notify_webhook`, `ac_notify_webhook`): `notify` was desktop-toast only and ChatOps shipped Slack only — this sends to **Slack / Discord / Microsoft Teams / raw** webhooks, building the transport-shaped payload (Slack & Teams MessageCard use `text`, Discord uses `content`) and POSTing via the egress-guarded HTTP client. The `poster` transport is injectable (or `set_default_poster`), so sending is unit-tested with no network. + ## What's new (2026-06-20) — Outbound CloudEvents Emitter Emit run/automation events as CloudEvents. Full reference: [`docs/source/Eng/doc/new_features/v49_features_doc.rst`](docs/source/Eng/doc/new_features/v49_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 609fe4b8..d50cd73a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) - [本次更新 (2026-06-20) — 对外 CloudEvents 发送器](#本次更新-2026-06-20--对外-cloudevents-发送器) - [本次更新 (2026-06-20) — 环境范围的带类型资产存储](#本次更新-2026-06-20--环境范围的带类型资产存储) - [本次更新 (2026-06-20) — 任务 / 流程挖掘(自动化候选发现)](#本次更新-2026-06-20--任务--流程挖掘自动化候选发现) @@ -101,6 +102,12 @@ --- +## 本次更新 (2026-06-20) — 多通道 Webhook 通知 + +通知 Teams/Discord/Slack/webhook。完整参考:[`docs/source/Zh/doc/new_features/v50_features_doc.rst`](../docs/source/Zh/doc/new_features/v50_features_doc.rst)。 + +- **`notify_webhook` / `WebhookChannel`**(`AC_notify_webhook`、`ac_notify_webhook`):`notify` 仅限桌面弹窗、ChatOps 只内建 Slack —— 本功能可发送到 **Slack / Discord / Microsoft Teams / raw** webhook,组出对应传输的载荷(Slack 与 Teams MessageCard 用 `text`,Discord 用 `content`)并通过受出口守卫保护的 HTTP 客户端 POST。`poster` 传输可注入(或 `set_default_poster`),因此发送在无网络下即可单元测试。 + ## 本次更新 (2026-06-20) — 对外 CloudEvents 发送器 将运行/自动化事件以 CloudEvents 发送。完整参考:[`docs/source/Zh/doc/new_features/v49_features_doc.rst`](../docs/source/Zh/doc/new_features/v49_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c6cddfe4..4fb83359 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) - [本次更新 (2026-06-20) — 對外 CloudEvents 發送器](#本次更新-2026-06-20--對外-cloudevents-發送器) - [本次更新 (2026-06-20) — 環境範圍的具型別資產儲存](#本次更新-2026-06-20--環境範圍的具型別資產儲存) - [本次更新 (2026-06-20) — 任務 / 流程探勘(自動化候選發現)](#本次更新-2026-06-20--任務--流程探勘自動化候選發現) @@ -101,6 +102,12 @@ --- +## 本次更新 (2026-06-20) — 多通道 Webhook 通知 + +通知 Teams/Discord/Slack/webhook。完整參考:[`docs/source/Zh/doc/new_features/v50_features_doc.rst`](../docs/source/Zh/doc/new_features/v50_features_doc.rst)。 + +- **`notify_webhook` / `WebhookChannel`**(`AC_notify_webhook`、`ac_notify_webhook`):`notify` 僅限桌面快顯、ChatOps 只內建 Slack —— 本功能可發送到 **Slack / Discord / Microsoft Teams / raw** webhook,組出對應傳輸的酬載(Slack 與 Teams MessageCard 用 `text`,Discord 用 `content`)並透過受出口守衛保護的 HTTP 用戶端 POST。`poster` 傳輸可注入(或 `set_default_poster`),因此發送在無網路下即可單元測試。 + ## 本次更新 (2026-06-20) — 對外 CloudEvents 發送器 將執行/自動化事件以 CloudEvents 發送。完整參考:[`docs/source/Zh/doc/new_features/v49_features_doc.rst`](../docs/source/Zh/doc/new_features/v49_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v50_features_doc.rst b/docs/source/Eng/doc/new_features/v50_features_doc.rst new file mode 100644 index 00000000..77cd86e6 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v50_features_doc.rst @@ -0,0 +1,42 @@ +Multi-Channel Webhook Notifications +=================================== + +The built-in ``notify`` is desktop-toast only, and ChatOps shipped Slack as the +only transport — but unattended runs want to alert Microsoft Teams, Discord, or a +generic incoming webhook too. Each is a simple JSON POST with a transport-shaped +payload (Slack and a Teams MessageCard use ``text``, Discord uses ``content``); +``notify_webhook`` builds the right body and POSTs it through the egress-guarded +HTTP client. + +The transport is injectable (a ``poster`` callable or a module-level default), +so sending is unit-testable with no network. Pure standard library; imports no +``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import notify_webhook, WebhookChannel + + notify_webhook("https://hooks.slack.com/...", "Run finished", transport="slack") + notify_webhook("https://discord.com/api/webhooks/...", "Build broke", + transport="discord", title="CI") + notify_webhook("https://prod.webhook.office.com/...", "Deploy done", + transport="teams", title="Release") + + chan = WebhookChannel("https://hooks.example.com/x", transport="raw") + result = chan.send("hello") # -> WebhookResult(ok, status, transport) + +``transport`` is ``slack`` / ``discord`` / ``teams`` / ``raw``; the result's +``ok`` reflects a 2xx status. Pass a ``poster(url, payload) -> status`` to +``WebhookChannel`` / ``notify_webhook`` (or install one with +``set_default_poster``) to route through a custom transport or a test fake. + +Executor command +---------------- + +``AC_notify_webhook`` takes ``url``, ``text`` (+ optional ``transport`` / +``title``) and returns ``{ok, status, transport}``. The same operation is exposed +as the MCP tool ``ac_notify_webhook`` and as a Script Builder command under +**Tools**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 5f3c742d..af0b5473 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -72,6 +72,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v47_features_doc doc/new_features/v48_features_doc doc/new_features/v49_features_doc + doc/new_features/v50_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v50_features_doc.rst b/docs/source/Zh/doc/new_features/v50_features_doc.rst new file mode 100644 index 00000000..8d27d72c --- /dev/null +++ b/docs/source/Zh/doc/new_features/v50_features_doc.rst @@ -0,0 +1,38 @@ +多通道 Webhook 通知 +=================== + +內建的 ``notify`` 僅限桌面快顯,而 ChatOps 只內建 Slack 一種傳輸 —— 但無人值守的執行也 +想通知 Microsoft Teams、Discord 或一般的 incoming webhook。每一種都是簡單的 JSON POST, +搭配對應傳輸的酬載結構(Slack 與 Teams MessageCard 用 ``text``,Discord 用 +``content``);``notify_webhook`` 會組出正確的本文,並透過受出口守衛保護的 HTTP 用戶端 +POST 出去。 + +傳輸可注入(``poster`` 可呼叫物件或模組層級預設),因此發送在無網路下即可單元測試。純標 +準函式庫;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import notify_webhook, WebhookChannel + + notify_webhook("https://hooks.slack.com/...", "Run finished", transport="slack") + notify_webhook("https://discord.com/api/webhooks/...", "Build broke", + transport="discord", title="CI") + notify_webhook("https://prod.webhook.office.com/...", "Deploy done", + transport="teams", title="Release") + + chan = WebhookChannel("https://hooks.example.com/x", transport="raw") + result = chan.send("hello") # -> WebhookResult(ok, status, transport) + +``transport`` 為 ``slack`` / ``discord`` / ``teams`` / ``raw``;結果的 ``ok`` 反映 2xx 狀 +態。可傳入 ``poster(url, payload) -> status`` 給 ``WebhookChannel`` / ``notify_webhook`` +(或以 ``set_default_poster`` 安裝一個)以走自訂傳輸或測試假物件。 + +執行器指令 +---------- + +``AC_notify_webhook`` 接受 ``url``、``text``(以及選用的 ``transport`` / ``title``),回傳 +``{ok, status, transport}``。相同操作亦提供為 MCP 工具 ``ac_notify_webhook``,以及 Script +Builder 中 **Tools** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index df10f670..9fad47fd 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -72,6 +72,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v47_features_doc doc/new_features/v48_features_doc doc/new_features/v49_features_doc + doc/new_features/v50_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 679f2ad9..67e9cc9e 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -272,6 +272,10 @@ from je_auto_control.utils.events import ( EventEmitter, post_cloudevent, to_cloudevent, ) +# Outbound chat/webhook notifications (Slack/Discord/Teams/raw) +from je_auto_control.utils.notify_channels import ( + WebhookChannel, WebhookResult, notify_webhook, set_default_poster, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -733,6 +737,7 @@ def start_autocontrol_gui(*args, **kwargs): "rank_automation_candidates", "Asset", "AssetStore", "AssetValue", "active_environment", "EventEmitter", "post_cloudevent", "to_cloudevent", + "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 612c8b17..ee671695 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1114,6 +1114,19 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Wrap data in a CloudEvents envelope; optionally POST it.", )) + specs.append(CommandSpec( + "AC_notify_webhook", "Tools", "Notify: Webhook/Chat", + fields=( + FieldSpec("url", FieldType.STRING, + placeholder="https://hooks.example.com/..."), + FieldSpec("text", FieldType.STRING), + FieldSpec("transport", FieldType.ENUM, optional=True, + default="raw", + choices=("raw", "slack", "discord", "teams")), + FieldSpec("title", FieldType.STRING, optional=True), + ), + description="Send a Slack/Discord/Teams/raw webhook notification.", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 1850f3a6..19b7f34a 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3287,6 +3287,15 @@ def _emit_event(event_type: str, data: Any = None, return result +def _notify_webhook(url: str, text: str, transport: str = "raw", + title: Optional[str] = None) -> Dict[str, Any]: + """Adapter: send a chat/webhook notification (slack/discord/teams/raw).""" + from je_auto_control.utils.notify_channels import notify_webhook + outcome = notify_webhook(url, text, transport=transport, title=title) + return {"ok": outcome.ok, "status": outcome.status, + "transport": outcome.transport} + + class Executor: """ Executor @@ -3566,6 +3575,7 @@ def __init__(self): "AC_get_asset": _get_asset, "AC_list_assets": _list_assets, "AC_emit_event": _emit_event, + "AC_notify_webhook": _notify_webhook, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index fdacad86..604ef48b 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3180,6 +3180,26 @@ def events_tools() -> List[MCPTool]: ] +def notify_channel_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_notify_webhook", + description=("Send a chat/webhook notification. 'transport' shapes " + "the payload: slack/discord/teams/raw (Slack & Teams " + "MessageCard use text, Discord uses content). POSTs via " + "the egress-guarded HTTP client. Returns {ok, status, " + "transport}."), + input_schema=schema( + {"url": {"type": "string"}, "text": {"type": "string"}, + "transport": {"type": "string", + "enum": ["raw", "slack", "discord", "teams"]}, + "title": {"type": "string"}}, ["url", "text"]), + handler=h.notify_webhook, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4242,7 +4262,7 @@ def media_assert_tools() -> List[MCPTool]: trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, - process_mining_tools, asset_tools, events_tools, + process_mining_tools, asset_tools, events_tools, notify_channel_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 5e628bdb..01af8b10 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1538,6 +1538,14 @@ def emit_event(event_type, data=None, source="je_auto_control", return result +def notify_webhook(url, text, transport="raw", title=None): + from je_auto_control.utils.notify_channels import ( + notify_webhook as _notify) + outcome = _notify(url, text, transport=transport, title=title) + return {"ok": outcome.ok, "status": outcome.status, + "transport": outcome.transport} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/je_auto_control/utils/notify_channels/__init__.py b/je_auto_control/utils/notify_channels/__init__.py new file mode 100644 index 00000000..aa06f59e --- /dev/null +++ b/je_auto_control/utils/notify_channels/__init__.py @@ -0,0 +1,8 @@ +"""Outbound chat/webhook notifications (Slack/Discord/Teams/raw).""" +from je_auto_control.utils.notify_channels.notify_channels import ( + WebhookChannel, WebhookResult, notify_webhook, set_default_poster, +) + +__all__ = [ + "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", +] diff --git a/je_auto_control/utils/notify_channels/notify_channels.py b/je_auto_control/utils/notify_channels/notify_channels.py new file mode 100644 index 00000000..b0775700 --- /dev/null +++ b/je_auto_control/utils/notify_channels/notify_channels.py @@ -0,0 +1,90 @@ +"""Send notifications to chat/webhook endpoints (Slack/Discord/Teams/raw). + +The built-in ``notify`` is desktop-toast only and ChatOps shipped Slack as the +only transport; unattended runs want to alert Teams/Discord/generic webhooks +too. Each is a simple JSON POST with a transport-specific payload shape — Slack +and a Teams MessageCard use ``text``, Discord uses ``content`` — so this builds +the right body and POSTs it through the egress-guarded HTTP client. + +The transport is injectable (a ``poster`` callable or a module-level default), +so sending is unit-testable with no network. Pure standard library; imports no +``PySide6``. +""" +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional + +TRANSPORT_RAW = "raw" +TRANSPORT_SLACK = "slack" +TRANSPORT_DISCORD = "discord" +TRANSPORT_TEAMS = "teams" + +_STATE: Dict[str, Any] = {"poster": None} + + +@dataclass(frozen=True) +class WebhookResult: + """The outcome of a webhook notification.""" + + ok: bool + status: int + transport: str + + +def set_default_poster(poster: Optional[Callable[[str, Dict[str, Any]], int]] + ) -> None: + """Install a module-level transport ``poster(url, payload) -> status``.""" + _STATE["poster"] = poster + + +def _payload(transport: str, text: str, title: Optional[str]) -> Dict[str, Any]: + if transport == TRANSPORT_DISCORD: + body = f"**{title}**\n{text}" if title else text + return {"content": body} + if transport == TRANSPORT_TEAMS: + card: Dict[str, Any] = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "text": text, + } + if title: + card["title"] = title + return card + if transport == TRANSPORT_SLACK: + return {"text": f"*{title}*\n{text}" if title else text} + payload: Dict[str, Any] = {"text": text} + if title: + payload["title"] = title + return payload + + +def _default_poster(url: str, payload: Dict[str, Any]) -> int: + from je_auto_control.utils.http_client.http_client import http_request + response = http_request(url, method="POST", json_body=payload, timeout=10.0) + return int(response.get("status", 0)) + + +class WebhookChannel: + """A notification channel for one webhook URL and transport.""" + + def __init__(self, url: str, *, transport: str = TRANSPORT_RAW, + poster: Optional[Callable[[str, Dict[str, Any]], int]] = None + ) -> None: + """``transport`` shapes the payload; ``poster`` overrides the sender.""" + self._url = url + self._transport = transport + self._poster = poster + + def send(self, text: str, *, title: Optional[str] = None) -> WebhookResult: + """Post ``text`` (and optional ``title``) to the channel.""" + poster = self._poster or _STATE["poster"] or _default_poster + status = int(poster(self._url, _payload(self._transport, text, title))) + return WebhookResult(200 <= status < 300, status, self._transport) + + +def notify_webhook(url: str, text: str, *, transport: str = TRANSPORT_RAW, + title: Optional[str] = None, + poster: Optional[Callable[[str, Dict[str, Any]], int]] = None + ) -> WebhookResult: + """Send a one-off webhook notification; return a :class:`WebhookResult`.""" + return WebhookChannel(url, transport=transport, poster=poster).send( + text, title=title) diff --git a/test/unit_test/headless/test_notify_channels_batch.py b/test/unit_test/headless/test_notify_channels_batch.py new file mode 100644 index 00000000..61fbf8de --- /dev/null +++ b/test/unit_test/headless/test_notify_channels_batch.py @@ -0,0 +1,95 @@ +"""Headless tests for multi-channel webhook notifications. The transport is +injected (or a module-default), so no network is used. Pure stdlib, no Qt.""" +import je_auto_control as ac +from je_auto_control.utils.notify_channels import ( + WebhookChannel, notify_webhook, set_default_poster) + + +def _capturing_poster(status=200): + sent = [] + + def poster(url, payload): + sent.append((url, payload)) + return status + + return sent, poster + + +def test_slack_payload_shape(): + sent, poster = _capturing_poster() + notify_webhook("https://h/x", "hi", transport="slack", poster=poster) + assert sent[0][1] == {"text": "hi"} + + +def test_discord_uses_content_key(): + sent, poster = _capturing_poster() + notify_webhook("https://h/x", "hi", transport="discord", title="T", + poster=poster) + assert "content" in sent[0][1] + assert "text" not in sent[0][1] + assert "T" in sent[0][1]["content"] + + +def test_teams_messagecard(): + sent, poster = _capturing_poster() + notify_webhook("https://h/x", "hi", transport="teams", title="T", + poster=poster) + card = sent[0][1] + assert card["@type"] == "MessageCard" + assert card["text"] == "hi" and card["title"] == "T" + + +def test_raw_payload(): + sent, poster = _capturing_poster() + notify_webhook("https://h/x", "hi", title="T", poster=poster) + assert sent[0][1] == {"text": "hi", "title": "T"} + + +def test_result_ok_by_status(): + _, ok_poster = _capturing_poster(204) + _, bad_poster = _capturing_poster(500) + assert notify_webhook("https://h/x", "hi", poster=ok_poster).ok is True + assert notify_webhook("https://h/x", "hi", poster=bad_poster).ok is False + + +def test_channel_reuse(): + sent, poster = _capturing_poster() + channel = WebhookChannel("https://h/x", transport="slack", poster=poster) + channel.send("a") + channel.send("b") + assert [p["text"] for _, p in sent] == ["a", "b"] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip_with_default_poster(): + sent, poster = _capturing_poster(200) + set_default_poster(poster) + try: + rec = ac.execute_action([[ + "AC_notify_webhook", + {"url": "https://h/x", "text": "done", "transport": "discord"}, + ]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["ok"] is True and out["transport"] == "discord" + assert sent and sent[0][1]["content"] == "done" + finally: + set_default_poster(None) # leave global state clean + + +def test_wiring(): + assert "AC_notify_webhook" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry) + names = {t.name for t in build_default_tool_registry()} + assert "ac_notify_webhook" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert "AC_notify_webhook" in cmds + + +def test_facade_exports(): + for attr in ("WebhookChannel", "WebhookResult", "notify_webhook", + "set_default_poster"): + assert hasattr(ac, attr) + assert attr in ac.__all__ From b35e1139ab4424d1c763f4116525e46ce846bf59 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 15:00:19 +0800 Subject: [PATCH 2/3] Mark Teams MessageCard @context http URI as reviewed (Sonar S5332) --- je_auto_control/utils/notify_channels/notify_channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/je_auto_control/utils/notify_channels/notify_channels.py b/je_auto_control/utils/notify_channels/notify_channels.py index b0775700..85f0fdc3 100644 --- a/je_auto_control/utils/notify_channels/notify_channels.py +++ b/je_auto_control/utils/notify_channels/notify_channels.py @@ -43,6 +43,8 @@ def _payload(transport: str, text: str, title: Optional[str]) -> Dict[str, Any]: if transport == TRANSPORT_TEAMS: card: Dict[str, Any] = { "@type": "MessageCard", + # NOSONAR python:S5332 reason: fixed MessageCard schema identifier + # required verbatim by Microsoft Teams, not a fetched URL. "@context": "http://schema.org/extensions", "text": text, } From 4031a6f0a03b94a703c8bb95a254b166e4977cb5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 15:05:45 +0800 Subject: [PATCH 3/3] Move NOSONAR S5332 inline on the Teams @context line --- je_auto_control/utils/notify_channels/notify_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/je_auto_control/utils/notify_channels/notify_channels.py b/je_auto_control/utils/notify_channels/notify_channels.py index 85f0fdc3..14a5b588 100644 --- a/je_auto_control/utils/notify_channels/notify_channels.py +++ b/je_auto_control/utils/notify_channels/notify_channels.py @@ -41,11 +41,11 @@ def _payload(transport: str, text: str, title: Optional[str]) -> Dict[str, Any]: body = f"**{title}**\n{text}" if title else text return {"content": body} if transport == TRANSPORT_TEAMS: + # The @context below is Microsoft Teams' fixed MessageCard schema + # identifier (required verbatim), not a URL that is ever fetched. card: Dict[str, Any] = { "@type": "MessageCard", - # NOSONAR python:S5332 reason: fixed MessageCard schema identifier - # required verbatim by Microsoft Teams, not a fetched URL. - "@context": "http://schema.org/extensions", + "@context": "http://schema.org/extensions", # NOSONAR python:S5332 "text": text, } if title: