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 existingp24-infra-adminbot.
Webhook create/delete: REST API is the primary path, not Playwright — corrected 2026-08-04.
create-webhook/delete-webhookfirst 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 realp24-infraguild 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_retry→discord_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.screenshotonRuntimeError, 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-inMAX_LOGIN_ATTEMPTS = 2cap 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 forcreate-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-Agentheader — cost significant debugging time before being traced to Discord’s own documented error code table. Every REST call in this file now sendsDISCORD_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-webhookfor the replacement, thendelete-webhookfor 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):
playwright1.48.0 installed into/opt/playwright-venvvia 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.FrameInfomissing) - 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 key | Description |
|---|---|
discord_radieu_password | Password for radieu@gmail.com Discord account |
DISCORD_TOTP_SECRET | TOTP 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 loginAs 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.sopsupdate_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_elStep 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:
| Permission | Integer |
|---|---|
| Send Messages | 2048 |
| Read Message History | 65536 |
| Embed Links | 16384 |
| Attach Files | 32768 |
Storage Matrix
| What | SOPS key | GH Secret | Service |
|---|---|---|---|
| New bot token | DISCORD_<BOT_NAME>_TOKEN | DISCORD_<BOT_NAME>_TOKEN | consuming service |
| Webhook URL | DISCORD_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 password | discord_radieu_password | — | already in monitoring.env.sops |
| TOTP secret | DISCORD_TOTP_SECRET | — | already 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
| Condition | Auto-fix | Escalate |
|---|---|---|
| Session expired (login redirect on navigation) | Re-login with stored credentials | — |
2FA required + DISCORD_TOTP_SECRET present | pyotp.TOTP(secret).now() | — |
2FA required + DISCORD_TOTP_SECRET missing | — | Create human-action GH issue: [Discord] 2FA blocker — DISCORD_TOTP_SECRET missing |
| CAPTCHA blocks New Application dialog | — | Create 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 Token | Retry once after 3s | Open bug GH issue if still failing; exit code 2 |
| SOPS canary fails after write | git checkout -- <sops_file> (revert) | Open P0 GH issue: [CRITICAL] SOPS corrupt — <file> |
| Token API verify returns 401 | Re-run save step once | Open bug GH issue; exit code 4 |
| Bot already in guild (auth shows “Already authorized”) | Skip auth — return success | — |
| Channel name already exists | Warn and return existing channel ID | — |
| Webhook name already exists in channel | — | Create 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 once | Open 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 name | — | Do 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/python3 | Install 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_TOKENExpected: 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
| Symptom | Action |
|---|---|
| 2FA prompted, no TOTP secret | Create human-action issue; user runs manual flow (Option B in rotate-discord-bot-token.md) |
| CAPTCHA blocks bot creation | Create human-action issue with description; abort with exit code 2 |
| Playwright timeout on any step | Create bug GH issue with traceback; abort |
| SOPS canary fails after write | Revert: git checkout -- secrets/monitoring.env.sops; create P0 issue immediately |
| Developer Portal shows no applications | Confirm logged in as radieu@gmail.com (not another account) |
| Guild missing from authorization dropdown | Verify 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.
-
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 ahuman-actionissue ([Discord] 2FA blocker — DISCORD_TOTP_SECRET missing) and stop here; a human must complete the login manually (Option B inrotate-discord-bot-token.md) or add the TOTP secret first.
-
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_okbefore proceeding — if it fails, fix the venv per Prerequisites above, do not attempt a Discord login on a broken Playwright install. -
--dry-runfirst — this still performs a real login + navigates the Developer Portal + creates the application + extracts the token (see the module docstring onaction_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_TOKENwas populated in that shell (never print it) — and separately confirm no SOPS write happened (git statusonsecrets/monitoring.env.sopsin the bms-4 checkout should show no change). -
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(seelogin_with_retry()) — if both attempts fail, the script stops itself rather than hammering the account further; investigate before re-running by hand. -
Verify the token via the
## Verificationsection above (GET /api/v10/applications/@me→ 200). -
Append a rotation-log row to
docs/secrets-rotation-log.mdper## Rotation Logabove, with the real fingerprint/date — never the token value. -
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.fillselector drift on a first live run) and reference issue #2666.
Related Playbooks
- Token rotation for existing bots:
rotate-discord-bot-token.md - SOPS write safety (Windows dev):
sops-windows-crlf.md - SOPS edit operations:
sops-edit-operations.md - Playwright rotation template:
playwright-rotation-template.md - Incident rotation path:
static-api-key-incident-rotation.md - Credential registry:
credential-automation-registry.md - Mocked unit tests:
scripts/tests/test_discord_provisioning.py - Related issues: #2643 (this provisioning expansion), #2649 (Phase 1 — script + playbook merged), #2666 (Phase 2 — code hardening + tests shipped here; live end-to-end run still pending a human-supervised session, see §Manual / Supervised Run Procedure)