Created
July 1, 2026 14:29
-
-
Save riceo/9ada6882ccecff7b01e17fab03697226 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # mm-reassign-bots.sh | |
| # Reassign Mattermost bot ownership to a single service account and, optionally, | |
| # re-enable bots that were disabled (e.g. when their previous owner was | |
| # deactivated). Plugin/system-managed bots are left untouched. | |
| # | |
| # Requires: bash 4+, curl, jq. A System Admin personal access token. | |
| # | |
| # Usage: | |
| # export MM_URL="https://chat.example.com" | |
| # export MM_TOKEN="<system-admin PAT>" | |
| # export SERVICE_ACCT_USERNAME="mattermost.bots" # optional, this is the default | |
| # export ENABLE_SCOPE="orphaned" # orphaned | all | none | |
| # DRY_RUN=true ./mm-reassign-bots.sh # preview (default) | |
| # DRY_RUN=false ./mm-reassign-bots.sh # execute | |
| # | |
| set -euo pipefail | |
| # ---------- config ---------- | |
| : "${MM_URL:?set MM_URL, e.g. https://chat.example.com}" | |
| : "${MM_TOKEN:?set MM_TOKEN to a system-admin personal access token}" | |
| SERVICE_ACCT_USERNAME="${SERVICE_ACCT_USERNAME:-mattermost.bots}" | |
| DRY_RUN="${DRY_RUN:-true}" # true = plan only, no writes | |
| ENABLE_SCOPE="${ENABLE_SCOPE:-orphaned}" # orphaned | all | none | |
| PER_PAGE=200 | |
| SNAPSHOT="bots-snapshot-$(date +%Y%m%d-%H%M%S).json" | |
| # Never reassign/enable these well-known plugin/system bots even if they | |
| # momentarily present a human owner_id. | |
| SKIP_USERNAMES_REGEX='^(system-bot|feedbackbot|appsbot|playbooks|boards|focalboard|calls|apps)$' | |
| # ---------- helpers ---------- | |
| # GET that aborts on HTTP error (used for reads we require to succeed) | |
| get() { curl -fsS -H "Authorization: Bearer $MM_TOKEN" "$1"; } | |
| # POST that never aborts; prints the HTTP status code | |
| post() { curl -sS -o /dev/null -w '%{http_code}' -X POST \ | |
| -H "Authorization: Bearer $MM_TOKEN" "$1"; } | |
| # true if the id resolves to a real Users row (i.e. a human/service owner) | |
| owner_is_user() { curl -fsS -o /dev/null -H "Authorization: Bearer $MM_TOKEN" \ | |
| "$MM_URL/api/v4/users/$1" 2>/dev/null; } | |
| # paginate GET /bots; arg $1 is an extra query string (e.g. "&only_orphaned=true") | |
| fetch_all_bots() { | |
| local page=0 out len | |
| while :; do | |
| out=$(get "$MM_URL/api/v4/bots?include_deleted=true&per_page=$PER_PAGE&page=$page${1:-}") | |
| len=$(jq 'length' <<<"$out") | |
| [ "$len" -gt 0 ] || break | |
| jq -c '.[]' <<<"$out" | |
| [ "$len" -lt "$PER_PAGE" ] && break | |
| page=$((page+1)) | |
| done | |
| } | |
| # ---------- resolve + validate service account ---------- | |
| NEW_OWNER=$(get "$MM_URL/api/v4/users/username/$SERVICE_ACCT_USERNAME" | jq -r '.id // empty') | |
| [ -n "$NEW_OWNER" ] || { echo "FATAL: service account '$SERVICE_ACCT_USERNAME' not found"; exit 1; } | |
| if ! get "$MM_URL/api/v4/users/$NEW_OWNER" | jq -e '.delete_at == 0' >/dev/null; then | |
| echo "FATAL: service account '$SERVICE_ACCT_USERNAME' is deactivated; reassigning to it would disable every bot" | |
| exit 1 | |
| fi | |
| if curl -fsS -o /dev/null -H "Authorization: Bearer $MM_TOKEN" \ | |
| "$MM_URL/api/v4/bots/$NEW_OWNER" 2>/dev/null; then | |
| echo "WARN: service account is itself a bot. A plain user owner is recommended; continuing." | |
| fi | |
| echo "Service account : $SERVICE_ACCT_USERNAME ($NEW_OWNER)" | |
| echo "Mode : DRY_RUN=$DRY_RUN ENABLE_SCOPE=$ENABLE_SCOPE" | |
| echo | |
| # ---------- gather state ---------- | |
| mapfile -t ALL_BOTS < <(fetch_all_bots "") | |
| echo "Bots found (incl. disabled): ${#ALL_BOTS[@]}" | |
| # Orphaned = owner currently deactivated. Capture BEFORE reassignment. | |
| ORPHANED_IDS=$(fetch_all_bots "&only_orphaned=true" | jq -r '.user_id' | sort -u || true) | |
| # Snapshot for rollback | |
| printf '%s\n' "${ALL_BOTS[@]}" | jq -s '.' > "$SNAPSHOT" | |
| echo "Snapshot written: $SNAPSHOT" | |
| echo | |
| # ---------- process ---------- | |
| reassigned=0; enabled=0; skipped=0; failed=0 | |
| for b in "${ALL_BOTS[@]}"; do | |
| buid=$(jq -r '.user_id' <<<"$b") | |
| bname=$(jq -r '.username' <<<"$b") | |
| owner=$(jq -r '.owner_id' <<<"$b") | |
| disabled=$(jq -r 'if .delete_at>0 then "yes" else "no" end' <<<"$b") | |
| # skip plugin/system-managed bots | |
| if grep -Eq "$SKIP_USERNAMES_REGEX" <<<"$bname" || ! owner_is_user "$owner"; then | |
| echo "SKIP $bname ($buid) owner=$owner [plugin/system-managed]" | |
| skipped=$((skipped+1)); continue | |
| fi | |
| # reassign owner | |
| if [ "$owner" != "$NEW_OWNER" ]; then | |
| if [ "$DRY_RUN" = true ]; then | |
| echo "PLAN reassign $bname ($owner -> $NEW_OWNER)" | |
| else | |
| code=$(post "$MM_URL/api/v4/bots/$buid/assign/$NEW_OWNER") | |
| if [ "$code" = 200 ]; then echo "OK reassigned $bname" | |
| else echo "FAIL($code) reassign $bname"; failed=$((failed+1)); fi | |
| fi | |
| reassigned=$((reassigned+1)) | |
| fi | |
| # decide whether to enable | |
| want_enable=false | |
| if [ "$disabled" = yes ]; then | |
| case "$ENABLE_SCOPE" in | |
| all) want_enable=true ;; | |
| orphaned) | |
| if grep -qx "$buid" <<<"$ORPHANED_IDS"; then want_enable=true; fi ;; | |
| none) : ;; | |
| esac | |
| fi | |
| if [ "$want_enable" = true ]; then | |
| if [ "$DRY_RUN" = true ]; then | |
| echo "PLAN enable $bname" | |
| else | |
| code=$(post "$MM_URL/api/v4/bots/$buid/enable") | |
| if [ "$code" = 200 ]; then echo "OK enabled $bname" | |
| else echo "FAIL($code) enable $bname"; failed=$((failed+1)); fi | |
| fi | |
| enabled=$((enabled+1)) | |
| fi | |
| done | |
| echo | |
| echo "-------- summary --------" | |
| echo "reassigned : $reassigned" | |
| echo "enabled : $enabled" | |
| echo "skipped : $skipped (plugin/system-managed)" | |
| echo "failed : $failed" | |
| echo "scope : $ENABLE_SCOPE" | |
| echo "dry_run : $DRY_RUN" | |
| [ "$DRY_RUN" = true ] && echo "(no changes made; set DRY_RUN=false to execute)" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment