blob: f8e3dda4105f29ffb30a5bd2e1b421178f452169 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Slack notification helper for Apache Juneau.
Posts a text payload to the Slack Workflow Builder webhook configured in
``.cursor/slack.local.yml`` (key ``notify_webhook_url``). The workflow then
posts the message into your personal notifications channel (the channel
configured as the "Send a message" recipient in the workflow — by
convention the same channel you set as ``default_channel_id``).
Why this exists
===============
Messages sent through the ``user-slack`` MCP are authored as *you*, and
Slack deliberately suppresses pushes for messages the recipient sent
themselves. A Slack Workflow Builder webhook posts from the workflow app's
identity instead, which does push on iOS/Android — same code path as a
message from any other human or app you share a channel with.
Because the workflow posts to a real channel (not a DM), the message has a
``message_ts`` you can thread-reply to. That's what makes "ask me on Slack
and wait" round-trips work: the push wakes your phone, you tap to open
Slack, reply in the thread, and Cursor polls that same thread for the
reply — one message, one thread, one conversation.
This is the backing script for:
- ``/slack notify <message>`` in ``.cursor/commands/slack.md`` (fire-and-
forget push).
- The "Notify me on Slack when done" / "Ask me on Slack and wait" trigger
phrases in ``AGENTS.md``. The wait-loop path combines this script with a
subsequent ``slack_read_channel`` lookup to resolve the posted message's
``message_ts``.
Matching the posted message
===========================
The Workflow Builder webhook response doesn't include a ``message_ts`` for
the message the workflow ends up posting, so callers that need the
timestamp (``/slack notify``, ``/slack ask``, the AGENTS.md wait loop)
have to locate the post in the channel after the fact. This script prints
the *pre-send* Unix timestamp on stdout as ``T0=<float>`` on success.
Callers should:
1. Sleep ~3–5 seconds to let Slack's workflow engine run.
2. ``slack_read_channel`` on ``default_channel_id`` (limit 5).
3. Pick the newest message authored by a bot (author id starts with ``B``)
whose ``message_ts`` is ``>= T0 - 5`` (small jitter tolerance). That's
the workflow-posted message.
No token is appended to the message body — the post goes out clean.
Usage
=====
::
python3 scripts/slack-notify.py "Build finished."
echo "Some message" | python3 scripts/slack-notify.py -
Exit codes
==========
:0: webhook accepted the payload (HTTP 200); ``T0=<float>`` printed on stdout.
:1: webhook URL not configured in ``slack.local.yml``.
:2: HTTP error posting to the webhook.
:3: missing message argument.
Setup
=====
See ``.cursor/slack.local.example.yml`` for the full Slack Workflow
Builder setup (trigger type **must** be "From a webhook"; the "Send a
message" step recipient **must** be your ``default_channel_id``
notifications channel, not yourself).
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / ".cursor" / "slack.local.yml"
def _load_webhook_url() -> str | None:
"""Extract notify_webhook_url from slack.local.yml without requiring PyYAML.
The config file is a flat, single-line key/value YAML; a tiny parser is
enough and avoids adding a dependency for a one-line read.
"""
if not CONFIG_PATH.exists():
return None
for raw in CONFIG_PATH.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
if key.strip() != "notify_webhook_url":
continue
value = value.strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
return value or None
return None
def _read_message(args: argparse.Namespace) -> str | None:
if not args.message:
return None
if len(args.message) == 1 and args.message[0] == "-":
text = sys.stdin.read()
return text.strip() or None
return " ".join(args.message).strip() or None
def _parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="slack-notify.py",
description="POST a message to the Slack Workflow Builder webhook in .cursor/slack.local.yml.",
)
p.add_argument(
"message",
nargs="*",
help="Message text. Pass '-' alone to read from stdin.",
)
return p.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = _parse_args(argv)
message = _read_message(args)
if not message:
print("error: no message provided (pass as arg or '-' for stdin)", file=sys.stderr)
return 3
url = _load_webhook_url()
if not url:
print(
"error: notify_webhook_url not set in .cursor/slack.local.yml\n"
" see .cursor/slack.local.example.yml for setup steps",
file=sys.stderr,
)
return 1
if not url.startswith("https://hooks.slack.com/triggers/"):
print(
f"error: notify_webhook_url does not look like a Workflow Builder webhook URL\n"
f" got: {url}\n"
f" expected: https://hooks.slack.com/triggers/...\n"
f" if yours starts with https://slack.com/shortcuts/... the workflow\n"
f" was created with the wrong trigger type; recreate with 'From a webhook'",
file=sys.stderr,
)
return 1
t0 = time.time()
payload = json.dumps({"text": message}).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8", errors="replace").strip()
if resp.status != 200:
print(f"error: webhook returned HTTP {resp.status}: {body}", file=sys.stderr)
return 2
print(f"T0={t0:.3f}")
return 0
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace").strip()
print(f"error: webhook HTTPError {e.code}: {detail}", file=sys.stderr)
return 2
except urllib.error.URLError as e:
print(f"error: webhook URLError: {e.reason}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))