diff --git a/docs/cookbook/07-slack.md b/docs/cookbook/07-slack.md index 6955754..bd6eb6b 100644 --- a/docs/cookbook/07-slack.md +++ b/docs/cookbook/07-slack.md @@ -33,6 +33,11 @@ Output is the CLI's piped-mode bytes in a code fence. stdout in the bot is a pipe, so by the CLI's own contract there is no colour to strip and the bytes match what `grapharc … | cat` prints on the host. +A successful `viz` reply carries one extra line: a *render this diagram* link. +The whole diagram is zlib-compressed into the URL fragment, which a browser +never sends to any server — mermaid.live's JavaScript renders it locally, so +following the link ships the diagram to no one. + ## Slack app setup (once, ~5 minutes) 1. → **Create New App** → *From a manifest*, pick diff --git a/grapharc/slack/format.py b/grapharc/slack/format.py index 7290c93..37c47ad 100644 --- a/grapharc/slack/format.py +++ b/grapharc/slack/format.py @@ -13,7 +13,10 @@ from __future__ import annotations +import base64 +import json import shlex +import zlib from grapharc.slack.runner import CommandResult @@ -39,6 +42,19 @@ def _truncate(body: str) -> tuple[str, int]: return body[:MAX_FENCE_CHARS], len(body) - MAX_FENCE_CHARS +def mermaid_live_url(code: str) -> str: + """A mermaid.live link with the whole diagram compressed into the fragment. + + The editor's `#pako:` form: zlib-deflated JSON, base64url. Everything after + the `#` is a URL fragment, which a browser never sends to the server — the + site's JavaScript renders the diagram locally, so following the link ships + the diagram to no one. + """ + payload = json.dumps({"code": code, "mermaid": {"theme": "default"}}) + packed = base64.urlsafe_b64encode(zlib.compress(payload.encode())).decode() + return f"https://mermaid.live/view#pako:{packed}" + + def format_result(result: CommandResult) -> str: shown = shlex.join(["grapharc", *result.argv]) if result.exit_code is None: @@ -64,4 +80,8 @@ def format_result(result: CommandResult) -> str: parts.append(f"_…{cut} more characters not shown._") if len(parts) == 1: parts.append("_(no output)_") + # `viz` prints raw Mermaid, which Slack shows as text. One extra line makes + # it a diagram: a link that renders it in the browser, locally. + if result.argv[:1] == ["viz"] and result.exit_code == 0 and result.stdout.strip(): + parts.append(f"<{mermaid_live_url(result.stdout.strip())}|render this diagram>") return "\n".join(parts) diff --git a/tests/test_slack_gateway.py b/tests/test_slack_gateway.py index 6fedb61..1fd41a6 100644 --- a/tests/test_slack_gateway.py +++ b/tests/test_slack_gateway.py @@ -15,7 +15,7 @@ from grapharc.slack.command import SlackCommandError, parse_command, usage_text from grapharc.slack.config import SlackBotConfig, SlackConfigError -from grapharc.slack.format import MAX_FENCE_CHARS, format_result +from grapharc.slack.format import MAX_FENCE_CHARS, format_result, mermaid_live_url from grapharc.slack.runner import CommandResult, run_command # --------------------------------------------------------------------------- @@ -147,6 +147,41 @@ def test_a_fence_in_the_output_cannot_break_out(): assert "\n```\n" not in body.rsplit("```", 1)[0] +def test_a_successful_viz_gets_a_render_link_and_the_url_round_trips(): + import base64 + import json + import zlib + + mermaid = 'flowchart TD\n start((start)) --> n0["triage"]' + result = CommandResult( + argv=["viz", "t.jsonl", "r1"], + exit_code=0, + stdout=mermaid + "\n", + stderr="", + duration_seconds=0.1, + timeout_seconds=60, + ) + message = format_result(result) + assert "mermaid.live/view#pako:" in message + + packed = mermaid_live_url(mermaid).split("#pako:", 1)[1] + decoded = json.loads(zlib.decompress(base64.urlsafe_b64decode(packed))) + assert decoded["code"] == mermaid + + +def test_a_failed_or_non_viz_command_gets_no_render_link(): + for argv, code in ((["viz", "t.jsonl", "r1"], 1), (["trace", "t.jsonl"], 0)): + result = CommandResult( + argv=argv, + exit_code=code, + stdout="flowchart TD", + stderr="", + duration_seconds=0.1, + timeout_seconds=60, + ) + assert "mermaid.live" not in format_result(result) + + # --------------------------------------------------------------------------- # Config and the bot's import posture. # ---------------------------------------------------------------------------