Files
yttrx-welcomebot/CLAUDE.md
T

9.4 KiB

CLAUDE.md

Guidance for Claude Code when working on yttrx-welcomebot (the welcome bot

  • abuse auto-silence bot for yttrx.com).

What this is / where it runs

A FastAPI webhook server. One Mastodon admin webhook delivers account.created, account.approved, and report.created to POST /webhook; the app dispatches:

  • account.created → classify the signup IP (RDAP org lookup); flagged (datacenter/hosting) signups get more scrutiny (held welcome, ip_blocks registration, DM to moderator) before falling through to a normal welcome.
  • account.approved → always DM the welcome (dedup-safe; this is what releases a held welcome once a human clears the approval queue).
  • report.created → classify the reported account, count distinct reporters, and auto-silence young/dormant accounts past the threshold — lowered for accounts with a flagged signup IP (see README).
Where What
Workstation Source of truth: ~/yttrx-welcomebot/ (this repo)
git.blairhaus.net/yttrx-welcomebot.git origin remote (HTTP Basic Auth, GIT_AUTH_USER/GIT_AUTH_PASS in ~/docker/.env on admin — same shared cred as every other git.blairhaus.net repo, embedded in the remote URL). No CI (.ci/run.sh) configured — deploy is a manual git pull on admin.yttrx.com, not push-triggered.
admin.yttrx.com Deploy target: /root/yttrx-welcomebot/ is a git checkout of the above (converted from an rsync-deployed tree 2026-07-02), Docker container yttrx-welcomebot on 127.0.0.1:8087, nginx site hookshooks.yttrx.com. ssh admin.yttrx.com logs in as root. Compose is docker-compose v1.29.2 (hyphenated, not docker compose).
mammut (ssh mammut) The Mastodon instance. tootctl/Rails run inside the live-web-1 container. Bot accounts + tokens live here.

Full ops history is in secondbrain yttrx-documentation/changelog.md; the user-memory note project_welcomebot has the current state.

HARD RULES

  • Never overwrite the .env on admin.yttrx.com. It holds the live WEBHOOK_SECRET, BOT_ACCESS_TOKEN, and ABUSE_BOT_TOKEN. It is untracked (gitignored) in the deploy checkout — git pull/git reset never touch it. Edit it in place for new vars.
  • Never commit secrets. .env is git- and docker-ignored. Tokens/passwords must not land in this repo, the changelog, or CLAUDE.md.
  • The bot silences (reversible), it does not suspend by default (ABUSE_ACTION=silence). It applies the action without report_id so the report stays open for human review.
  • git commands on admin.yttrx.com run as root against a repo whose files are chowned to waffles (see below) — root's global config there already has safe.directory /root/yttrx-welcomebot set to avoid the "dubious ownership" error; don't need to re-add it, but if the repo is ever reconstructed at that path from scratch, re-run git config --global --add safe.directory /root/yttrx-welcomebot.

Deploy procedure (workstation → admin.yttrx.com)

# 1. Test locally (offline, no network)
cd ~/yttrx-welcomebot
python3 -m venv .venv && . .venv/bin/activate && pip install -q -r requirements.txt
WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py   # -> ALL TESTS PASSED

# 2. Commit + push to origin (git.blairhaus.net)
git add -A && git commit -m "..."
git push origin main

# 3. Pull on admin.yttrx.com (.env is gitignored — untouched by pull/reset)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && git pull origin main'

# 4. Re-normalise ownership if new files were added (chown is not tracked by git)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && chown -R waffles:waffles . && \
  chown root:root .env; chmod 600 .env'

# 5. If new env vars were added this release, set them IN PLACE on the box, e.g.
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
  grep -q "^NEW_VAR=" .env || echo "NEW_VAR=value" >> .env'

# 6. Rebuild + restart (named volume welcomebot-data persists the dedup db;
#    the volume name is derived from the directory basename, which is
#    unchanged, so it survived the rsync -> git-checkout conversion)
ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && docker-compose up -d --build'

Rollback is now git checkout <previous-sha> -- . (or git revert) instead of restoring .bak files. .env.bak-* snapshots from past deploys remain on the box; the full pre-git-conversion tree (/root/yttrx-welcomebot.pre-git-*/) was removed 2026-07-02 once the git checkout was verified identical.

Verify

ssh admin.yttrx.com 'curl -s localhost:8087/healthz'          # {"ok":true}
ssh admin.yttrx.com 'docker logs --tail 20 yttrx-welcomebot'  # clean startup
# confirm the running image + config:
ssh admin.yttrx.com 'docker exec yttrx-welcomebot python -c \
  "import app.main as m; print(m.ABUSE_DRY_RUN, m.ABUSE_ACTION, sorted(m.ABUSE_ALLOWLIST))"'

Viewing logs

A welcomebot-logs CLI is installed at /usr/local/bin/welcomebot-logs on admin.yttrx.com (source: bin/welcomebot-logs in this repo). It wraps docker logs for the container:

ssh admin.yttrx.com welcomebot-logs                 # last 200 lines
ssh admin.yttrx.com welcomebot-logs -f --abuse      # follow abuse activity only
ssh admin.yttrx.com welcomebot-logs -n 1000 --since 24h
ssh admin.yttrx.com welcomebot-logs --ip --since 24h  # signup-IP scrutiny decisions
ssh admin.yttrx.com 'welcomebot-logs --help'

Inspecting signup-IP classification history

welcomebot-signups (also /usr/local/bin/welcomebot-signups, source bin/welcomebot-signups) reads the persistent signup_ip sqlite table directly — a permanent record of every classification decision, independent of log retention:

ssh admin.yttrx.com welcomebot-signups              # last 20 decisions
ssh admin.yttrx.com welcomebot-signups --flagged    # only datacenter-flagged signups
ssh admin.yttrx.com welcomebot-signups -a someuser
ssh admin.yttrx.com welcomebot-signups -n all

Both CLIs are just files tracked in this repo now — after editing either one, reinstall with:

ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && git pull origin main && \
  install -m755 bin/welcomebot-logs bin/welcomebot-signups /usr/local/bin/'

Rollback

ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
  cp app/main.py.bak-YYYYMMDD app/main.py && cp .env.bak-YYYYMMDD .env && \
  docker-compose up -d --build'

Rollout safety: dry-run

ABUSE_DRY_RUN=true makes the abuse handler log + DM what it would do without silencing anyone. The shipped .env.example default is true; production is currently false (live and acting). To re-enter dry-run:

ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \
  sed -i "s/^ABUSE_DRY_RUN=.*/ABUSE_DRY_RUN=true/" .env && docker-compose up -d'

The moderator bot token (gotcha)

The abuse handler posts to POST /api/v1/admin/accounts/:id/action, which needs the admin:write:accounts OAuth scope — separate from admin:write:reports. The bot account (bot) is an Admin (so it has the role permissions), but a token created in the UI may omit admin:write:accounts → silence returns 403.

IP-scrutiny's register_ip_block needs an additional scope, admin:write:ip_blocks, to POST /api/v1/admin/ip_blocks — mint (or re-mint) the token with it included, or IP_SCRUTINY_AUTO_IPBLOCK writes will 403 (logged as an error; nothing else in the bot is affected).

Mint a correctly-scoped token from the Rails console on mammut:

ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner "
acct = Account.find_local(%q{bot})
scopes = %q{read:statuses write:statuses admin:read:reports admin:write:accounts admin:write:ip_blocks}
app = Doorkeeper::Application.create!(name: %q{Abuse handler vN}, scopes: scopes, redirect_uri: %q{urn:ietf:wg:oauth:2.0:oob})
tok = Doorkeeper::AccessToken.create!(application_id: app.id, resource_owner_id: acct.user.id, scopes: scopes)
puts tok.token
"'

Put the result in ABUSE_BOT_TOKEN on the box's .env, restart, and revoke the old token (Doorkeeper::AccessToken.find(<id>).revoke).

Harmless write-permission probe (no real account touched — 403 = missing scope, 404 = authorized):

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H "Authorization: Bearer $ABUSE_BOT_TOKEN" -d "type=none" \
  https://yttrx.com/api/v1/admin/accounts/0/action

Same idea for the admin:write:ip_blocks scope IP-scrutiny needs (403 = missing scope; 422 = authorized, just missing/invalid params — no block is created either way):

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H "Authorization: Bearer $ABUSE_BOT_TOKEN" \
  https://yttrx.com/api/v1/admin/ip_blocks

Mastodon side (mammut)

  • The webhook (Administration → Webhooks) is already subscribed to account.created, account.approved, report.created — no change needed for code redeploys.
  • Staff are never auto-silenced: ABUSE_SKIP_PRIVILEGED=true reads the payload target_account.role (skips any assigned staff role, id > 0), plus the explicit ABUSE_ALLOWLIST. Current staff: waffles/tommertron (Owner), davis/bot (Admin).

Appeals page

Silenced users are DMed ABUSE_HELP_URL = https://welcome.yttrx.com/posts/account-limited/. That page is a Hugo (Compost) content file at admin.yttrx.com:/var/www/html/welcome/content/posts/, rebuilt with ./build.sh in that dir (see secondbrain misc-sites.md).