#!/bin/bash
# This file is in the public domain.
# Transmit a Challenger TAN by SMS using the ClickSend v3 API.
#
# Usage:
#   echo "MESSAGE" | $0 +41791234567
#
# Note that unlike challenger-send-sms(1), the argument is the bare phone
# number: this script is invoked by the dispatcher, which has already extracted
# and syntax-checked it.
#
# Environment:
#   CLICKSEND_USERNAME
#   CLICKSEND_API_KEY
#
# Exit codes (see challenger-send-sms-clicksend(1)).  Success is 0 or the
# 200-210 band; every other value is a failure.  The success band is up at
# 200 and not at 1 because libgnunetutil reports a failed exec() of the
# helper as exit code 1, which must not be mistaken for a delivery.
# ClickSend collapses every bad number into INVALID_RECIPIENT, so
# this helper never reports 11, 12 or 13.
#
#    0 delivered    201 accepted         202 duplicate suppressed
#   14 invalid number 15 blocked
#   20 offline        21 expired           22 carrier
#   30 provider       31 rejected          32 rate limited     33 no verdict
#   40 config         41 authorization     42 balance
#   50 unclassified

set -eu

if [ -z "${CLICKSEND_USERNAME:-}" ]
then
    echo "CLICKSEND_USERNAME not set in environment" 1>&2
    exit 40
fi
if [ -z "${CLICKSEND_API_KEY:-}" ]
then
    echo "CLICKSEND_API_KEY not set in environment" 1>&2
    exit 40
fi

if [ $# -ne 1 ]
then
    echo "Usage: $0 <phone_number>" 1>&2
    exit 40
fi

PHONE_NUMBER="$1"
MESSAGE=$(cat -)

API="https://rest.clicksend.com/v3"

# Total time we are willing to wait for a delivery confirmation.  A message
# still in flight when this expires is reported as accepted (1), not failed.
DEADLINE=$(( SECONDS + 5 ))

BODY=$(mktemp "${TMPDIR:-/tmp}/clicksend-sms-XXXXXX")
trap 'rm -f "$BODY"' EXIT

# Build the request with jq rather than by interpolation: the message is
# attacker-influenced text and any quote, backslash or newline in it would
# otherwise produce malformed JSON.
PAYLOAD=$(jq -n \
             --arg to "$PHONE_NUMBER" \
             --arg body "$MESSAGE" \
             '{messages: [{source: "challenger", to: $to, body: $body}]}')

# curl --fail is deliberately not used anywhere in this script: ClickSend puts
# all of its diagnostic value in the response body, which --fail discards.
HTTP=$(curl --silent --show-error \
            --output "$BODY" \
            --write-out '%{http_code}' \
            --url "$API/sms/send" \
            --request POST \
            --header 'Content-Type: application/json' \
            --user "$CLICKSEND_USERNAME:$CLICKSEND_API_KEY" \
            --data "$PAYLOAD") || {
    echo "ClickSend: could not be reached" 1>&2
    exit 30
}

RESPONSE_CODE=$(jq -r '.response_code // empty' < "$BODY" 2>/dev/null || true)

if [ "SUCCESS" != "$RESPONSE_CODE" ]
then
    echo "ClickSend: request failed (HTTP $HTTP, $RESPONSE_CODE) for $PHONE_NUMBER" 1>&2
    cat "$BODY" 1>&2
    case "$RESPONSE_CODE" in
        UNAUTHORIZED | FORBIDDEN)  exit 41 ;;
        TOO_MANY_REQUESTS)         exit 32 ;;
        BAD_REQUEST | NOT_FOUND | METHOD_NOT_ALLOWED) exit 31 ;;
        INTERNAL_SERVER_ERROR)     exit 30 ;;
    esac
    case "$HTTP" in
        401 | 403) exit 41 ;;
        429)       exit 32 ;;
        4??)       exit 31 ;;
        *)         exit 30 ;;
    esac
fi

# The envelope only says the request was understood.  ClickSend documents that
# the per-message status is the one that reflects what happened to the message,
# and that its value SUCCESS means "added to queue" -- not "delivered".
MESSAGE_STATUS=$(jq -r '.data.messages[0].status // empty' < "$BODY" 2>/dev/null || true)

case "$MESSAGE_STATUS" in
    SUCCESS)
        ;;
    INVALID_RECIPIENT)
        # Failed ClickSend's format or prefix check; never sent, never charged.
        echo "ClickSend: $PHONE_NUMBER rejected as an invalid recipient" 1>&2
        exit 14
        ;;
    THROTTLED)
        # An identical message was recently accepted for this recipient, so the
        # same TAN is already on its way.  Suppressing the duplicate is not a
        # failure and must not cause a retry.
        echo "ClickSend: duplicate suppressed, an identical message to $PHONE_NUMBER is already in flight" 1>&2
        exit 202
        ;;
    INSUFFICIENT_CREDIT)
        echo "ClickSend: account is out of message credits" 1>&2
        exit 42
        ;;
    INVALID_CREDENTIALS | MISSING_CREDENTIALS | ACCOUNT_NOT_ACTIVATED)
        echo "ClickSend: $MESSAGE_STATUS" 1>&2
        exit 41
        ;;
    COUNTRY_NOT_ENABLED | REGISTRATION_NEEDED | INVALID_SENDER_ID)
        echo "ClickSend: $MESSAGE_STATUS for $PHONE_NUMBER" 1>&2
        exit 15
        ;;
    INTERNAL_ERROR | SOMETHING_IS_WRONG)
        echo "ClickSend: $MESSAGE_STATUS" 1>&2
        exit 30
        ;;
    EMPTY_MESSAGE | MISSING_REQUIRED_FIELDS | INVALID_SCHEDULE | TOO_MANY_RECIPIENTS | NOT_ENOUGH_PERMISSION_TO_LIST_ID)
        echo "ClickSend: $MESSAGE_STATUS" 1>&2
        exit 31
        ;;
    *)
        echo "ClickSend: unclassified message status '$MESSAGE_STATUS' for $PHONE_NUMBER" 1>&2
        cat "$BODY" 1>&2
        exit 50
        ;;
esac

MESSAGE_ID=$(jq -r '.data.messages[0].message_id // empty' < "$BODY" 2>/dev/null || true)

if [ -z "$MESSAGE_ID" ]
then
    echo "ClickSend: queued message for $PHONE_NUMBER but returned no message_id" 1>&2
    exit 33
fi

# Every final delivery failure is reported as status_code 301, with the reason
# only in the free-text status_text, whose wording ClickSend does not promise to
# keep stable (it already differs between the receipts and history endpoints).
# Unrecognised text therefore degrades to 50 rather than being guessed at.
classify_failure ()
{
    case "$1" in
        *"Unknown subscriber"* | *"Unidentified subscriber"* | *"Illegal subscriber"*)
            echo 14 ;;
        *"not supporting SMS"*)
            echo 13 ;;
        *"Absent subscriber"* | *"Memory capacity exceeded"*)
            echo 20 ;;
        *"receipt expired"*)
            echo 21 ;;
        *"congestion"* | *"Rejected by the recipient network"* | *"Network timeout"* | *"Network routing error"*)
            echo 22 ;;
        *"Anti-spam"*)
            echo 15 ;;
        *)
            echo 50 ;;
    esac
}

# Poll sms/history rather than sms/receipts/<id>: the receipts endpoint reads a
# pushed-DLR inbox that needs a POLL automation rule configured on the account
# and is consumed on read, so it 404s for an ordinary send.
while [ "$SECONDS" -lt "$DEADLINE" ]
do
    sleep 1
    curl --silent --show-error \
         --output "$BODY" \
         --url "$API/sms/history?q=message_id:$MESSAGE_ID" \
         --user "$CLICKSEND_USERNAME:$CLICKSEND_API_KEY" || continue
    STATUS_CODE=$(jq -r '.data.data[0].status_code // empty' < "$BODY" 2>/dev/null || true)
    STATUS_TEXT=$(jq -r '.data.data[0].status_text // ""' < "$BODY" 2>/dev/null || true)
    STATUS=$(jq -r '.data.data[0].status // ""' < "$BODY" 2>/dev/null || true)
    case "$STATUS" in
        Cancelled | CancelledAfterReview)
            echo "ClickSend: message to $PHONE_NUMBER was stopped ($STATUS)" 1>&2
            exit 15
            ;;
    esac
    case "$STATUS_CODE" in
        201)
            echo "ClickSend: delivered to $PHONE_NUMBER" 1>&2
            exit 0
            ;;
        301)
            RESULT=$(classify_failure "$STATUS_TEXT")
            echo "ClickSend: delivery to $PHONE_NUMBER failed: $STATUS_TEXT" 1>&2
            exit "$RESULT"
            ;;
        *)
            # 200 (queued/sent/scheduled) and 300 (temporary, ClickSend retries
            # for up to 72h) both mean "still in flight".
            continue
            ;;
    esac
done

echo "ClickSend: queued message for $PHONE_NUMBER, delivery not confirmed within the poll window" 1>&2
exit 201
