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