Created
June 29, 2021 00:13
-
-
Save zk-1/df97cd356c069cb15456ff33858ede35 to your computer and use it in GitHub Desktop.
Migrate profile fields for one or all members of a Slack workspace
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 | |
# Name: Slack Profile Field Migrate | |
# Usage: slack-profile-field-migrate.sh | |
# Description: Migrate profile fields for one or all members of a Slack workspace | |
# Author: Zoë Kelly ([email protected]) | |
# License: MIT | |
# Created: 2021-06 | |
# Updated: 2021-06-28 | |
## Note: | |
# User Token Scopes required: | |
# users:read | |
# users.profile:read | |
# users.profile:write | |
# admin | |
# -- Add your Slack API token here -- | |
app_api_token="" | |
log_file="$HOME/Desktop/slack-profile-field-migrate-log.csv" | |
interval_time=2 | |
bold_and_red() { tput setaf 1; tput bold; } | |
bold_and_green() { tput setaf 2; tput bold; } | |
bold_and_yellow() { tput setaf 3; tput bold; } | |
bold_and_blue() { tput setaf 4; tput bold; } | |
bold_and_grey() { tput setaf 8; tput bold; } | |
reset_font() { tput sgr0; } | |
is_macos() { uname -s | grep -qs "Darwin"; } | |
uriencode() { jq -nr --arg v "$1" '$v|@uri'; } | |
shh() { "$@" > /dev/null 2>&1; } | |
bin_exists() { shh type "$@"; } | |
epoch_to_local() { perl -e '$date = localtime('$1');print "$date\n";'; } | |
sec_to_time_string() { | |
printf '%d hours, %d mins, %d secs\n' $(($1/60/60%24)) $(($1%3600/60)) $(($1%60)) | |
} | |
slack_get() { | |
curl -s --cookie "$cookie" -X GET \ | |
-H "Accept: application/json" \ | |
-H "Content-Type: application/x-www-form-urlencoded" \ | |
-H "Authorization: Bearer $2" \ | |
"https://slack.com/api/$1" | |
} | |
slack_post_data() { | |
curl -s --cookie "$cookie" -X POST \ | |
-H "Accept: application/json" \ | |
-H "Content-Type: application/json; charset=UTF-8" \ | |
-H "Authorization: Bearer $2" \ | |
-d "$3" \ | |
"https://slack.com/api/$1" | |
} | |
slack_get_paginate() { | |
if [[ "$1" == *"?"* ]]; then | |
_the_url="$1&limit=900" | |
else | |
_the_url="$1?limit=900" | |
fi | |
_resp_body="$(slack_get "$_the_url" "$2")" | |
_next_page="$(echo "$_resp_body" | jq -r '.response_metadata.next_cursor')" | |
while [[ $_next_page != 'null' ]] && [[ -n $_next_page ]]; do | |
_next_response="$(slack_get "$_the_url&cursor=$_next_page" "$2")" | |
_resp_body="$_resp_body$_next_response" | |
_next_page="$(echo "$_next_response" | jq -r '.response_metadata.next_cursor')" | |
done | |
echo "$_resp_body" | |
} | |
choose_user_profile_field() { | |
IFS=$'\n' | |
declare -a _user_fields_array=() | |
_built_in_fields="Profile: Title title | |
Profile: Phone phone | |
Profile: Real Name real_name | |
Profile: Display Name display_name | |
Profile: Status Text status_text | |
Profile: Status Emoji status_emoji | |
Profile: Pronouns pronouns | |
" | |
_custom_fields="$(slack_get "team.profile.get" "$app_api_token" | jq -r '.profile.fields[] | select(.options.is_protected==true | not) | "Custom: " + .label + " " + .id')" | |
_user_fields_array+=($_built_in_fields $_custom_fields) | |
_user_fields="$(echo "${_user_fields_array[*]}")" | |
if [[ ! -n "$_user_fields" ]]; then | |
return "none" | |
fi | |
for _t in $_user_fields ; do | |
_user_fields_menu_prep="$_user_fields_menu_prep$(echo && printf "%s" "$_t" | awk -F$'\t' '{printf($1)}')" | |
done | |
_user_fields_menu="$(echo "$_user_fields_menu_prep")" | |
PS3="> " | |
select _uf in $_user_fields_menu; do | |
echo "$(echo ${_user_fields_array[$(($REPLY-1))]})" | |
break | |
done | |
IFS=$default_IFS | |
echo "$_the_field" | |
_user_fields_array=() | |
_user_fields_menu_prep="" | |
} | |
if ! bin_exists jq; then | |
echo "'jq' binary not found. Please install using 'brew install jq' | |
if you don't have Homebrew, visit https://brew.sh to install" | |
exit 1 | |
fi | |
if ! is_macos; then | |
echo "This script currently only supports macOS, sorry!" | |
exit 1 | |
fi | |
if [[ -z "$app_api_token" ]]; then | |
read -rp "Enter Slack API token: " app_api_token | |
fi | |
caller_user_id="$(slack_get "auth.test" "$app_api_token" | jq -r '.user_id')" | |
caller_user_is_admin="$(slack_get "users.info?user=$caller_user_id" "$app_api_token" | jq -r '.user.is_admin')" | |
caller_user_is_primary_owner="$(slack_get "users.info?user=$caller_user_id" "$app_api_token" | jq -r '.user.is_primary_owner')" | |
if [[ "$caller_user_is_admin" == "" || "$caller_user_is_admin" == "null" ]]; then | |
bold_and_red; echo "Error identifying calling user, check scopes"; reset_font | |
exit 1 | |
fi | |
echo | |
echo "-------------- Slack Profile Field Migrate --------------" | |
echo | |
bold_and_blue; echo "Choose SOURCE field"; reset_font | |
src_field_info="$(choose_user_profile_field)" | |
src_field_type="$(echo "$src_field_info" | cut -d ':' -f 1)" | |
src_field_label="$(echo "$src_field_info" | cut -d ' ' -f 1)" | |
src_field_id="$(echo "$src_field_info" | cut -d ' ' -f 2)" | |
echo | |
bold_and_blue; echo "Choose DESTINATION field"; reset_font | |
dst_field_info="$(choose_user_profile_field)" | |
dst_field_type="$(echo "$dst_field_info" | cut -d ':' -f 1)" | |
dst_field_label="$(echo "$dst_field_info" | cut -d ' ' -f 1)" | |
dst_field_id="$(echo "$dst_field_info" | cut -d ' ' -f 2)" | |
echo | |
declare -a menu_opts=() | |
menu_opts+=("Copy for one user" "Copy for all users in workspace") | |
PS3="> " | |
select menu_opt in "${menu_opts[@]}"; do | |
i=0 | |
if [[ ${menu_opt} == "${menu_opts[$((i++))]}" ]]; then | |
pf_scope="user" | |
elif [[ ${menu_opt} == "${menu_opts[$((i++))]}" ]]; then | |
pf_scope="all" | |
else | |
echo "Bye!" | |
exit 0 | |
fi | |
break | |
done | |
menu_opts=() | |
if [[ "$pf_scope" == "user" ]]; then | |
read -rp "Enter the user's Slack ID: " user_id | |
user_info="$(slack_get "users.info?user=$user_id" "$app_api_token")" | |
user_profile_extended="$(slack_get "users.profile.get?user=$user_id" "$app_api_token" | jq '.profile')" | |
user_info="$(echo "$user_info" | jq --argjson profile "$user_profile_extended" '.user.profile = $profile')" | |
if echo "$user_info" | jq -r '.ok' | grep -qs 'true'; then | |
user_name="$(echo "$user_info" | jq -r '.user.profile.real_name')" | |
user_dispname="$(echo "$user_info" | jq -r '.user.profile.display_name')" | |
user_title="$(echo "$user_info" | jq -r '.user.profile.title')" | |
echo "User name: $user_name" | |
echo "Display name: @$user_dispname" | |
echo "User ID: $user_id" | |
echo "Title: $user_title" | |
else | |
echo "User not found" | |
exit 1 | |
fi | |
target_user_is_admin="$(echo "$user_info" | jq -r '.user.is_admin')" | |
if [[ "$user_id" != "$caller_user_id" && "$caller_user_is_admin" != "true" ]]; then | |
bold_and_red; echo "You are not an admin, check scopes"; reset_font | |
exit 1 | |
elif [[ "$user_id" != "$caller_user_id" && "$target_user_is_admin" == "true" && "$caller_user_is_primary_owner" == "false" ]]; then | |
bold_and_red; echo "The user you have chosen is an admin of this Workspace, and it's not possible to update fields for another admin unless you are a primary owner."; reset_font | |
exit 1 | |
else | |
if [[ "$src_field_type" == "Profile" ]]; then | |
src_field_now_val="$(echo "$user_info" | jq -r --arg src_field_id "$src_field_id" 'try(.user.profile[$src_field_id])')" | |
elif [[ "$src_field_type" == "Custom" ]]; then | |
src_field_now="$(echo "$user_info" | jq --arg src_field_id "$src_field_id" 'try(.user.profile.fields[$src_field_id])')" | |
src_field_now_val="$(echo "$src_field_now" | jq -r 'try(.value)')" | |
src_field_now_alt="$(echo "$src_field_now" | jq -r 'try(.alt)')" | |
fi | |
if [[ "$dst_field_type" == "Profile" ]]; then | |
dst_field_now_val="$(echo "$user_info" | jq -r --arg dst_field_id "$dst_field_id" 'try(.user.profile[$dst_field_id])')" | |
elif [[ "$dst_field_type" == "Custom" ]]; then | |
dst_field_now="$(echo "$user_info" | jq --arg dst_field_id "$dst_field_id" 'try(.user.profile.fields[$dst_field_id])')" | |
dst_field_now_val="$(echo "$dst_field_now" | jq -r 'try(.value)')" | |
dst_field_now_alt="$(echo "$dst_field_now" | jq -r 'try(.alt)')" | |
fi | |
[[ "$src_field_now_val" == "null" ]] && src_field_now_val="" || src_field_now_val="${src_field_now_val/\\/\\\\}"; | |
[[ "$src_field_now_alt" == "null" ]] && src_field_now_alt="" || src_field_now_alt="${src_field_now_alt/\\/\\\\}"; | |
[[ "$dst_field_now_val" == "null" ]] && dst_field_now_val="" || dst_field_now_val="${dst_field_now_val/\\/\\\\}"; | |
[[ "$dst_field_now_alt" == "null" ]] && dst_field_now_alt="" || dst_field_now_alt="${dst_field_now_alt/\\/\\\\}"; | |
echo "Source field value: $src_field_now_val" | |
echo "Source field alt: $src_field_now_alt" | |
echo "Destination field current value: $dst_field_now_val" | |
echo "Destination field current alt: $dst_field_now_alt" | |
echo | |
read -rp "Proceed with copying '$src_field_label' to '$dst_field_label' for $user_name? (y/n): " proceed_pf | |
if [[ "$proceed_pf" == "y" ]]; then | |
if [[ "$dst_field_type" == "Profile" ]]; then | |
pf_set_res="$(slack_post_data "users.profile.set" "$app_api_token" "{\"user\":\"$user_id\",\"profile\":{\"$dst_field_id\":\"$src_field_now_val\"}}")" | |
elif [[ "$dst_field_type" == "Custom" ]]; then | |
pf_set_res="$(slack_post_data "users.profile.set" "$app_api_token" "{\"user\":\"$user_id\",\"profile\":{\"fields\":{\"$dst_field_id\":{\"value\":\"$src_field_now_val\",\"alt\":\"$src_field_now_alt\"}}}}")" | |
fi | |
pf_set_res_ok="$(echo "$pf_set_res" | jq -r '.ok')" | |
echo | |
if [[ "$pf_set_res_ok" == "true" ]]; then | |
echo "Done!" | |
exit 0 | |
else | |
echo "There was an error" | |
echo "$pf_set_res" | |
exit 1 | |
fi | |
else | |
exit 1 | |
fi | |
fi | |
elif [[ "$pf_scope" == "all" ]]; then | |
if [[ "$caller_user_is_admin" != "true" ]]; then | |
bold_and_red; echo "You are not an admin, check scopes"; reset_font | |
exit 1 | |
fi | |
echo | |
if [[ "$caller_user_is_primary_owner" == "true" ]]; then | |
echo "Now fetching all active, non-bot users. This may take some time.." | |
all_users_nlsv="$(slack_get_paginate "users.list" "$app_api_token" | jq -r '.members[] | select (.id=="USLACKBOT" | not) | select(.deleted==false and .is_bot==false) | .id')" | |
else | |
echo "Now fetching all active, non-bot, non-admin users. This may take some time.." | |
all_users_nlsv="$(slack_get_paginate "users.list" "$app_api_token" | jq -r '.members[] | select (.id=="USLACKBOT" | not) | select(.deleted==false and .is_bot==false and .is_admin==false) | .id')" | |
fi | |
all_users_count=$(echo "$all_users_nlsv" | wc -l | tr -d ' ' ) | |
aprox_total_time="$(sec_to_time_string $((all_users_count * interval_time)))" | |
echo | |
echo "$all_users_count users fetched" | |
echo | |
read -rp "Also copy over blank values? (y/n): " include_blank_vals | |
echo | |
read -rp "Dry-run, or for real? (d/r): " for_realz | |
echo | |
echo "▶︎ To keep within rate limits, this feature runs at a $interval_time second interval (quicker for skipped ones)" | |
echo "▶︎ This feature will not copy fields for Slack admins or owners, since Slack does not allow this." | |
echo "▶︎ Estimated time required (not accounting for skipped users and rate limit backoffs): $aprox_total_time" | |
echo "▶︎ Log file will be $log_file" | |
echo "▶︎ Best viewed on a terminal emulator 180 characters wide or wider:" | |
echo "************************************************************************************************************************************************************************************" | |
sleep 2 | |
if [[ "$for_realz" == "r" ]]; then | |
bold_and_red; echo "This will copy '$src_field_label' to '$dst_field_label' FOR ALL USERS" | |
read -rp "To confirm, type 'Yes, I am absolutely sure': " pf_cp_all_users_confirm | |
reset_font | |
fi | |
if [[ "$for_realz" == "d" || $pf_cp_all_users_confirm == "Yes, I am absolutely sure" ]]; then | |
echo | |
start_epoch="$(date +%s)" | |
echo "\"timestamp\",\"user_id\",\"user_name\",\"status\",\"src_field\",\"dst_field\",\"value\",\"was_rate_lim\",\"error\"" > $log_file | |
ani_frame=0; incr=0; successes=0; fails=0; skips=0; rate_limited=0; | |
was_rate_limited=0 | |
for id in $all_users_nlsv; do | |
now_epoch="$(date +%s)" | |
elapsed_time="$(sec_to_time_string $((now_epoch - start_epoch)))" | |
aprox_time_left="$(sec_to_time_string $(((all_users_count - incr) * interval_time)))" | |
pf_user_info="$(slack_get "users.info?user=$id" "$app_api_token")" | |
pf_user_profile_extended="$(slack_get "users.profile.get?user=$id" "$app_api_token" | jq '.profile')" | |
pf_user_info="$(echo "$pf_user_info" | jq --argjson profile "$pf_user_profile_extended" '.user.profile = $profile')" | |
pf_user_ok="$(echo "$pf_user_info" | jq -r '.ok')" | |
pf_user_name="$(echo "$pf_user_info" | jq -r '.user.profile.real_name')" | |
status_text="$incr / $all_users_count ELAPSED: $elapsed_time EST TIME LEFT: $aprox_time_left ✔:$successes 𐄂:$fails ❏:$skips ⌚︎:$rate_limited USER: $pf_user_name " | |
case "$ani_frame" in | |
0) echo " ▝▗ $status_text";; | |
1) echo " ▖▘ $status_text";; | |
esac | |
tput cuu1 tput el | |
tput sgr0 | |
if [[ $ani_frame == 1 ]]; then ani_frame=0; else ((ani_frame++)); fi | |
if [[ "$src_field_type" == "Profile" ]]; then | |
src_field_now_val="$(echo "$pf_user_info" | jq -r --arg src_field_id "$src_field_id" 'try(.user.profile[$src_field_id])')" | |
elif [[ "$src_field_type" == "Custom" ]]; then | |
src_field_now="$(echo "$pf_user_info" | jq --arg src_field_id "$src_field_id" 'try(.user.profile.fields[$src_field_id])')" | |
src_field_now_val="$(echo "$src_field_now" | jq -r 'try(.value)')" | |
src_field_now_alt="$(echo "$src_field_now" | jq -r 'try(.alt)')" | |
else | |
src_field_now_val="" | |
src_field_now_alt="" | |
fi | |
[[ "$src_field_now_val" == "null" ]] && src_field_now_val="" || src_field_now_val="${src_field_now_val/\\/\\\\}"; | |
[[ "$src_field_now_alt" == "null" ]] && src_field_now_alt="" || src_field_now_alt="${src_field_now_alt/\\/\\\\}"; | |
proceed_repeat=1 | |
while [[ "$proceed_repeat" == 1 ]]; do | |
if [[ "$pf_user_ok" != "true" ]]; then | |
pf_set_res_ok="fail_user_read" | |
elif [[ -z "$src_field_now_val" && "$include_blank_vals" != "y" ]]; then | |
pf_set_res_ok="skip_blank"; | |
else | |
if [[ "$for_realz" == "r" ]]; then | |
if [[ "$dst_field_type" == "Profile" ]]; then | |
pf_set_res="$(slack_post_data "users.profile.set" "$app_api_token" "{\"user\":\"$id\",\"profile\":{\"$dst_field_id\":\"$src_field_now_val\"}}")" | |
elif [[ "$dst_field_type" == "Custom" ]]; then | |
pf_set_res="$(slack_post_data "users.profile.set" "$app_api_token" "{\"user\":\"$id\",\"profile\":{\"fields\":{\"$dst_field_id\":{\"value\":\"$src_field_now_val\",\"alt\":\"$src_field_now_alt\"}}}}")" | |
fi | |
pf_set_res_ok="$(echo "$pf_set_res" | jq -r '.ok')" | |
else | |
pf_set_res_ok="true" | |
fi | |
fi | |
if [[ "$pf_set_res_ok" == "skip_blank" ]]; then | |
log_status="skip_blank" | |
pf_final_err="" | |
proceed_repeat=0 | |
sleep 0.6 | |
((skips++)) | |
elif [[ "$pf_set_res_ok" == "true" ]]; then | |
log_status="success" | |
pf_final_err="" | |
proceed_repeat=0 | |
sleep $interval_time | |
((successes++)) | |
elif [[ "$pf_set_res_ok" == "fail_user_read" ]]; then | |
log_status="fail_user_read" | |
pf_final_err="$(echo "$pf_user_info" | jq -r '.error')" | |
proceed_repeat=0 | |
sleep 0.6 | |
((fails++)) | |
else | |
pf_final_err="$(echo "$pf_set_res" | jq -r '.error')" | |
if [[ "$pf_final_err" == "ratelimited" ]]; then | |
log_status="rate_limited" | |
proceed_repeat=1 | |
was_rate_limited=1 | |
((rate_limited++)) | |
sleep 5 | |
elif [[ "$pf_final_err" == "" ]]; then | |
log_status="fail_blank_resp" | |
proceed_repeat=0 | |
sleep $interval_time | |
((fails++)) | |
else | |
log_status="fail_user_write" | |
proceed_repeat=0 | |
sleep $interval_time | |
((fails++)) | |
fi | |
fi | |
done | |
echo "\"$(date +%s)\",\"$id\",\"$pf_user_name\",\"$log_status\",\"$src_field_label\",\"$dst_field_label\",\"$src_field_now_val\",\"$was_rate_limited\",\"$pf_final_err\"" >> $log_file | |
pf_final_err="" | |
log_status="" | |
was_rate_limited=0 | |
((incr++)) | |
done | |
now_epoch="$(date +%s)" | |
echo | |
bold_and_blue; echo "Process completed in $(sec_to_time_string $((now_epoch - start_epoch))), with $successes successes, $fails fails, $skips blanks skipped and $rate_limited rate limits hit."; reset_font | |
fi | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment