Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions .agents/skills/docs-seo-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,12 @@ After making fixes, review every change before presenting to the user. Run throu

If instructed to send a report to Slack, post a summary after the audit completes. This works regardless of whether fixes were made.

**Post at most once per run.** This mirrors the "Never post twice for one run" rule in `.agents/references/skill-authoring-guidelines.md` and the same-run guard in `weekly-404-monitor`. Revise the summary wording as many times as you like before sending — never after. Once a post attempt succeeds (`ok: true`), treat the notification as terminal for this run: do not re-post to fix a typo, tighten wording, or add a detail you forgot. A second, "cleaned up" post is a worse outcome than an imperfect first one.

**Check for an existing same-day post before sending.** Before posting, check whether a top-level message matching `*SEO Audit — <today's date>*` already exists in the target channel — this catches both a rerun of this agent for the same day and a mid-run retry after an apparent failure that actually succeeded. Skip the post if one is found.

1. Check if `BUZZ_SLACK_TOKEN` environment variable exists.
2. If the token exists, send a summary to the channel the user specified (or the channel configured in the agent's instructions).
2. If the token exists, compose the summary (see the categorization and format rules below) and send it with the one-shot poster script in the "Sending the notification" section, which performs the dedupe check and the post in a single invocation.

**Categorizing issues in the summary:** Before composing the message, cross-reference every issue against the title exceptions list above and check whether the issue has a local source file. Classify each issue into exactly one bucket:
- **Fixed** — issues you resolved in this run
Expand Down Expand Up @@ -299,21 +303,22 @@ PR: <pr_url>
• <N> titles too short/long
```

Send using:
### Sending the notification

Use the one-shot poster script rather than a raw `curl chat.postMessage` call. `curl` has no dedupe check and no `ok` verification, which is exactly the gap that let this skill post the same run's summary twice: an agent revised the wording mid-run and re-sent instead of treating the first successful post as final. The script (following the `aeo_crosslink_audit` one-shot-poster pattern) reads the token from the environment, checks channel history for an existing same-day post, and posts only when none is found:

```bash
curl -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $BUZZ_SLACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel": "<CHANNEL_ID>",
"text": "<formatted_summary>",
"unfurl_links": false,
"unfurl_media": false
}'
cat > /tmp/seo-audit-summary.txt << 'EOF'
<formatted_summary>
EOF

python3 .agents/skills/docs-seo-audit/scripts/notify_seo_slack.py \
--channel "<CHANNEL_ID>" \
--date "$(date +%Y-%m-%d)" \
--message-file /tmp/seo-audit-summary.txt
```

If `BUZZ_SLACK_TOKEN` is not set, skip the notification and note that the token is required.
The script exits `0` whether it skipped (dedupe hit), posted successfully, or found no token. It exits non-zero both for an actual Slack API post failure and for a same-day history check that could not be verified (a paginated `conversations.history` call failing or erroring) — an unverified history check is never treated as an empty one, since that would risk posting a duplicate on exactly the ambiguous retry path this script exists to prevent. A non-zero exit means the post did not go through, so a retry there is a fresh attempt, not a duplicate of a completed post; do not retry after a `0` exit. If `BUZZ_SLACK_TOKEN` is not set, the script skips the notification and says so on stderr — no separate check is needed.

## Dependencies

Expand Down
152 changes: 152 additions & 0 deletions .agents/skills/docs-seo-audit/scripts/notify_seo_slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""One-shot Slack poster for the SEO audit summary, with same-day dedupe.

Ensures a single audit run never posts its Slack summary more than once,
even across a "let me clean up the wording" retry: it checks the channel
for an existing top-level SEO Audit message dated today before posting,
and skips when one is already there. There is no separate post-then-check
step to race against, so an agent that runs this twice in one turn cannot
produce two messages.

Usage:
python3 notify_seo_slack.py --channel CHANNEL_ID --date YYYY-MM-DD \
--message-file /path/to/summary.txt

Exits 0 when the notification was posted, skipped due to dedupe, or
skipped because the token env var is unset. Exits non-zero when the
Slack API fails to post, or when the same-day history check itself
could not be verified — an unverified history check is treated as a
failure rather than an empty history, so a duplicate is never posted
on the ambiguous retry path. A non-zero exit is the only case that
warrants a retry.
"""
import argparse
import datetime
import json
import os
import sys
import urllib.error
import urllib.request

SLACK_API = "https://slack.com/api"


def is_top_level(message: dict) -> bool:
"""A message is top-level (not a reply within a thread) when it has no
``thread_ts``, or ``thread_ts`` equals its own ``ts`` (the parent of a
thread is still that thread's original top-level post)."""
thread_ts = message.get("thread_ts")
return thread_ts is None or thread_ts == message.get("ts")


def find_existing_post(messages: list, date_str: str) -> bool:
"""Return True if ``messages`` already contains a top-level SEO Audit
summary for ``date_str`` (e.g. ``2026-08-28``)."""
prefix = f"*SEO Audit — {date_str}*"
for message in messages:
if not is_top_level(message):
continue
if message.get("text", "").startswith(prefix):
return True
return False


def day_start_ts(date_str: str) -> float:
"""Return the Unix timestamp for the start (UTC midnight) of ``date_str``,
used as the ``oldest`` bound so pagination can stop once history is older
than the target date."""
day = datetime.datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=datetime.timezone.utc)
return day.timestamp()


def fetch_messages_for_date(token: str, channel: str, date_str: str, page_size: int = 200) -> list:
"""Fetch every message posted on or after the start of ``date_str`` by
paginating ``conversations.history`` with ``oldest`` set to that day's
start. A fixed-size single page can push an earlier same-day summary out
of a busy channel's dedupe window, so this keeps requesting the next
cursor until Slack reports no more pages."""
oldest = day_start_ts(date_str)
messages = []
cursor = None
while True:
url = (
f"{SLACK_API}/conversations.history?channel={channel}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] URL-encode the opaque next_cursor value (and assert the encoded follow-up URL in the pagination regression test). Slack cursors can contain query-reserved characters: a + becomes a space and an & starts a new parameter here, so the next request can use an invalid cursor, miss later same-day messages, and allow a duplicate summary. Build the query with urllib.parse.urlencode instead of interpolating the cursor.

f"&limit={page_size}&oldest={oldest}"
)
if cursor:
url += f"&cursor={cursor}"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
result = json.load(resp)
if not result.get("ok"):
raise RuntimeError(f"conversations.history failed: {result.get('error')}")
messages.extend(result.get("messages", []))
cursor = result.get("response_metadata", {}).get("next_cursor")
if not cursor:
break
return messages


def post_message(token: str, channel: str, text: str) -> dict:
payload = json.dumps(
{
"channel": channel,
"text": text,
"unfurl_links": False,
"unfurl_media": False,
}
).encode()
req = urllib.request.Request(
f"{SLACK_API}/chat.postMessage",
data=payload,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--channel", required=True, help="Slack channel ID to post to")
parser.add_argument("--date", required=True, help="Today's date, e.g. 2026-08-28")
parser.add_argument("--message-file", required=True, help="Path to the message text to post")
parser.add_argument("--token-env", default="BUZZ_SLACK_TOKEN", help="Env var holding the Slack bot token")
args = parser.parse_args()

token = os.environ.get(args.token_env, "")
if not token:
print(f"{args.token_env} not set — skipping Slack notification", file=sys.stderr)
return 0

with open(args.message_file, "r", encoding="utf-8") as f:
message = f.read()

try:
recent = fetch_messages_for_date(token, args.channel, args.date)
except (urllib.error.URLError, RuntimeError) as exc:
# An unverified history check can't rule out an earlier same-day post,
# so fail closed rather than risk sending a duplicate: report the
# failure and skip the post instead of posting on an empty history.
print(f"error: could not check channel history ({exc}); skipping post to avoid a possible duplicate", file=sys.stderr)
return 1

if find_existing_post(recent, args.date):
print(f"Skipping post: a SEO Audit summary for {args.date} already exists in {args.channel}.")
return 0

try:
result = post_message(token, args.channel, message)
except urllib.error.URLError as exc:
print(f"Slack post failed: {exc}", file=sys.stderr)
return 1

if not result.get("ok"):
print(f"Slack error: {result.get('error')}", file=sys.stderr)
return 1

print("Posted SEO Audit summary to Slack.")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading