|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Send a Teams DM as the bot. Outbound-only — uses the cached conversation |
| 3 | +# reference written by fetch-conv-ref.sh. |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# ./send-dm.sh "message body" # plain text |
| 7 | +# ./send-dm.sh --markdown "**hi**" # rendered as markdown |
| 8 | +set -euo pipefail |
| 9 | + |
| 10 | +CONFIG_DIR="${HOME}/.config/teams-dm" |
| 11 | +CONV_REF="${CONFIG_DIR}/conv-ref.json" |
| 12 | +ENV_FILE="${CONFIG_DIR}/env" |
| 13 | +TOKEN_CACHE="${TMPDIR:-/tmp}/teams-dm-token" |
| 14 | + |
| 15 | +[[ -f "${CONV_REF}" ]] || { echo "ERR: ${CONV_REF} not found. Run fetch-conv-ref.sh first." >&2; exit 1; } |
| 16 | +[[ -f "${ENV_FILE}" ]] || { echo "ERR: ${ENV_FILE} not found. See README for required vars." >&2; exit 1; } |
| 17 | + |
| 18 | +# shellcheck disable=SC1090 |
| 19 | +source "${ENV_FILE}" |
| 20 | +: "${BOT_APP_ID:?BOT_APP_ID missing in ${ENV_FILE}}" |
| 21 | +: "${BOT_APP_SECRET:?BOT_APP_SECRET missing in ${ENV_FILE}}" |
| 22 | +: "${<credential-env>:?<credential-env> missing in ${ENV_FILE}}" |
| 23 | + |
| 24 | +text_format="plain" |
| 25 | +if [[ "${1:-}" == "--markdown" ]]; then |
| 26 | + text_format="markdown" |
| 27 | + shift |
| 28 | +fi |
| 29 | +text="${1:?usage: send-dm.sh [--markdown] \"message\"}" |
| 30 | + |
| 31 | +# Reuse cached token. Cache stores access_token + absolute expiry epoch on two |
| 32 | +# lines. Honor the token endpoint's expires_in minus a 60s clock-skew margin. |
| 33 | +# Atomic write via mktemp+mv. mkdir-based lock prevents concurrent invocations |
| 34 | +# from stampeding the token endpoint when the cache expires (mkdir is atomic |
| 35 | +# on all POSIX filesystems — no flock/portable lock-file dance needed). |
| 36 | +LOCK_DIR="${TOKEN_CACHE}.lock" |
| 37 | +acquire_lock() { |
| 38 | + local tries=0 |
| 39 | + until mkdir "${LOCK_DIR}" 2>/dev/null; do |
| 40 | + tries=$((tries + 1)) |
| 41 | + [[ $tries -gt 50 ]] && { echo "ERR: could not acquire token lock after 5s" >&2; return 1; } |
| 42 | + sleep 0.1 |
| 43 | + done |
| 44 | + trap 'rmdir "${LOCK_DIR}" 2>/dev/null' EXIT |
| 45 | +} |
| 46 | +release_lock() { |
| 47 | + rmdir "${LOCK_DIR}" 2>/dev/null || true |
| 48 | + trap - EXIT |
| 49 | +} |
| 50 | + |
| 51 | +get_token() { |
| 52 | + if [[ -f "${TOKEN_CACHE}" ]]; then |
| 53 | + local cached_exp cached_token |
| 54 | + cached_exp="$(sed -n '2p' "${TOKEN_CACHE}" 2>/dev/null || echo 0)" |
| 55 | + cached_token="$(sed -n '1p' "${TOKEN_CACHE}" 2>/dev/null || true)" |
| 56 | + if [[ -n "${cached_token}" && "${cached_exp}" =~ ^[0-9]+$ ]]; then |
| 57 | + if (( cached_exp - 60 > $(date +%s) )); then |
| 58 | + echo "${cached_token}" |
| 59 | + return |
| 60 | + fi |
| 61 | + fi |
| 62 | + fi |
| 63 | + # Cache miss or expired — serialize the refresh. |
| 64 | + acquire_lock || return 1 |
| 65 | + # Re-check after acquiring lock — another process may have refreshed. |
| 66 | + if [[ -f "${TOKEN_CACHE}" ]]; then |
| 67 | + local cached_exp cached_token |
| 68 | + cached_exp="$(sed -n '2p' "${TOKEN_CACHE}" 2>/dev/null || echo 0)" |
| 69 | + cached_token="$(sed -n '1p' "${TOKEN_CACHE}" 2>/dev/null || true)" |
| 70 | + if [[ -n "${cached_token}" && "${cached_exp}" =~ ^[0-9]+$ ]] && (( cached_exp - 60 > $(date +%s) )); then |
| 71 | + release_lock |
| 72 | + echo "${cached_token}" |
| 73 | + return |
| 74 | + fi |
| 75 | + fi |
| 76 | + local response |
| 77 | + response="$(curl -sS -X POST \ |
| 78 | + "https://login.microsoftonline.com/${<credential-env>}/oauth2/v2.0/token" \ |
| 79 | + -d "client_id=${BOT_APP_ID}" \ |
| 80 | + -d "client_secret=${BOT_APP_SECRET}" \ |
| 81 | + -d "scope=https://api.botframework.com/.default" \ |
| 82 | + -d "grant_type=client_credentials")" |
| 83 | + local parsed |
| 84 | + parsed="$(echo "${response}" | python3 -c " |
| 85 | +import json, sys, time |
| 86 | +d = json.load(sys.stdin) |
| 87 | +if 'access_token' not in d: |
| 88 | + sys.exit('ERR: token response missing access_token: ' + json.dumps(d)) |
| 89 | +exp = int(time.time()) + int(d.get('expires_in', 3600)) |
| 90 | +print(d['access_token']) |
| 91 | +print(exp) |
| 92 | +")" |
| 93 | + local tmp |
| 94 | + tmp="$(mktemp "${TOKEN_CACHE}.XXXXXX")" |
| 95 | + printf '%s\n' "${parsed}" > "${tmp}" |
| 96 | + chmod 600 "${tmp}" |
| 97 | + mv "${tmp}" "${TOKEN_CACHE}" |
| 98 | + release_lock |
| 99 | + echo "${parsed%%$'\n'*}" |
| 100 | +} |
| 101 | + |
| 102 | +token="$(get_token)" |
| 103 | +conv_id="$(python3 -c "import json; print(json.load(open('${CONV_REF}'))['conversationId'])")" |
| 104 | +service_url="$(python3 -c "import json; print(json.load(open('${CONV_REF}'))['serviceUrl'].rstrip('/'))")" |
| 105 | + |
| 106 | +body="$(python3 -c " |
| 107 | +import json, sys |
| 108 | +print(json.dumps({ |
| 109 | + 'type': 'message', |
| 110 | + 'textFormat': sys.argv[1], |
| 111 | + 'text': sys.argv[2], |
| 112 | +})) |
| 113 | +" "${text_format}" "${text}")" |
| 114 | + |
| 115 | +http_code="$(curl -sS -o /tmp/teams-dm-resp -w "%{http_code}" \ |
| 116 | + -X POST "${service_url}/v3/conversations/${conv_id}/activities" \ |
| 117 | + -H "Authorization: Bearer ${token}" \ |
| 118 | + -H "Content-Type: application/json" \ |
| 119 | + -d "${body}")" |
| 120 | + |
| 121 | +if [[ "${http_code}" == "401" ]]; then |
| 122 | + # Token may have rotated — bust cache and retry once. |
| 123 | + rm -f "${TOKEN_CACHE}" |
| 124 | + token="$(get_token)" |
| 125 | + http_code="$(curl -sS -o /tmp/teams-dm-resp -w "%{http_code}" \ |
| 126 | + -X POST "${service_url}/v3/conversations/${conv_id}/activities" \ |
| 127 | + -H "Authorization: Bearer ${token}" \ |
| 128 | + -H "Content-Type: application/json" \ |
| 129 | + -d "${body}")" |
| 130 | +fi |
| 131 | + |
| 132 | +if [[ "${http_code}" != "200" && "${http_code}" != "201" ]]; then |
| 133 | + echo "ERR: send failed (HTTP ${http_code})" >&2 |
| 134 | + cat /tmp/teams-dm-resp >&2 |
| 135 | + exit 1 |
| 136 | +fi |
| 137 | +echo "Sent." |
0 commit comments