#!/usr/bin/env bash # handset — drive your phone number from the terminal. # # Install: curl -fsSL https://www.handset.app/cli -o /usr/local/bin/handset && chmod +x /usr/local/bin/handset # Auth: export HANDSET_PASSCODE=... (the passcode you use in the app) # # Everything speaks to https://www.handset.app, the same API the browser uses. set -euo pipefail BASE="${HANDSET_URL:-${PHONE_URL:-https://www.handset.app}}" PASS="${HANDSET_PASSCODE:-${PHONE_PASSCODE:-${APP_PASSCODE:-}}}" die() { echo "$*" >&2; exit 1; } [ -n "$PASS" ] || die "Set HANDSET_PASSCODE to the passcode you use in the app." command -v python3 >/dev/null || die "handset needs python3 to read responses." api() { # method path [body] local method="$1" path="$2" body="${3:-}" if [ -n "$body" ]; then curl -fsS -X "$method" "$BASE$path" \ -H "Authorization: Bearer $PASS" -H "Content-Type: application/json" -d "$body" else curl -fsS -X "$method" "$BASE$path" -H "Authorization: Bearer $PASS" fi } # Accept what a person would type. A bare 10 digits is assumed US — the only # safe assumption for a US line. Anything else needs its country code, because # guessing one wrong dials a stranger. e164() { local d; d=$(printf '%s' "$1" | tr -cd '0-9+') case "$d" in +*) printf '%s' "$d" ;; 00*) printf '+%s' "${d#00}" ;; ??????????) printf '+1%s' "$d" ;; 1??????????) printf '+%s' "$d" ;; *) printf '%s' "$d" ;; esac } # A name resolves through your contacts. Several matches is an error, not a # guess: the cost of picking wrong is calling the wrong person. resolve() { local q="$1" n; n=$(e164 "$q") if printf '%s' "$n" | grep -qE '^\+[1-9][0-9]{7,14}$'; then printf '%s' "$n"; return; fi api GET /api/contacts | python3 -c ' import json,sys q=sys.argv[1].lower() hits=[c for c in json.load(sys.stdin)["contacts"] if c["numbers"] and q in c["name"].lower()] if not hits: sys.exit("No contact matches \"%s\", and it is not a number either." % sys.argv[1]) if len(hits)>1: sys.exit("\"%s\" matches %d contacts:\n%s\nBe more specific, or pass the number." % (sys.argv[1], len(hits), "\n".join(" %s - %s" % (c["name"], c["numbers"][0]) for c in hits))) print(hits[0]["numbers"][0])' "$q" } usage() { cat <<'USAGE' handset link [--auto] URL that opens the dialer, loaded and ready handset call ring your phone, then connect them handset find [query] search contacts handset messages inbound texts handset activity recent calls handset settings show settings handset port where the number port has got to handset port start begin one handset port set provider|account|pin|name|address|notes handset port next advance a step, if it's allowed handset port back step back USAGE } cmd="${1:-}"; shift || true case "$cmd" in link) who="${1:?usage: handset link }"; n=$(resolve "$who") extra=""; [ "${2:-}" = "--auto" ] && extra="&call=1" printf '%s/?to=%s%s\n' "$BASE" "$(printf '%s' "$n" | sed 's/+/%2B/')" "$extra" ;; call) who="${1:?usage: handset call }"; n=$(resolve "$who") api POST /api/call-via-phone "{\"to\":\"$n\"}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print("Ringing %s - answer to be connected." % d["ringing"])' ;; find) api GET /api/contacts | python3 -c ' import json,sys q=(sys.argv[1] if len(sys.argv)>1 else "").lower() for c in json.load(sys.stdin)["contacts"]: hay=" ".join(filter(None,[c["name"],c.get("company"),c.get("title")])).lower() if q and q not in hay and not any(q in n for n in c["numbers"]): continue print("%-16s %s%s" % (c["numbers"][0] if c["numbers"] else "-", c["name"], " . "+c["company"] if c.get("company") else ""))' "${1:-}" ;; messages) api GET /api/messages | python3 -c ' import json,sys d=json.load(sys.stdin) if not d["messages"]: print("No messages."); raise SystemExit print("%d message(s), %d unread\n" % (d["count"], d["unread"])) for m in d["messages"]: print("%s%s %s" % (" " if m["readAt"] else "* ", m["fromNumber"], m["receivedAt"][:16].replace("T"," "))) print(" %s\n" % m["body"])' ;; activity) api GET /api/activity | python3 -c ' import json,sys cs=json.load(sys.stdin)["calls"] if not cs: print("No calls yet."); raise SystemExit for c in cs: other=c["fromNumber"] if c["direction"]=="in" else c["toNumber"] what="missed" if c["direction"]=="in" and c["duration"]==0 else "%ss"%c["duration"] print("%s %s %-16s %s" % (c["occurredAt"][:16].replace("T"," "), "in " if c["direction"]=="in" else "out", other, what))' ;; settings) api GET /api/settings | python3 -c ' import json,sys s=json.load(sys.stdin) print("handset %s" % (s["handsetNumber"] or "-")) print("ring handset %s" % ("on" if s["forwardToHandset"] else "off")) print("missed-call mail %s -> %s" % ("on" if s["emailOnMissed"] else "off", s["notifyEmail"] or "-"))' ;; port) sub="${1:-status}"; shift || true case "$sub" in start) n=$(e164 "${1:?usage: handset port start }") api POST /api/port "{\"number\":\"$n\"}" >/dev/null ;; set) f="${1:?usage: handset port set }"; v="${2:?missing value}" case "$f" in provider) k=provider ;; account) k=accountNumber ;; pin) k=accountPin ;; name) k=billingName ;; address) k=billingAddress ;; notes) k=notes ;; *) die "unknown field "$f" — provider|account|pin|name|address|notes" ;; esac api POST /api/port "{\"$k\":\"$v\"}" >/dev/null ;; next) # The server refuses illegal steps; surface the refusal, don't retry it. out=$(curl -sS -X POST "$BASE/api/port/advance" -H "Authorization: Bearer $PASS" -H "Content-Type: application/json" -d '{}') printf '%s' "$out" | python3 -c 'import json,sys d=json.load(sys.stdin) if d.get("error"): sys.exit("Refused: %s" % d["error"])' ;; back) api POST /api/port/back '{}' >/dev/null ;; status) ;; *) die "usage: handset port [start|set|next|back]" ;; esac api GET /api/port | python3 -c ' import json,sys d=json.load(sys.stdin); p=d["port"] if not p: print("No port in progress. Start one: handset port start "); raise SystemExit steps=d["steps"]; at=steps.index(p["status"]) print("Porting %s from %s\n" % (p["number"], p["provider"] or "?")) for i,s in enumerate(steps): mark = "x" if i" if i==at else " ") print(" [%s] %s" % (mark, d["info"][s]["title"])) print("\n%s" % d["info"][p["status"]]["blurb"]) if d["missing"]: print("\nStill needed: %s" % ", ".join(d["missing"])) act = d["info"][p["status"]]["action"] if act: print("\nNext: %s (handset port next)" % act)' ;; ""|-h|--help|help) usage ;; *) usage; exit 1 ;; esac