Playbook: Discord Channel and Bot Provisioning via Playwright

Service: Discord Developer Portal (https://discord.com/developers/applications) Script: scripts/discord-provisioning.py SOPS file (default): secrets/monitoring.env.sops Playwright runtime: bms-4 (root@54.36.123.110), Python 3.12 venv at /opt/playwright-venv

Scope: This playbook covers the full provisioning lifecycle — creating channels, creating bot applications, adding bots to guilds, extracting tokens, and creating/deleting incoming webhooks. It extends (does NOT replace) docs/playbooks/rotate-discord-bot-token.md, which handles token rotation for the existing p24-infra-admin bot.

Webhook create/delete: REST API is the primary path, not Playwright — corrected 2026-08-04. create-webhook/delete-webhook first try the Discord Bot REST API (DISCORD_BOT_TOKEN + the bot holding Manage Webhooks on the target channel) — no browser, no login, no 2FA, no CAPTCHA risk. Proven live against the real p24-infra guild the same day this was written. Playwright is kept only as a fallback for channels the bot hasn’t been granted access to yet — see the live-run finding directly below before relying on it. See §Step 2b for both paths.

Live-supervised run 2026-08-04 (issue #2666) — key finding: Playwright login hits hCaptcha. The first-ever live run of this playbook’s login flow (login_with_retrydiscord_login) reached Discord’s login form correctly (email + password submitted) but was immediately served an hCaptcha “Wait! Are you human?” challenge before 2FA — confirmed via the diagnostic screenshot (page.screenshot on RuntimeError, per the Self-Healing Table below). This is not solvable headless; it is Discord/Cloudflare bot-detection on the browser fingerprint itself, not a selector-drift bug. The built-in MAX_LOGIN_ATTEMPTS = 2 cap stopped the run safely with no further risk to the account. Practical effect: every action in this file that requires a live login (create-bot, create-channel, add-bot-to-guild, and the Playwright fallback for create-webhook/delete-webhook) is currently blocked by this, not just untested. Only the REST API webhook path (§Step 2b) is proven to work end-to-end today. Fixing the Playwright path (headed browser + display, a different anti-detection approach, or accepting it as human-only) is an open follow-up, not yet solved.

Separately found and fixed during the same session: Discord/Cloudflare returns JSON error 40333 (“internal network error”) and blocks the request outright when a REST call has no User-Agent header — cost significant debugging time before being traced to Discord’s own documented error code table. Every REST call in this file now sends DISCORD_USER_AGENT; apply the same fix to any other ad-hoc Discord API call written in the future.


When to use

  • Adding a new Discord channel to an existing guild
  • Creating a new bot application and adding it to a guild
  • Extracting a bot token for the first time after creating a bot
  • Automating bot provisioning for new infra services (alert bots, report bots, etc.)
  • Creating a new incoming webhook (e.g. for a new error-notification channel)
  • Rotating an existing webhook URL (compromised, or its channel changed) — create-webhook for the replacement, then delete-webhook for the old one once distribution is confirmed

Prerequisites

Playwright on bms-4

Playwright 1.48+ requires Python 3.12. On bms-4 (Ubuntu 22.04), install via:

# Install Python 3.12 (deadsnakes PPA)
add-apt-repository -y ppa:deadsnakes/ppa
apt-get update -qq
apt-get install -y python3.12 python3.12-venv
 
# Create venv and install playwright
python3.12 -m venv /opt/playwright-venv
/opt/playwright-venv/bin/pip install playwright
/opt/playwright-venv/bin/playwright install chromium --with-deps
 
# Smoke test — expected output: playwright_ok
/opt/playwright-venv/bin/python3 -c "
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
b = p.chromium.launch(headless=True)
print('playwright_ok')
b.close()
p.stop()
"

BMS4 smoke test result (2026-07-03):

  • playwright 1.48.0 installed into /opt/playwright-venv via pip
  • Chromium 149.0.7827.55 (headless-shell) downloaded to /root/.cache/ms-playwright/
  • Python 3.10 system packages: incompatible with Playwright 1.40+ (inspect.FrameInfo missing)
  • Resolution: Python 3.12 venv required (install via deadsnakes PPA above)

⚠️ n8n subprocess incompatibility — direct SSH only, NEVER via n8n Code node

Finding from the Phase 1 smoke test (issue #2666): the n8n external worker running on bms-4 injects a custom inspect module into the Python subprocess environment it spawns for Code nodes, shadowing the stdlib inspect module. Playwright’s sync API imports and introspects inspect at load time (inspect.FrameInfo etc.) — with the shadowed module, the import fails or behaves incorrectly in ways that look like a Playwright bug but are actually an n8n environment problem.

Playwright works correctly outside n8n — confirmed against /opt/playwright-venv + Chromium 149 via direct SSH.

Rule: always run discord-provisioning.py via direct SSH into bms-4 (root@54.36.123.110), activating /opt/playwright-venv yourself. Never invoke it from an n8n Code node or n8n subprocess — even indirectly (e.g. an n8n workflow that shells out to this script). If you must trigger provisioning from an n8n workflow, have the workflow call out to a plain SSH/webhook target that runs the script outside n8n’s own Python subprocess, not exec/Code node Python execution.

# Correct — direct SSH, not via n8n
ssh root@54.36.123.110 "source /opt/playwright-venv/bin/activate && \
  python3 /opt/p24-infra/scripts/discord-provisioning.py --action create-bot ... "

Discord credentials (from SOPS)

Read safely on bms-4 — never print values:

export SOPS_AGE_KEY_FILE=/root/.age/p24-infra-keys.txt
 
DISCORD_EMAIL="radieu@gmail.com"
DISCORD_PASS=$(sops --decrypt --input-type dotenv --output-type dotenv \
  /opt/p24-infra/secrets/monitoring.env.sops \
  | grep "^discord_radieu_password=" | cut -d= -f2-)
 
DISCORD_TOTP=$(sops --decrypt --input-type dotenv --output-type dotenv \
  /opt/p24-infra/secrets/monitoring.env.sops \
  | grep "^DISCORD_TOTP_SECRET=" | cut -d= -f2-)
unset DISCORD_PASS DISCORD_TOTP  # clear immediately after use
SOPS keyDescription
discord_radieu_passwordPassword for radieu@gmail.com Discord account
DISCORD_TOTP_SECRETTOTP secret for 2FA (base32 format, for pyotp.TOTP(secret).now())

TOTP / 2FA readiness — check BEFORE spending a live login attempt

The whole flow requires logging into the real Discord account. If 2FA is enabled (expected) and DISCORD_TOTP_SECRET is not present in secrets/monitoring.env.sops, the login step will hit a live 2FA prompt with no way to complete it unattended — discord_login() raises immediately with a clear message, and the run must escalate to a human-action GH issue rather than retry (retrying a login you can’t complete just burns attempts against the lockout/CAPTCHA risk below).

Always run this presence check (name only, never the value) as the very first step of any supervised run, before opening a browser:

sops --decrypt --input-type dotenv --output-type dotenv \
  /opt/p24-infra/secrets/monitoring.env.sops \
  | grep -c "^DISCORD_TOTP_SECRET="
# 1 = present, can attempt automated 2FA · 0 = escalate to human-action, do not attempt login

As of this playbook update (2026-08-01, issue #2666 Phase 2): this presence check has not yet been run against the live SOPS file — no live SOPS decrypt or Discord login was performed while shipping the Phase 2 code/tests/docs in this PR (see status note at the top of this playbook). Confirming presence/absence of DISCORD_TOTP_SECRET is the first action item for whoever runs the actual supervised session below.


Step 1 — Discord Login

The login flow is reused by all provisioning actions.

Playwright selectors (stable as of Discord web v300+):

page.goto("https://discord.com/login")
page.fill('input[name="email"]', email)         # email field
page.fill('input[name="password"]', password)   # password field
page.click('button[type="submit"]')              # Log In button
# If 2FA appears (URL still on /login after submit):
if "/login" in page.url:
    import pyotp
    totp_code = pyotp.TOTP(totp_secret).now()
    page.fill('input[autocomplete="one-time-code"]', totp_code)
    page.click('button[type="submit"]')
page.wait_for_url("**/channels/**", timeout=15000)

Step 2 — Create a Channel

REST API path (preferred — no browser, same pattern as webhooks in §Step 2b)

Verified against Discord’s own API reference 2026-08-04: POST /guilds/{guild_id}/channels genuinely exists (unlike bot/application creation — see the status note at the top of this file). Requires only DISCORD_BOT_TOKEN + the bot holding Manage Channels in the guild.

r = requests.post(f'{DISCORD_API}/guilds/{guild_id}/channels',
                   headers={**HEADERS, 'Content-Type': 'application/json'},
                   json={'name': channel_name, 'type': 0})   # 0=text, 2=voice
channel_id = r.json()['id']

create_channel_rest() in scripts/discord-provisioning.py wraps this; action_create_channel tries it automatically first, falling back to Playwright below only if DISCORD_BOT_TOKEN is missing or the call fails (typically 403 — bot lacks Manage Channels in this guild).

Rename / move / delete an existing channel — REST API only, no Playwright equivalent. PATCH /channels/{id} (rename and/or move via parent_id) and DELETE /channels/{id} are stable, permission-gated endpoints with no login/CAPTCHA surface at all — there is nothing a browser fallback would add, so update-channel/delete-channel don’t have one; a missing DISCORD_BOT_TOKEN or a 403 just fails outright (exit 1/2) rather than falling back.

# Rename and/or move (only send the fields you're changing)
requests.patch(f'{DISCORD_API}/channels/{channel_id}',
                headers={**HEADERS, 'Content-Type': 'application/json'},
                json={'name': new_name, 'parent_id': new_category_id})
 
# Delete
requests.delete(f'{DISCORD_API}/channels/{channel_id}', headers=HEADERS)
python3 discord-provisioning.py --action update-channel --channel <CHANNEL_ID> \
  --name "new-name" --category <NEW_CATEGORY_ID> --sops-file secrets/monitoring.env.sops
 
python3 discord-provisioning.py --action delete-channel --channel <CHANNEL_ID> \
  --sops-file secrets/monitoring.env.sops

update_channel_rest()/delete_channel_rest() wrap these; --name and/or --category — at least one required for update-channel, either renames, moves, or both in one call.

Playwright path (fallback — currently blocked by hCaptcha, see status note above)

Navigation path: Discord App → guild sidebar → hover category → click + → set type/name → Create Channel

Playwright flow:

page.goto(f"https://discord.com/channels/{guild_id}")
# Hover over the target category to reveal + button
page.locator(f'[data-list-item-id="channels___{category_id}"]').hover()
page.click('[aria-label="Add Channel"]')          # + icon next to category
# Select channel type (0=text, 2=voice)
page.click(f'[value="{channel_type}"]')
# Enter channel name
page.fill('input[placeholder="new-channel"]', channel_name)
page.click('button:has-text("Create Channel")')
# Extract new channel ID from redirect URL
page.wait_for_url(f"**/channels/{guild_id}/**", timeout=10000)
channel_id = page.url.split("/")[-1]

Guild and category IDs: obtained via Discord Developer Mode (Settings → Advanced → Developer Mode → right-click guild or category → Copy ID).


Step 2b — Create or Delete a Webhook

REST API path (preferred — proven live 2026-08-04, no browser)

No login, no 2FA, no CAPTCHA risk. Requires only DISCORD_BOT_TOKEN (already in secrets/monitoring.env.sops) and the bot holding Manage Webhooks on the target channel (Server Settings → Roles → the bot’s role → Manage Webhooks, or per-channel via Channel → Edit Channel → Permissions — a one-time human grant, not part of the automation).

import requests
 
DISCORD_API = 'https://discord.com/api/v10'
# Cloudflare returns JSON error 40333 and blocks the request if this is missing:
HEADERS = {'Authorization': f'Bot {bot_token}', 'User-Agent':
           'p24-infra-bot (https://github.com/radieu/p24-infra, 1.0)'}
 
# Create
r = requests.post(f'{DISCORD_API}/channels/{channel_id}/webhooks',
                   headers={**HEADERS, 'Content-Type': 'application/json'},
                   json={'name': webhook_name})
data = r.json()
webhook_url = f"https://discord.com/api/webhooks/{data['id']}/{data['token']}"  # shown once
 
# List (to find an existing webhook's id by name, for delete)
r = requests.get(f'{DISCORD_API}/channels/{channel_id}/webhooks', headers=HEADERS)
webhook_id = next(w['id'] for w in r.json() if w['name'] == webhook_name)
 
# Delete
requests.delete(f'{DISCORD_API}/webhooks/{webhook_id}', headers=HEADERS)

create_webhook_rest() / find_webhook_by_name_rest() / delete_webhook_rest() in scripts/discord-provisioning.py wrap this. action_create_webhook/action_delete_webhook try this path automatically first — only falling back to Playwright below if DISCORD_BOT_TOKEN is missing from SOPS or the REST call fails (typically 403 — bot lacks the permission on this specific channel).

Known 403 on the p24-infra-scripts-errors-class channels as of this writing: the bot did not have Manage Webhooks anywhere until granted on the p24-monitoring channel during this session’s live test. Any other channel’s webhook (e.g. a channel the bot has never been explicitly granted access to) will 403 on REST and fall back to the Playwright path below — which is currently blocked by hCaptcha (see the status note at the top of this file). Grant the permission on each new target channel before relying on REST there.

Playwright path (fallback — currently blocked by hCaptcha, see status note above)

Navigation path (create): channel → gear icon (Edit Channel) → Integrations tab → Create Webhook → rename → Copy Webhook URL → Save Changes

Playwright flow:

page.goto(f"https://discord.com/channels/{guild_id}/{channel_id}")
page.click('[aria-label="Edit Channel"]')
page.click('div[role="tab"]:has-text("Integrations")')
page.click('button:has-text("Create Webhook")')
page.click('div[class*="webhookName"]')             # open the new default-named webhook
page.locator('input[class*="webhookNameInput"]').fill(webhook_name)
# "Copy Webhook URL" writes to the OS clipboard, not the DOM — read it back via the
# page's own clipboard API instead (requires the context permission below).
page.click('button:has-text("Copy Webhook URL")')
webhook_url = page.evaluate('navigator.clipboard.readText()')
page.click('button:has-text("Save Changes")')

Required context permission (headless Chromium denies clipboard access by default):

context = browser.new_context(permissions=['clipboard-read', 'clipboard-write'])

Navigation path (delete): channel → gear icon → Integrations tab → click the named webhook row → Delete Webhook → confirm

page.click('button:has-text("Delete Webhook")')
page.click('button:has-text("Yes, do it!")')

CLI usage:

# Create — persists to one or two SOPS files atomically
python3 discord-provisioning.py --action create-webhook \
  --webhook-name "p24-monitoring" --guild <GUILD_ID> --channel <CHANNEL_ID> \
  --sops-file secrets/n8n-bms4.env.sops --sops-file2 secrets/vps-h1.env.sops \
  --secret-key DISCORD_WEBHOOK_URL --issue 2889
 
# Delete — clean-up step for rotation, run only after the new URL is confirmed live
python3 discord-provisioning.py --action delete-webhook \
  --webhook-name "<old-webhook-display-name>" --guild <GUILD_ID> --channel <CHANNEL_ID>

Both commands try REST first automatically (no flag needed) and only fall back to a live Playwright login if DISCORD_BOT_TOKEN is absent from SOPS or the REST call fails — --guild is only actually used by the Playwright fallback path but is still required by the CLI parser for both actions.

Rotation order: create the new webhook first, distribute + verify it, then delete the old one — never delete-then-create. A channel briefly having two live webhooks is harmless; a channel briefly having zero breaks whatever posts to it.

Selectors still not live-verified — moot for now, not just untested: the live run never got past Discord’s login page (hCaptcha, see the status note at the top), so these selectors have never actually been exercised regardless of their correctness. Don’t assume this path works until the hCaptcha problem is solved; use REST API above instead.


Step 3 — Create a Bot Application

Navigation path: Developer Portal → New Application → name → Bot → Add Bot → Reset Token

page.goto("https://discord.com/developers/applications")
page.click('button:has-text("New Application")')
page.fill('input[placeholder="Give your application a name"]', app_name)
# Accept ToS checkbox if shown
try:
    page.check('input[type="checkbox"]', timeout=2000)
except Exception:
    pass
page.click('button:has-text("Create")')
page.wait_for_url("**/developers/applications/**", timeout=10000)
app_id = page.url.split("/")[5]   # extract numeric app ID from URL
 
# Navigate to Bot section and add bot
page.click('a:has-text("Bot")')
page.click('button:has-text("Add Bot")')
page.click('button:has-text("Yes, do it!")')
page.wait_for_selector('h5:has-text("TOKEN")', timeout=10000)

Step 4 — Extract Bot Token (single-reveal pattern)

Critical: The token is displayed only once. Capture it immediately and never log it.

page.click('button:has-text("Reset Token")')
page.click('button:has-text("Yes, do it!")')
page.wait_for_selector('[class*="token"]', timeout=10000)
# Read token from DOM — never pass to print() or any log sink
token_el = (page.query_selector('[class*="tokenValue"]')
            or page.query_selector('.token-cell'))
if not token_el:
    raise RuntimeError("Token element not found — Discord UI may have changed selectors")
token = token_el.inner_text().strip()
# Mandatory: close browser immediately after capture
context.close()
browser.close()
playwright_instance.stop()
del token_el

Step 5 — Add Bot to Guild

Navigation path: Application → OAuth2 → URL Generator → scopes: bot → permissions → open URL → authorize

page.goto(f"https://discord.com/developers/applications/{app_id}/oauth2/url-generator")
page.check('label:has-text("bot") input[type="checkbox"]')   # bot scope
page.check('[title="Send Messages"] input[type="checkbox"]') # 2048
page.check('[title="Read Message History"] input[type="checkbox"]') # 65536
# Read generated URL
generated_url = page.input_value('input[readonly]')
 
# Open authorization URL in same browser
page.goto(generated_url)
page.select_option('select', value=str(guild_id))
page.click('button:has-text("Authorize")')
page.wait_for_url("**/oauth2/authorized**", timeout=15000)

Permission integer reference:

PermissionInteger
Send Messages2048
Read Message History65536
Embed Links16384
Attach Files32768

Storage Matrix

WhatSOPS keyGH SecretService
New bot tokenDISCORD_<BOT_NAME>_TOKENDISCORD_<BOT_NAME>_TOKENconsuming service
Webhook URLDISCORD_WEBHOOK_URL / P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (per consumer)— (webhooks are not stored in GH Secrets)see secret-rotation-access-matrix.md §Key cross-file duplications for which SOPS files each key lives in
Login passworddiscord_radieu_passwordalready in monitoring.env.sops
TOTP secretDISCORD_TOTP_SECRETalready in monitoring.env.sops

Naming convention: DISCORD_<SERVICE>_BOT_TOKEN where <SERVICE> identifies the consuming service (e.g. DISCORD_ALERTS_BOT_TOKEN, DISCORD_REPORTS_BOT_TOKEN).


Self-Healing Table

ConditionAuto-fixEscalate
Session expired (login redirect on navigation)Re-login with stored credentials
2FA required + DISCORD_TOTP_SECRET presentpyotp.TOTP(secret).now()
2FA required + DISCORD_TOTP_SECRET missingCreate human-action GH issue: [Discord] 2FA blocker — DISCORD_TOTP_SECRET missing
CAPTCHA blocks New Application dialogCreate human-action GH issue; exit code 2
hCaptcha at the login form itself (before 2FA)Confirmed live 2026-08-04, not hypothetical. login_with_retry’s 2-attempt cap stops the run safely; do not raise the cap or retry manually — repeated attempts risk an actual account flag. Not fixable by better selectors. Escalate to human-action; for webhooks specifically, use the REST API path (§Step 2b) instead, which needs no login at all
Token element not found after Reset TokenRetry once after 3sOpen bug GH issue if still failing; exit code 2
SOPS canary fails after writegit checkout -- <sops_file> (revert)Open P0 GH issue: [CRITICAL] SOPS corrupt — <file>
Token API verify returns 401Re-run save step onceOpen bug GH issue; exit code 4
Bot already in guild (auth shows “Already authorized”)Skip auth — return success
Channel name already existsWarn and return existing channel ID
Webhook name already exists in channelCreate with a disambiguated name or delete the existing one first (script does not silently reuse — ambiguous target is safer left alone, same principle as the orphaned-service-account lesson in docs/secrets-rotation-log.md)
Clipboard read returns empty after “Copy Webhook URL”Retry the click onceOpen bug GH issue — Discord UI may have changed the copy-button selector or clipboard permission wasn’t granted on the context
delete-webhook finds no matching nameDo not guess at a similarly-named webhook; RuntimeError is deliberate — verify the exact display name first
Playwright import fails (Python 3.10 system packages)Use /opt/playwright-venv/bin/python3Install Python 3.12 venv (see Prerequisites)

Verification

After provisioning, verify the bot token without exposing its value:

# bms-4 (bash) — extract token from SOPS, verify via API, never echo
NEW_TOKEN=$(sops --decrypt --input-type dotenv --output-type dotenv \
  /opt/p24-infra/secrets/monitoring.env.sops \
  | grep "^DISCORD_<BOT_NAME>_TOKEN=" | cut -d= -f2-)
 
HTTP_STATUS=$(curl -sf -o /tmp/discord_verify.json \
  -w "%{http_code}" \
  -H "Authorization: Bot $NEW_TOKEN" \
  https://discord.com/api/v10/applications/@me)
 
echo "HTTP status: $HTTP_STATUS"
if [ "$HTTP_STATUS" = "200" ]; then
  python3 -c "import json; d=json.load(open('/tmp/discord_verify.json')); \
    print('App:', d.get('name'), '| id:', d.get('id'))"
  rm -f /tmp/discord_verify.json
else
  echo "ERROR: Token invalid — re-check SOPS write and canary"
  rm -f /tmp/discord_verify.json
  exit 1
fi
unset NEW_TOKEN

Expected: HTTP 200 with the bot application name and numeric ID.


Browser Cleanup (mandatory)

After any token operation, always close the browser:

context.close()
browser.close()
playwright_instance.stop()

Never leave the Discord Developer Portal open after a token reset or extraction — the revealed token remains accessible in browser history otherwise. See the security note in docs/playbooks/rotate-discord-bot-token.md §Browser-close is mandatory.


Rotation Log

After provisioning that creates a new token, append to docs/secrets-rotation-log.md:

| <YYYY-MM-DD> | DISCORD_<BOT_NAME>_TOKEN | initial provisioning (#<issue>) | claude (Playwright) | SOPS monitoring.env.sops + GH Secret |

action_create_webhook/save_webhook_url append their own row automatically for each SOPS file written (see append_rotation_log call in save_webhook_url) — no manual step needed for webhook rotations specifically, unlike the bot-token row above.


Escalation

SymptomAction
2FA prompted, no TOTP secretCreate human-action issue; user runs manual flow (Option B in rotate-discord-bot-token.md)
CAPTCHA blocks bot creationCreate human-action issue with description; abort with exit code 2
Playwright timeout on any stepCreate bug GH issue with traceback; abort
SOPS canary fails after writeRevert: git checkout -- secrets/monitoring.env.sops; create P0 issue immediately
Developer Portal shows no applicationsConfirm logged in as radieu@gmail.com (not another account)
Guild missing from authorization dropdownVerify guild_id is correct; user account must be in the guild

On any unrecoverable failure, send a Discord alert embed via P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL and open a GH issue per the Error Notification Standard in CLAUDE.md.


Manual / Supervised Run Procedure

This procedure must be run by a human, or by a Claude session with explicit foreground human supervision — never by an unattended background agent. It performs a live login to the real radieu@gmail.com Discord account; repeated failed automated logins risk triggering a CAPTCHA or an account lockout on an account also used for infra alerting.

  1. Pre-flight — confirm TOTP readiness (name only, no login yet):

    ssh root@54.36.123.110 "sops --decrypt --input-type dotenv --output-type dotenv \
      /opt/p24-infra/secrets/monitoring.env.sops | grep -c '^DISCORD_TOTP_SECRET='"
    • 1 → automated 2FA is possible, continue to step 2.
    • 0 → do not attempt a login. File a human-action issue ([Discord] 2FA blocker — DISCORD_TOTP_SECRET missing) and stop here; a human must complete the login manually (Option B in rotate-discord-bot-token.md) or add the TOTP secret first.
  2. Playwright smoke test outside n8n (confirms /opt/playwright-venv + Chromium, no Discord login yet):

    ssh root@54.36.123.110 "/opt/playwright-venv/bin/python3 -c \"
    from playwright.sync_api import sync_playwright
    p = sync_playwright().start()
    b = p.chromium.launch(headless=True)
    print('playwright_ok')
    b.close(); p.stop()
    \""

    Must print playwright_ok before proceeding — if it fails, fix the venv per Prerequisites above, do not attempt a Discord login on a broken Playwright install.

  3. --dry-run first — this still performs a real login + navigates the Developer Portal + creates the application + extracts the token (see the module docstring on action_create_bot()), but skips the SOPS/GH Secret write. This is intentionally the same live-account risk class as a full run — it is a dry run of persistence, not of login.

    ssh root@54.36.123.110 "source /opt/playwright-venv/bin/activate && \
      python3 /opt/p24-infra/scripts/discord-provisioning.py \
        --action create-bot --name test-bot-phase2 --guild <GUILD_ID> \
        --sops-file secrets/monitoring.env.sops --secret-key DISCORD_TEST_BOT_TOKEN \
        --issue 2666 --dry-run"

    Confirm exit code 0 and that $DRY_RUN_TOKEN was populated in that shell (never print it) — and separately confirm no SOPS write happened (git status on secrets/monitoring.env.sops in the bms-4 checkout should show no change).

  4. Full run (drop --dry-run) only after step 3 succeeds cleanly:

    ssh root@54.36.123.110 "source /opt/playwright-venv/bin/activate && \
      python3 /opt/p24-infra/scripts/discord-provisioning.py \
        --action create-bot --name test-bot-phase2 --guild <GUILD_ID> \
        --sops-file secrets/monitoring.env.sops --secret-key DISCORD_TEST_BOT_TOKEN --issue 2666"

    Login is capped at MAX_LOGIN_ATTEMPTS = 2 (see login_with_retry()) — if both attempts fail, the script stops itself rather than hammering the account further; investigate before re-running by hand.

  5. Verify the token via the ## Verification section above (GET /api/v10/applications/@me → 200).

  6. Append a rotation-log row to docs/secrets-rotation-log.md per ## Rotation Log above, with the real fingerprint/date — never the token value.

  7. Open a PR with whatever selector fixes were needed during the run (Discord’s web UI changes without notice — expect at least minor page.click/page.fill selector drift on a first live run) and reference issue #2666.