1 // Package textmagic wraps the API for textmagic.com 2 package textmagic // import "vimagination.zapto.org/textmagic" 3 4 import ( 5 "encoding/json" 6 "io" 7 "net/http" 8 "net/url" 9 "strings" 10 "time" 11 12 "vimagination.zapto.org/memio" 13 ) 14 15 var apiURLPrefix = "https://www.textmagic.com/app/api?" 16 17 const ( 18 cmdAccount = "account" 19 cmdCheckNumber = "check_number" 20 cmdDeleteReply = "delete_reply" 21 cmdMessageStatus = "message_status" 22 cmdReceive = "receive" 23 cmdSend = "send" 24 ) 25 26 // TextMagic contains the data necessary for performing API requests 27 type TextMagic struct { 28 username, password string 29 } 30 31 // New constructs a new TextMagic session 32 func New(username, password string) TextMagic { 33 return TextMagic{username, password} 34 } 35 36 func (t TextMagic) sendAPI(cmd string, params url.Values, data interface{}) error { 37 params.Set("username", t.username) 38 params.Set("password", t.password) 39 params.Set("cmd", cmd) 40 r, err := http.Get(apiURLPrefix + params.Encode()) 41 if err != nil { 42 return RequestError{cmd, err} 43 } 44 defer r.Body.Close() 45 if r.StatusCode != http.StatusOK { 46 return StatusError{cmd, r.StatusCode} 47 } 48 cL := r.ContentLength 49 if cL < 0 { 50 cL = 1024 51 } 52 jsonData := make(memio.Buffer, 0, cL) // avoid allocation using io.Pipe? 53 var apiError APIError 54 err = json.NewDecoder(io.TeeReader(r.Body, &jsonData)).Decode(&apiError) 55 if err != nil { 56 return JSONError{cmd, err} 57 } 58 if apiError.Code != 0 { 59 apiError.Cmd = cmd 60 return apiError 61 } 62 return json.Unmarshal(jsonData, data) 63 } 64 65 type balance struct { 66 Balance float32 `json:"balance"` 67 } 68 69 // Account returns the balance of the given TextMagic account 70 func (t TextMagic) Account() (float32, error) { 71 var b balance 72 if err := t.sendAPI(cmdAccount, url.Values{}, &b); err != nil { 73 return 0, err 74 } 75 return b.Balance, nil 76 } 77 78 // DeliveryNotificationCode is a representation of the status of a delivery 79 type DeliveryNotificationCode string 80 81 // Status returns the type of status based on the code 82 func (d DeliveryNotificationCode) Status() string { 83 switch d { 84 case "q", "r", "a", "b", "s": 85 return "intermediate" 86 case "d", "f", "e", "j", "u": 87 return "final" 88 } 89 return "unknown" 90 } 91 92 func (d DeliveryNotificationCode) String() string { 93 switch d { 94 case "q": 95 return "The message is queued on the TextMagic server." 96 case "r": 97 return "The message has been sent to the mobile operator." 98 case "a": 99 return "The mobile operator has acknowledged the message." 100 case "b": 101 return "The mobile operator has queued the message." 102 case "d": 103 return "The message has been successfully delivered to the handset." 104 case "f": 105 return "An error occurred while delivering message." 106 case "e": 107 return "An error occurred while sending message." 108 case "j": 109 return "The mobile operator has rejected the message." 110 case "s": 111 return "This message is scheduled to be sent later." 112 default: 113 return "The status is unknown." 114 115 } 116 } 117 118 // Status represents all of the information about a sent/pending message 119 type Status struct { 120 Text string `json:"text"` 121 Status DeliveryNotificationCode `json:"status"` 122 Created int64 `json:"created_time"` 123 Reply string `json:"reply_number"` 124 Cost float32 `json:"credits_cost"` 125 Completed int64 `json:"completed_time"` 126 } 127 128 const joinSep = "," 129 130 // MessageStatus gathers information about the messages with the given ids 131 func (t TextMagic) MessageStatus(ids []string) (map[string]Status, error) { 132 statuses := make(map[string]Status) 133 for _, tIds := range splitSlice(ids) { 134 messageIds := strings.Join(tIds, joinSep) 135 strStatuses := make(map[string]Status) 136 err := t.sendAPI(cmdMessageStatus, url.Values{"ids": {messageIds}}, strStatuses) 137 if err != nil { 138 return statuses, err 139 } 140 for messageID, status := range strStatuses { 141 statuses[messageID] = status 142 } 143 } 144 return statuses, nil 145 } 146 147 // Number represents the information about a phone number 148 type Number struct { 149 Price float32 `json:"price"` 150 Country string `json:"country"` 151 } 152 153 // CheckNumber is used to get the cost and country for the given phone numbers 154 func (t TextMagic) CheckNumber(numbers []string) (map[string]Number, error) { 155 ns := make(map[string]Number) 156 if err := t.sendAPI(cmdCheckNumber, url.Values{"phone": {strings.Join(numbers, joinSep)}}, ns); err != nil { 157 return nil, err 158 } 159 return ns, nil 160 } 161 162 type deleted struct { 163 Deleted []string `json:"deleted"` 164 } 165 166 // DeleteReply will simple delete message replies with the given ids 167 func (t TextMagic) DeleteReply(ids []string) ([]string, error) { 168 toRet := make([]string, 0, len(ids)) 169 for _, tIds := range splitSlice(ids) { 170 var d deleted 171 if err := t.sendAPI(cmdDeleteReply, url.Values{"deleted": {strings.Join(tIds, joinSep)}}, &d); err != nil { 172 return toRet, err 173 } 174 toRet = append(toRet, d.Deleted...) 175 } 176 return toRet, nil 177 } 178 179 // Message represents the information about a received message 180 type Message struct { 181 ID uint64 `json:"message_id"` 182 From string `json:"from"` 183 Timestamp int64 `json:"timestamp"` 184 Text string `json:"text"` 185 } 186 187 type received struct { 188 Messages []Message `json:"messages"` 189 Unread uint64 `json:"unread"` 190 } 191 192 // Receive will retrieve the number of unread messages and the 100 latest 193 // replies 194 func (t TextMagic) Receive(lastRetrieved uint64) (uint64, []Message, error) { 195 var r received 196 err := t.sendAPI(cmdReceive, url.Values{"last_retrieved_id": {utos(lastRetrieved)}}, &r) 197 return r.Unread, r.Messages, err 198 } 199 200 // Option is a type representing a message sending option 201 type Option func(u url.Values) 202 203 // From is an option to modify the sender of a message 204 func From(from string) Option { 205 return func(u url.Values) { 206 u.Set("from", from) 207 } 208 } 209 210 // MaxLength is an option to limit the length of a message 211 func MaxLength(length uint64) Option { 212 if length > 3 { 213 length = 3 214 } 215 return func(u url.Values) { 216 u.Set("max_length", utos(length)) 217 } 218 } 219 220 // CutExtra sets the option to automatically trim overlong messages 221 func CutExtra() Option { 222 return func(u url.Values) { 223 u.Set("cut_extra", "1") 224 } 225 } 226 227 // SendTime sets the option to schedule the sending of a message for a specific 228 // time 229 func SendTime(t time.Time) Option { 230 return func(u url.Values) { 231 u.Set("send_time", t.Format(time.RFC3339)) 232 } 233 } 234 235 type messageResponse struct { 236 IDs map[string]string `json:"message_id"` 237 Text string `json:"sent_text"` 238 Parts uint `json:"parts_count"` 239 } 240 241 // Send will send a message to the given recipients. It takes options to modify 242 // the scheduling. sender and length of the message 243 func (t TextMagic) Send(message string, to []string, options ...Option) (map[string]string, string, uint, error) { 244 var ( 245 params = url.Values{} 246 text string 247 parts uint 248 ids = make(map[string]string) 249 ) 250 // check message for unicode/invalid chars 251 params.Set("text", message) 252 params.Set("unicode", "0") 253 for _, o := range options { 254 o(params) 255 } 256 for _, numbers := range splitSlice(to) { 257 params.Set("phone", strings.Join(numbers, joinSep)) 258 var m messageResponse 259 if err := t.sendAPI(cmdSend, params, &m); err != nil { 260 return ids, text, parts, err 261 } 262 if parts == 0 { 263 parts = m.Parts 264 text = m.Text 265 } 266 for id, number := range m.IDs { 267 ids[id] = number 268 } 269 } 270 return ids, text, parts, nil 271 } 272 273 // Errors 274 275 // APIError is an error returned when the incorrect or unexpected data is 276 // received 277 type APIError struct { 278 Cmd string 279 Code int `json:"error_code"` 280 Message string `json:"error_message"` 281 } 282 283 func (a APIError) Error() string { 284 return "command " + a.Cmd + " returned the following API error: " + a.Message 285 } 286 287 // RequestError is an error which wraps an error that occurs while making an 288 // API call 289 type RequestError struct { 290 Cmd string 291 Err error 292 } 293 294 func (r RequestError) Error() string { 295 return "command " + r.Cmd + " returned the following error while makeing the API call: " + r.Err.Error() 296 } 297 298 // StatusError is an error that is returned when a non-200 OK http response is 299 // received 300 type StatusError struct { 301 Cmd string 302 StatusCode int 303 } 304 305 func (s StatusError) Error() string { 306 return "command " + s.Cmd + " returned a non-200 OK response: " + http.StatusText(s.StatusCode) 307 } 308 309 // JSONError is an error that wraps a JSON error 310 type JSONError struct { 311 Cmd string 312 Err error 313 } 314 315 func (j JSONError) Error() string { 316 return "command " + j.Cmd + " returned malformed JSON: " + j.Err.Error() 317 } 318