Skip to content

Instantly share code, notes, and snippets.

@riceo
Created July 1, 2026 14:29
Show Gist options
  • Select an option

  • Save riceo/9ada6882ccecff7b01e17fab03697226 to your computer and use it in GitHub Desktop.

Select an option

Save riceo/9ada6882ccecff7b01e17fab03697226 to your computer and use it in GitHub Desktop.
#!/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