textmagic - textmagic.go

// Package textmagic wraps the API for textmagic.com.
package textmagic // import "vimagination.zapto.org/textmagic"

import (
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"vimagination.zapto.org/memio"
)

var apiURLPrefix = "https://www.textmagic.com/app/api?"

const (
	cmdAccount       = "account"
	cmdCheckNumber   = "check_number"
	cmdDeleteReply   = "delete_reply"
	cmdMessageStatus = "message_status"
	cmdReceive       = "receive"
	cmdSend          = "send"
)

// TextMagic contains the data necessary for performing API requests.
type TextMagic struct {
	username, password string
}

// New constructs a new TextMagic session.
func New(username, password string) TextMagic {
	return TextMagic{username, password}
}

func (t TextMagic) sendAPI(cmd string, params url.Values, data interface{}) error {
	params.Set("username", t.username)
	params.Set("password", t.password)
	params.Set("cmd", cmd)

	r, err := http.Get(apiURLPrefix + params.Encode())
	if err != nil {
		return RequestError{cmd, err}
	}

	defer r.Body.Close()

	if r.StatusCode != http.StatusOK {
		return StatusError{cmd, r.StatusCode}
	}

	cL := r.ContentLength
	if cL < 0 {
		cL = 1024
	}

	jsonData := make(memio.Buffer, 0, cL) // avoid allocation using io.Pipe?

	var apiError APIError

	if err = json.NewDecoder(io.TeeReader(r.Body, &jsonData)).Decode(&apiError); err != nil {
		return JSONError{cmd, err}
	}

	if apiError.Code != 0 {
		apiError.Cmd = cmd

		return apiError
	}

	return json.Unmarshal(jsonData, data)
}

type balance struct {
	Balance float32 `json:"balance"`
}

// Account returns the balance of the given TextMagic account.
func (t TextMagic) Account() (float32, error) {
	var b balance

	if err := t.sendAPI(cmdAccount, url.Values{}, &b); err != nil {
		return 0, err
	}

	return b.Balance, nil
}

// DeliveryNotificationCode is a representation of the status of a delivery.
type DeliveryNotificationCode string

// Status returns the type of status based on the code.
func (d DeliveryNotificationCode) Status() string {
	switch d {
	case "q", "r", "a", "b", "s":
		return "intermediate"
	case "d", "f", "e", "j", "u":
		return "final"
	}

	return "unknown"
}

func (d DeliveryNotificationCode) String() string {
	switch d {
	case "q":
		return "The message is queued on the TextMagic server."
	case "r":
		return "The message has been sent to the mobile operator."
	case "a":
		return "The mobile operator has acknowledged the message."
	case "b":
		return "The mobile operator has queued the message."
	case "d":
		return "The message has been successfully delivered to the handset."
	case "f":
		return "An error occurred while delivering message."
	case "e":
		return "An error occurred while sending message."
	case "j":
		return "The mobile operator has rejected the message."
	case "s":
		return "This message is scheduled to be sent later."
	default:
		return "The status is unknown."

	}
}

// Status represents all of the information about a sent/pending message.
type Status struct {
	Text      string                   `json:"text"`
	Status    DeliveryNotificationCode `json:"status"`
	Created   int64                    `json:"created_time"`
	Reply     string                   `json:"reply_number"`
	Cost      float32                  `json:"credits_cost"`
	Completed int64                    `json:"completed_time"`
}

const joinSep = ","

// MessageStatus gathers information about the messages with the given ids.
func (t TextMagic) MessageStatus(ids []string) (map[string]Status, error) {
	statuses := make(map[string]Status)

	for _, tIds := range splitSlice(ids) {
		messageIds := strings.Join(tIds, joinSep)
		strStatuses := make(map[string]Status)

		if err := t.sendAPI(cmdMessageStatus, url.Values{"ids": {messageIds}}, strStatuses); err != nil {
			return statuses, err
		}

		for messageID, status := range strStatuses {
			statuses[messageID] = status
		}
	}

	return statuses, nil
}

// Number represents the information about a phone number.
type Number struct {
	Price   float32 `json:"price"`
	Country string  `json:"country"`
}

// CheckNumber is used to get the cost and country for the given phone numbers.
func (t TextMagic) CheckNumber(numbers []string) (map[string]Number, error) {
	ns := make(map[string]Number)

	if err := t.sendAPI(cmdCheckNumber, url.Values{"phone": {strings.Join(numbers, joinSep)}}, ns); err != nil {
		return nil, err
	}

	return ns, nil
}

type deleted struct {
	Deleted []string `json:"deleted"`
}

// DeleteReply will simple delete message replies with the given ids.
func (t TextMagic) DeleteReply(ids []string) ([]string, error) {
	toRet := make([]string, 0, len(ids))

	for _, tIds := range splitSlice(ids) {
		var d deleted

		if err := t.sendAPI(cmdDeleteReply, url.Values{"deleted": {strings.Join(tIds, joinSep)}}, &d); err != nil {
			return toRet, err
		}

		toRet = append(toRet, d.Deleted...)
	}

	return toRet, nil
}

// Message represents the information about a received message.
type Message struct {
	ID        uint64 `json:"message_id"`
	From      string `json:"from"`
	Timestamp int64  `json:"timestamp"`
	Text      string `json:"text"`
}

type received struct {
	Messages []Message `json:"messages"`
	Unread   uint64    `json:"unread"`
}

// Receive will retrieve the number of unread messages and the 100 latest
// replies.
func (t TextMagic) Receive(lastRetrieved uint64) (uint64, []Message, error) {
	var r received

	err := t.sendAPI(cmdReceive, url.Values{"last_retrieved_id": {utos(lastRetrieved)}}, &r)

	return r.Unread, r.Messages, err
}

// Option is a type representing a message sending option.
type Option func(u url.Values)

// From is an option to modify the sender of a message.
func From(from string) Option {
	return func(u url.Values) {
		u.Set("from", from)
	}
}

// MaxLength is an option to limit the length of a message.
func MaxLength(length uint64) Option {
	if length > 3 {
		length = 3
	}

	return func(u url.Values) {
		u.Set("max_length", utos(length))
	}
}

// CutExtra sets the option to automatically trim overlong messages.
func CutExtra() Option {
	return func(u url.Values) {
		u.Set("cut_extra", "1")
	}
}

// SendTime sets the option to schedule the sending of a message for a specific
// time.
func SendTime(t time.Time) Option {
	return func(u url.Values) {
		u.Set("send_time", t.Format(time.RFC3339))
	}
}

type messageResponse struct {
	IDs   map[string]string `json:"message_id"`
	Text  string            `json:"sent_text"`
	Parts uint              `json:"parts_count"`
}

// Send will send a message to the given recipients. It takes options to modify
// the scheduling. sender and length of the message.
func (t TextMagic) Send(message string, to []string, options ...Option) (map[string]string, string, uint, error) {
	var (
		params = url.Values{}
		text   string
		parts  uint
		ids    = make(map[string]string)
	)

	// check message for unicode/invalid chars
	params.Set("text", message)
	params.Set("unicode", "0")

	for _, o := range options {
		o(params)
	}

	for _, numbers := range splitSlice(to) {
		params.Set("phone", strings.Join(numbers, joinSep))

		var m messageResponse

		if err := t.sendAPI(cmdSend, params, &m); err != nil {
			return ids, text, parts, err
		}

		if parts == 0 {
			parts = m.Parts
			text = m.Text
		}

		for id, number := range m.IDs {
			ids[id] = number
		}
	}

	return ids, text, parts, nil
}

// Errors.

// APIError is an error returned when the incorrect or unexpected data is
// received.
type APIError struct {
	Cmd     string
	Code    int    `json:"error_code"`
	Message string `json:"error_message"`
}

func (a APIError) Error() string {
	return "command " + a.Cmd + " returned the following API error: " + a.Message
}

// RequestError is an error which wraps an error that occurs while making an
// API call.
type RequestError struct {
	Cmd string
	Err error
}

func (r RequestError) Error() string {
	return "command " + r.Cmd + " returned the following error while making the API call: " + r.Err.Error()
}

// StatusError is an error that is returned when a non-200 OK http response is
// received.
type StatusError struct {
	Cmd        string
	StatusCode int
}

func (s StatusError) Error() string {
	return "command " + s.Cmd + " returned a non-200 OK response: " + http.StatusText(s.StatusCode)
}

// JSONError is an error that wraps a JSON error.
type JSONError struct {
	Cmd string
	Err error
}

func (j JSONError) Error() string {
	return "command " + j.Cmd + " returned malformed JSON: " + j.Err.Error()
}