├── .mailmap ├── .travis.yml ├── AUTHORS.txt ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── accounts.go ├── accounts_test.go ├── alert_descriptions.go ├── alerts.go ├── alerts_example_test.go ├── alerts_test.go ├── applications.go ├── applications_test.go ├── calls.go ├── calls_test.go ├── codes.go ├── conferences.go ├── conferences_test.go ├── doc.go ├── example_messages_test.go ├── example_test.go ├── http.go ├── http_ctx.go ├── http_noctx.go ├── http_test.go ├── keys.go ├── media.go ├── media_go17.go ├── media_goprevious.go ├── media_test.go ├── messages.go ├── messages_test.go ├── outgoingcallerids.go ├── outgoingcallerids_test.go ├── page.go ├── participants.go ├── phonenumbers.go ├── phonenumbers_test.go ├── prices_messaging.go ├── prices_messaging_test.go ├── prices_number.go ├── prices_number_test.go ├── prices_voice.go ├── prices_voice_test.go ├── queue.go ├── recording.go ├── recording_test.go ├── responses_test.go ├── token ├── access_token.go ├── access_token_grant.go ├── access_token_grant_test.go ├── access_token_test.go └── example_test.go ├── transcription.go ├── transcription_test.go ├── twilioclient ├── capabilities.go ├── capabilities_test.go └── example_test.go ├── types.go ├── types_test.go ├── validation.go └── validation_test.go /.mailmap: -------------------------------------------------------------------------------- 1 | Kevin Burke Kevin Burke 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | go_import_path: github.com/saintpete/twilio-go 2 | 3 | language: go 4 | go_import_path: github.com/saintpete/twilio-go 5 | 6 | go: 7 | - 1.6 8 | - 1.7 9 | - tip 10 | 11 | before_script: 12 | - go get ./... 13 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Fabrizio Moscon 2 | Kevin Burke 3 | Marcus Westin 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## 0.55 4 | 5 | Handle new HTTPS-friendly media URLs. 6 | 7 | Support deleting/releasing phone numbers via IncomingNumbers.Release(ctx, sid). 8 | 9 | Initial support for the Pricing API (https://pricing.twilio.com). 10 | 11 | Add an AUTHORS.txt. 12 | 13 | ## 0.54 14 | 15 | Add Recordings.GetTranscriptions() to get Transcriptions for a recording. The 16 | Transcriptions resource doesn't support filtering by Recording Sid. 17 | 18 | ## 0.53 19 | 20 | Add Alert.StatusCode() function for retrieving a HTTP status code (if one 21 | exists) from an alert. 22 | 23 | ## 0.52 24 | 25 | Copy url.Values for GetXInRange() functions before modifying them. 26 | 27 | ## 0.51 28 | 29 | Implement GetNextConferencesInRange 30 | 31 | ## 0.50 32 | 33 | Implement GetConferencesInRange. Fix paging error in 34 | GetCallsInRange/GetMessagesInRange. 35 | 36 | ## 0.47 37 | 38 | Implement GetNextXInRange - if you have a next page URI and want to get an 39 | Iterator (instead of starting with a url.Values). 40 | 41 | ## 0.45 42 | 43 | Fix go 1.6 (messages_example_test) relied on the stdlib Context package by 44 | accident. 45 | 46 | ## 0.44 47 | 48 | Support filtering Calls/Messages down to the nanosecond in a TZ-aware way, with 49 | Calls.GetCallsInRange / Messages/GetMessagesInRange. 50 | 51 | ## 0.42 52 | 53 | Add more Description fields based on errors I've received in the past. There 54 | are probably more to be found, but this is a good start. 55 | 56 | ## 0.41 57 | 58 | Use the same JWT library instead of using two different ones. 59 | 60 | Add Description() for Alert bodies. 61 | 62 | ## 0.40 63 | 64 | Fix next page URL's for Twilio Monitor 65 | 66 | ## 0.39 67 | 68 | The data in Update() requests was silently being ignored. They are not ignored 69 | any more. 70 | 71 | Support the Accounts resource. 72 | 73 | Add RequestOnBehalfOf function to make requests on behalf of a subaccount. 74 | 75 | Fixes short tests that were broken in 0.38 76 | 77 | ## 0.37 78 | 79 | Support Outgoing Caller ID's 80 | 81 | ## 0.36 82 | 83 | Support Keys 84 | 85 | ## 0.35 86 | 87 | Added Ended(), EndedUnsuccessfully() helpers to a Call, and FriendlyPrice() to 88 | a Transcription. 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Kevin Burke and Chris Bennett. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test vet release 2 | 3 | # would be great to make the bash location portable but not sure how 4 | SHELL = /bin/bash 5 | 6 | WRITE_MAILMAP := $(shell command -v write_mailmap) 7 | BUMP_VERSION := $(shell command -v bump_version) 8 | STATICCHECK := $(shell command -v staticcheck) 9 | 10 | test: vet 11 | go test -short ./... 12 | 13 | vet: 14 | ifndef STATICCHECK 15 | go get -u honnef.co/go/staticcheck/cmd/staticcheck 16 | endif 17 | go vet ./... 18 | staticcheck ./... 19 | 20 | race-test: vet 21 | go test -race ./... 22 | 23 | release: race-test 24 | ifndef BUMP_VERSION 25 | go get github.com/Shyp/bump_version 26 | endif 27 | bump_version minor http.go 28 | 29 | authors: 30 | ifndef WRITE_MAILMAP 31 | go get github.com/kevinburke/write_mailmap 32 | endif 33 | write_mailmap > AUTHORS.txt 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twilio-go 2 | 3 | A client for accessing the Twilio API with several nice features: 4 | 5 | - Easy-to-use helpers for purchasing phone numbers and sending MMS messages 6 | 7 | - E.164 support, times that are parsed into a time.Time, and other smart types. 8 | 9 | - Finer grained control over timeouts with a Context, and the library uses 10 | wall-clock HTTP timeouts, not socket timeouts. 11 | 12 | - Easy debugging network traffic by setting DEBUG_HTTP_TRAFFIC=true in your 13 | environment. 14 | 15 | - Easily find calls and messages that occurred between a particular 16 | set of `time.Time`s, down to the nanosecond, with GetCallsInRange / 17 | GetMessagesInRange. 18 | 19 | - It's clear when the library will make a network request, there are no 20 | unexpected latency spikes when paging from one resource to the next. 21 | 22 | - Uses threads to fetch resources concurrently; for example, has methods to 23 | fetch all Media for a Message concurrently. 24 | 25 | - Usable, [one sentence descriptions of Alerts][alert-descriptions]. 26 | 27 | [alert-descriptions]: https://godoc.org/github.com/saintpete/twilio-go#Alert.Description 28 | 29 | Here are some example use cases: 30 | 31 | ```go 32 | const sid = "AC123" 33 | const token = "456bef" 34 | 35 | client := twilio.NewClient(sid, token, nil) 36 | 37 | // Send a message 38 | msg, err := client.Messages.SendMessage("+14105551234", "+14105556789", "Sent via go :) ✓", nil) 39 | 40 | // Start a phone call 41 | call, err := client.Calls.MakeCall("+14105551234", "+14105556789", 42 | "https://kev.inburke.com/zombo/zombocom.mp3") 43 | 44 | // Buy a number 45 | number, err := client.IncomingNumbers.BuyNumber("+14105551234") 46 | 47 | // Get all calls from a number 48 | data := url.Values{} 49 | data.Set("From", "+14105551234") 50 | callPage, err := client.Calls.GetPage(context.TODO(), data) 51 | 52 | // Iterate over calls 53 | iterator := client.Calls.GetPageIterator(url.Values{}) 54 | for { 55 | page, err := iterator.Next(context.TODO()) 56 | if err == twilio.NoMoreResults { 57 | break 58 | } 59 | fmt.Println("start", page.Start) 60 | } 61 | ``` 62 | 63 | A [complete documentation reference can be found at 64 | godoc.org](https://godoc.org/github.com/saintpete/twilio-go). 65 | 66 | The API is open to, but unlikely to change, and currently only covers 67 | these resources: 68 | 69 | - Alerts 70 | - Applications 71 | - Calls 72 | - Conferences 73 | - Incoming Phone Numbers 74 | - Keys 75 | - Messages 76 | - Media 77 | - Outgoing Caller ID's 78 | - Queues 79 | - Recordings 80 | - Transcriptions 81 | - Access Tokens for IPMessaging, Video and Programmable Voice SDK 82 | - Pricing 83 | 84 | ### Error Parsing 85 | 86 | If the twilio-go client gets an error from 87 | the Twilio API, we attempt to convert it to a 88 | [`rest.Error`](https://godoc.org/github.com/kevinburke/rest#Error) before 89 | returning. Here's an example 404. 90 | 91 | ``` 92 | &rest.Error{ 93 | Title: "The requested resource ... was not found", 94 | ID: "20404", 95 | Detail: "", 96 | Instance: "", 97 | Type: "https://www.twilio.com/docs/errors/20404", 98 | StatusCode: 404 99 | } 100 | ``` 101 | 102 | Not all errors will be a `rest.Error` however - HTTP timeouts, canceled 103 | context.Contexts, and JSON parse errors (HTML error pages, bad gateway 104 | responses from proxies) may also be returned as plain Go errors. 105 | 106 | ### Twiml Generation 107 | 108 | There are no plans to support Twiml generation in this library. It may be 109 | more readable and maintainable to manually write the XML involved in a Twiml 110 | response. 111 | 112 | ### API Problems this Library Solves For You 113 | 114 | - Media URL's are returned over HTTP. twilio-go rewrites these URL's to be 115 | HTTPS before returning them to you. 116 | 117 | - A subset of Notifications returned code 4107, which doesn't exist. These 118 | notifications should have error code 14107. We rewrite the error code 119 | internally before returning it to you. 120 | 121 | - The only provided API for filtering calls or messages by date grabs all 122 | messages for an entire day, and the day ranges are only available for UTC. Use 123 | GetCallsInRange or GetMessagesInRange to do timezone-aware, finer-grained date 124 | filtering. 125 | 126 | ### Errata 127 | 128 | You can get Alerts for a given Call or MMS by passing `ResourceSid=CA123` as 129 | a filter to Alerts.GetPage. This functionality is not documented in the API. 130 | 131 | [zero-results]: https://github.com/saintpete/twilio-go/issues/8 132 | -------------------------------------------------------------------------------- /accounts.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | // This resource is a little special because the endpoint is GET 10 | // /2010-04-01/Accounts.json, there's no extra resource off Accounts. So in 11 | // places we manually insert the `.json` in the right place. 12 | 13 | // We need to build this relative to the root, but users can override the 14 | // APIVersion, so give them a chance to before our init runs. 15 | var accountPathPart string 16 | 17 | func init() { 18 | accountPathPart = "/" + APIVersion + "/Accounts" 19 | } 20 | 21 | type Account struct { 22 | Sid string `json:"sid"` 23 | FriendlyName string `json:"friendly_name"` 24 | Type string `json:"type"` 25 | AuthToken string `json:"auth_token"` 26 | OwnerAccountSid string `json:"owner_account_sid"` 27 | DateCreated TwilioTime `json:"date_created"` 28 | DateUpdated TwilioTime `json:"date_updated"` 29 | Status Status `json:"status"` 30 | SubresourceURIs map[string]string `json:"subresource_uris"` 31 | URI string `json:"uri"` 32 | } 33 | 34 | type AccountPage struct { 35 | Page 36 | Accounts []*Account `json:"accounts"` 37 | } 38 | 39 | type AccountService struct { 40 | client *Client 41 | } 42 | 43 | func (a *AccountService) Get(ctx context.Context, sid string) (*Account, error) { 44 | acct := new(Account) 45 | // hack because this is not a resource off of the account sid 46 | sidJSON := sid + ".json" 47 | err := a.client.GetResource(ctx, accountPathPart, sidJSON, acct) 48 | return acct, err 49 | } 50 | 51 | // Create a new Account with the specified values. 52 | // 53 | // https://www.twilio.com/docs/api/rest/subaccounts#creating-subaccounts 54 | func (a *AccountService) Create(ctx context.Context, data url.Values) (*Account, error) { 55 | acct := new(Account) 56 | err := a.client.CreateResource(ctx, accountPathPart+".json", data, acct) 57 | return acct, err 58 | } 59 | 60 | // Update the key with the given data. Valid parameters may be found here: 61 | // https://www.twilio.com/docs/api/rest/keys#instance-post 62 | func (a *AccountService) Update(ctx context.Context, sid string, data url.Values) (*Account, error) { 63 | acct := new(Account) 64 | // hack because this is not a resource off of the account sid 65 | sidJSON := sid + ".json" 66 | err := a.client.UpdateResource(ctx, accountPathPart, sidJSON, data, acct) 67 | return acct, err 68 | } 69 | 70 | func (a *AccountService) GetPage(ctx context.Context, data url.Values) (*AccountPage, error) { 71 | iter := a.GetPageIterator(data) 72 | return iter.Next(ctx) 73 | } 74 | 75 | // AccountPageIterator lets you retrieve consecutive AccountPages. 76 | type AccountPageIterator struct { 77 | p *PageIterator 78 | } 79 | 80 | // GetPageIterator returns a AccountPageIterator with the given page 81 | // filters. Call iterator.Next() to get the first page of resources (and again 82 | // to retrieve subsequent pages). 83 | func (c *AccountService) GetPageIterator(data url.Values) *AccountPageIterator { 84 | iter := NewPageIterator(c.client, data, accountPathPart+".json") 85 | return &AccountPageIterator{ 86 | p: iter, 87 | } 88 | } 89 | 90 | // Next returns the next page of resources. If there are no more resources, 91 | // NoMoreResults is returned. 92 | func (c *AccountPageIterator) Next(ctx context.Context) (*AccountPage, error) { 93 | cp := new(AccountPage) 94 | err := c.p.Next(ctx, cp) 95 | if err != nil { 96 | return nil, err 97 | } 98 | c.p.SetNextPageURI(cp.NextPageURI) 99 | return cp, nil 100 | } 101 | -------------------------------------------------------------------------------- /accounts_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | func TestAccountGet(t *testing.T) { 13 | t.Parallel() 14 | client, server := getServer(accountInstance) 15 | defer server.Close() 16 | sid := "AC58f1e8f2b1c6b88ca90a012a4be0c279" 17 | acct, err := client.Accounts.Get(context.Background(), sid) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if acct.Sid != sid { 22 | t.Errorf("wrong sid") 23 | } 24 | if acct.Type != "Full" { 25 | t.Errorf("wrong type") 26 | } 27 | } 28 | 29 | func TestAccountCreate(t *testing.T) { 30 | t.Parallel() 31 | client, server := getServer(accountCreateResponse) 32 | defer server.Close() 33 | data := url.Values{} 34 | newname := "new account name 1478105087" 35 | data.Set("FriendlyName", newname) 36 | acct, err := client.Accounts.Create(context.Background(), data) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if acct.FriendlyName != newname { 41 | t.Errorf("new account name incorrect") 42 | } 43 | } 44 | 45 | func TestAccountUpdateLive(t *testing.T) { 46 | if testing.Short() { 47 | t.Skip("skipping HTTP request in short mode") 48 | } 49 | t.Parallel() 50 | sid := "ACdd54a711c3d4031ac500c5236ab121d7" 51 | data := url.Values{} 52 | newname := "new account name " + strconv.FormatInt(time.Now().UTC().Unix(), 10) 53 | data.Set("FriendlyName", newname) 54 | acct, err := envClient.Accounts.Update(context.Background(), sid, data) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | if acct.FriendlyName != newname { 59 | t.Errorf("new account name incorrect") 60 | } 61 | } 62 | 63 | func TestAccountUpdateInMemory(t *testing.T) { 64 | t.Parallel() 65 | client, server := getServer(accountInstance) 66 | defer server.Close() 67 | sid := "AC58f1e8f2b1c6b88ca90a012a4be0c279" 68 | data := url.Values{} 69 | data.Set("FriendlyName", "kevin account woo") 70 | acct, err := client.Accounts.Update(context.Background(), sid, data) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | if acct.FriendlyName != "kevin account woo" { 75 | t.Errorf("new account name incorrect") 76 | } 77 | } 78 | 79 | func TestAccountGetPage(t *testing.T) { 80 | t.Parallel() 81 | client, server := getServer(accountList) 82 | defer server.Close() 83 | data := url.Values{} 84 | data.Set("PageSize", "2") 85 | page, err := client.Accounts.GetPage(context.Background(), data) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | if len(page.Accounts) != 2 { 90 | t.Errorf("expected accounts len to be 2") 91 | } 92 | if page.Accounts[0].Sid != "AC0cd9be8fd5e6e4fa0a04f50ac1caca4e" { 93 | t.Errorf("wrong sid") 94 | } 95 | } 96 | 97 | func TestAccountGetPageIterator(t *testing.T) { 98 | if testing.Short() { 99 | t.Skip("skipping HTTP request in short mode") 100 | } 101 | t.Parallel() 102 | data := url.Values{} 103 | data.Set("PageSize", "2") 104 | iter := envClient.Accounts.GetPageIterator(data) 105 | page, err := iter.Next(context.Background()) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | if len(page.Accounts) != 2 { 110 | t.Errorf("expected accounts len to be 2") 111 | } 112 | page, err = iter.Next(context.Background()) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | if len(page.Accounts) != 2 { 117 | t.Errorf("expected accounts len to be 2") 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /alert_descriptions.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | var alertDestination = []byte(` 4 | { 5 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 6 | "alert_text": "sourceComponent=14100&ErrorCode=14101&LogLevel=ERROR&Msg=The+destination+number+for+a+TwiML+message+can+not+be+the+same+as+the+originating+number+of+an+incoming+message.&EmailNotification=false", 7 | "api_version": "2008-08-01", 8 | "date_created": "2016-10-26T01:11:13Z", 9 | "date_generated": "2016-10-26T01:11:12Z", 10 | "date_updated": "2016-10-26T01:11:18Z", 11 | "error_code": "14101", 12 | "log_level": "error", 13 | "more_info": "https://www.twilio.com/docs/errors/14101", 14 | "request_headers": null, 15 | "request_method": "POST", 16 | "request_url": "https://kev.inburke.com/zombo/zombo.php", 17 | "request_variables": "ToCountry=US&ToState=CA&SmsMessageSid=SM1521fa559fb923c1ed64f56cd1bed8ef&NumMedia=0&ToCity=BRENTWOOD&FromZip=94514&SmsSid=SM1521fa559fb923c1ed64f56cd1bed8ef&FromState=CA&SmsStatus=received&FromCity=BRENTWOOD&Body=twilio-go+testing%21&FromCountry=US&To=%2B19253920364&ToZip=94514&NumSegments=1&MessageSid=SM1521fa559fb923c1ed64f56cd1bed8ef&AccountSid=AC58f1e8f2b1c6b88ca90a012a4be0c279&From=%2B19253920364&ApiVersion=2010-04-01", 18 | "resource_sid": "SM1521fa559fb923c1ed64f56cd1bed8ef", 19 | "response_body": "You can do anything at ZomboCom. Anything at all.", 20 | "response_headers": "Transfer-Encoding=chunked&X-Cache=MISS+from+ip-172-18-20-243.ec2.internal&Server=cloudflare-nginx&X-Cache-Lookup=MISS+from+ip-172-18-20-243.ec2.internal%3A3128&Content-Type=text%2Fxml%3Bcharset%3Dutf-8&Date=Wed%2C+26+Oct+2016+01%3A11%3A13+GMT&CF-RAY=2f7a0875aaca21b0-EWR&X-Powered-By=PHP%2F5.6.16", 21 | "service_sid": null, 22 | "sid": "NO7e3853acc314b52d8b6babd04ede0a39", 23 | "url": "https://monitor.twilio.com/v1/Alerts/NO7e3853acc314b52d8b6babd04ede0a39" 24 | } 25 | `) 26 | 27 | var alert11200 = []byte(` 28 | { 29 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 30 | "alert_text": "Msg&sourceComponent=12000&ErrorCode=11200&httpResponse=405&url=https%3A%2F%2Fkev.inburke.com%2Fzombo%2Fzombocom.mp3&LogLevel=ERROR", 31 | "api_version": "2010-04-01", 32 | "date_created": "2016-10-27T02:34:21Z", 33 | "date_generated": "2016-10-27T02:34:21Z", 34 | "date_updated": "2016-10-27T02:34:23Z", 35 | "error_code": "11200", 36 | "log_level": "error", 37 | "more_info": "https://www.twilio.com/docs/errors/11200", 38 | "request_headers": null, 39 | "request_method": "POST", 40 | "request_url": "https://kev.inburke.com/zombo/zombocom.mp3", 41 | "request_variables": "Called=%2B19252717005&ToState=CA&CallerCountry=US&Direction=outbound-api&CallerState=CA&ToZip=94596&CallSid=CA6d27370cbbfb605521fe8800bb73f2d2&To=%2B19252717005&CallerZip=94514&ToCountry=US&ApiVersion=2010-04-01&CalledZip=94596&CalledCity=PLEASANTON&CallStatus=in-progress&From=%2B19253920364&AccountSid=AC58f1e8f2b1c6b88ca90a012a4be0c279&CalledCountry=US&CallerCity=BRENTWOOD&Caller=%2B19253920364&FromCountry=US&ToCity=PLEASANTON&FromCity=BRENTWOOD&CalledState=CA&FromZip=94514&FromState=CA", 42 | "resource_sid": "CA6d27370cbbfb605521fe8800bb73f2d2", 43 | "response_body": "\r\n405 Not Allowed\r\n\r\n

405 Not Allowed

\r\n
nginx
\r\n\r\n", 44 | "response_headers": "Transfer-Encoding=chunked&Server=cloudflare-nginx&CF-RAY=2f82bf9cb8102204-EWR&Set-Cookie=__cfduid%3Dd46f1cfd57d664c3038ae66f1c1de9e751477535661%3B+expires%3DFri%2C+27-Oct-17+02%3A34%3A21+GMT%3B+path%3D%2F%3B+domain%3D.inburke.com%3B+HttpOnly&Date=Thu%2C+27+Oct+2016+02%3A34%3A21+GMT&Content-Type=text%2Fhtml", 45 | "service_sid": null, 46 | "sid": "NO00ed1fb4aa449be2434d54ec8e492349", 47 | "url": "https://monitor.twilio.com/v1/Alerts/NO00ed1fb4aa449be2434d54ec8e492349" 48 | } 49 | `) 50 | 51 | // Note 4107 in response, I reported this to Twilio Support 52 | var alert14107 = []byte(` 53 | { 54 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 55 | "alert_text": "EmailNotification=true&LogLevel=ERROR&To=%2B19253920364&Msg=Reply+rate+limit+hit+replying+to+%2B14156305833+from+%2B19253920364+over+2014-02-05+14%3A06%3A59.0&ErrorCode=4107&From=%2B14156305833&RepliesSent=16", 56 | "api_version": null, 57 | "date_created": "2014-02-05T22:07:02Z", 58 | "date_generated": "2014-02-05T22:07:02Z", 59 | "date_updated": "2014-02-05T22:07:03Z", 60 | "error_code": "4107", 61 | "log_level": "error", 62 | "more_info": "https://www.twilio.com/docs/errors/4107", 63 | "request_headers": null, 64 | "request_method": null, 65 | "request_url": null, 66 | "request_variables": null, 67 | "resource_sid": "SM77db020c59a94d4f29ac4d43b8bef592", 68 | "response_body": null, 69 | "response_headers": null, 70 | "service_sid": null, 71 | "sid": "NOf57de2bb5cccd77c67288a7c433fa9d5", 72 | "url": "https://monitor.twilio.com/v1/Alerts/NOf57de2bb5cccd77c67288a7c433fa9d5" 73 | } 74 | `) 75 | 76 | var alertUnknown = []byte(` 77 | { 78 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 79 | "alert_text": "", 80 | "api_version": "2010-04-01", 81 | "date_created": "2016-10-27T02:34:21Z", 82 | "date_generated": "2016-10-27T02:34:21Z", 83 | "date_updated": "2016-10-27T02:34:23Z", 84 | "error_code": "235342434", 85 | "log_level": "error", 86 | "more_info": "https://www.twilio.com/docs/errors/93455", 87 | "request_headers": null, 88 | "request_method": "POST", 89 | "request_url": "https://kev.inburke.com/zombo/zombocom.mp3", 90 | "request_variables": "", 91 | "resource_sid": "CA6d27370cbbfb605521fe8800bb73f2d2", 92 | "response_body": "", 93 | "response_headers": "", 94 | "service_sid": null, 95 | "sid": "NO00ed1fb4aa449be2434d54ec8e492349", 96 | "url": "https://monitor.twilio.com/v1/Alerts/NO00ed1fb4aa449be2434d54ec8e492349" 97 | } 98 | `) 99 | 100 | var alert13225 = []byte(` 101 | { 102 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 103 | "alert_text": "phonenumber=%2B886225475050&LogLevel=WARN&Msg=forbidden+phone+number+&ErrorCode=13225", 104 | "api_version": "2008-08-01", 105 | "date_created": "2014-03-22T09:25:48Z", 106 | "date_generated": "2014-03-22T09:25:48Z", 107 | "date_updated": "2014-03-22T09:25:49Z", 108 | "error_code": "13225", 109 | "log_level": "warning", 110 | "more_info": "https://www.twilio.com/docs/errors/13225", 111 | "request_headers": null, 112 | "request_method": "POST", 113 | "request_url": "http://twilio-amaze-client.herokuapp.com/client/incoming", 114 | "request_variables": "AccountSid=AC58f1e8f2b1c6b88ca90a012a4be0c279&ApplicationSid=AP7d6fd7b9a8894e36877dc2355da381c8&Caller=client%3Ajoey_ramone&CallStatus=ringing&Called=&To=&PhoneNumber=%2B886225475050&CallSid=CA2d4f6f887f3d24b5fdb945ff88ef8e41&From=client%3Ajoey_ramone&Direction=inbound&CallerID=%2B19252724527&ApiVersion=2010-04-01", 115 | "resource_sid": "CA2d4f6f887f3d24b5fdb945ff88ef8e41", 116 | "response_body": "+886225475050", 117 | "response_headers": "Date=Sat%2C+22+Mar+2014+09%3A25%3A47+GMT&Content-Length=126&Content-Type=text%2Fhtml%3B+charset%3Dutf-8&Server=Werkzeug%2F0.9.1+Python%2F2.7.4", 118 | "service_sid": null, 119 | "sid": "NOd6b8b10848fdb8b50198fdad4c43b102", 120 | "url": "https://monitor.twilio.com/v1/Alerts/NOd6b8b10848fdb8b50198fdad4c43b102" 121 | } 122 | `) 123 | 124 | var alert13227 = []byte(` 125 | { 126 | "account_sid": "AC58f1e8f2b1c6b88ca90a012a4be0c279", 127 | "alert_text": "phonenumber=%2B864008895080&LogLevel=WARN&Msg=not+authorized+to+call+&ErrorCode=13227", 128 | "api_version": "2008-08-01", 129 | "date_created": "2014-03-20T01:19:39Z", 130 | "date_generated": "2014-03-20T01:19:39Z", 131 | "date_updated": "2014-03-20T01:19:39Z", 132 | "error_code": "13227", 133 | "log_level": "warning", 134 | "more_info": "https://www.twilio.com/docs/errors/13227", 135 | "request_headers": null, 136 | "request_method": "POST", 137 | "request_url": "http://twilio-amaze-client.herokuapp.com/client/incoming", 138 | "request_variables": "AccountSid=AC58f1e8f2b1c6b88ca90a012a4be0c279&ApplicationSid=AP7d6fd7b9a8894e36877dc2355da381c8&Caller=client%3Ajoey_ramone&CallStatus=ringing&Called=&To=&PhoneNumber=%2B864008895080&CallSid=CA36e37790f601cffb56008f5ea0ef8ab9&From=client%3Ajoey_ramone&Direction=inbound&CallerID=%2B19252724527&ApiVersion=2010-04-01", 139 | "resource_sid": "CA36e37790f601cffb56008f5ea0ef8ab9", 140 | "response_body": "+864008895080", 141 | "response_headers": "Date=Thu%2C+20+Mar+2014+01%3A19%3A38+GMT&Content-Length=126&Content-Type=text%2Fhtml%3B+charset%3Dutf-8&Server=Werkzeug%2F0.9.1+Python%2F2.7.4", 142 | "service_sid": null, 143 | "sid": "NO5951001c254600e61c5ee189e0165680", 144 | "url": "https://monitor.twilio.com/v1/Alerts/NO5951001c254600e61c5ee189e0165680" 145 | } 146 | `) 147 | -------------------------------------------------------------------------------- /alerts.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | types "github.com/kevinburke/go-types" 12 | 13 | "golang.org/x/net/context" 14 | ) 15 | 16 | const alertPathPart = "Alerts" 17 | 18 | type AlertService struct { 19 | client *Client 20 | } 21 | 22 | type Alert struct { 23 | Sid string `json:"sid"` 24 | AccountSid string `json:"account_sid"` 25 | // For Calls, AlertText is a series of key=value pairs separated by 26 | // ampersands 27 | AlertText string `json:"alert_text"` 28 | APIVersion string `json:"api_version"` 29 | DateCreated TwilioTime `json:"date_created"` 30 | DateGenerated TwilioTime `json:"date_generated"` 31 | DateUpdated TwilioTime `json:"date_updated"` 32 | ErrorCode Code `json:"error_code"` 33 | LogLevel LogLevel `json:"log_level"` 34 | MoreInfo string `json:"more_info"` 35 | RequestMethod string `json:"request_method"` 36 | RequestURL string `json:"request_url"` 37 | RequestVariables Values `json:"request_variables"` 38 | ResponseBody string `json:"response_body"` 39 | ResponseHeaders Values `json:"response_headers"` 40 | ResourceSid string `json:"resource_sid"` 41 | ServiceSid json.RawMessage `json:"service_sid"` 42 | URL string `json:"url"` 43 | } 44 | 45 | type AlertPage struct { 46 | Meta Meta `json:"meta"` 47 | Alerts []*Alert `json:"alerts"` 48 | } 49 | 50 | func (a *AlertService) Get(ctx context.Context, sid string) (*Alert, error) { 51 | alert := new(Alert) 52 | err := a.client.GetResource(ctx, alertPathPart, sid, alert) 53 | return alert, err 54 | } 55 | 56 | // GetPage returns a single Page of resources, filtered by data. 57 | // 58 | // See https://www.twilio.com/docs/api/monitor/alerts#list-get-filters. 59 | func (a *AlertService) GetPage(ctx context.Context, data url.Values) (*AlertPage, error) { 60 | return a.GetPageIterator(data).Next(ctx) 61 | } 62 | 63 | // AlertPageIterator lets you retrieve consecutive pages of resources. 64 | type AlertPageIterator interface { 65 | // Next returns the next page of resources. If there are no more resources, 66 | // NoMoreResults is returned. 67 | Next(context.Context) (*AlertPage, error) 68 | } 69 | 70 | type alertPageIterator struct { 71 | p *PageIterator 72 | } 73 | 74 | // GetAlertsInRange gets an Iterator containing conferences in the range 75 | // [start, end), optionally further filtered by data. GetAlertsInRange 76 | // panics if start is not before end. Any date filters provided in data will 77 | // be ignored. If you have an end, but don't want to specify a start, use 78 | // twilio.Epoch for start. If you have a start, but don't want to specify an 79 | // end, use twilio.HeatDeath for end. 80 | // 81 | // Assumes that Twilio returns resources in chronological order, latest 82 | // first. If this assumption is incorrect, your results will not be correct. 83 | // 84 | // Returned AlertPages will have at most PageSize results, but may have fewer, 85 | // based on filtering. 86 | func (a *AlertService) GetAlertsInRange(start time.Time, end time.Time, data url.Values) AlertPageIterator { 87 | if start.After(end) { 88 | panic("start date is after end date") 89 | } 90 | d := url.Values{} 91 | if data != nil { 92 | for k, v := range data { 93 | d[k] = v 94 | } 95 | } 96 | d.Del("Page") // just in case 97 | if start != Epoch { 98 | startFormat := start.UTC().Format(time.RFC3339) 99 | d.Set("StartDate", startFormat) 100 | } 101 | if end != HeatDeath { 102 | // If you specify "StartTime<=YYYY-MM-DD", the *latest* result returned 103 | // will be midnight (the earliest possible second) on DD. We want all 104 | // of the results for DD so we need to specify DD+1 in the API. 105 | // 106 | // TODO validate midnight-instant math more closely, since I don't think 107 | // Twilio returns the correct results for that instant. 108 | endFormat := end.UTC().Format(time.RFC3339) 109 | d.Set("EndDate", endFormat) 110 | } 111 | iter := NewPageIterator(a.client, d, alertPathPart) 112 | return &alertDateIterator{ 113 | start: start, 114 | end: end, 115 | p: iter, 116 | } 117 | } 118 | 119 | // GetNextAlertsInRange retrieves the page at the nextPageURI and continues 120 | // retrieving pages until any results are found in the range given by start or 121 | // end, or we determine there are no more records to be found in that range. 122 | // 123 | // If AlertPage is non-nil, it will have at least one result. 124 | func (a *AlertService) GetNextAlertsInRange(start time.Time, end time.Time, nextPageURI string) AlertPageIterator { 125 | if nextPageURI == "" { 126 | panic("nextpageuri is empty") 127 | } 128 | iter := NewNextPageIterator(a.client, callsPathPart) 129 | iter.SetNextPageURI(types.NullString{Valid: true, String: nextPageURI}) 130 | return &alertDateIterator{ 131 | start: start, 132 | end: end, 133 | p: iter, 134 | } 135 | } 136 | 137 | type alertDateIterator struct { 138 | p *PageIterator 139 | start time.Time 140 | end time.Time 141 | } 142 | 143 | // Next returns the next page of resources. We may need to fetch multiple 144 | // pages from the Twilio API before we find one in the right date range, so 145 | // latency may be higher than usual. If page is non-nil, it contains at least 146 | // one result. 147 | func (a *alertDateIterator) Next(ctx context.Context) (*AlertPage, error) { 148 | var page *AlertPage 149 | for { 150 | // just wipe it clean every time to avoid remnants hanging around 151 | page = new(AlertPage) 152 | if err := a.p.Next(ctx, page); err != nil { 153 | return nil, err 154 | } 155 | if len(page.Alerts) == 0 { 156 | return nil, NoMoreResults 157 | } 158 | times := make([]time.Time, len(page.Alerts), len(page.Alerts)) 159 | for i, alert := range page.Alerts { 160 | if !alert.DateCreated.Valid { 161 | // we really should not ever hit this case but if we can't parse 162 | // a date, better to give you back an error than to give you back 163 | // a list of alerts that may or may not be in the time range 164 | return nil, fmt.Errorf("Couldn't verify the date of alert: %#v", alert) 165 | } 166 | times[i] = alert.DateCreated.Time 167 | } 168 | if containsResultsInRange(a.start, a.end, times) { 169 | indexesToDelete := indexesOutsideRange(a.start, a.end, times) 170 | // reverse order so we don't delete the wrong index 171 | for i := len(indexesToDelete) - 1; i >= 0; i-- { 172 | index := indexesToDelete[i] 173 | page.Alerts = append(page.Alerts[:index], page.Alerts[index+1:]...) 174 | } 175 | a.p.SetNextPageURI(page.Meta.NextPageURL) 176 | return page, nil 177 | } 178 | if shouldContinuePaging(a.start, times) { 179 | a.p.SetNextPageURI(page.Meta.NextPageURL) 180 | continue 181 | } else { 182 | // should not continue paging and no results in range, stop 183 | return nil, NoMoreResults 184 | } 185 | } 186 | } 187 | 188 | // GetPageIterator returns a AlertPageIterator with the given page 189 | // filters. Call iterator.Next() to get the first page of resources (and again 190 | // to retrieve subsequent pages). 191 | func (a *AlertService) GetPageIterator(data url.Values) AlertPageIterator { 192 | iter := NewPageIterator(a.client, data, alertPathPart) 193 | return &alertPageIterator{ 194 | p: iter, 195 | } 196 | } 197 | 198 | // Next returns the next page of resources. If there are no more resources, 199 | // NoMoreResults is returned. 200 | func (a *alertPageIterator) Next(ctx context.Context) (*AlertPage, error) { 201 | ap := new(AlertPage) 202 | err := a.p.Next(ctx, ap) 203 | if err != nil { 204 | return nil, err 205 | } 206 | a.p.SetNextPageURI(ap.Meta.NextPageURL) 207 | return ap, nil 208 | } 209 | 210 | func (a *Alert) description() string { 211 | vals, err := url.ParseQuery(a.AlertText) 212 | if err == nil && a.ErrorCode != 0 { 213 | switch a.ErrorCode { 214 | case CodeHTTPRetrievalFailure: 215 | s := "HTTP retrieval failure" 216 | if resp := vals.Get("httpResponse"); resp != "" { 217 | s = fmt.Sprintf("%s: status code %s when fetching TwiML", s, resp) 218 | } 219 | return s 220 | case CodeReplyLimitExceeded: 221 | msg := vals.Get("Msg") 222 | if msg == "" { 223 | break 224 | } 225 | if idx := strings.Index(msg, "over"); idx >= 0 { 226 | return msg[:idx] 227 | } 228 | return msg 229 | case CodeDocumentParseFailure: 230 | // There's a more detailed error message here but it doesn't really 231 | // make sense in a sentence context: "Error on line 18 of document: 232 | // Content is not allowed in trailing section." 233 | return "Document parse failure" 234 | case CodeSayInvalidText: 235 | return "The text of the Say verb was empty or un-parsable" 236 | case CodeForbiddenPhoneNumber, CodeNoInternationalAuthorization: 237 | if vals.Get("Msg") != "" && vals.Get("phonenumber") != "" { 238 | return strings.TrimSpace(vals.Get("Msg")) + " " + vals.Get("phonenumber") 239 | } 240 | default: 241 | if msg := vals.Get("Msg"); msg != "" { 242 | return msg 243 | } 244 | if a.MoreInfo != "" { 245 | return fmt.Sprintf("Error %d: %s", a.ErrorCode, a.MoreInfo) 246 | } 247 | return fmt.Sprintf("Error %d", a.ErrorCode) 248 | } 249 | } 250 | if a.MoreInfo != "" { 251 | return "Unknown failure: " + a.MoreInfo 252 | } 253 | return "Unknown failure" 254 | } 255 | 256 | // Description tries as hard as possible to give you a one sentence description 257 | // of this Alert, based on its contents. Description does not include a 258 | // trailing period. 259 | func (a *Alert) Description() string { 260 | return capitalize(strings.TrimSpace(strings.TrimSuffix(a.description(), "."))) 261 | } 262 | 263 | // StatusCode attempts to return a HTTP status code for this Alert. Returns 264 | // 0 if the status code cannot be found. 265 | func (a *Alert) StatusCode() int { 266 | vals, err := url.ParseQuery(a.AlertText) 267 | if err != nil { 268 | return 0 269 | } 270 | if code := vals.Get("httpResponse"); code != "" { 271 | i, err := strconv.ParseInt(code, 10, 64) 272 | if err == nil && i > 99 && i < 600 { 273 | return int(i) 274 | } 275 | } 276 | return 0 277 | } 278 | -------------------------------------------------------------------------------- /alerts_example_test.go: -------------------------------------------------------------------------------- 1 | package twilio_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | 8 | twilio "github.com/saintpete/twilio-go" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | func ExampleAlertService_GetPage() { 13 | client := twilio.NewClient("AC123", "123", nil) 14 | data := url.Values{} 15 | data.Set("ResourceSid", "SM123") 16 | page, err := client.Monitor.Alerts.GetPage(context.TODO(), data) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | for _, alert := range page.Alerts { 21 | fmt.Println(alert.Sid) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /alerts_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "testing" 8 | "time" 9 | 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | func TestGetAlert(t *testing.T) { 14 | t.Parallel() 15 | client, s := getServer(alertInstanceResponse) 16 | defer s.Close() 17 | sid := "NO00ed1fb4aa449be2434d54ec8e492349" 18 | alert, err := client.Monitor.Alerts.Get(context.Background(), sid) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | if alert.Sid != sid { 23 | t.Errorf("expected Sid to be %s, got %s", sid, alert.Sid) 24 | } 25 | if city := alert.RequestVariables.Get("CallerCity"); city != "BRENTWOOD" { 26 | t.Errorf("expected to get BRENTWOOD for CallerCity, got %s", city) 27 | } 28 | } 29 | 30 | func TestGetAlertPage(t *testing.T) { 31 | t.Parallel() 32 | client, s := getServer(alertListResponse) 33 | defer s.Close() 34 | data := url.Values{"PageSize": []string{"2"}} 35 | page, err := client.Monitor.Alerts.GetPage(context.Background(), data) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | if len(page.Alerts) != 2 { 40 | t.Errorf("expected 2 alerts, got %d", len(page.Alerts)) 41 | } 42 | if page.Meta.Key != "alerts" { 43 | t.Errorf("expected Key to be 'alerts', got %s", page.Meta.Key) 44 | } 45 | if page.Meta.PageSize != 2 { 46 | t.Errorf("expected PageSize to be 2, got %d", page.Meta.PageSize) 47 | } 48 | if page.Meta.Page != 0 { 49 | t.Errorf("expected Page to be 0, got %d", page.Meta.Page) 50 | } 51 | if page.Meta.PreviousPageURL.Valid != false { 52 | t.Errorf("expected previousPage.Valid to be false, got true") 53 | } 54 | if page.Alerts[0].LogLevel != LogLevelError { 55 | t.Errorf("expected LogLevel to be Error, got %s", page.Alerts[0].LogLevel) 56 | } 57 | if page.Alerts[0].RequestMethod != "POST" { 58 | t.Errorf("expected RequestMethod to be 'POST', got %s", page.Alerts[0].RequestMethod) 59 | } 60 | if page.Alerts[0].ErrorCode != CodeHTTPRetrievalFailure { 61 | t.Errorf("expected ErrorCode to be '11200', got %d", page.Alerts[0].ErrorCode) 62 | } 63 | } 64 | 65 | func TestAlertFullPath(t *testing.T) { 66 | if testing.Short() { 67 | t.Skip("skipping HTTP request in short mode") 68 | } 69 | // sigh, we need to hit the real URL for the Base stuff to work. 70 | t.Parallel() 71 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 72 | defer cancel() 73 | data := url.Values{"PageSize": []string{"2"}} 74 | iter := envClient.Monitor.Alerts.GetPageIterator(data) 75 | _, err := iter.Next(ctx) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | _, err = iter.Next(ctx) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | // TODO figure out a good way to assert what URL gets hit; add a TestClient 84 | // or something. 85 | } 86 | 87 | func TestGetAlertIterator(t *testing.T) { 88 | if testing.Short() { 89 | t.Skip("skipping HTTP request in short mode") 90 | } 91 | t.Parallel() 92 | // TODO make a better assertion here or a server that can return different 93 | // responses 94 | iter := envClient.Monitor.Alerts.GetPageIterator(url.Values{"PageSize": []string{"35"}}) 95 | count := uint(0) 96 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 97 | defer cancel() 98 | for { 99 | page, err := iter.Next(ctx) 100 | if err == NoMoreResults { 101 | break 102 | } 103 | if err != nil { 104 | t.Fatal(err) 105 | break 106 | } 107 | if page.Meta.Page < count { 108 | t.Fatal("too small page number") 109 | return 110 | } 111 | count++ 112 | if count > 15 { 113 | fmt.Println("count > 15") 114 | t.Fail() 115 | break 116 | } 117 | } 118 | if count < 10 { 119 | t.Errorf("Too small of a count - expected at least 10, got %d", count) 120 | } 121 | } 122 | 123 | func TestCapitalize(t *testing.T) { 124 | if capitalize("S") != "S" { 125 | t.Errorf("wrong") 126 | } 127 | if capitalize("s") != "S" { 128 | t.Errorf("wrong") 129 | } 130 | if capitalize("booo") != "Booo" { 131 | t.Errorf("wrong") 132 | } 133 | } 134 | 135 | var descriptionTests = []struct { 136 | in []byte 137 | expected string 138 | }{ 139 | {alertDestination, "The destination number for a TwiML message can not be the same as the originating number of an incoming message"}, 140 | {alert11200, "HTTP retrieval failure: status code 405 when fetching TwiML"}, 141 | {alert14107, "Reply rate limit hit replying to +14156305833 from +19253920364"}, 142 | {alert13225, "Forbidden phone number +886225475050"}, 143 | {alert13227, "Not authorized to call +864008895080"}, 144 | {alertUnknown, "Error 235342434: https://www.twilio.com/docs/errors/93455"}, 145 | } 146 | 147 | func TestAlertDescription(t *testing.T) { 148 | for _, tt := range descriptionTests { 149 | alert := new(Alert) 150 | if err := json.Unmarshal(tt.in, alert); err != nil { 151 | panic(err) 152 | } 153 | if desc := alert.Description(); desc != tt.expected { 154 | t.Errorf("bad description: got %s, want %s", desc, tt.expected) 155 | } 156 | } 157 | } 158 | 159 | func TestAlertStatusCode(t *testing.T) { 160 | alert := new(Alert) 161 | alert.AlertText = "Msg&sourceComponent=12000&ErrorCode=11200&httpResponse=405&url=https%3A%2F%2Fkev.inburke.com%2Fzombo%2Fzombocom.mp3&LogLevel=ERROR" 162 | if code := alert.StatusCode(); code != 405 { 163 | t.Errorf("expected Code to be 405, got %d", code) 164 | } 165 | alert.AlertText = "Msg&sourceComponent=12000&ErrorCode=11200&httpResponse=4050&url=https%3A%2F%2Fkev.inburke.com%2Fzombo%2Fzombocom.mp3&LogLevel=ERROR" 166 | if code := alert.StatusCode(); code != 0 { 167 | t.Errorf("expected Code to be 0, got %d", code) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /applications.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const applicationPathPart = "Applications" 10 | 11 | type ApplicationService struct { 12 | client *Client 13 | } 14 | 15 | // A Twilio Application. For more documentation, see 16 | // https://www.twilio.com/docs/api/rest/applications#instance 17 | type Application struct { 18 | AccountSid string `json:"account_sid"` 19 | APIVersion string `json:"api_version"` 20 | DateCreated TwilioTime `json:"date_created"` 21 | DateUpdated TwilioTime `json:"date_updated"` 22 | FriendlyName string `json:"friendly_name"` 23 | MessageStatusCallback string `json:"message_status_callback"` 24 | Sid string `json:"sid"` 25 | SMSFallbackMethod string `json:"sms_fallback_method"` 26 | SMSFallbackURL string `json:"sms_fallback_url"` 27 | SMSURL string `json:"sms_url"` 28 | StatusCallback string `json:"status_callback"` 29 | StatusCallbackMethod string `json:"status_callback_method"` 30 | URI string `json:"uri"` 31 | VoiceCallerIDLookup bool `json:"voice_caller_id_lookup"` 32 | VoiceFallbackMethod string `json:"voice_fallback_method"` 33 | VoiceFallbackURL string `json:"voice_fallback_url"` 34 | VoiceMethod string `json:"voice_method"` 35 | VoiceURL string `json:"voice_url"` 36 | } 37 | 38 | type ApplicationPage struct { 39 | Page 40 | Applications []*Application `json:"applications"` 41 | } 42 | 43 | func (c *ApplicationService) Get(ctx context.Context, sid string) (*Application, error) { 44 | application := new(Application) 45 | err := c.client.GetResource(ctx, applicationPathPart, sid, application) 46 | return application, err 47 | } 48 | 49 | func (c *ApplicationService) GetPage(ctx context.Context, data url.Values) (*ApplicationPage, error) { 50 | iter := c.GetPageIterator(data) 51 | return iter.Next(ctx) 52 | } 53 | 54 | // Create a new Application. This request must include a FriendlyName, and can 55 | // include these values: 56 | // https://www.twilio.com/docs/api/rest/applications#list-post-optional-parameters 57 | func (c *ApplicationService) Create(ctx context.Context, data url.Values) (*Application, error) { 58 | application := new(Application) 59 | err := c.client.CreateResource(ctx, applicationPathPart, data, application) 60 | return application, err 61 | } 62 | 63 | // Update the application with the given data. Valid parameters may be found here: 64 | // https://www.twilio.com/docs/api/rest/applications#instance-post 65 | func (a *ApplicationService) Update(ctx context.Context, sid string, data url.Values) (*Application, error) { 66 | application := new(Application) 67 | err := a.client.UpdateResource(ctx, applicationPathPart, sid, data, application) 68 | return application, err 69 | } 70 | 71 | // Delete the Application with the given sid. If the Application has already been 72 | // deleted, or does not exist, Delete returns nil. If another error or a 73 | // timeout occurs, the error is returned. 74 | func (r *ApplicationService) Delete(ctx context.Context, sid string) error { 75 | return r.client.DeleteResource(ctx, applicationPathPart, sid) 76 | } 77 | 78 | // ApplicationPageIterator lets you retrieve consecutive pages of resources. 79 | type ApplicationPageIterator struct { 80 | p *PageIterator 81 | } 82 | 83 | // GetPageIterator returns a ApplicationPageIterator with the given page 84 | // filters. Call iterator.Next() to get the first page of resources (and again 85 | // to retrieve subsequent pages). 86 | func (c *ApplicationService) GetPageIterator(data url.Values) *ApplicationPageIterator { 87 | iter := NewPageIterator(c.client, data, applicationPathPart) 88 | return &ApplicationPageIterator{ 89 | p: iter, 90 | } 91 | } 92 | 93 | // Next returns the next page of resources. If there are no more resources, 94 | // NoMoreResults is returned. 95 | func (c *ApplicationPageIterator) Next(ctx context.Context) (*ApplicationPage, error) { 96 | ap := new(ApplicationPage) 97 | err := c.p.Next(ctx, ap) 98 | if err != nil { 99 | return nil, err 100 | } 101 | c.p.SetNextPageURI(ap.NextPageURI) 102 | return ap, nil 103 | } 104 | -------------------------------------------------------------------------------- /applications_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "testing" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | func TestApplicationGet(t *testing.T) { 10 | t.Parallel() 11 | client, server := getServer(applicationInstance) 12 | defer server.Close() 13 | application, err := client.Applications.Get(context.Background(), "AP7d6fd7b9a8894e36877dc2355da381c8") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | if application.FriendlyName != "Hackpack for Heroku and Flask" { 18 | t.Error("bad friendly name") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /calls.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | types "github.com/kevinburke/go-types" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | const callsPathPart = "Calls" 14 | 15 | type CallService struct { 16 | client *Client 17 | } 18 | 19 | type Call struct { 20 | Sid string `json:"sid"` 21 | From PhoneNumber `json:"from"` 22 | To PhoneNumber `json:"to"` 23 | Status Status `json:"status"` 24 | StartTime TwilioTime `json:"start_time"` 25 | EndTime TwilioTime `json:"end_time"` 26 | Duration TwilioDuration `json:"duration"` 27 | AccountSid string `json:"account_sid"` 28 | Annotation json.RawMessage `json:"annotation"` 29 | AnsweredBy NullAnsweredBy `json:"answered_by"` 30 | CallerName types.NullString `json:"caller_name"` 31 | DateCreated TwilioTime `json:"date_created"` 32 | DateUpdated TwilioTime `json:"date_updated"` 33 | Direction Direction `json:"direction"` 34 | ForwardedFrom PhoneNumber `json:"forwarded_from"` 35 | GroupSid string `json:"group_sid"` 36 | ParentCallSid string `json:"parent_call_sid"` 37 | PhoneNumberSid string `json:"phone_number_sid"` 38 | Price string `json:"price"` 39 | PriceUnit string `json:"price_unit"` 40 | APIVersion string `json:"api_version"` 41 | URI string `json:"uri"` 42 | } 43 | 44 | // Ended returns true if the Call has reached a terminal state, and false 45 | // otherwise, or if the state can't be determined. 46 | func (c *Call) Ended() bool { 47 | // https://www.twilio.com/docs/api/rest/call#call-status-values 48 | switch c.Status { 49 | case StatusCompleted, StatusCanceled, StatusFailed, StatusBusy, StatusNoAnswer: 50 | return true 51 | default: 52 | return false 53 | } 54 | } 55 | 56 | // EndedUnsuccessfully returns true if the Call has reached a terminal state 57 | // and that state isn't "completed". 58 | func (c *Call) EndedUnsuccessfully() bool { 59 | // https://www.twilio.com/docs/api/rest/call#call-status-values 60 | switch c.Status { 61 | case StatusCanceled, StatusFailed, StatusBusy, StatusNoAnswer: 62 | return true 63 | default: 64 | return false 65 | } 66 | } 67 | 68 | // FriendlyPrice flips the sign of the Price (which is usually reported from 69 | // the API as a negative number) and adds an appropriate currency symbol in 70 | // front of it. For example, a PriceUnit of "USD" and a Price of "-1.25" is 71 | // reported as "$1.25". 72 | func (c *Call) FriendlyPrice() string { 73 | if c == nil { 74 | return "" 75 | } 76 | return price(c.PriceUnit, c.Price) 77 | } 78 | 79 | // A CallPage contains a Page of calls. 80 | type CallPage struct { 81 | Page 82 | Calls []*Call `json:"calls"` 83 | } 84 | 85 | func (c *CallService) Get(ctx context.Context, sid string) (*Call, error) { 86 | call := new(Call) 87 | err := c.client.GetResource(ctx, callsPathPart, sid, call) 88 | return call, err 89 | } 90 | 91 | // Update the call with the given data. Valid parameters may be found here: 92 | // https://www.twilio.com/docs/api/rest/change-call-state#post-parameters 93 | func (c *CallService) Update(ctx context.Context, sid string, data url.Values) (*Call, error) { 94 | call := new(Call) 95 | err := c.client.UpdateResource(ctx, callsPathPart, sid, data, call) 96 | return call, err 97 | } 98 | 99 | // Cancel an in-progress Call with the given sid. Cancel will not affect 100 | // in-progress Calls, only those in queued or ringing. 101 | func (c *CallService) Cancel(sid string) (*Call, error) { 102 | data := url.Values{} 103 | data.Set("Status", string(StatusCanceled)) 104 | return c.Update(context.Background(), sid, data) 105 | } 106 | 107 | // Hang up an in-progress call. 108 | func (c *CallService) Hangup(sid string) (*Call, error) { 109 | data := url.Values{} 110 | data.Set("Status", string(StatusCompleted)) 111 | return c.Update(context.Background(), sid, data) 112 | } 113 | 114 | // Redirect the given call to the given URL. 115 | func (c *CallService) Redirect(sid string, u *url.URL) (*Call, error) { 116 | data := url.Values{} 117 | data.Set("Url", u.String()) 118 | return c.Update(context.Background(), sid, data) 119 | } 120 | 121 | // Initiate a new Call. 122 | func (c *CallService) Create(ctx context.Context, data url.Values) (*Call, error) { 123 | call := new(Call) 124 | err := c.client.CreateResource(ctx, callsPathPart, data, call) 125 | return call, err 126 | } 127 | 128 | // MakeCall starts a new Call from the given phone number to the given phone 129 | // number, dialing the url when the call connects. MakeCall is a wrapper around 130 | // Create; if you need more configuration, call that function directly. 131 | func (c *CallService) MakeCall(from string, to string, u *url.URL) (*Call, error) { 132 | data := url.Values{} 133 | data.Set("From", from) 134 | data.Set("To", to) 135 | data.Set("Url", u.String()) 136 | return c.Create(context.Background(), data) 137 | } 138 | 139 | func (c *CallService) GetPage(ctx context.Context, data url.Values) (*CallPage, error) { 140 | iter := c.GetPageIterator(data) 141 | return iter.Next(ctx) 142 | } 143 | 144 | // GetCallsInRange gets an Iterator containing calls in the range [start, end), 145 | // optionally further filtered by data. GetCallsInRange panics if start is not 146 | // before end. Any date filters provided in data will be ignored. If you have 147 | // an end, but don't want to specify a start, use twilio.Epoch for start. If 148 | // you have a start, but don't want to specify an end, use twilio.HeatDeath for 149 | // end. 150 | // 151 | // Assumes that Twilio returns resources in chronological order, latest 152 | // first. If this assumption is incorrect, your results will not be correct. 153 | // 154 | // Returned CallPages will have at most PageSize results, but may have fewer, 155 | // based on filtering. 156 | func (c *CallService) GetCallsInRange(start time.Time, end time.Time, data url.Values) CallPageIterator { 157 | if start.After(end) { 158 | panic("start date is after end date") 159 | } 160 | d := url.Values{} 161 | if data != nil { 162 | for k, v := range data { 163 | d[k] = v 164 | } 165 | } 166 | d.Del("StartTime") 167 | d.Del("Page") // just in case 168 | if start != Epoch { 169 | startFormat := start.UTC().Format(APISearchLayout) 170 | d.Set("StartTime>", startFormat) 171 | } 172 | if end != HeatDeath { 173 | // If you specify "StartTime<=YYYY-MM-DD", the *latest* result returned 174 | // will be midnight (the earliest possible second) on DD. We want all 175 | // of the results for DD so we need to specify DD+1 in the API. 176 | // 177 | // TODO validate midnight-instant math more closely, since I don't think 178 | // Twilio returns the correct results for that instant. 179 | endFormat := end.UTC().Add(24 * time.Hour).Format(APISearchLayout) 180 | d.Set("StartTime<", endFormat) 181 | } 182 | iter := NewPageIterator(c.client, d, callsPathPart) 183 | return &callDateIterator{ 184 | start: start, 185 | end: end, 186 | p: iter, 187 | } 188 | } 189 | 190 | // GetNextCallsInRange retrieves the page at the nextPageURI and continues 191 | // retrieving pages until any results are found in the range given by start or 192 | // end, or we determine there are no more records to be found in that range. 193 | // 194 | // If CallPage is non-nil, it will have at least one result. 195 | func (c *CallService) GetNextCallsInRange(start time.Time, end time.Time, nextPageURI string) CallPageIterator { 196 | if nextPageURI == "" { 197 | panic("nextpageuri is empty") 198 | } 199 | iter := NewNextPageIterator(c.client, callsPathPart) 200 | iter.SetNextPageURI(types.NullString{Valid: true, String: nextPageURI}) 201 | return &callDateIterator{ 202 | start: start, 203 | end: end, 204 | p: iter, 205 | } 206 | } 207 | 208 | type callDateIterator struct { 209 | p *PageIterator 210 | start time.Time 211 | end time.Time 212 | } 213 | 214 | // Next returns the next page of resources. We may need to fetch multiple 215 | // pages from the Twilio API before we find one in the right date range, so 216 | // latency may be higher than usual. If page is non-nil, it contains at least 217 | // one result. 218 | func (c *callDateIterator) Next(ctx context.Context) (*CallPage, error) { 219 | var page *CallPage 220 | for { 221 | // just wipe it clean every time to avoid remnants hanging around 222 | page = new(CallPage) 223 | if err := c.p.Next(ctx, page); err != nil { 224 | return nil, err 225 | } 226 | if len(page.Calls) == 0 { 227 | return nil, NoMoreResults 228 | } 229 | times := make([]time.Time, len(page.Calls), len(page.Calls)) 230 | for i, call := range page.Calls { 231 | if !call.DateCreated.Valid { 232 | // we really should not ever hit this case but if we can't parse 233 | // a date, better to give you back an error than to give you back 234 | // a list of calls that may or may not be in the time range 235 | return nil, fmt.Errorf("Couldn't verify the date of call: %#v", call) 236 | } 237 | times[i] = call.DateCreated.Time 238 | } 239 | if containsResultsInRange(c.start, c.end, times) { 240 | indexesToDelete := indexesOutsideRange(c.start, c.end, times) 241 | // reverse order so we don't delete the wrong index 242 | for i := len(indexesToDelete) - 1; i >= 0; i-- { 243 | index := indexesToDelete[i] 244 | page.Calls = append(page.Calls[:index], page.Calls[index+1:]...) 245 | } 246 | c.p.SetNextPageURI(page.NextPageURI) 247 | return page, nil 248 | } 249 | if shouldContinuePaging(c.start, times) { 250 | c.p.SetNextPageURI(page.NextPageURI) 251 | continue 252 | } else { 253 | // should not continue paging and no results in range, stop 254 | return nil, NoMoreResults 255 | } 256 | } 257 | } 258 | 259 | // CallPageIterator lets you retrieve consecutive pages of resources. 260 | type CallPageIterator interface { 261 | // Next returns the next page of resources. If there are no more resources, 262 | // NoMoreResults is returned. 263 | Next(context.Context) (*CallPage, error) 264 | } 265 | 266 | type callPageIterator struct { 267 | p *PageIterator 268 | } 269 | 270 | // GetPageIterator returns an iterator which can be used to retrieve pages. 271 | func (c *CallService) GetPageIterator(data url.Values) CallPageIterator { 272 | iter := NewPageIterator(c.client, data, callsPathPart) 273 | return &callPageIterator{ 274 | p: iter, 275 | } 276 | } 277 | 278 | // Next returns the next page of resources. If there are no more resources, 279 | // NoMoreResults is returned. 280 | func (c *callPageIterator) Next(ctx context.Context) (*CallPage, error) { 281 | cp := new(CallPage) 282 | err := c.p.Next(ctx, cp) 283 | if err != nil { 284 | return nil, err 285 | } 286 | c.p.SetNextPageURI(cp.NextPageURI) 287 | return cp, nil 288 | } 289 | 290 | // GetRecordings returns an array of recordings for this Call. Note there may 291 | // be more than one Page of results. 292 | func (c *CallService) GetRecordings(ctx context.Context, callSid string, data url.Values) (*RecordingPage, error) { 293 | if data == nil { 294 | data = url.Values{} 295 | } 296 | // Cheat - hit the Recordings list view with a filter instead of 297 | // GET /calls/CA123/Recordings. The former is probably more reliable 298 | data.Set("CallSid", callSid) 299 | return c.client.Recordings.GetPage(ctx, data) 300 | } 301 | 302 | // GetRecordings returns an iterator of recording pages for this Call. 303 | // Note there may be more than one Page of results. 304 | func (c *CallService) GetRecordingsIterator(callSid string, data url.Values) *RecordingPageIterator { 305 | if data == nil { 306 | data = url.Values{} 307 | } 308 | // Cheat - hit the Recordings list view with a filter instead of 309 | // GET /calls/CA123/Recordings. The former is probably more reliable 310 | data.Set("CallSid", callSid) 311 | return c.client.Recordings.GetPageIterator(data) 312 | } 313 | -------------------------------------------------------------------------------- /calls_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func TestGetCall(t *testing.T) { 12 | if testing.Short() { 13 | t.Skip("skipping HTTP request in short mode") 14 | } 15 | t.Parallel() 16 | sid := "CAa98f7bbc9bc4980a44b128ca4884ca73" 17 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 18 | defer cancel() 19 | call, err := envClient.Calls.Get(ctx, sid) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if call.Sid != sid { 24 | t.Errorf("expected Sid to equal %s, got %s", sid, call.Sid) 25 | } 26 | } 27 | 28 | func TestGetCallRecordings(t *testing.T) { 29 | if testing.Short() { 30 | t.Skip("skipping HTTP request in short mode") 31 | } 32 | t.Parallel() 33 | sid := "CA14365760c10f73392c5440bdfb70c212" 34 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 35 | defer cancel() 36 | recordings, err := envClient.Calls.GetRecordings(ctx, sid, nil) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if l := len(recordings.Recordings); l != 1 { 41 | t.Fatalf("expected 1 recording, got %d", l) 42 | } 43 | rsid := "REd04242a0544234abba080942e0535505" 44 | if r := recordings.Recordings[0].Sid; r != rsid { 45 | t.Errorf("expected recording sid to be %s, got %s", rsid, r) 46 | } 47 | if recordings.NextPageURI.Valid { 48 | t.Errorf("expected next page uri to be invalid, got %v", recordings.NextPageURI) 49 | } 50 | } 51 | 52 | func TestMakeCall(t *testing.T) { 53 | t.Parallel() 54 | client, server := getServer(makeCallResponse) 55 | defer server.Close() 56 | u, _ := url.Parse("https://kev.inburke.com/zombo/zombocom.mp3") 57 | call, err := client.Calls.MakeCall("+19253920364", "+19252717005", u) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | if call.To != "+19252717005" { 62 | t.Errorf("Wrong To phone number: %s", call.To) 63 | } 64 | if call.Status != StatusQueued { 65 | t.Errorf("Wrong status: %s", call.Status) 66 | } 67 | } 68 | 69 | func TestGetCallRange(t *testing.T) { 70 | if testing.Short() { 71 | t.Skip("skipping HTTP request in short mode") 72 | } 73 | t.Parallel() 74 | // from Kevin's account, there should be exactly 2 results in this range. 75 | data := url.Values{} 76 | data.Set("PageSize", "2") 77 | nyc, err := time.LoadLocation("America/New_York") 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | // 10:34:00 Oct 26 to 19:25:59 Oct 27 NYC time. I made 2 calls in this 82 | // range, and 5 calls on this day. There are 2 calls before this time on 83 | // this day, and 1 call after. 84 | start := time.Date(2016, 10, 26, 22, 34, 00, 00, nyc) 85 | end := time.Date(2016, 10, 27, 19, 25, 59, 00, nyc) 86 | iter := envClient.Calls.GetCallsInRange(start, end, data) 87 | count := 0 88 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 89 | defer cancel() 90 | for { 91 | count++ 92 | page, err := iter.Next(ctx) 93 | if err == NoMoreResults { 94 | break 95 | } 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | if len(page.Calls) != 2 { 100 | t.Errorf("expected 2 calls in result set, got %d", len(page.Calls)) 101 | break 102 | } 103 | // the 19:25 call on 10-27 104 | if page.Calls[0].Sid != "CA5757109d6dcbc4bebf6847f5dd45191e" { 105 | t.Errorf("wrong sid") 106 | } 107 | // the 22:34 call on 10-26 108 | if page.Calls[1].Sid != "CA47b862ce3b99a6d79939320a9aa54a02" { 109 | t.Errorf("wrong sid") 110 | } 111 | } 112 | if count != 2 { 113 | t.Errorf("wrong count, expected exactly 2 calls to Next(), got %d", count) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /codes.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | // A Twilio error code. A full list can be found here: 9 | // https://www.twilio.com/docs/api/errors/reference 10 | type Code int 11 | 12 | func (c *Code) convCode(i *int, err error) error { 13 | if err != nil { 14 | return err 15 | } 16 | if *i == 4107 { 17 | // Twilio incorrectly sent back 14107 as 4107 in some cases. Not sure 18 | // how often this happened or if the problem is more general 19 | *i = 14107 20 | } 21 | *c = Code(*i) 22 | return nil 23 | } 24 | 25 | func (c *Code) UnmarshalJSON(b []byte) error { 26 | s := new(string) 27 | if err := json.Unmarshal(b, s); err == nil { 28 | if *s == "" || *s == "null" { 29 | *c = Code(0) 30 | return nil 31 | } 32 | i, err := strconv.Atoi(*s) 33 | return c.convCode(&i, err) 34 | } 35 | i := new(int) 36 | err := json.Unmarshal(b, i) 37 | return c.convCode(i, err) 38 | } 39 | 40 | const CodeHTTPRetrievalFailure = 11200 41 | const CodeHTTPConnectionFailure = 11205 42 | const CodeHTTPProtocolViolation = 11206 43 | const CodeReplyLimitExceeded = 14107 44 | const CodeDocumentParseFailure = 12100 45 | const CodeForbiddenPhoneNumber = 13225 46 | const CodeNoInternationalAuthorization = 13227 47 | const CodeSayInvalidText = 13520 48 | const CodeQueueOverflow = 30001 49 | const CodeAccountSuspended = 30002 50 | const CodeUnreachable = 30003 51 | const CodeMessageBlocked = 30004 52 | const CodeUnknownDestination = 30005 53 | const CodeLandline = 30006 54 | const CodeCarrierViolation = 30007 55 | const CodeUnknownError = 30008 56 | const CodeMissingSegment = 30009 57 | const CodeMessagePriceExceedsMaxPrice = 30010 58 | -------------------------------------------------------------------------------- /conferences.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | types "github.com/kevinburke/go-types" 9 | 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | const conferencePathPart = "Conferences" 14 | 15 | type ConferenceService struct { 16 | client *Client 17 | } 18 | 19 | type Conference struct { 20 | Sid string `json:"sid"` 21 | // Call status, StatusInProgress or StatusCompleted 22 | Status Status `json:"status"` 23 | FriendlyName string `json:"friendly_name"` 24 | // The conference region, probably "us1" 25 | Region string `json:"region"` 26 | DateCreated TwilioTime `json:"date_created"` 27 | AccountSid string `json:"account_sid"` 28 | APIVersion string `json:"api_version"` 29 | DateUpdated TwilioTime `json:"date_updated"` 30 | URI string `json:"uri"` 31 | } 32 | 33 | type ConferencePage struct { 34 | Page 35 | Conferences []*Conference 36 | } 37 | 38 | func (c *ConferenceService) Get(ctx context.Context, sid string) (*Conference, error) { 39 | conference := new(Conference) 40 | err := c.client.GetResource(ctx, conferencePathPart, sid, conference) 41 | return conference, err 42 | } 43 | 44 | func (c *ConferenceService) GetPage(ctx context.Context, data url.Values) (*ConferencePage, error) { 45 | return c.GetPageIterator(data).Next(ctx) 46 | } 47 | 48 | // GetConferencesInRange gets an Iterator containing conferences in the range 49 | // [start, end), optionally further filtered by data. GetConferencesInRange 50 | // panics if start is not before end. Any date filters provided in data will 51 | // be ignored. If you have an end, but don't want to specify a start, use 52 | // twilio.Epoch for start. If you have a start, but don't want to specify an 53 | // end, use twilio.HeatDeath for end. 54 | // 55 | // Assumes that Twilio returns resources in chronological order, latest 56 | // first. If this assumption is incorrect, your results will not be correct. 57 | // 58 | // Returned ConferencePages will have at most PageSize results, but may have fewer, 59 | // based on filtering. 60 | func (c *ConferenceService) GetConferencesInRange(start time.Time, end time.Time, data url.Values) ConferencePageIterator { 61 | if start.After(end) { 62 | panic("start date is after end date") 63 | } 64 | d := url.Values{} 65 | if data != nil { 66 | for k, v := range data { 67 | d[k] = v 68 | } 69 | } 70 | d.Del("DateCreated") 71 | d.Del("Page") // just in case 72 | if start != Epoch { 73 | startFormat := start.UTC().Format(APISearchLayout) 74 | d.Set("DateCreated>", startFormat) 75 | } 76 | if end != HeatDeath { 77 | // If you specify "StartTime<=YYYY-MM-DD", the *latest* result returned 78 | // will be midnight (the earliest possible second) on DD. We want all 79 | // of the results for DD so we need to specify DD+1 in the API. 80 | // 81 | // TODO validate midnight-instant math more closely, since I don't think 82 | // Twilio returns the correct results for that instant. 83 | endFormat := end.UTC().Add(24 * time.Hour).Format(APISearchLayout) 84 | d.Set("DateCreated<", endFormat) 85 | } 86 | iter := NewPageIterator(c.client, d, conferencePathPart) 87 | return &conferenceDateIterator{ 88 | start: start, 89 | end: end, 90 | p: iter, 91 | } 92 | } 93 | 94 | // GetNextConferencesInRange retrieves the page at the nextPageURI and continues 95 | // retrieving pages until any results are found in the range given by start or 96 | // end, or we determine there are no more records to be found in that range. 97 | // 98 | // If ConferencePage is non-nil, it will have at least one result. 99 | func (c *ConferenceService) GetNextConferencesInRange(start time.Time, end time.Time, nextPageURI string) ConferencePageIterator { 100 | if nextPageURI == "" { 101 | panic("nextpageuri is empty") 102 | } 103 | iter := NewNextPageIterator(c.client, conferencePathPart) 104 | iter.SetNextPageURI(types.NullString{Valid: true, String: nextPageURI}) 105 | return &conferenceDateIterator{ 106 | start: start, 107 | end: end, 108 | p: iter, 109 | } 110 | } 111 | 112 | type conferenceDateIterator struct { 113 | p *PageIterator 114 | start time.Time 115 | end time.Time 116 | } 117 | 118 | // Next returns the next page of resources. We may need to fetch multiple 119 | // pages from the Twilio API before we find one in the right date range, so 120 | // latency may be higher than usual. If page is non-nil, it contains at least 121 | // one result. 122 | func (c *conferenceDateIterator) Next(ctx context.Context) (*ConferencePage, error) { 123 | var page *ConferencePage 124 | for { 125 | // just wipe it clean every time to avoid remnants hanging around 126 | page = new(ConferencePage) 127 | if err := c.p.Next(ctx, page); err != nil { 128 | return nil, err 129 | } 130 | if len(page.Conferences) == 0 { 131 | return nil, NoMoreResults 132 | } 133 | times := make([]time.Time, len(page.Conferences), len(page.Conferences)) 134 | for i, conference := range page.Conferences { 135 | if !conference.DateCreated.Valid { 136 | // we really should not ever hit this case but if we can't parse 137 | // a date, better to give you back an error than to give you back 138 | // a list of conferences that may or may not be in the time range 139 | return nil, fmt.Errorf("Couldn't verify the date of conference: %#v", conference) 140 | } 141 | times[i] = conference.DateCreated.Time 142 | } 143 | if containsResultsInRange(c.start, c.end, times) { 144 | indexesToDelete := indexesOutsideRange(c.start, c.end, times) 145 | // reverse order so we don't delete the wrong index 146 | for i := len(indexesToDelete) - 1; i >= 0; i-- { 147 | index := indexesToDelete[i] 148 | page.Conferences = append(page.Conferences[:index], page.Conferences[index+1:]...) 149 | } 150 | c.p.SetNextPageURI(page.NextPageURI) 151 | return page, nil 152 | } 153 | if shouldContinuePaging(c.start, times) { 154 | c.p.SetNextPageURI(page.NextPageURI) 155 | continue 156 | } else { 157 | // should not continue paging and no results in range, stop 158 | return nil, NoMoreResults 159 | } 160 | } 161 | } 162 | 163 | type ConferencePageIterator interface { 164 | // Next returns the next page of resources. If there are no more resources, 165 | // NoMoreResults is returned. 166 | Next(context.Context) (*ConferencePage, error) 167 | } 168 | 169 | // ConferencePageIterator lets you retrieve consecutive ConferencePages. 170 | type conferencePageIterator struct { 171 | p *PageIterator 172 | } 173 | 174 | // GetPageIterator returns a ConferencePageIterator with the given page 175 | // filters. Call iterator.Next() to get the first page of resources (and again 176 | // to retrieve subsequent pages). 177 | func (c *ConferenceService) GetPageIterator(data url.Values) ConferencePageIterator { 178 | return &conferencePageIterator{ 179 | p: NewPageIterator(c.client, data, conferencePathPart), 180 | } 181 | } 182 | 183 | // Next returns the next page of resources. If there are no more resources, 184 | // NoMoreResults is returned. 185 | func (c *conferencePageIterator) Next(ctx context.Context) (*ConferencePage, error) { 186 | cp := new(ConferencePage) 187 | err := c.p.Next(ctx, cp) 188 | if err != nil { 189 | return nil, err 190 | } 191 | c.p.SetNextPageURI(cp.NextPageURI) 192 | return cp, nil 193 | } 194 | -------------------------------------------------------------------------------- /conferences_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func TestGetConferencePage(t *testing.T) { 12 | t.Parallel() 13 | client, s := getServer(conferencePage) 14 | defer s.Close() 15 | data := url.Values{"PageSize": []string{"3"}} 16 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 17 | defer cancel() 18 | conferences, err := client.Conferences.GetPage(ctx, data) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | if len(conferences.Conferences) != 3 { 23 | t.Errorf("expected to get 3 conferences, got %d", len(conferences.Conferences)) 24 | } 25 | } 26 | 27 | func TestGetConference(t *testing.T) { 28 | t.Parallel() 29 | client, s := getServer(conferenceInstance) 30 | defer s.Close() 31 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 32 | defer cancel() 33 | conference, err := client.Conferences.Get(ctx, conferenceInstanceSid) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | if conference.Sid != conferenceInstanceSid { 38 | t.Errorf("expected Sid to be %s, got %s", conferenceInstanceSid, conference.Sid) 39 | } 40 | if conference.FriendlyName != "testConference" { 41 | t.Errorf("expected FriendlyName to be 'testConference', got %s", conference.FriendlyName) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package twilio simplifies interaction with the Twilio API. 2 | // 3 | // The twilio-go library should be your first choice for interacting with the 4 | // Twilio API; it offers forward compatibility, very fine-grained control of 5 | // API access, best-in-class control over how long to wait for requests to 6 | // complete, and great debuggability when things go wrong. Get started by 7 | // creating a Client: 8 | // 9 | // client := twilio.NewClient("AC123", "123", nil) 10 | // 11 | // All of the Twilio resources are available as properties on the Client. Let's 12 | // walk through some of the example use cases. 13 | // 14 | // Creating a Resource 15 | // 16 | // Resources that can create new methods take a url.Values as an argument, 17 | // and pass all arguments to the Twilio API. This method ensures forward 18 | // compatibility; any new arguments that get invented can be added in 19 | // client-side code. 20 | // 21 | // data := url.Values{"To": []string{"+1foo"}, "From": []string{"+1foo"}, 22 | // "Body": []string{"+1foo"}} 23 | // msg, err := client.Messages.Create(context.TODO(), data) 24 | // 25 | // Getting an Instance Resource 26 | // 27 | // Call Get() with a particular sid. 28 | // 29 | // number, err := client.IncomingNumbers.Get(context.TODO(), "PN123") 30 | // fmt.Println(number.PhoneNumber) 31 | // 32 | // Updating an Instance Resource 33 | // 34 | // Call Update() with a particular sid and a url.Values. 35 | // 36 | // data := url.Values{} 37 | // data.Set("Status", string(twilio.StatusCompleted)) 38 | // call, err := client.Calls.Update("CA123", data) 39 | // 40 | // Getting a List Resource 41 | // 42 | // There are two flavors of interaction. First, if all you want is a single 43 | // Page of resources, optionally with filters: 44 | // 45 | // page, err := client.Recordings.GetPage(context.TODO(), url.Values{}) 46 | // 47 | // To control the page size, set "PageSize": "N" in the url.Values{} field. 48 | // Twilio defaults to returning 50 results per page if this is not set. 49 | // 50 | // Alternatively you can get a PageIterator and call Next() to repeatedly 51 | // retrieve pages. 52 | // 53 | // iterator := client.Calls.GetPageIterator(url.Values{"From": []string{"+1foo"}}) 54 | // for { 55 | // page, err := iterator.Next(context.TODO()) 56 | // // NoMoreResults means you've reached the end. 57 | // if err == twilio.NoMoreResults { 58 | // break 59 | // } 60 | // fmt.Println("start", page.Start) 61 | // } 62 | // 63 | // Twilio Monitor 64 | // 65 | // Twilio Monitor subresources are available on the Client under the Monitor 66 | // field, e.g. 67 | // 68 | // alert, err := client.Monitor.Alerts.Get(context.TODO(), "NO123") 69 | // 70 | // Custom Types 71 | // 72 | // There are several custom types and helper functions designed to make your 73 | // job easier. Where possible, we try to parse values from the Twilio API into 74 | // a datatype that makes more sense. For example, we try to parse timestamps 75 | // into Time values, durations into time.Duration, integer values into uints, 76 | // even if the API returns them as strings, e.g. "3". 77 | // 78 | // All phone numbers have type PhoneNumber. By default these are 79 | // E.164, but can be printed in Friendly()/Local() variations as well. 80 | // 81 | // num, _ := twilio.NewPhoneNumber("+1 (415) 555-1234") 82 | // fmt.Println(num.Friendly()) // "+1 415 555 1234" 83 | // 84 | // Any times returned from the Twilio API are of type TwilioTime, which has 85 | // two properties - Valid (a bool), and Time (a time.Time). Check Valid before 86 | // using the related Time. 87 | // 88 | // if msg.DateSent.Valid { 89 | // fmt.Println(msg.DateSent.Time.Format(time.Kitchen) 90 | // } 91 | // 92 | // There are constants for every Status in the API, for example StatusQueued, 93 | // which has the value "queued". You can call Friendly() on any Status to get 94 | // an uppercase version of the status, e.g. 95 | // 96 | // twilio.StatusInProgress.Friendly() // "In Progress" 97 | package twilio 98 | -------------------------------------------------------------------------------- /example_messages_test.go: -------------------------------------------------------------------------------- 1 | package twilio_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "time" 8 | 9 | twilio "github.com/saintpete/twilio-go" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | func ExampleMessageService_GetMessagesInRange() { 14 | // Get all messages between 10:34:00 Oct 26 and 19:25:59 Oct 27, NYC time. 15 | nyc, _ := time.LoadLocation("America/New_York") 16 | start := time.Date(2016, 10, 26, 22, 34, 00, 00, nyc) 17 | end := time.Date(2016, 10, 27, 19, 25, 59, 00, nyc) 18 | 19 | client := twilio.NewClient("AC123", "123", nil) 20 | iter := client.Messages.GetMessagesInRange(start, end, url.Values{}) 21 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 22 | defer cancel() 23 | for { 24 | page, err := iter.Next(ctx) 25 | if err == twilio.NoMoreResults { 26 | break 27 | } 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | for i, message := range page.Messages { 32 | fmt.Printf("%d: %s (%s)", i, message.Sid, message.DateCreated.Time) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package twilio_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/kevinburke/rest" 10 | twilio "github.com/saintpete/twilio-go" 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | var callURL, _ = url.Parse("https://kev.inburke.com/zombo/zombocom.mp3") 15 | 16 | func Example() { 17 | client := twilio.NewClient("AC123", "123", nil) 18 | 19 | // Send a SMS 20 | msg, _ := client.Messages.SendMessage("+14105551234", "+14105556789", "Sent via go :) ✓", nil) 21 | fmt.Println(msg.Sid, msg.FriendlyPrice()) 22 | 23 | // Make a call 24 | call, _ := client.Calls.MakeCall("+14105551234", "+14105556789", callURL) 25 | fmt.Println(call.Sid, call.FriendlyPrice()) 26 | 27 | _, err := client.IncomingNumbers.BuyNumber("+1badnumber") 28 | // Twilio API errors are converted to rest.Error types 29 | if err != nil { 30 | restErr, ok := err.(*rest.Error) 31 | if ok { 32 | fmt.Println(restErr.Title) 33 | fmt.Println(restErr.Type) 34 | } 35 | } 36 | 37 | // Find all calls from a number 38 | data := url.Values{"From": []string{"+14105551234"}} 39 | iterator := client.Calls.GetPageIterator(data) 40 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 41 | defer cancel() 42 | for { 43 | page, err := iterator.Next(ctx) 44 | if err == twilio.NoMoreResults { 45 | break 46 | } 47 | for _, call := range page.Calls { 48 | fmt.Println(call.Sid, call.To) 49 | } 50 | } 51 | } 52 | 53 | func ExampleCallService_GetCallsInRange() { 54 | // Get all calls between 10:34:00 Oct 26 and 19:25:59 Oct 27, NYC time. 55 | nyc, _ := time.LoadLocation("America/New_York") 56 | start := time.Date(2016, 10, 26, 22, 34, 00, 00, nyc) 57 | end := time.Date(2016, 10, 27, 19, 25, 59, 00, nyc) 58 | 59 | client := twilio.NewClient("AC123", "123", nil) 60 | iter := client.Calls.GetCallsInRange(start, end, url.Values{}) 61 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 62 | defer cancel() 63 | for { 64 | page, err := iter.Next(ctx) 65 | if err == twilio.NoMoreResults { 66 | break 67 | } 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | for i, call := range page.Calls { 72 | fmt.Printf("%d: %s (%s)", i, call.Sid, call.DateCreated.Time) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/kevinburke/rest" 14 | "golang.org/x/net/context" 15 | ) 16 | 17 | // The twilio-go version. Run "make release" to bump this number. 18 | const Version = "0.55" 19 | const userAgent = "twilio-go/" + Version 20 | 21 | // The base URL serving the API. Override this for testing. 22 | var BaseURL = "https://api.twilio.com" 23 | 24 | // The base URL for Twilio Monitor. 25 | var MonitorBaseURL = "https://monitor.twilio.com" 26 | 27 | // Version of the Twilio Monitor API. 28 | const MonitorVersion = "v1" 29 | 30 | // The base URL for Twilio Pricing. 31 | var PricingBaseURL = "https://pricing.twilio.com" 32 | 33 | // Version of the Twilio Pricing API. 34 | const PricingVersion = "v1" 35 | 36 | // The APIVersion to use. Your mileage may vary using other values for the 37 | // APIVersion; the resource representations may not match. 38 | const APIVersion = "2010-04-01" 39 | 40 | type Client struct { 41 | *rest.Client 42 | Monitor *Client 43 | Pricing *Client 44 | 45 | // FullPath takes a path part (e.g. "Messages") and 46 | // returns the full API path, including the version (e.g. 47 | // "/2010-04-01/Accounts/AC123/Messages"). 48 | FullPath func(pathPart string) string 49 | // The API version. 50 | APIVersion string 51 | 52 | AccountSid string 53 | AuthToken string 54 | 55 | // The API Client uses these resources 56 | Accounts *AccountService 57 | Applications *ApplicationService 58 | Calls *CallService 59 | Conferences *ConferenceService 60 | IncomingNumbers *IncomingNumberService 61 | Keys *KeyService 62 | Media *MediaService 63 | Messages *MessageService 64 | OutgoingCallerIDs *OutgoingCallerIDService 65 | Queues *QueueService 66 | Recordings *RecordingService 67 | Transcriptions *TranscriptionService 68 | 69 | // NewMonitorClient initializes these services 70 | Alerts *AlertService 71 | 72 | // NewPricingClient initializes these services 73 | Voice *VoicePriceService 74 | Messaging *MessagingPriceService 75 | PhoneNumbers *PhoneNumberPriceService 76 | } 77 | 78 | const defaultTimeout = 30*time.Second + 500*time.Millisecond 79 | 80 | // An error returned by the Twilio API. We don't want to expose this - let's 81 | // try to standardize on the fields in the HTTP problem spec instead. 82 | type twilioError struct { 83 | Code int `json:"code"` 84 | Message string `json:"message"` 85 | MoreInfo string `json:"more_info"` 86 | // This will be ignored in favor of the actual HTTP status code 87 | Status int `json:"status"` 88 | } 89 | 90 | func parseTwilioError(resp *http.Response) error { 91 | resBody, err := ioutil.ReadAll(resp.Body) 92 | if err != nil { 93 | return err 94 | } 95 | if err := resp.Body.Close(); err != nil { 96 | return err 97 | } 98 | rerr := new(twilioError) 99 | err = json.Unmarshal(resBody, rerr) 100 | if err != nil { 101 | return fmt.Errorf("invalid response body: %s", string(resBody)) 102 | } 103 | if rerr.Message == "" { 104 | return fmt.Errorf("invalid response body: %s", string(resBody)) 105 | } 106 | return &rest.Error{ 107 | Title: rerr.Message, 108 | Type: rerr.MoreInfo, 109 | ID: strconv.FormatInt(int64(rerr.Code), 10), 110 | StatusCode: resp.StatusCode, 111 | } 112 | } 113 | 114 | func NewMonitorClient(accountSid string, authToken string, httpClient *http.Client) *Client { 115 | if httpClient == nil { 116 | httpClient = &http.Client{Timeout: defaultTimeout} 117 | } 118 | restClient := rest.NewClient(accountSid, authToken, MonitorBaseURL) 119 | c := &Client{Client: restClient, AccountSid: accountSid, AuthToken: authToken} 120 | c.FullPath = func(pathPart string) string { 121 | return "/" + c.APIVersion + "/" + pathPart 122 | } 123 | c.APIVersion = MonitorVersion 124 | c.Alerts = &AlertService{client: c} 125 | return c 126 | } 127 | 128 | // returns a new Client to use the pricing API 129 | func NewPricingClient(accountSid string, authToken string, httpClient *http.Client) *Client { 130 | if httpClient == nil { 131 | httpClient = &http.Client{Timeout: defaultTimeout} 132 | } 133 | restClient := rest.NewClient(accountSid, authToken, PricingBaseURL) 134 | c := &Client{Client: restClient, AccountSid: accountSid, AuthToken: authToken} 135 | c.APIVersion = PricingVersion 136 | c.FullPath = func(pathPart string) string { 137 | return "/" + c.APIVersion + "/" + pathPart 138 | } 139 | c.Voice = &VoicePriceService{ 140 | Countries: &CountryVoicePriceService{client: c}, 141 | Numbers: &NumberVoicePriceService{client: c}, 142 | } 143 | c.Messaging = &MessagingPriceService{ 144 | Countries: &CountryMessagingPriceService{client: c}, 145 | } 146 | c.PhoneNumbers = &PhoneNumberPriceService{ 147 | Countries: &CountryPhoneNumberPriceService{client: c}, 148 | } 149 | return c 150 | } 151 | 152 | // NewClient creates a Client for interacting with the Twilio API. This is the 153 | // main entrypoint for API interactions; view the methods on the subresources 154 | // for more information. 155 | func NewClient(accountSid string, authToken string, httpClient *http.Client) *Client { 156 | 157 | if httpClient == nil { 158 | httpClient = &http.Client{Timeout: defaultTimeout} 159 | } 160 | restClient := rest.NewClient(accountSid, authToken, BaseURL) 161 | restClient.Client = httpClient 162 | restClient.UploadType = rest.FormURLEncoded 163 | restClient.ErrorParser = parseTwilioError 164 | 165 | c := &Client{Client: restClient, AccountSid: accountSid, AuthToken: authToken} 166 | c.APIVersion = APIVersion 167 | 168 | c.FullPath = func(pathPart string) string { 169 | return "/" + strings.Join([]string{c.APIVersion, "Accounts", c.AccountSid, pathPart + ".json"}, "/") 170 | } 171 | c.Monitor = NewMonitorClient(accountSid, authToken, httpClient) 172 | c.Pricing = NewPricingClient(accountSid, authToken, httpClient) 173 | 174 | c.Accounts = &AccountService{client: c} 175 | c.Applications = &ApplicationService{client: c} 176 | c.Calls = &CallService{client: c} 177 | c.Conferences = &ConferenceService{client: c} 178 | c.Keys = &KeyService{client: c} 179 | c.Media = &MediaService{client: c} 180 | c.Messages = &MessageService{client: c} 181 | c.OutgoingCallerIDs = &OutgoingCallerIDService{client: c} 182 | c.Queues = &QueueService{client: c} 183 | c.Recordings = &RecordingService{client: c} 184 | c.Transcriptions = &TranscriptionService{client: c} 185 | 186 | c.IncomingNumbers = &IncomingNumberService{ 187 | NumberPurchasingService: &NumberPurchasingService{ 188 | client: c, 189 | pathPart: "", 190 | }, 191 | client: c, 192 | Local: &NumberPurchasingService{ 193 | client: c, 194 | pathPart: "Local", 195 | }, 196 | TollFree: &NumberPurchasingService{ 197 | client: c, 198 | pathPart: "TollFree", 199 | }, 200 | } 201 | return c 202 | } 203 | 204 | // RequestOnBehalfOf will make all future client requests using the same 205 | // Account Sid and Auth Token for Basic Auth, but will use the provided 206 | // subaccountSid in the URL. Use this to make requests on behalf of a 207 | // subaccount, using the parent account's credentials. 208 | // 209 | // RequestOnBehalfOf is *not* thread safe, and modifies the Client's behavior 210 | // for all requests going forward. 211 | // 212 | // RequestOnBehalfOf should only be used with api.twilio.com, not (for example) 213 | // Twilio Monitor. 214 | // 215 | // To authenticate using a subaccount sid / auth token, create a new Client 216 | // using that account's credentials. 217 | func (c *Client) RequestOnBehalfOf(subaccountSid string) { 218 | c.FullPath = func(pathPart string) string { 219 | return "/" + strings.Join([]string{c.APIVersion, "Accounts", subaccountSid, pathPart + ".json"}, "/") 220 | } 221 | } 222 | 223 | // GetResource retrieves an instance resource with the given path part (e.g. 224 | // "/Messages") and sid (e.g. "MM123"). 225 | func (c *Client) GetResource(ctx context.Context, pathPart string, sid string, v interface{}) error { 226 | sidPart := strings.Join([]string{pathPart, sid}, "/") 227 | return c.MakeRequest(ctx, "GET", sidPart, nil, v) 228 | } 229 | 230 | // CreateResource makes a POST request to the given resource. 231 | func (c *Client) CreateResource(ctx context.Context, pathPart string, data url.Values, v interface{}) error { 232 | return c.MakeRequest(ctx, "POST", pathPart, data, v) 233 | } 234 | 235 | func (c *Client) UpdateResource(ctx context.Context, pathPart string, sid string, data url.Values, v interface{}) error { 236 | sidPart := strings.Join([]string{pathPart, sid}, "/") 237 | return c.MakeRequest(ctx, "POST", sidPart, data, v) 238 | } 239 | 240 | func (c *Client) DeleteResource(ctx context.Context, pathPart string, sid string) error { 241 | sidPart := strings.Join([]string{pathPart, sid}, "/") 242 | err := c.MakeRequest(ctx, "DELETE", sidPart, nil, nil) 243 | if err == nil { 244 | return nil 245 | } 246 | rerr, ok := err.(*rest.Error) 247 | if ok && rerr.StatusCode == http.StatusNotFound { 248 | return nil 249 | } 250 | return err 251 | } 252 | 253 | func (c *Client) ListResource(ctx context.Context, pathPart string, data url.Values, v interface{}) error { 254 | return c.MakeRequest(ctx, "GET", pathPart, data, v) 255 | } 256 | 257 | // GetNextPage fetches the Page at fullUri and decodes it into v. fullUri 258 | // should be a next_page_uri returned in the response to a paging request, and 259 | // should be the full path, eg "/2010-04-01/.../Messages?Page=1&PageToken=..." 260 | func (c *Client) GetNextPage(ctx context.Context, fullUri string, v interface{}) error { 261 | // for monitor etc. 262 | if strings.HasPrefix(fullUri, c.Base) { 263 | fullUri = fullUri[len(c.Base):] 264 | } 265 | return c.MakeRequest(ctx, "GET", fullUri, nil, v) 266 | } 267 | 268 | // Make a request to the Twilio API. 269 | func (c *Client) MakeRequest(ctx context.Context, method string, pathPart string, data url.Values, v interface{}) error { 270 | if !strings.HasPrefix(pathPart, "/"+c.APIVersion) { 271 | pathPart = c.FullPath(pathPart) 272 | } 273 | rb := new(strings.Reader) 274 | if data != nil && (method == "POST" || method == "PUT") { 275 | rb = strings.NewReader(data.Encode()) 276 | } 277 | if method == "GET" && data != nil { 278 | pathPart = pathPart + "?" + data.Encode() 279 | } 280 | req, err := c.NewRequest(method, pathPart, rb) 281 | if err != nil { 282 | return err 283 | } 284 | req = withContext(req, ctx) 285 | if ua := req.Header.Get("User-Agent"); ua == "" { 286 | req.Header.Set("User-Agent", userAgent) 287 | } else { 288 | req.Header.Set("User-Agent", userAgent+" "+ua) 289 | } 290 | return c.Do(req, &v) 291 | } 292 | -------------------------------------------------------------------------------- /http_ctx.go: -------------------------------------------------------------------------------- 1 | // +build go1.7 2 | 3 | package twilio 4 | 5 | import ( 6 | "net/http" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func withContext(r *http.Request, ctx context.Context) *http.Request { 12 | return r.WithContext(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /http_noctx.go: -------------------------------------------------------------------------------- 1 | // +build !go1.7 2 | 3 | package twilio 4 | 5 | import ( 6 | "net/http" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func withContext(r *http.Request, ctx context.Context) *http.Request { 12 | return r 13 | } 14 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/kevinburke/rest" 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | // invalid status here on purpose to check we use a different one. 15 | var notFoundResp = []byte("{\"code\": 20404, \"message\": \"The requested resource /2010-04-01/Accounts/AC58f1e8f2b1c6b88ca90a012a4be0c279/Calls/unknown.json was not found\", \"more_info\": \"https://www.twilio.com/docs/errors/20404\", \"status\": 428}") 16 | 17 | var errorServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 19 | w.WriteHeader(404) 20 | w.Write(notFoundResp) 21 | })) 22 | 23 | func Test404Error(t *testing.T) { 24 | t.Parallel() 25 | client := NewClient("", "", nil) 26 | client.Base = errorServer.URL 27 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) 28 | defer cancel() 29 | sid := "unknown" 30 | _, err := client.Calls.Get(ctx, sid) 31 | if err == nil { 32 | t.Fatal("expected non-nil error, got nil") 33 | } 34 | rerr, ok := err.(*rest.Error) 35 | if !ok { 36 | t.Fatalf("expected to convert err %v to rest.Error, couldn't", err) 37 | } 38 | if !strings.Contains(rerr.Title, "The requested resource /2010-04-01") { 39 | t.Errorf("expected Title to contain 'The requested resource', got %s", rerr.Title) 40 | } 41 | if rerr.ID != "20404" { 42 | t.Errorf("expected ID to be 20404, got %s", rerr.ID) 43 | } 44 | if rerr.Type != "https://www.twilio.com/docs/errors/20404" { 45 | t.Errorf("expected Type to be a Twilio URL, got %s", rerr.Type) 46 | } 47 | if rerr.StatusCode != 404 { 48 | t.Errorf("expected StatusCode to be 404, got %d", rerr.StatusCode) 49 | } 50 | } 51 | 52 | func TestContext(t *testing.T) { 53 | if testing.Short() { 54 | t.Skip("skipping HTTP request in short mode") 55 | } 56 | t.Parallel() 57 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) 58 | defer cancel() 59 | _, err := envClient.Calls.Get(ctx, "unknown") 60 | if err == nil { 61 | t.Fatal("expected Err to be non-nil, got nil") 62 | } 63 | // I wish it had context.DeadlineExceeded, doesn't seem to be the case. 64 | ok := strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "canceled") 65 | if !ok { 66 | t.Errorf("bad error message: %v", err) 67 | } 68 | } 69 | 70 | func TestCancelStopsRequest(t *testing.T) { 71 | if testing.Short() { 72 | t.Skip("skipping HTTP request in short mode") 73 | } 74 | t.Parallel() 75 | ctx, cancel := context.WithCancel(context.Background()) 76 | sid := "CAa98f7bbc9bc4980a44b128ca4884ca73" 77 | go func() { 78 | time.Sleep(30 * time.Millisecond) 79 | cancel() 80 | }() 81 | _, err := envClient.Calls.Get(ctx, sid) 82 | if err == nil { 83 | t.Fatal("expected Err to be non-nil, got nil") 84 | } 85 | if !strings.Contains(err.Error(), "canceled") { 86 | t.Errorf("bad error message: %#v", err) 87 | } 88 | } 89 | 90 | func TestOnBehalfOf(t *testing.T) { 91 | t.Parallel() 92 | want := "/2010-04-01/Accounts/AC345/Calls/CA123.json" 93 | called := false 94 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 95 | if r.URL.Path != want { 96 | t.Errorf("expected Path to be %s, got %s", want, r.URL.Path) 97 | } 98 | called = true 99 | w.WriteHeader(200) 100 | w.Write([]byte("{}")) 101 | })) 102 | defer s.Close() 103 | c := NewClient("AC123", "456bef", nil) 104 | c.Base = s.URL 105 | c.RequestOnBehalfOf("AC345") 106 | c.Calls.Get(context.Background(), "CA123") 107 | if called != true { 108 | t.Errorf("expected called to be true, got false") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /keys.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const keyPathPart = "Keys" 10 | 11 | type KeyService struct { 12 | client *Client 13 | } 14 | 15 | // A Twilio Key. For more documentation, see 16 | // https://www.twilio.com/docs/api/rest/keys#instance 17 | type Key struct { 18 | DateCreated TwilioTime `json:"date_created"` 19 | DateUpdated TwilioTime `json:"date_updated"` 20 | Sid string `json:"sid"` 21 | FriendlyName string `json:"friendly_name"` 22 | Secret string `json:"secret"` 23 | } 24 | 25 | type KeyPage struct { 26 | Page 27 | Keys []*Key `json:"keys"` 28 | } 29 | 30 | func (c *KeyService) Get(ctx context.Context, sid string) (*Key, error) { 31 | key := new(Key) 32 | err := c.client.GetResource(ctx, keyPathPart, sid, key) 33 | return key, err 34 | } 35 | 36 | func (c *KeyService) GetPage(ctx context.Context, data url.Values) (*KeyPage, error) { 37 | iter := c.GetPageIterator(data) 38 | return iter.Next(ctx) 39 | } 40 | 41 | // Create a new Key. Note the Secret is only returned in response to a Create, 42 | // you can't retrieve it later. 43 | // 44 | // https://www.twilio.com/docs/api/rest/keys#list-post 45 | func (c *KeyService) Create(ctx context.Context, data url.Values) (*Key, error) { 46 | key := new(Key) 47 | err := c.client.CreateResource(ctx, keyPathPart, data, key) 48 | return key, err 49 | } 50 | 51 | // Update the key with the given data. Valid parameters may be found here: 52 | // https://www.twilio.com/docs/api/rest/keys#instance-post 53 | func (a *KeyService) Update(ctx context.Context, sid string, data url.Values) (*Key, error) { 54 | key := new(Key) 55 | err := a.client.UpdateResource(ctx, keyPathPart, sid, data, key) 56 | return key, err 57 | } 58 | 59 | // Delete the Key with the given sid. If the Key has already been 60 | // deleted, or does not exist, Delete returns nil. If another error or a 61 | // timeout occurs, the error is returned. 62 | func (r *KeyService) Delete(ctx context.Context, sid string) error { 63 | return r.client.DeleteResource(ctx, keyPathPart, sid) 64 | } 65 | 66 | // KeyPageIterator lets you retrieve consecutive pages of resources. 67 | type KeyPageIterator struct { 68 | p *PageIterator 69 | } 70 | 71 | // GetPageIterator returns a KeyPageIterator with the given page 72 | // filters. Call iterator.Next() to get the first page of resources (and again 73 | // to retrieve subsequent pages). 74 | func (c *KeyService) GetPageIterator(data url.Values) *KeyPageIterator { 75 | iter := NewPageIterator(c.client, data, keyPathPart) 76 | return &KeyPageIterator{ 77 | p: iter, 78 | } 79 | } 80 | 81 | // Next returns the next page of resources. If there are no more resources, 82 | // NoMoreResults is returned. 83 | func (c *KeyPageIterator) Next(ctx context.Context) (*KeyPage, error) { 84 | kp := new(KeyPage) 85 | err := c.p.Next(ctx, kp) 86 | if err != nil { 87 | return nil, err 88 | } 89 | c.p.SetNextPageURI(kp.NextPageURI) 90 | return kp, nil 91 | } 92 | -------------------------------------------------------------------------------- /media.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "image" 8 | "image/gif" 9 | "image/jpeg" 10 | "image/png" 11 | "io" 12 | "io/ioutil" 13 | "net/http" 14 | "net/http/httputil" 15 | "net/url" 16 | "os" 17 | "strings" 18 | 19 | "golang.org/x/net/context" 20 | ) 21 | 22 | // A MediaService lets you retrieve a message's associated Media. 23 | type MediaService struct { 24 | client *Client 25 | } 26 | 27 | func mediaPathPart(messageSid string) string { 28 | return "Messages/" + messageSid + "/Media" 29 | } 30 | 31 | type MediaPage struct { 32 | Page 33 | MediaList []*Media `json:"media_list"` 34 | } 35 | 36 | type Media struct { 37 | Sid string `json:"sid"` 38 | ContentType string `json:"content_type"` 39 | AccountSid string `json:"account_sid"` 40 | DateCreated TwilioTime `json:"date_created"` 41 | DateUpdated TwilioTime `json:"date_updated"` 42 | ParentSid string `json:"parent_sid"` 43 | URI string `json:"uri"` 44 | } 45 | 46 | func (m *MediaService) GetPage(ctx context.Context, messageSid string, data url.Values) (*MediaPage, error) { 47 | mp := new(MediaPage) 48 | err := m.client.ListResource(ctx, mediaPathPart(messageSid), data, mp) 49 | return mp, err 50 | } 51 | 52 | // Get returns a Media struct representing a Media instance, or an error. 53 | func (m *MediaService) Get(ctx context.Context, messageSid string, sid string) (*Media, error) { 54 | me := new(Media) 55 | err := m.client.GetResource(ctx, mediaPathPart(messageSid), sid, me) 56 | return me, err 57 | } 58 | 59 | // GetURL returns a URL that can be retrieved to download the given image. 60 | func (m *MediaService) GetURL(ctx context.Context, messageSid string, sid string) (*url.URL, error) { 61 | uriEnd := strings.Join([]string{mediaPathPart(messageSid), sid}, "/") 62 | path := m.client.FullPath(uriEnd) 63 | // We want the media, not the .json representation 64 | if strings.HasSuffix(path, ".json") { 65 | path = path[:len(path)-len(".json")] 66 | } 67 | urlStr := m.client.Client.Base + path 68 | count := 0 69 | for { 70 | req, err := http.NewRequest("GET", urlStr, nil) 71 | if err != nil { 72 | return nil, err 73 | } 74 | req = withContext(req, ctx) 75 | req.SetBasicAuth(m.client.AccountSid, m.client.AuthToken) 76 | req.Header.Set("User-Agent", userAgent) 77 | if os.Getenv("DEBUG_HTTP_TRAFFIC") == "true" || os.Getenv("DEBUG_HTTP_REQUEST") == "true" { 78 | b := new(bytes.Buffer) 79 | bits, _ := httputil.DumpRequestOut(req, true) 80 | if len(bits) > 0 && bits[len(bits)-1] != '\n' { 81 | bits = append(bits, '\n') 82 | } 83 | b.Write(bits) 84 | io.Copy(os.Stderr, b) 85 | } 86 | resp, err := MediaClient.Do(req) 87 | if err != nil { 88 | return nil, err 89 | } 90 | if os.Getenv("DEBUG_HTTP_TRAFFIC") == "true" || os.Getenv("DEBUG_HTTP_RESPONSES") == "true" { 91 | b := new(bytes.Buffer) 92 | bits, _ := httputil.DumpResponse(resp, true) 93 | if len(bits) > 0 && bits[len(bits)-1] != '\n' { 94 | bits = append(bits, '\n') 95 | } 96 | b.Write(bits) 97 | io.Copy(os.Stderr, b) 98 | } else { 99 | io.Copy(ioutil.Discard, resp.Body) 100 | } 101 | 102 | resp.Body.Close() 103 | // This is brittle because we need to detect/rewrite the S3 URL. 104 | // I don't want to hard code a S3 URL but we have to do some 105 | // substitution. 106 | location := resp.Header.Get("Location") 107 | if location == "" { 108 | return nil, errors.New("twilio: Couldn't follow redirect") 109 | } 110 | u, err := url.Parse(location) 111 | if err != nil { 112 | return nil, err 113 | } 114 | if strings.Contains(u.Host, "amazonaws") && strings.Count(u.Host, ".") == 2 && u.Scheme == "https" { 115 | return u, nil 116 | } 117 | if strings.Contains(u.Host, "media.twiliocdn.com.") && strings.Contains(u.Host, "amazonaws") { 118 | // This is the URL we can use to download the content. The URL that 119 | // Twilio gives us back is insecure and uses HTTP. Rewrite it to 120 | // use the HTTPS path-based URL scheme. 121 | // 122 | // https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html 123 | if u.Scheme == "http" { 124 | u.Host = strings.Replace(u.Host, "media.twiliocdn.com.", "", 1) 125 | u.Path = "/media.twiliocdn.com" + u.Path 126 | u.Scheme = "https" 127 | } 128 | return u, nil 129 | } 130 | count++ 131 | if count > 5 { 132 | return nil, errors.New("twilio: too many redirects") 133 | } 134 | urlStr = location 135 | } 136 | } 137 | 138 | // GetImage downloads a Media object and returns an image.Image. The 139 | // documentation isn't great on what happens - as of October 2016, we make a 140 | // request to the Twilio API, then to media.twiliocdn.com, then to a S3 URL. We 141 | // then download that image and decode it based on the provided content-type. 142 | func (m *MediaService) GetImage(ctx context.Context, messageSid string, sid string) (image.Image, error) { 143 | u, err := m.GetURL(ctx, messageSid, sid) 144 | if err != nil { 145 | return nil, err 146 | } 147 | if u.Scheme == "http" { 148 | return nil, fmt.Errorf("Attempted to download image over insecure URL: %s", u.String()) 149 | } 150 | req, err := http.NewRequest("GET", u.String(), nil) 151 | if err != nil { 152 | return nil, err 153 | } 154 | req = withContext(req, ctx) 155 | req.Header.Set("User-Agent", userAgent) 156 | resp, err := MediaClient.Do(req) 157 | if err != nil { 158 | return nil, err 159 | } 160 | defer resp.Body.Close() 161 | // https://www.twilio.com/docs/api/rest/accepted-mime-types#supported 162 | ctype := resp.Header.Get("Content-Type") 163 | switch ctype { 164 | case "image/jpeg": 165 | return jpeg.Decode(resp.Body) 166 | case "image/gif": 167 | return gif.Decode(resp.Body) 168 | case "image/png": 169 | return png.Decode(resp.Body) 170 | default: 171 | io.Copy(ioutil.Discard, resp.Body) 172 | return nil, fmt.Errorf("twilio: Unknown content-type %s", ctype) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /media_go17.go: -------------------------------------------------------------------------------- 1 | // +build go1.7 2 | 3 | package twilio 4 | 5 | import "net/http" 6 | 7 | // MediaClient is used for fetching images and does not follow redirects. 8 | var MediaClient = http.Client{ 9 | Timeout: defaultTimeout, 10 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 11 | return http.ErrUseLastResponse 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /media_goprevious.go: -------------------------------------------------------------------------------- 1 | // +build !go1.7 2 | 3 | package twilio 4 | 5 | import ( 6 | "errors" 7 | "net/http" 8 | ) 9 | 10 | // MediaClient is used for fetching images and does not follow redirects. 11 | var MediaClient = http.Client{ 12 | Timeout: defaultTimeout, 13 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 14 | // TODO not sure if this works. 15 | return errors.New("use last response") 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /media_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | func TestGetURL(t *testing.T) { 14 | if testing.Short() { 15 | t.Skip("skipping HTTP request in short mode") 16 | } 17 | t.Parallel() 18 | sid := os.Getenv("TWILIO_ACCOUNT_SID") 19 | c := NewClient(sid, os.Getenv("TWILIO_AUTH_TOKEN"), nil) 20 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 21 | defer cancel() 22 | // These are tied to Kevin's account, sorry I don't have a better way to do 23 | // this. 24 | u, err := c.Media.GetURL(ctx, "MM89a8c4a6891c53054e9cd604922bfb61", "ME4f366233682e811f63f73220bc07fc34") 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | if u == nil { 29 | t.Fatal(errors.New("got nil url")) 30 | } 31 | str := u.String() 32 | if !strings.HasPrefix(str, "https://s3-external-1.amazonaws.com/media.twiliocdn.com/"+sid) { 33 | t.Errorf("wrong url: %s", str) 34 | } 35 | } 36 | 37 | func TestGetImage(t *testing.T) { 38 | if testing.Short() { 39 | t.Skip("skipping HTTP request in short mode") 40 | } 41 | t.Parallel() 42 | sid := os.Getenv("TWILIO_ACCOUNT_SID") 43 | c := NewClient(sid, os.Getenv("TWILIO_AUTH_TOKEN"), nil) 44 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 45 | defer cancel() 46 | // These are tied to Kevin's account, sorry I don't have a better way to do 47 | // this. 48 | i, err := c.Media.GetImage(ctx, "MM89a8c4a6891c53054e9cd604922bfb61", "ME4f366233682e811f63f73220bc07fc34") 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | bounds := i.Bounds() 53 | if bounds.Max.X < 50 || bounds.Max.Y < 50 { 54 | t.Errorf("Invalid picture bounds: %v", bounds) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /messages.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sync" 7 | "time" 8 | 9 | types "github.com/kevinburke/go-types" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | const messagesPathPart = "Messages" 14 | 15 | type MessageService struct { 16 | client *Client 17 | } 18 | 19 | // The direction of the message. 20 | type Direction string 21 | 22 | // Friendly prints out a friendly version of the Direction, following the 23 | // example shown in the Twilio Dashboard. 24 | func (d Direction) Friendly() string { 25 | switch d { 26 | case DirectionOutboundReply: 27 | return "Reply" 28 | case DirectionOutboundCall: 29 | return "Outgoing (from call)" 30 | case DirectionOutboundAPI: 31 | return "Outgoing (from API)" 32 | case DirectionInbound: 33 | return "Incoming" 34 | case DirectionOutboundDial: 35 | return "Outgoing (via Dial)" 36 | case DirectionTrunkingTerminating: 37 | return "Trunking (terminating)" 38 | case DirectionTrunkingOriginating: 39 | return "Trunking (originating)" 40 | default: 41 | return string(d) 42 | } 43 | } 44 | 45 | const DirectionOutboundReply = Direction("outbound-reply") 46 | const DirectionInbound = Direction("inbound") 47 | const DirectionOutboundCall = Direction("outbound-call") 48 | const DirectionOutboundAPI = Direction("outbound-api") 49 | const DirectionOutboundDial = Direction("outbound-dial") 50 | const DirectionTrunkingTerminating = Direction("trunking-terminating") 51 | const DirectionTrunkingOriginating = Direction("trunking-originating") 52 | 53 | type Message struct { 54 | Sid string `json:"sid"` 55 | Body string `json:"body"` 56 | From PhoneNumber `json:"from"` 57 | To PhoneNumber `json:"to"` 58 | Price string `json:"price"` 59 | Status Status `json:"status"` 60 | AccountSid string `json:"account_sid"` 61 | MessagingServiceSid types.NullString `json:"messaging_service_sid"` 62 | DateCreated TwilioTime `json:"date_created"` 63 | DateUpdated TwilioTime `json:"date_updated"` 64 | DateSent TwilioTime `json:"date_sent"` 65 | NumSegments Segments `json:"num_segments"` 66 | NumMedia NumMedia `json:"num_media"` 67 | PriceUnit string `json:"price_unit"` 68 | Direction Direction `json:"direction"` 69 | SubresourceURIs map[string]string `json:"subresource_uris"` 70 | URI string `json:"uri"` 71 | APIVersion string `json:"api_version"` 72 | ErrorCode Code `json:"error_code"` 73 | ErrorMessage string `json:"error_message"` 74 | } 75 | 76 | // FriendlyPrice flips the sign of the Price (which is usually reported from 77 | // the API as a negative number) and adds an appropriate currency symbol in 78 | // front of it. For example, a PriceUnit of "USD" and a Price of "-1.25" is 79 | // reported as "$1.25". 80 | func (m *Message) FriendlyPrice() string { 81 | return price(m.PriceUnit, m.Price) 82 | } 83 | 84 | // A MessagePage contains a Page of messages. 85 | type MessagePage struct { 86 | Page 87 | Messages []*Message `json:"messages"` 88 | } 89 | 90 | // Create a message with the given url.Values. For more information on valid 91 | // values, see https://www.twilio.com/docs/api/rest/sending-messages or use the 92 | // SendMessage helper. 93 | func (m *MessageService) Create(ctx context.Context, data url.Values) (*Message, error) { 94 | msg := new(Message) 95 | err := m.client.CreateResource(ctx, messagesPathPart, data, msg) 96 | return msg, err 97 | } 98 | 99 | // SendMessage is a convenience wrapper around Create. 100 | func (m *MessageService) SendMessage(from string, to string, body string, mediaURLs []*url.URL) (*Message, error) { 101 | v := url.Values{ 102 | "Body": []string{body}, 103 | "From": []string{from}, 104 | "To": []string{to}, 105 | } 106 | if mediaURLs != nil { 107 | for _, mediaURL := range mediaURLs { 108 | v.Add("MediaUrl", mediaURL.String()) 109 | } 110 | } 111 | return m.Create(context.Background(), v) 112 | } 113 | 114 | // MessagePageIterator lets you retrieve consecutive pages of resources. 115 | type MessagePageIterator interface { 116 | // Next returns the next page of resources. If there are no more resources, 117 | // NoMoreResults is returned. 118 | Next(context.Context) (*MessagePage, error) 119 | } 120 | 121 | type messagePageIterator struct { 122 | p *PageIterator 123 | } 124 | 125 | // Next returns the next page of resources. If there are no more resources, 126 | // NoMoreResults is returned. 127 | func (m *messagePageIterator) Next(ctx context.Context) (*MessagePage, error) { 128 | mp := new(MessagePage) 129 | err := m.p.Next(ctx, mp) 130 | if err != nil { 131 | return nil, err 132 | } 133 | m.p.SetNextPageURI(mp.NextPageURI) 134 | return mp, nil 135 | } 136 | 137 | // GetPageIterator returns an iterator which can be used to retrieve pages. 138 | func (m *MessageService) GetPageIterator(data url.Values) MessagePageIterator { 139 | iter := NewPageIterator(m.client, data, messagesPathPart) 140 | return &messagePageIterator{ 141 | p: iter, 142 | } 143 | } 144 | 145 | func (m *MessageService) Get(ctx context.Context, sid string) (*Message, error) { 146 | msg := new(Message) 147 | err := m.client.GetResource(ctx, messagesPathPart, sid, msg) 148 | return msg, err 149 | } 150 | 151 | // GetPage returns a single page of resources. To retrieve multiple pages, use 152 | // GetPageIterator. 153 | func (m *MessageService) GetPage(ctx context.Context, data url.Values) (*MessagePage, error) { 154 | iter := m.GetPageIterator(data) 155 | return iter.Next(ctx) 156 | } 157 | 158 | // GetMessagesInRange gets an Iterator containing calls in the range [start, 159 | // end), optionally further filtered by data. GetMessagesInRange panics if 160 | // start is not before end. Any date filters provided in data will be ignored. 161 | // If you have an end, but don't want to specify a start, use twilio.Epoch for 162 | // start. If you have a start, but don't want to specify an end, use 163 | // twilio.HeatDeath for end. 164 | // 165 | // Assumes that Twilio returns resources in chronological order, latest 166 | // first. If this assumption is incorrect, your results will not be correct. 167 | // 168 | // Returned MessagePages will have at most PageSize results, but may have 169 | // fewer, based on filtering. 170 | func (c *MessageService) GetMessagesInRange(start time.Time, end time.Time, data url.Values) MessagePageIterator { 171 | if start.After(end) { 172 | panic("start date is after end date") 173 | } 174 | d := url.Values{} 175 | if data != nil { 176 | for k, v := range data { 177 | d[k] = v 178 | } 179 | } 180 | d.Del("DateSent") 181 | d.Del("Page") // just in case 182 | // Omit these parameters if they are the sentinel values, since I think 183 | // that API paging will be faster. 184 | if start != Epoch { 185 | startFormat := start.UTC().Format(APISearchLayout) 186 | d.Set("DateSent>", startFormat) 187 | } 188 | if end != HeatDeath { 189 | // If you specify "DateSent<=YYYY-MM-DD", the *latest* result returned 190 | // will be midnight (the earliest possible second) on DD. We want all of 191 | // the results for DD so we need to specify DD+1 in the API. 192 | // 193 | // TODO validate midnight-instant math more closely, since I don't think 194 | // Twilio returns the correct results for that instant. 195 | endFormat := end.UTC().Add(24 * time.Hour).Format(APISearchLayout) 196 | d.Set("DateSent<", endFormat) 197 | } 198 | iter := NewPageIterator(c.client, d, messagesPathPart) 199 | return &messageDateIterator{ 200 | start: start, 201 | end: end, 202 | p: iter, 203 | } 204 | } 205 | 206 | // GetNextMessagesInRange retrieves the page at the nextPageURI and continues 207 | // retrieving pages until any results are found in the range given by start or 208 | // end, or we determine there are no more records to be found in that range. 209 | // 210 | // If MessagePage is non-nil, it will have at least one result. 211 | func (c *MessageService) GetNextMessagesInRange(start time.Time, end time.Time, nextPageURI string) MessagePageIterator { 212 | if nextPageURI == "" { 213 | panic("nextpageuri is empty") 214 | } 215 | iter := NewNextPageIterator(c.client, messagesPathPart) 216 | iter.SetNextPageURI(types.NullString{Valid: true, String: nextPageURI}) 217 | return &messageDateIterator{ 218 | start: start, 219 | end: end, 220 | p: iter, 221 | } 222 | } 223 | 224 | type messageDateIterator struct { 225 | p *PageIterator 226 | start time.Time 227 | end time.Time 228 | } 229 | 230 | // Next returns the next page of resources. We may need to fetch multiple 231 | // pages from the Twilio API before we find one in the right date range, so 232 | // latency may be higher than usual. 233 | func (c *messageDateIterator) Next(ctx context.Context) (*MessagePage, error) { 234 | var page *MessagePage 235 | for { 236 | // just wipe it clean every time to avoid remnants hanging around 237 | page = new(MessagePage) 238 | if err := c.p.Next(ctx, page); err != nil { 239 | return nil, err 240 | } 241 | if len(page.Messages) == 0 { 242 | return nil, NoMoreResults 243 | } 244 | times := make([]time.Time, len(page.Messages), len(page.Messages)) 245 | for i, message := range page.Messages { 246 | 247 | if !message.DateCreated.Valid { 248 | // we really should not ever hit this case but if we can't parse 249 | // a date, better to give you back an error than to give you back 250 | // a list of messages that may or may not be in the time range 251 | return nil, fmt.Errorf("Couldn't verify the date of message: %#v", message) 252 | } 253 | times[i] = message.DateCreated.Time 254 | } 255 | if containsResultsInRange(c.start, c.end, times) { 256 | indexesToDelete := indexesOutsideRange(c.start, c.end, times) 257 | // reverse order so we don't delete the wrong index 258 | for i := len(indexesToDelete) - 1; i >= 0; i-- { 259 | index := indexesToDelete[i] 260 | page.Messages = append(page.Messages[:index], page.Messages[index+1:]...) 261 | } 262 | c.p.SetNextPageURI(page.NextPageURI) 263 | return page, nil 264 | } 265 | if shouldContinuePaging(c.start, times) { 266 | c.p.SetNextPageURI(page.NextPageURI) 267 | continue 268 | } else { 269 | // should not continue paging and no results in range, stop 270 | return nil, NoMoreResults 271 | } 272 | } 273 | } 274 | 275 | // GetMediaURLs gets the URLs of any media for this message. This uses threads 276 | // to retrieve all URLs simultaneously; if retrieving any URL fails, we return 277 | // an error for the entire request. 278 | // 279 | // The data can be used to filter the list of returned Media as described here: 280 | // https://www.twilio.com/docs/api/rest/media#list-get-filters 281 | // 282 | // As of October 2016, only 10 MediaURLs are permitted per message. No attempt 283 | // is made to page through media resources; omit the PageSize parameter in 284 | // data, or set it to a value greater than 10, to retrieve all resources. 285 | func (m *MessageService) GetMediaURLs(ctx context.Context, sid string, data url.Values) ([]*url.URL, error) { 286 | page, err := m.client.Media.GetPage(ctx, sid, data) 287 | if err != nil { 288 | return nil, err 289 | } 290 | if len(page.MediaList) == 0 { 291 | urls := make([]*url.URL, 0, 0) 292 | return urls, nil 293 | } 294 | urls := make([]*url.URL, len(page.MediaList)) 295 | errs := make([]error, len(page.MediaList)) 296 | var wg sync.WaitGroup 297 | wg.Add(len(page.MediaList)) 298 | for i, media := range page.MediaList { 299 | go func(i int, media *Media) { 300 | url, err := m.client.Media.GetURL(ctx, sid, media.Sid) 301 | urls[i] = url 302 | errs[i] = err 303 | wg.Done() 304 | }(i, media) 305 | } 306 | wg.Wait() 307 | // todo - we could probably return more quickly in the result of a failure. 308 | for _, err := range errs { 309 | if err != nil { 310 | return nil, err 311 | } 312 | } 313 | return urls, nil 314 | } 315 | -------------------------------------------------------------------------------- /messages_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "golang.org/x/net/context" 13 | ) 14 | 15 | func TestGet(t *testing.T) { 16 | if testing.Short() { 17 | t.Skip("skipping HTTP request in short mode") 18 | } 19 | t.Parallel() 20 | sid := "SM26b3b00f8def53be77c5697183bfe95e" 21 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 22 | defer cancel() 23 | msg, err := envClient.Messages.Get(ctx, sid) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | if msg.Sid != sid { 28 | t.Errorf("expected Sid to equal %s, got %s", sid, msg.Sid) 29 | } 30 | } 31 | 32 | func TestGetPage(t *testing.T) { 33 | if testing.Short() { 34 | t.Skip("skipping HTTP request in short mode") 35 | } 36 | t.Parallel() 37 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 38 | defer cancel() 39 | page, err := envClient.Messages.GetPage(ctx, url.Values{"PageSize": []string{"5"}}) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | if len(page.Messages) != 5 { 44 | t.Fatalf("expected len(messages) to be 5, got %d", len(page.Messages)) 45 | } 46 | } 47 | 48 | func TestSendMessage(t *testing.T) { 49 | t.Parallel() 50 | client, s := getServer(sendMessageResponse) 51 | defer s.Close() 52 | msg, err := client.Messages.SendMessage(from, to, "twilio-go testing!", nil) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | if msg.From != from { 57 | t.Errorf("expected From to be from, got error") 58 | } 59 | if msg.Body != "twilio-go testing!" { 60 | t.Errorf("expected Body to be twilio-go testing, got %s", msg.Body) 61 | } 62 | if msg.NumSegments != 1 { 63 | t.Errorf("expected NumSegments to be 1, got %d", msg.NumSegments) 64 | } 65 | if msg.Status != StatusQueued { 66 | t.Errorf("expected Status to be StatusQueued, got %s", msg.Status) 67 | } 68 | } 69 | 70 | func TestGetMessage(t *testing.T) { 71 | if testing.Short() { 72 | t.Skip("skipping HTTP request in short mode") 73 | } 74 | t.Parallel() 75 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 76 | defer cancel() 77 | msg, err := envClient.Messages.Get(ctx, "SM5a52bc49b2354703bfdea7e92b44b385") 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | if msg.ErrorCode != CodeUnknownDestination { 82 | t.Errorf("expected Code to be %d, got %d", CodeUnknownDestination, msg.ErrorCode) 83 | } 84 | if msg.ErrorMessage == "" { 85 | t.Errorf(`expected ErrorMessage to be non-empty, got ""`) 86 | } 87 | } 88 | 89 | func TestIterateAll(t *testing.T) { 90 | if testing.Short() { 91 | t.Skip("skipping HTTP request in short mode") 92 | } 93 | t.Parallel() 94 | iter := envClient.Messages.GetPageIterator(url.Values{"PageSize": []string{"500"}}) 95 | count := 0 96 | start := uint(0) 97 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 98 | defer cancel() 99 | for { 100 | page, err := iter.Next(ctx) 101 | if err == NoMoreResults { 102 | break 103 | } 104 | if count > 0 && (page.Start <= start || page.Start-start > 500) { 105 | t.Fatalf("expected page.Start to be greater than previous, got %d, previous %d", page.Start, start) 106 | return 107 | } else { 108 | start = page.Start 109 | } 110 | if err != nil { 111 | t.Fatal(err) 112 | break 113 | } 114 | count++ 115 | if count > 15 { 116 | fmt.Println("count > 15") 117 | t.Fail() 118 | break 119 | } 120 | } 121 | if count < 10 { 122 | t.Errorf("Too small of a count - expected at least 10, got %d", count) 123 | } 124 | } 125 | 126 | func TestGetMediaURLs(t *testing.T) { 127 | if testing.Short() { 128 | t.Skip("skipping HTTP request in short mode") 129 | } 130 | t.Parallel() 131 | sid := os.Getenv("TWILIO_ACCOUNT_SID") 132 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 133 | defer cancel() 134 | urls, err := envClient.Messages.GetMediaURLs(ctx, "MM89a8c4a6891c53054e9cd604922bfb61", nil) 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | if len(urls) != 1 { 139 | t.Errorf("Wrong number of URLs returned: %d", len(urls)) 140 | } 141 | if !strings.HasPrefix(urls[0].String(), "https://s3-external-1.amazonaws.com/media.twiliocdn.com/"+sid) { 142 | t.Errorf("wrong url: %s", urls[0].String()) 143 | } 144 | } 145 | 146 | func TestDecode(t *testing.T) { 147 | t.Parallel() 148 | msg := new(Message) 149 | if err := json.Unmarshal(getMessageResponse, &msg); err != nil { 150 | t.Fatal(err) 151 | } 152 | if msg.Sid != "SM26b3b00f8def53be77c5697183bfe95e" { 153 | t.Errorf("wrong sid") 154 | } 155 | got := msg.DateCreated.Time.Format(time.RFC3339) 156 | want := "2016-09-20T22:59:57Z" 157 | if got != want { 158 | t.Errorf("msg.DateCreated: got %s, want %s", got, want) 159 | } 160 | if msg.Direction != DirectionOutboundReply { 161 | t.Errorf("wrong direction") 162 | } 163 | if msg.Status != StatusDelivered { 164 | t.Errorf("wrong status") 165 | } 166 | if msg.Body != "Welcome to ZomboCom." { 167 | t.Errorf("wrong body") 168 | } 169 | if msg.From != PhoneNumber("+19253920364") { 170 | t.Errorf("wrong from") 171 | } 172 | if msg.FriendlyPrice() != "$0.0075" { 173 | t.Errorf("wrong friendly price %v, want %v", msg.FriendlyPrice(), "$0.00750") 174 | } 175 | } 176 | 177 | func TestStatusFriendly(t *testing.T) { 178 | t.Parallel() 179 | if StatusQueued.Friendly() != "Queued" { 180 | t.Errorf("expected StatusQueued.Friendly to equal Queued, got %s", StatusQueued.Friendly()) 181 | } 182 | s := Status("in-progress") 183 | if f := s.Friendly(); f != "In Progress" { 184 | t.Errorf("expected In Progress.Friendly to equal In Progress, got %s", f) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /outgoingcallerids.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const callerIDPathPart = "OutgoingCallerIds" 10 | 11 | type OutgoingCallerIDService struct { 12 | client *Client 13 | } 14 | 15 | type OutgoingCallerID struct { 16 | Sid string `json:"sid"` 17 | FriendlyName string `json:"friendly_name"` 18 | PhoneNumber PhoneNumber `json:"phone_number"` 19 | AccountSid string `json:"account_sid"` 20 | DateCreated TwilioTime `json:"date_created"` 21 | DateUpdated TwilioTime `json:"date_updated"` 22 | URI string `json:"uri"` 23 | } 24 | 25 | type CallerIDRequest struct { 26 | AccountSid string `json:"account_sid"` 27 | PhoneNumber PhoneNumber `json:"phone_number"` 28 | FriendlyName string `json:"friendly_name"` 29 | // Usually six digits, but a string to avoid stripping leading 0's 30 | ValidationCode string `json:"validation_code"` 31 | CallSid string `json:"call_sid"` 32 | } 33 | 34 | type OutgoingCallerIDPage struct { 35 | Page 36 | OutgoingCallerIDs []*OutgoingCallerID `json:"outgoing_caller_ids"` 37 | } 38 | 39 | // Create a new OutgoingCallerID. Note the ValidationCode is only returned in 40 | // response to a Create, you can't retrieve it later. 41 | // 42 | // https://www.twilio.com/docs/api/rest/outgoing-caller-ids#list-post 43 | func (c *OutgoingCallerIDService) Create(ctx context.Context, data url.Values) (*CallerIDRequest, error) { 44 | id := new(CallerIDRequest) 45 | err := c.client.CreateResource(ctx, callerIDPathPart, data, id) 46 | return id, err 47 | } 48 | 49 | func (o *OutgoingCallerIDService) Get(ctx context.Context, sid string) (*OutgoingCallerID, error) { 50 | id := new(OutgoingCallerID) 51 | err := o.client.GetResource(ctx, callerIDPathPart, sid, id) 52 | return id, err 53 | } 54 | 55 | func (o *OutgoingCallerIDService) GetPage(ctx context.Context, data url.Values) (*OutgoingCallerIDPage, error) { 56 | op := new(OutgoingCallerIDPage) 57 | err := o.client.ListResource(ctx, callerIDPathPart, data, op) 58 | return op, err 59 | } 60 | 61 | // Update the caller ID with the given data. Valid parameters may be found here: 62 | // https://www.twilio.com/docs/api/rest/outgoing-caller-ids#list 63 | func (o *OutgoingCallerIDService) Update(ctx context.Context, sid string, data url.Values) (*OutgoingCallerID, error) { 64 | id := new(OutgoingCallerID) 65 | err := o.client.UpdateResource(ctx, callerIDPathPart, sid, data, id) 66 | return id, err 67 | } 68 | 69 | // Delete the Caller ID with the given sid. If the ID has already been deleted, 70 | // or does not exist, Delete returns nil. If another error or a timeout occurs, 71 | // the error is returned. 72 | func (o *OutgoingCallerIDService) Delete(ctx context.Context, sid string) error { 73 | return o.client.DeleteResource(ctx, callerIDPathPart, sid) 74 | } 75 | 76 | // OutgoingCallerIDPageIterator lets you retrieve consecutive pages of resources. 77 | type OutgoingCallerIDPageIterator struct { 78 | p *PageIterator 79 | } 80 | 81 | // GetPageIterator returns a OutgoingCallerIDPageIterator with the given page 82 | // filters. Call iterator.Next() to get the first page of resources (and again 83 | // to retrieve subsequent pages). 84 | func (o *OutgoingCallerIDService) GetPageIterator(data url.Values) *OutgoingCallerIDPageIterator { 85 | iter := NewPageIterator(o.client, data, callerIDPathPart) 86 | return &OutgoingCallerIDPageIterator{ 87 | p: iter, 88 | } 89 | } 90 | 91 | // Next returns the next page of resources. If there are no more resources, 92 | // NoMoreResults is returned. 93 | func (o *OutgoingCallerIDPageIterator) Next(ctx context.Context) (*OutgoingCallerIDPage, error) { 94 | op := new(OutgoingCallerIDPage) 95 | err := o.p.Next(ctx, op) 96 | if err != nil { 97 | return nil, err 98 | } 99 | o.p.SetNextPageURI(op.NextPageURI) 100 | return op, nil 101 | } 102 | -------------------------------------------------------------------------------- /outgoingcallerids_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func TestGetCallerID(t *testing.T) { 11 | t.Parallel() 12 | client, server := getServer(callerIDInstance) 13 | defer server.Close() 14 | id, err := client.OutgoingCallerIDs.Get(context.Background(), "PNca86cf94c7d4f89e0bd45bfa7d9b9e7d") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | if id.PhoneNumber.Friendly() != "+1 925-271-7005" { 19 | t.Errorf("got bad phone number, want %s", id.PhoneNumber.Friendly()) 20 | } 21 | } 22 | 23 | func TestVerifyCallerID(t *testing.T) { 24 | t.Parallel() 25 | client, server := getServer(callerIDVerify) 26 | defer server.Close() 27 | data := url.Values{} 28 | data.Set("PhoneNumber", "+14105551234") 29 | data.Set("FriendlyName", "test friendly name") 30 | id, err := client.OutgoingCallerIDs.Create(context.Background(), data) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | if id.PhoneNumber.Friendly() != "+1 410-555-1234" { 35 | t.Errorf("got bad phone number, want %s", id.PhoneNumber.Friendly()) 36 | } 37 | if len(id.ValidationCode) != 6 { 38 | t.Errorf("expected 6 digit validation code, got %s", id.ValidationCode) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /page.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | "time" 8 | 9 | types "github.com/kevinburke/go-types" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | type Page struct { 14 | FirstPageURI string `json:"first_page_uri"` 15 | Start uint `json:"start"` 16 | End uint `json:"end"` 17 | NumPages uint `json:"num_pages"` 18 | Total uint `json:"total"` 19 | NextPageURI types.NullString `json:"next_page_uri"` 20 | PreviousPageURI types.NullString `json:"previous_page_uri"` 21 | PageSize uint `json:"page_size"` 22 | } 23 | 24 | type Meta struct { 25 | FirstPageURL string `json:"first_page_url"` 26 | NextPageURL types.NullString `json:"next_page_url"` 27 | PreviousPageURL types.NullString `json:"previous_page_url"` 28 | Key string `json:"key"` 29 | Page uint `json:"page"` 30 | PageSize uint `json:"page_size"` 31 | } 32 | 33 | // NoMoreResults is returned if you reach the end of the result set while 34 | // paging through resources. 35 | var NoMoreResults = errors.New("twilio: No more results") 36 | 37 | type PageIterator struct { 38 | client *Client 39 | nextPageURI types.NullString 40 | data url.Values 41 | count uint 42 | pathPart string 43 | } 44 | 45 | func (p *PageIterator) SetNextPageURI(npuri types.NullString) { 46 | if npuri.Valid == false { 47 | p.nextPageURI = npuri 48 | return 49 | } 50 | if strings.HasPrefix(npuri.String, p.client.Base) { 51 | npuri.String = npuri.String[len(p.client.Base):] 52 | } 53 | p.nextPageURI = npuri 54 | } 55 | 56 | // Next asks for the next page of resources and decodes the results into v. 57 | func (p *PageIterator) Next(ctx context.Context, v interface{}) error { 58 | var err error 59 | switch { 60 | case p.nextPageURI.Valid: 61 | err = p.client.GetNextPage(ctx, p.nextPageURI.String, v) 62 | case p.count == 0: 63 | err = p.client.ListResource(ctx, p.pathPart, p.data, v) 64 | default: 65 | return NoMoreResults 66 | } 67 | if err != nil { 68 | return err 69 | } 70 | p.count++ 71 | return nil 72 | } 73 | 74 | // NewPageIterator returns a PageIterator that can be used to iterate through 75 | // values. Call Next() to get the first page of values (and again to get 76 | // subsequent pages). If there are no more results, NoMoreResults is returned. 77 | func NewPageIterator(client *Client, data url.Values, pathPart string) *PageIterator { 78 | return &PageIterator{ 79 | data: data, 80 | client: client, 81 | count: 0, 82 | nextPageURI: types.NullString{}, 83 | pathPart: pathPart, 84 | } 85 | } 86 | 87 | // NewNextPageIterator returns a PageIterator based on the provided 88 | // nextPageURI, and is designed for iterating if you have a nextPageURI and not 89 | // a list of query values. 90 | // 91 | // NewNextPageIterator panics if nextPageURI is empty. 92 | func NewNextPageIterator(client *Client, nextPageURI string) *PageIterator { 93 | if nextPageURI == "" { 94 | panic("nextpageuri is empty") 95 | } 96 | return &PageIterator{ 97 | data: url.Values{}, 98 | client: client, 99 | nextPageURI: types.NullString{Valid: true, String: nextPageURI}, 100 | pathPart: "", 101 | count: 0, 102 | } 103 | } 104 | 105 | // containsResultsInRange returns true if any results are in the range 106 | // [start, end). 107 | func containsResultsInRange(start time.Time, end time.Time, results []time.Time) bool { 108 | for _, result := range results { 109 | if (result.Equal(start) || result.After(start)) && result.Before(end) { 110 | return true 111 | } 112 | } 113 | return false 114 | } 115 | 116 | // shouldContinuePaging returns true if fetching more results (that have 117 | // earlier timestamps than the provided results) could possibly return results 118 | // in the range. shouldContinuePaging assumes results is sorted so the first 119 | // result in the slice has the latest timestamp, and the last result in the 120 | // slice has the earliest timestamp. shouldContinuePaging panics if results is 121 | // empty. 122 | func shouldContinuePaging(start time.Time, results []time.Time) bool { 123 | // the last result in results is the earliest. if the earliest result is 124 | // before the start, fetching more resources may return more results. 125 | if len(results) == 0 { 126 | panic("zero length result set") 127 | } 128 | last := results[len(results)-1] 129 | return last.After(start) 130 | } 131 | 132 | // indexesOutsideRange returns the indexes of times in results that are outside 133 | // of [start, end). indexesOutsideRange panics if start is later than end. 134 | func indexesOutsideRange(start time.Time, end time.Time, results []time.Time) []int { 135 | if start.After(end) { 136 | panic("start date is after end date") 137 | } 138 | indexes := make([]int, 0, len(results)) 139 | for i, result := range results { 140 | if result.Equal(end) || result.After(end) { 141 | indexes = append(indexes, i) 142 | } 143 | if result.Before(start) { 144 | indexes = append(indexes, i) 145 | } 146 | } 147 | return indexes 148 | } 149 | -------------------------------------------------------------------------------- /participants.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | // It's difficult to work on this API since Twilio doesn't return Participants 4 | // after a conference ends. 5 | // 6 | // https://github.com/saintpete/logrole/issues/4 7 | 8 | type ParticipantService struct { 9 | client *Client 10 | } 11 | 12 | type Participant struct { 13 | AccountSid string `json:"account_sid"` 14 | CallSid string `json:"call_sid"` 15 | ConferenceSid string `json:"conference_sid"` 16 | DateCreated TwilioTime `json:"date_created"` 17 | DateUpdated TwilioTime `json:"date_updated"` 18 | EndConferenceOnExit bool `json:"end_conference_on_exit"` 19 | Hold bool `json:"hold"` 20 | Muted bool `json:"muted"` 21 | StartConferenceOnEnter bool `json:"start_conference_on_enter"` 22 | URI string `json:"uri"` 23 | } 24 | 25 | func participantPathPart(conferenceSid string) string { 26 | return "Conferences/" + conferenceSid + "/Participants" 27 | } 28 | -------------------------------------------------------------------------------- /phonenumbers.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | types "github.com/kevinburke/go-types" 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | const numbersPathPart = "IncomingPhoneNumbers" 11 | 12 | type NumberPurchasingService struct { 13 | client *Client 14 | pathPart string 15 | } 16 | 17 | type IncomingNumberService struct { 18 | *NumberPurchasingService 19 | client *Client 20 | Local *NumberPurchasingService 21 | TollFree *NumberPurchasingService 22 | } 23 | 24 | type NumberCapability struct { 25 | MMS bool `json:"mms"` 26 | SMS bool `json:"sms"` 27 | Voice bool `json:"voice"` 28 | } 29 | 30 | type IncomingPhoneNumber struct { 31 | Sid string `json:"sid"` 32 | PhoneNumber PhoneNumber `json:"phone_number"` 33 | FriendlyName string `json:"friendly_name"` 34 | DateCreated TwilioTime `json:"date_created"` 35 | AccountSid string `json:"account_sid"` 36 | AddressRequirements string `json:"address_requirements"` 37 | APIVersion string `json:"api_version"` 38 | Beta bool `json:"beta"` 39 | Capabilities *NumberCapability `json:"capabilities"` 40 | DateUpdated TwilioTime `json:"date_updated"` 41 | EmergencyAddressSid types.NullString `json:"emergency_address_sid"` 42 | EmergencyStatus string `json:"emergency_status"` 43 | SMSApplicationSid string `json:"sms_application_sid"` 44 | SMSFallbackMethod string `json:"sms_fallback_method"` 45 | SMSFallbackURL string `json:"sms_fallback_url"` 46 | SMSMethod string `json:"sms_method"` 47 | SMSURL string `json:"sms_url"` 48 | StatusCallback string `json:"status_callback"` 49 | StatusCallbackMethod string `json:"status_callback_method"` 50 | TrunkSid types.NullString `json:"trunk_sid"` 51 | URI string `json:"uri"` 52 | VoiceApplicationSid string `json:"voice_application_sid"` 53 | VoiceCallerIDLookup bool `json:"voice_caller_id_lookup"` 54 | VoiceFallbackMethod string `json:"voice_fallback_method"` 55 | VoiceFallbackURL string `json:"voice_fallback_url"` 56 | VoiceMethod string `json:"voice_method"` 57 | VoiceURL string `json:"voice_url"` 58 | } 59 | 60 | type IncomingPhoneNumberPage struct { 61 | Page 62 | IncomingPhoneNumbers []*IncomingPhoneNumber `json:"incoming_phone_numbers"` 63 | } 64 | 65 | // Create a phone number (buy a number) with the given values. 66 | // 67 | // https://www.twilio.com/docs/api/rest/incoming-phone-numbers#toll-free-incomingphonenumber-factory-resource 68 | func (n *NumberPurchasingService) Create(ctx context.Context, data url.Values) (*IncomingPhoneNumber, error) { 69 | number := new(IncomingPhoneNumber) 70 | pathPart := numbersPathPart 71 | if n.pathPart != "" { 72 | pathPart += "/" + n.pathPart 73 | } 74 | err := n.client.CreateResource(ctx, pathPart, data, number) 75 | return number, err 76 | } 77 | 78 | // BuyNumber attempts to buy the provided phoneNumber and returns it if 79 | // successful. 80 | func (ipn *IncomingNumberService) BuyNumber(phoneNumber string) (*IncomingPhoneNumber, error) { 81 | data := url.Values{"PhoneNumber": []string{phoneNumber}} 82 | return ipn.NumberPurchasingService.Create(context.Background(), data) 83 | } 84 | 85 | // Get retrieves a single IncomingPhoneNumber. 86 | func (ipn *IncomingNumberService) Get(ctx context.Context, sid string) (*IncomingPhoneNumber, error) { 87 | number := new(IncomingPhoneNumber) 88 | err := ipn.client.GetResource(ctx, numbersPathPart, sid, number) 89 | return number, err 90 | } 91 | 92 | // Release removes an IncomingPhoneNumber from your account. 93 | func (ipn *IncomingNumberService) Release(ctx context.Context, sid string) error { 94 | return ipn.client.DeleteResource(ctx, numbersPathPart, sid) 95 | } 96 | 97 | // GetPage retrieves an IncomingPhoneNumberPage, filtered by the given data. 98 | func (ins *IncomingNumberService) GetPage(ctx context.Context, data url.Values) (*IncomingPhoneNumberPage, error) { 99 | iter := ins.GetPageIterator(data) 100 | return iter.Next(ctx) 101 | } 102 | 103 | type IncomingPhoneNumberPageIterator struct { 104 | p *PageIterator 105 | } 106 | 107 | // GetPageIterator returns an iterator which can be used to retrieve pages. 108 | func (c *IncomingNumberService) GetPageIterator(data url.Values) *IncomingPhoneNumberPageIterator { 109 | iter := NewPageIterator(c.client, data, numbersPathPart) 110 | return &IncomingPhoneNumberPageIterator{ 111 | p: iter, 112 | } 113 | } 114 | 115 | // Next returns the next page of resources. If there are no more resources, 116 | // NoMoreResults is returned. 117 | func (c *IncomingPhoneNumberPageIterator) Next(ctx context.Context) (*IncomingPhoneNumberPage, error) { 118 | cp := new(IncomingPhoneNumberPage) 119 | err := c.p.Next(ctx, cp) 120 | if err != nil { 121 | return nil, err 122 | } 123 | c.p.SetNextPageURI(cp.NextPageURI) 124 | return cp, nil 125 | } 126 | -------------------------------------------------------------------------------- /phonenumbers_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | 8 | "github.com/kevinburke/rest" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | func TestGetNumberPage(t *testing.T) { 13 | if testing.Short() { 14 | t.Skip("skipping HTTP request in short mode") 15 | } 16 | t.Parallel() 17 | data := url.Values{"PageSize": []string{"1000"}} 18 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 19 | defer cancel() 20 | numbers, err := envClient.IncomingNumbers.GetPage(ctx, data) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | if len(numbers.IncomingPhoneNumbers) == 0 { 25 | t.Error("expected to get a list of phone numbers, got back 0") 26 | } 27 | } 28 | 29 | func TestBuyNumber(t *testing.T) { 30 | if testing.Short() { 31 | t.Skip("skipping HTTP request in short mode") 32 | } 33 | t.Parallel() 34 | _, err := envClient.IncomingNumbers.BuyNumber("+1foobar") 35 | if err == nil { 36 | t.Fatal("expected to get an error, got nil") 37 | } 38 | rerr, ok := err.(*rest.Error) 39 | if !ok { 40 | t.Fatal("couldn't cast err to a rest.Error") 41 | } 42 | expected := "+1foobar is not a valid number" 43 | if rerr.Title != expected { 44 | t.Errorf("expected Title to be %s, got %s", expected, rerr.Title) 45 | } 46 | if rerr.StatusCode != 400 { 47 | t.Errorf("expected StatusCode to be 400, got %d", rerr.StatusCode) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /prices_messaging.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const messagingPathPart = "Messaging" 10 | 11 | type MessagingPriceService struct { 12 | Countries *CountryMessagingPriceService 13 | } 14 | 15 | type CountryMessagingPriceService struct { 16 | client *Client 17 | } 18 | 19 | type OutboundSMSPrice struct { 20 | Carrier string `json:"carrier"` 21 | MCC string `json:"mcc"` 22 | MNC string `json:"mnc"` 23 | Prices []InboundPrice `json:"prices"` 24 | } 25 | 26 | type MessagePrice struct { 27 | Country string `json:"country"` 28 | IsoCountry string `json:"iso_country"` 29 | OutboundSMSPrices []OutboundSMSPrice `json:"outbound_sms_prices"` 30 | InboundSmsPrices []InboundPrice `json:"inbound_sms_prices"` 31 | PriceUnit string `json:"price_unit"` 32 | URL string `json:"url"` 33 | } 34 | 35 | // returns the message price by country 36 | func (cmps *CountryMessagingPriceService) Get(ctx context.Context, isoCountry string) (*MessagePrice, error) { 37 | messagePrice := new(MessagePrice) 38 | err := cmps.client.GetResource(ctx, messagingPathPart+"/Countries", isoCountry, messagePrice) 39 | return messagePrice, err 40 | } 41 | 42 | // returns a list of countries where Twilio messaging services are available and the corresponding URL 43 | // for retrieving the country specific messaging prices. 44 | func (cmps *CountryMessagingPriceService) GetPage(ctx context.Context, data url.Values) (*CountriesPricePage, error) { 45 | return cmps.GetPageIterator(data).Next(ctx) 46 | } 47 | 48 | // GetPageIterator returns an iterator which can be used to retrieve pages. 49 | func (cmps *CountryMessagingPriceService) GetPageIterator(data url.Values) *CountryPricePageIterator { 50 | iter := NewPageIterator(cmps.client, data, messagingPathPart+"/Countries") 51 | return &CountryPricePageIterator{ 52 | p: iter, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /prices_messaging_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func TestGetMessagePrice(t *testing.T) { 11 | t.Parallel() 12 | client, server := getServer(messagePriceGB) 13 | defer server.Close() 14 | 15 | isoCountry := "GB" 16 | expectedCountryName := "United Kingdom" 17 | expectedPriceUnit := "USD" 18 | 19 | messagePrice, err := client.Pricing.Messaging.Countries.Get(context.Background(), isoCountry) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if messagePrice == nil { 24 | t.Error("expected message price to be returned") 25 | } 26 | if messagePrice.Country != expectedCountryName { 27 | t.Errorf("Expected message price country to be %s, but got %s\n", expectedCountryName, messagePrice.Country) 28 | } 29 | if messagePrice.IsoCountry != isoCountry { 30 | t.Errorf("Expected message price iso country to be %s, but got %s\n", isoCountry, messagePrice.IsoCountry) 31 | } 32 | if messagePrice.PriceUnit != expectedPriceUnit { 33 | t.Errorf("Expected message price unit to be %s, but got %s\n", expectedPriceUnit, messagePrice.PriceUnit) 34 | } 35 | if messagePrice.InboundSmsPrices == nil { 36 | t.Error("Expected message price to contain InboundSmsPrices") 37 | } 38 | if messagePrice.OutboundSMSPrices == nil { 39 | t.Error("Expected message price to contain OutboundSMSPrices") 40 | } 41 | 42 | inboundPriceMap := make(map[string]bool) 43 | for _, inPrice := range messagePrice.InboundSmsPrices { 44 | inboundPriceMap[inPrice.NumberType] = true 45 | } 46 | // inboundPriceMap => map[local:true mobile:true shortcode:true] 47 | _, ok := inboundPriceMap["local"] 48 | if ok == false { 49 | t.Error("Expected inbound price to contain a price for local calls") 50 | } 51 | 52 | outboundCarrierPriceMap := make(map[string]bool) 53 | for _, outPrice := range messagePrice.OutboundSMSPrices { 54 | if outPrice.MCC == "" { 55 | t.Errorf("Each outbound SMS price should have a MCC, got %+v\n", outPrice) 56 | } 57 | if outPrice.MNC == "" { 58 | t.Errorf("Each outbound SMS price should have a MNC, got %+v\n", outPrice) 59 | } 60 | if outPrice.Carrier == "" { 61 | t.Errorf("Each outbound SMS price should have a Carrier, got %+v\n", outPrice) 62 | } 63 | if outPrice.Prices == nil { 64 | t.Errorf("Each outbound SMS price should have prices, got %+v\n", outPrice) 65 | } 66 | outboundCarrierPriceMap[outPrice.Carrier] = true 67 | } 68 | _, ok = outboundCarrierPriceMap["Other"] 69 | if ok == false { 70 | t.Error("Expected outbound price to contain a price for the carrier Other") 71 | } 72 | } 73 | 74 | func TestGetMessagingPricePage(t *testing.T) { 75 | t.Parallel() 76 | client, server := getServer(messagingCountriesPage) 77 | defer server.Close() 78 | 79 | data := url.Values{"PageSize": []string{"10"}} 80 | page, err := client.Pricing.Messaging.Countries.GetPage(context.Background(), data) 81 | 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | if len(page.Countries) == 0 { 86 | t.Error("expected to get a list of countries, got back 0") 87 | } 88 | if len(page.Countries) != 10 { 89 | t.Errorf("expected 10 countries, got %d", len(page.Countries)) 90 | } 91 | if page.Meta.Key != "countries" { 92 | t.Errorf("expected Key to be 'countries', got %s", page.Meta.Key) 93 | } 94 | if page.Meta.PageSize != 10 { 95 | t.Errorf("expected PageSize to be 10, got %d", page.Meta.PageSize) 96 | } 97 | if page.Meta.Page != 0 { 98 | t.Errorf("expected Page to be 0, got %d", page.Meta.Page) 99 | } 100 | if page.Meta.PreviousPageURL.Valid != false { 101 | t.Errorf("expected previousPage.Valid to be false, got true") 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /prices_number.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const phoneNumbersPathPart = "PhoneNumbers" 10 | 11 | type PhoneNumberPriceService struct { 12 | Countries *CountryPhoneNumberPriceService 13 | } 14 | 15 | type CountryPhoneNumberPriceService struct { 16 | client *Client 17 | } 18 | 19 | type PhoneNumberPrice struct { 20 | BasePrice string `json:"base_price"` 21 | CurrentPrice string `json:"current_price"` 22 | NumberType string `json:"number_type"` 23 | } 24 | 25 | type NumberPrice struct { 26 | Country string `json:"country"` 27 | IsoCountry string `json:"iso_country"` 28 | PhoneNumberPrices []PhoneNumberPrice `json:"phone_number_prices"` 29 | PriceUnit string `json:"price_unit"` 30 | URL string `json:"url"` 31 | } 32 | 33 | type PriceCountry struct { 34 | Country string `json:"country"` 35 | IsoCountry string `json:"iso_country"` 36 | URL string `json:"url"` 37 | } 38 | 39 | type CountriesPricePage struct { 40 | Meta Meta `json:"meta"` 41 | Countries []*PriceCountry `json:"countries"` 42 | } 43 | 44 | // returns the phone number price by country 45 | func (cpnps *CountryPhoneNumberPriceService) Get(ctx context.Context, isoCountry string) (*NumberPrice, error) { 46 | numberPrice := new(NumberPrice) 47 | err := cpnps.client.GetResource(ctx, phoneNumbersPathPart+"/Countries", isoCountry, numberPrice) 48 | return numberPrice, err 49 | } 50 | 51 | // returns a list of countries where Twilio phone numbers are supported 52 | func (cpnps *CountryPhoneNumberPriceService) GetPage(ctx context.Context, data url.Values) (*CountriesPricePage, error) { 53 | return cpnps.GetPageIterator(data).Next(ctx) 54 | } 55 | 56 | type CountryPricePageIterator struct { 57 | p *PageIterator 58 | } 59 | 60 | // GetPageIterator returns an iterator which can be used to retrieve pages. 61 | func (cpnps *CountryPhoneNumberPriceService) GetPageIterator(data url.Values) *CountryPricePageIterator { 62 | iter := NewPageIterator(cpnps.client, data, phoneNumbersPathPart+"/Countries") 63 | return &CountryPricePageIterator{ 64 | p: iter, 65 | } 66 | } 67 | 68 | // Next returns the next page of resources. If there are no more resources, 69 | // NoMoreResults is returned. 70 | func (c *CountryPricePageIterator) Next(ctx context.Context) (*CountriesPricePage, error) { 71 | cp := new(CountriesPricePage) 72 | err := c.p.Next(ctx, cp) 73 | if err != nil { 74 | return nil, err 75 | } 76 | c.p.SetNextPageURI(cp.Meta.NextPageURL) 77 | return cp, nil 78 | } 79 | -------------------------------------------------------------------------------- /prices_number_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func TestGetPhoneNumberPriceGB(t *testing.T) { 11 | t.Parallel() 12 | client, server := getServer(phoneNumberPriceGB) 13 | defer server.Close() 14 | 15 | isoCountry := "GB" 16 | expectedCountryName := "United Kingdom" 17 | expectedPriceUnit := "USD" 18 | 19 | numPrice, err := client.Pricing.PhoneNumbers.Countries.Get(context.Background(), isoCountry) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if numPrice == nil { 24 | t.Error("expected voice price to be returned") 25 | } 26 | if numPrice.Country != expectedCountryName { 27 | t.Errorf("Expected voice price country to be %s, but got %s\n", expectedCountryName, numPrice.Country) 28 | } 29 | if numPrice.IsoCountry != isoCountry { 30 | t.Errorf("Expected voice price iso country to be %s, but got %s\n", isoCountry, numPrice.IsoCountry) 31 | } 32 | if numPrice.PriceUnit != expectedPriceUnit { 33 | t.Errorf("Expected voice price unit to be %s, but got %s\n", expectedPriceUnit, numPrice.PriceUnit) 34 | } 35 | if numPrice.PhoneNumberPrices == nil { 36 | t.Error("Expected voice price to contain PhoneNumberPrices") 37 | } 38 | 39 | numTypePriceMap := make(map[string]bool) 40 | for _, price := range numPrice.PhoneNumberPrices { 41 | numTypePriceMap[price.NumberType] = true 42 | } 43 | // numTypePriceMap => map[mobile:true national:true toll free:true local:true] 44 | _, ok := numTypePriceMap["local"] 45 | if ok == false { 46 | t.Error("Expected number price to contain a price for a local number") 47 | } 48 | } 49 | 50 | func TestGetPhoneNumbersPricePage(t *testing.T) { 51 | t.Parallel() 52 | client, server := getServer(phoneNumberCountriesPage) 53 | defer server.Close() 54 | 55 | data := url.Values{"PageSize": []string{"10"}} 56 | page, err := client.Pricing.PhoneNumbers.Countries.GetPage(context.Background(), data) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | if len(page.Countries) == 0 { 61 | t.Error("expected to get a list of countries, got back 0") 62 | } 63 | if len(page.Countries) != 10 { 64 | t.Errorf("expected 10 countries, got %d", len(page.Countries)) 65 | } 66 | if page.Meta.Key != "countries" { 67 | t.Errorf("expected Key to be 'countries', got %s", page.Meta.Key) 68 | } 69 | if page.Meta.PageSize != 10 { 70 | t.Errorf("expected PageSize to be 10, got %d", page.Meta.PageSize) 71 | } 72 | if page.Meta.Page != 0 { 73 | t.Errorf("expected Page to be 0, got %d", page.Meta.Page) 74 | } 75 | if page.Meta.PreviousPageURL.Valid != false { 76 | t.Errorf("expected previousPage.Valid to be false, got true") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /prices_voice.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const voicePathPart = "Voice" 10 | 11 | type VoicePriceService struct { 12 | Countries *CountryVoicePriceService 13 | Numbers *NumberVoicePriceService 14 | } 15 | 16 | type CountryVoicePriceService struct { 17 | client *Client 18 | } 19 | 20 | type NumberVoicePriceService struct { 21 | client *Client 22 | } 23 | 24 | type PrefixPrice struct { 25 | BasePrice string `json:"base_price"` 26 | CurrentPrice string `json:"current_price"` 27 | FriendlyName string `json:"friendly_name"` 28 | Prefixes []string `json:"prefixes"` 29 | } 30 | 31 | type InboundPrice struct { 32 | BasePrice string `json:"base_price"` 33 | CurrentPrice string `json:"current_price"` 34 | NumberType string `json:"number_type"` 35 | } 36 | 37 | type OutboundCallPrice struct { 38 | BasePrice string `json:"base_price"` 39 | CurrentPrice string `json:"current_price"` 40 | } 41 | 42 | type VoicePrice struct { 43 | Country string `json:"country"` 44 | IsoCountry string `json:"iso_country"` 45 | OutboundPrefixPrices []PrefixPrice `json:"outbound_prefix_prices"` 46 | InboundCallPrices []InboundPrice `json:"inbound_call_prices"` 47 | PriceUnit string `json:"price_unit"` 48 | URL string `json:"url"` 49 | } 50 | 51 | type VoiceNumberPrice struct { 52 | Country string `json:"country"` 53 | IsoCountry string `json:"iso_country"` 54 | Number string `json:"number"` 55 | InboundCallPrice InboundPrice `json:"inbound_call_price"` 56 | OutboundCallPrice OutboundCallPrice `json:"outbound_call_price"` 57 | PriceUnit string `json:"price_unit"` 58 | URL string `json:"url"` 59 | } 60 | 61 | // returns the call price by country 62 | func (cvps *CountryVoicePriceService) Get(ctx context.Context, isoCountry string) (*VoicePrice, error) { 63 | voicePrice := new(VoicePrice) 64 | err := cvps.client.GetResource(ctx, voicePathPart+"/Countries", isoCountry, voicePrice) 65 | return voicePrice, err 66 | } 67 | 68 | // returns the call price by number 69 | func (nvps *NumberVoicePriceService) Get(ctx context.Context, number string) (*VoiceNumberPrice, error) { 70 | voiceNumPrice := new(VoiceNumberPrice) 71 | err := nvps.client.GetResource(ctx, voicePathPart+"/Numbers", number, voiceNumPrice) 72 | return voiceNumPrice, err 73 | } 74 | 75 | // returns a list of countries where Twilio voice services are available and the corresponding URL 76 | // for retrieving the country specific voice prices. 77 | func (cvps *CountryVoicePriceService) GetPage(ctx context.Context, data url.Values) (*CountriesPricePage, error) { 78 | return cvps.GetPageIterator(data).Next(ctx) 79 | } 80 | 81 | // GetPageIterator returns an iterator which can be used to retrieve pages. 82 | func (cvps *CountryVoicePriceService) GetPageIterator(data url.Values) *CountryPricePageIterator { 83 | iter := NewPageIterator(cvps.client, data, voicePathPart+"/Countries") 84 | return &CountryPricePageIterator{ 85 | p: iter, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /prices_voice_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func TestGetVoicePriceUS(t *testing.T) { 11 | t.Parallel() 12 | client, server := getServer(voicePriceUS) 13 | defer server.Close() 14 | 15 | isoCountry := "US" 16 | expectedCountryName := "United States" 17 | expectedPriceUnit := "USD" 18 | 19 | voicePrice, err := client.Pricing.Voice.Countries.Get(context.Background(), isoCountry) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if voicePrice == nil { 24 | t.Error("expected voice price to be returned") 25 | } 26 | if voicePrice.Country != expectedCountryName { 27 | t.Errorf("Expected voice price country to be %s, but got %s\n", expectedCountryName, voicePrice.Country) 28 | } 29 | if voicePrice.IsoCountry != isoCountry { 30 | t.Errorf("Expected voice price iso country to be %s, but got %s\n", isoCountry, voicePrice.IsoCountry) 31 | } 32 | if voicePrice.PriceUnit != expectedPriceUnit { 33 | t.Errorf("Expected voice price unit to be %s, but got %s\n", expectedPriceUnit, voicePrice.PriceUnit) 34 | } 35 | if voicePrice.InboundCallPrices == nil { 36 | t.Error("Expected voice price to contain InboundCallPrices") 37 | } 38 | if voicePrice.OutboundPrefixPrices == nil { 39 | t.Error("Expected voice price to contain OutboundPrefixPrices") 40 | } 41 | 42 | inboundPriceMap := make(map[string]bool) 43 | for _, inPrice := range voicePrice.InboundCallPrices { 44 | inboundPriceMap[inPrice.NumberType] = true 45 | } 46 | // inboundPriceMap => map[local:true toll free:true] 47 | _, ok := inboundPriceMap["local"] 48 | if ok == false { 49 | t.Error("Expected inbound price to contain a price for local calls") 50 | } 51 | 52 | outboundPrefixPriceMap := make(map[string]bool) 53 | for _, outPrice := range voicePrice.OutboundPrefixPrices { 54 | for _, prefix := range outPrice.Prefixes { 55 | outboundPrefixPriceMap[prefix] = true 56 | } 57 | } 58 | // outboundPrefixPriceMap => map[1907:true 1844:true 1866:true 1877:true 1:true 1808:true 1800:true 1855:true 1888:true] 59 | _, ok = outboundPrefixPriceMap["1"] 60 | if ok == false { 61 | t.Error("Expected outbound price to contain a price for the prefix 1") 62 | } 63 | } 64 | 65 | func TestGetVoicePriceGB(t *testing.T) { 66 | t.Parallel() 67 | client, server := getServer(voicePricesGB) 68 | defer server.Close() 69 | 70 | isoCountry := "GB" 71 | expectedCountryName := "United Kingdom" 72 | expectedPriceUnit := "USD" 73 | 74 | voicePrice, err := client.Pricing.Voice.Countries.Get(context.Background(), isoCountry) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | if voicePrice == nil { 79 | t.Error("expected voice price to be returned") 80 | } 81 | if voicePrice.Country != expectedCountryName { 82 | t.Errorf("Expected voice price country to be %s, but got %s\n", expectedCountryName, voicePrice.Country) 83 | } 84 | if voicePrice.IsoCountry != isoCountry { 85 | t.Errorf("Expected voice price iso country to be %s, but got %s\n", isoCountry, voicePrice.IsoCountry) 86 | } 87 | if voicePrice.PriceUnit != expectedPriceUnit { 88 | t.Errorf("Expected voice price unit to be %s, but got %s\n", expectedPriceUnit, voicePrice.PriceUnit) 89 | } 90 | if voicePrice.InboundCallPrices == nil { 91 | t.Error("Expected voice price to contain InboundCallPrices") 92 | } 93 | if voicePrice.OutboundPrefixPrices == nil { 94 | t.Error("Expected voice price to contain OutboundPrefixPrices") 95 | } 96 | 97 | inboundPriceMap := make(map[string]bool) 98 | for _, inPrice := range voicePrice.InboundCallPrices { 99 | inboundPriceMap[inPrice.NumberType] = true 100 | } 101 | _, ok := inboundPriceMap["local"] 102 | if ok == false { 103 | t.Error("Expected inbound price to contain a price for local calls") 104 | } 105 | 106 | outboundPrefixPriceMap := make(map[string]bool) 107 | for _, outPrice := range voicePrice.OutboundPrefixPrices { 108 | for _, prefix := range outPrice.Prefixes { 109 | outboundPrefixPriceMap[prefix] = true 110 | } 111 | } 112 | _, ok = outboundPrefixPriceMap["44"] 113 | if ok == false { 114 | t.Error("Expected outbound price to contain a price for the prefix 44") 115 | } 116 | } 117 | 118 | func TestGetVoicePriceNumber(t *testing.T) { 119 | t.Parallel() 120 | client, server := getServer(voicePriceNumberUS) 121 | defer server.Close() 122 | 123 | expectedIsoCountry := "US" 124 | expectedCountryName := "United States" 125 | expectedPriceUnit := "USD" 126 | 127 | voicePriceNum, err := client.Pricing.Voice.Numbers.Get(context.Background(), from) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | if voicePriceNum == nil { 132 | t.Error("expected voice price to be returned") 133 | } 134 | if voicePriceNum.Number != from { 135 | t.Errorf("Expected voice price number to be %s, but got %s\n", from, voicePriceNum.Number) 136 | } 137 | if voicePriceNum.Country != expectedCountryName { 138 | t.Errorf("Expected voice price country to be %s, but got %s\n", expectedCountryName, voicePriceNum.Country) 139 | } 140 | if voicePriceNum.IsoCountry != expectedIsoCountry { 141 | t.Errorf("Expected voice price iso country to be %s, but got %s\n", expectedIsoCountry, voicePriceNum.IsoCountry) 142 | } 143 | if voicePriceNum.PriceUnit != expectedPriceUnit { 144 | t.Errorf("Expected voice price unit to be %s, but got %s\n", expectedPriceUnit, voicePriceNum.PriceUnit) 145 | } 146 | if voicePriceNum.InboundCallPrice.BasePrice != "" { 147 | t.Error("Expected voice price to not contain an InboundCallPrice") 148 | } 149 | if voicePriceNum.OutboundCallPrice.BasePrice == "" { 150 | t.Error("Expected voice price to contain an OutboundPrefixPrice") 151 | } 152 | } 153 | 154 | func TestGetVoicePricePage(t *testing.T) { 155 | t.Parallel() 156 | client, server := getServer(voiceCountriesPage) 157 | defer server.Close() 158 | 159 | data := url.Values{"PageSize": []string{"10"}} 160 | page, err := client.Pricing.Voice.Countries.GetPage(context.Background(), data) 161 | if err != nil { 162 | t.Fatal(err) 163 | } 164 | if len(page.Countries) == 0 { 165 | t.Error("expected to get a list of countries, got back 0") 166 | } 167 | if len(page.Countries) != 10 { 168 | t.Errorf("expected 10 countries, got %d", len(page.Countries)) 169 | } 170 | if page.Meta.Key != "countries" { 171 | t.Errorf("expected Key to be 'countries', got %s", page.Meta.Key) 172 | } 173 | if page.Meta.PageSize != 10 { 174 | t.Errorf("expected PageSize to be 10, got %d", page.Meta.PageSize) 175 | } 176 | if page.Meta.Page != 0 { 177 | t.Errorf("expected Page to be 0, got %d", page.Meta.Page) 178 | } 179 | if page.Meta.PreviousPageURL.Valid != false { 180 | t.Errorf("expected previousPage.Valid to be false, got true") 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const queuePathPart = "Queues" 10 | 11 | type QueueService struct { 12 | client *Client 13 | } 14 | 15 | type Queue struct { 16 | Sid string `json:"sid"` 17 | AverageWaitTime uint `json:"average_wait_time"` 18 | CurrentSize uint `json:"current_size"` 19 | FriendlyName string `json:"friendly_name"` 20 | MaxSize uint `json:"max_size"` 21 | DateCreated TwilioTime `json:"date_created"` 22 | DateUpdated TwilioTime `json:"date_updated"` 23 | AccountSid string `json:"account_sid"` 24 | URI string `json:"uri"` 25 | } 26 | 27 | type QueuePage struct { 28 | Page 29 | Queues []*Queue 30 | } 31 | 32 | // Get returns a single Queue or an error. 33 | func (c *QueueService) Get(ctx context.Context, sid string) (*Queue, error) { 34 | queue := new(Queue) 35 | err := c.client.GetResource(ctx, queuePathPart, sid, queue) 36 | return queue, err 37 | } 38 | 39 | // Create a new Queue. 40 | func (c *QueueService) Create(ctx context.Context, data url.Values) (*Queue, error) { 41 | queue := new(Queue) 42 | err := c.client.CreateResource(ctx, queuePathPart, data, queue) 43 | return queue, err 44 | } 45 | 46 | // Delete the Queue with the given sid. If the Queue has 47 | // already been deleted, or does not exist, Delete returns nil. If another 48 | // error or a timeout occurs, the error is returned. 49 | func (c *QueueService) Delete(ctx context.Context, sid string) error { 50 | return c.client.DeleteResource(ctx, queuePathPart, sid) 51 | } 52 | 53 | func (c *QueueService) GetPage(ctx context.Context, data url.Values) (*QueuePage, error) { 54 | iter := c.GetPageIterator(data) 55 | return iter.Next(ctx) 56 | } 57 | 58 | type QueuePageIterator struct { 59 | p *PageIterator 60 | } 61 | 62 | // GetPageIterator returns a QueuePageIterator with the given page filters. 63 | // Call iterator.Next() to get the first page of resources (and again to 64 | // retrieve subsequent pages). 65 | func (c *QueueService) GetPageIterator(data url.Values) *QueuePageIterator { 66 | iter := NewPageIterator(c.client, data, queuePathPart) 67 | return &QueuePageIterator{ 68 | p: iter, 69 | } 70 | } 71 | 72 | // Next returns the next page of resources. If there are no more resources, 73 | // NoMoreResults is returned. 74 | func (c *QueuePageIterator) Next(ctx context.Context) (*QueuePage, error) { 75 | qp := new(QueuePage) 76 | err := c.p.Next(ctx, qp) 77 | if err != nil { 78 | return nil, err 79 | } 80 | c.p.SetNextPageURI(qp.NextPageURI) 81 | return qp, nil 82 | } 83 | -------------------------------------------------------------------------------- /recording.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | type RecordingService struct { 11 | client *Client 12 | } 13 | 14 | const recordingsPathPart = "Recordings" 15 | 16 | type Recording struct { 17 | Sid string `json:"sid"` 18 | Duration TwilioDuration `json:"duration"` 19 | CallSid string `json:"call_sid"` 20 | Status Status `json:"status"` 21 | Price string `json:"price"` 22 | PriceUnit string `json:"price_unit"` 23 | DateCreated TwilioTime `json:"date_created"` 24 | AccountSid string `json:"account_sid"` 25 | APIVersion string `json:"api_version"` 26 | Channels uint `json:"channels"` 27 | DateUpdated TwilioTime `json:"date_updated"` 28 | URI string `json:"uri"` 29 | } 30 | 31 | // URL returns the URL that can be used to play this recording, based on the 32 | // extension. No error is returned if you provide an invalid extension. As of 33 | // October 2016, the valid values are ".wav" and ".mp3". 34 | func (r *Recording) URL(extension string) string { 35 | if !strings.HasPrefix(extension, ".") { 36 | extension = "." + extension 37 | } 38 | return strings.Join([]string{BaseURL, r.APIVersion, "Accounts", r.AccountSid, recordingsPathPart, r.Sid + extension}, "/") 39 | } 40 | 41 | // FriendlyPrice flips the sign of the Price (which is usually reported from 42 | // the API as a negative number) and adds an appropriate currency symbol in 43 | // front of it. For example, a PriceUnit of "USD" and a Price of "-1.25" is 44 | // reported as "$1.25". 45 | func (r *Recording) FriendlyPrice() string { 46 | if r == nil { 47 | return "" 48 | } 49 | return price(r.PriceUnit, r.Price) 50 | } 51 | 52 | type RecordingPage struct { 53 | Page 54 | Recordings []*Recording 55 | } 56 | 57 | func (r *RecordingService) Get(ctx context.Context, sid string) (*Recording, error) { 58 | recording := new(Recording) 59 | err := r.client.GetResource(ctx, recordingsPathPart, sid, recording) 60 | return recording, err 61 | } 62 | 63 | // Delete the Recording with the given sid. If the Recording has already been 64 | // deleted, or does not exist, Delete returns nil. If another error or a 65 | // timeout occurs, the error is returned. 66 | func (r *RecordingService) Delete(ctx context.Context, sid string) error { 67 | return r.client.DeleteResource(ctx, recordingsPathPart, sid) 68 | } 69 | 70 | func (r *RecordingService) GetPage(ctx context.Context, data url.Values) (*RecordingPage, error) { 71 | iter := r.GetPageIterator(data) 72 | return iter.Next(ctx) 73 | } 74 | 75 | func (r *RecordingService) GetTranscriptions(ctx context.Context, recordingSid string, data url.Values) (*TranscriptionPage, error) { 76 | if data == nil { 77 | data = url.Values{} 78 | } 79 | tp := new(TranscriptionPage) 80 | err := r.client.ListResource(ctx, recordingsPathPart+"/"+recordingSid+"/Transcriptions", data, tp) 81 | return tp, err 82 | } 83 | 84 | type RecordingPageIterator struct { 85 | p *PageIterator 86 | } 87 | 88 | // GetPageIterator returns an iterator which can be used to retrieve pages. 89 | func (r *RecordingService) GetPageIterator(data url.Values) *RecordingPageIterator { 90 | iter := NewPageIterator(r.client, data, recordingsPathPart) 91 | return &RecordingPageIterator{ 92 | p: iter, 93 | } 94 | } 95 | 96 | // Next returns the next page of resources. If there are no more resources, 97 | // NoMoreResults is returned. 98 | func (r *RecordingPageIterator) Next(ctx context.Context) (*RecordingPage, error) { 99 | rp := new(RecordingPage) 100 | err := r.p.Next(ctx, rp) 101 | if err != nil { 102 | return nil, err 103 | } 104 | r.p.SetNextPageURI(rp.NextPageURI) 105 | return rp, nil 106 | } 107 | -------------------------------------------------------------------------------- /recording_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func TestGetRecording(t *testing.T) { 11 | if testing.Short() { 12 | t.Skip("skipping HTTP request in short mode") 13 | } 14 | t.Parallel() 15 | sid := "REd04242a0544234abba080942e0535505" 16 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 17 | defer cancel() 18 | recording, err := envClient.Recordings.Get(ctx, sid) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | if recording.Sid != sid { 23 | t.Errorf("expected Sid to equal %s, got %s", sid, recording.Sid) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /token/access_token.go: -------------------------------------------------------------------------------- 1 | // Package token generates valid tokens for Twilio Client SDKs. 2 | 3 | // Create them on your server and pass them to a client to help verify a 4 | // client's identity, and to grant access to features in client API's. 5 | // 6 | // For more information, please see the Twilio docs: 7 | // https://www.twilio.com/docs/api/rest/access-tokens 8 | package token 9 | 10 | import ( 11 | "fmt" 12 | "sync" 13 | "time" 14 | 15 | jwt "github.com/dgrijalva/jwt-go" 16 | ) 17 | 18 | const jwtContentType = "twilio-fpa;v=1" 19 | 20 | type myCustomClaims struct { 21 | Grants map[string]interface{} `json:"grants"` 22 | *jwt.StandardClaims 23 | } 24 | 25 | // AccessToken holds properties that can generate a signature to talk to the 26 | // Messaging/Voice/Video API's. 27 | type AccessToken struct { 28 | NotBefore time.Time 29 | accountSid string 30 | apiKey string 31 | apiSecret []byte 32 | identity string 33 | ttl time.Duration 34 | grants map[string]interface{} 35 | mu sync.Mutex 36 | } 37 | 38 | // New generates a new AccessToken with the specified properties. identity is 39 | // a unique ID for a particular user. 40 | // 41 | // To generate an apiKey and apiSecret follow this instructions: 42 | // https://www.twilio.com/docs/api/rest/access-tokens#jwt-format 43 | func New(accountSid, apiKey, apiSecret, identity string, ttl time.Duration) *AccessToken { 44 | return &AccessToken{ 45 | accountSid: accountSid, 46 | apiKey: apiKey, 47 | apiSecret: []byte(apiSecret), 48 | identity: identity, 49 | ttl: ttl, 50 | grants: make(map[string]interface{}), 51 | } 52 | } 53 | 54 | func (t *AccessToken) AddGrant(grant Grant) { 55 | t.mu.Lock() 56 | t.grants["identity"] = t.identity 57 | t.grants[grant.Key()] = grant.ToPayload() 58 | t.mu.Unlock() 59 | } 60 | 61 | func (t *AccessToken) JWT() (string, error) { 62 | now := time.Now().UTC() 63 | 64 | stdClaims := &jwt.StandardClaims{ 65 | Id: fmt.Sprintf("%s-%d", t.apiKey, now.Unix()), 66 | ExpiresAt: now.Add(t.ttl).Unix(), 67 | Issuer: t.apiKey, 68 | IssuedAt: now.Unix(), 69 | Subject: t.accountSid, 70 | } 71 | if !t.NotBefore.IsZero() { 72 | stdClaims.NotBefore = t.NotBefore.UTC().Unix() 73 | } 74 | 75 | t.mu.Lock() 76 | defer t.mu.Unlock() 77 | claims := myCustomClaims{ 78 | Grants: t.grants, 79 | StandardClaims: stdClaims, 80 | } 81 | 82 | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 83 | jwtToken.Header["cty"] = jwtContentType 84 | return jwtToken.SignedString(t.apiSecret) 85 | } 86 | -------------------------------------------------------------------------------- /token/access_token_grant.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | const ( 4 | ipMessagingGrant = "ip_messaging" 5 | conversationsGrant = "rtc" 6 | voiceGrant = "voice" 7 | videoGrant = "video" 8 | keyServiceSid = "service_id" 9 | keyEndpointId = "endpoint_id" 10 | keyDepRoleSide = "deployment_role_sid" 11 | keyPushCredSid = "push_credential_sid" 12 | keyVoiceOutgoing = "outgoing" 13 | keyConfProfSid = "configuration_profile_sid" 14 | keyAppSid = "application_sid" 15 | keyVoiceParams = "params" 16 | ) 17 | 18 | // Grant is a Twilio SID resource that can be added to an AccessToken for extra 19 | // services. Implement this interface to create a custom Grant. 20 | type Grant interface { 21 | ToPayload() map[string]interface{} 22 | Key() string 23 | } 24 | 25 | // IPMessageGrant is a grant for accessing Twilio IP Messaging 26 | type IPMessageGrant struct { 27 | serviceSid string 28 | endpointID string 29 | deploymentRoleSid string 30 | pushCredentialSid string 31 | } 32 | 33 | func NewIPMessageGrant(serviceSid, endpointID, deploymentRoleSid, pushCredentialSid string) *IPMessageGrant { 34 | return &IPMessageGrant{ 35 | serviceSid: serviceSid, 36 | endpointID: endpointID, 37 | deploymentRoleSid: deploymentRoleSid, 38 | pushCredentialSid: pushCredentialSid, 39 | } 40 | } 41 | 42 | func (gr *IPMessageGrant) ToPayload() map[string]interface{} { 43 | grant := make(map[string]interface{}) 44 | if len(gr.serviceSid) > 0 { 45 | grant[keyServiceSid] = gr.serviceSid 46 | } 47 | if len(gr.endpointID) > 0 { 48 | grant[keyEndpointId] = gr.endpointID 49 | } 50 | if len(gr.deploymentRoleSid) > 0 { 51 | grant[keyDepRoleSide] = gr.deploymentRoleSid 52 | } 53 | if len(gr.pushCredentialSid) > 0 { 54 | grant[keyPushCredSid] = gr.pushCredentialSid 55 | } 56 | return grant 57 | } 58 | 59 | func (gr *IPMessageGrant) Key() string { 60 | return ipMessagingGrant 61 | } 62 | 63 | // ConversationsGrant is for Twilio Conversations 64 | type ConversationsGrant struct { 65 | configurationProfileSid string 66 | } 67 | 68 | func NewConversationsGrant(sid string) *ConversationsGrant { 69 | return &ConversationsGrant{configurationProfileSid: sid} 70 | } 71 | 72 | func (gr *ConversationsGrant) ToPayload() map[string]interface{} { 73 | if len(gr.configurationProfileSid) > 0 { 74 | return map[string]interface{}{ 75 | keyConfProfSid: gr.configurationProfileSid, 76 | } 77 | } 78 | return make(map[string]interface{}) 79 | } 80 | 81 | func (gr *ConversationsGrant) Key() string { 82 | return conversationsGrant 83 | } 84 | 85 | // VoiceGrant is a grant for accessing Twilio IP Messaging 86 | type VoiceGrant struct { 87 | outgoingApplicationSid string // application sid to call when placing outgoing call 88 | outgoingApplicationParams map[string]interface{} // request params to pass to the application 89 | endpointID string // Specify an endpoint identifier for this device, which will allow the developer to direct calls to a specific endpoint when multiple devices are associated with a single identity 90 | pushCredentialSid string // Push Credential Sid to use when registering to receive incoming call notifications 91 | } 92 | 93 | func NewVoiceGrant(outAppSid string, outAppParams map[string]interface{}, endpointID string, pushCredentialSid string) *VoiceGrant { 94 | return &VoiceGrant{ 95 | outgoingApplicationSid: outAppSid, 96 | outgoingApplicationParams: outAppParams, 97 | endpointID: endpointID, 98 | pushCredentialSid: pushCredentialSid, 99 | } 100 | } 101 | 102 | func (gr *VoiceGrant) ToPayload() map[string]interface{} { 103 | outVoice := make(map[string]interface{}) 104 | if len(gr.outgoingApplicationSid) > 0 { 105 | outVoice[keyAppSid] = gr.outgoingApplicationSid 106 | } 107 | if len(gr.outgoingApplicationParams) > 0 { 108 | outVoice[keyVoiceParams] = gr.outgoingApplicationParams 109 | } 110 | 111 | grant := make(map[string]interface{}) 112 | grant[keyVoiceOutgoing] = outVoice 113 | if len(gr.endpointID) > 0 { 114 | grant[keyEndpointId] = gr.endpointID 115 | } 116 | if len(gr.pushCredentialSid) > 0 { 117 | grant[keyPushCredSid] = gr.pushCredentialSid 118 | } 119 | return grant 120 | } 121 | 122 | func (gr *VoiceGrant) Key() string { 123 | return voiceGrant 124 | } 125 | 126 | // VideoGrant is for Twilio Programmable Video access 127 | type VideoGrant struct { 128 | configurationProfileSid string 129 | } 130 | 131 | func NewVideoGrant(sid string) *VideoGrant { 132 | return &VideoGrant{configurationProfileSid: sid} 133 | } 134 | 135 | func (gr *VideoGrant) ToPayload() map[string]interface{} { 136 | if len(gr.configurationProfileSid) > 0 { 137 | return map[string]interface{}{ 138 | keyConfProfSid: gr.configurationProfileSid, 139 | } 140 | } 141 | return make(map[string]interface{}) 142 | } 143 | 144 | func (gr *VideoGrant) Key() string { 145 | return videoGrant 146 | } 147 | -------------------------------------------------------------------------------- /token/access_token_grant_test.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | const ( 9 | SERVICE_SID = "123234567567" 10 | ENDPOINT_ID = "asdfghjklpoiuytrewq" 11 | DEP_ROLE_SID = "1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol" 12 | PUSH_CRED_SID = "cde3xsw2zaq1vfr4bgtnhy6mju78ijhgtf" 13 | PROFILE_SID = "erfergrtugdifuovudsfhguidhgouidrhg" 14 | ) 15 | 16 | func TestIPMessageGrant(t *testing.T) { 17 | t.Parallel() 18 | ipMsgGrant := NewIPMessageGrant(SERVICE_SID, ENDPOINT_ID, DEP_ROLE_SID, PUSH_CRED_SID) 19 | 20 | if ipMsgGrant.Key() != ipMessagingGrant { 21 | t.Errorf("key expected to be %s, got %s\n", ipMessagingGrant, ipMsgGrant.Key()) 22 | } 23 | 24 | if ipMsgGrant.ToPayload()[keyServiceSid] != SERVICE_SID { 25 | t.Errorf("%s expected to be %s, got %s\n", keyServiceSid, SERVICE_SID, ipMsgGrant.ToPayload()[keyServiceSid]) 26 | } 27 | 28 | if ipMsgGrant.ToPayload()[keyEndpointId] != ENDPOINT_ID { 29 | t.Errorf("%s expected to be %s, got %s\n", keyEndpointId, ENDPOINT_ID, ipMsgGrant.ToPayload()[keyEndpointId]) 30 | } 31 | 32 | if ipMsgGrant.ToPayload()[keyDepRoleSide] != DEP_ROLE_SID { 33 | t.Errorf("%s expected to be %s, got %s\n", keyDepRoleSide, DEP_ROLE_SID, ipMsgGrant.ToPayload()[keyDepRoleSide]) 34 | } 35 | 36 | if ipMsgGrant.ToPayload()[keyPushCredSid] != PUSH_CRED_SID { 37 | t.Errorf("%s expected to be %s, got %s\n", keyPushCredSid, PUSH_CRED_SID, ipMsgGrant.ToPayload()[keyPushCredSid]) 38 | } 39 | } 40 | 41 | func TestConversationsGrant(t *testing.T) { 42 | t.Parallel() 43 | convGrant := NewConversationsGrant(PROFILE_SID) 44 | 45 | if convGrant.Key() != conversationsGrant { 46 | t.Errorf("key expected to be %s, got %s\n", conversationsGrant, convGrant.Key()) 47 | } 48 | 49 | if convGrant.ToPayload()[keyConfProfSid] != PROFILE_SID { 50 | t.Errorf("%s expected to be %s, got %s\n", keyConfProfSid, PROFILE_SID, convGrant.ToPayload()[keyConfProfSid]) 51 | } 52 | } 53 | 54 | func TestVoiceGrant(t *testing.T) { 55 | t.Parallel() 56 | params := map[string]interface{}{ 57 | "extra": "wefergdfgdf", 58 | "logged_id": true, 59 | "data": map[string]interface{}{ 60 | "id": 101212434, 61 | "name": "John", 62 | "created_at": time.Now(), 63 | }, 64 | } 65 | vcGrnt := NewVoiceGrant(APP_SID, params, ENDPOINT_ID, PUSH_CRED_SID) 66 | 67 | if vcGrnt.Key() != voiceGrant { 68 | t.Errorf("key expected to be %s, got %s\n", voiceGrant, vcGrnt.Key()) 69 | } 70 | 71 | if vcGrnt.ToPayload()[keyVoiceOutgoing] == nil { 72 | t.Errorf("expected payload %s to exist", keyVoiceOutgoing) 73 | } 74 | 75 | payload := vcGrnt.ToPayload() 76 | outgoingMap := payload[keyVoiceOutgoing].(map[string]interface{}) 77 | if outgoingMap[keyAppSid] != APP_SID { 78 | t.Errorf("Expected payload [%s][%s] to be %s, got %s\n", keyVoiceOutgoing, keyAppSid, APP_SID, outgoingMap[keyAppSid]) 79 | } 80 | 81 | if outgoingMap[keyVoiceParams] == nil { 82 | t.Errorf("Expected payload [%s][%s] to exist\n", keyVoiceOutgoing, keyVoiceParams) 83 | } 84 | 85 | endpointId := vcGrnt.ToPayload()[keyEndpointId] 86 | if endpointId != ENDPOINT_ID { 87 | t.Errorf("Expected payload %s to be %s, got %s\n", keyEndpointId, ENDPOINT_ID, endpointId) 88 | } 89 | 90 | pushCredSid := vcGrnt.ToPayload()[keyPushCredSid] 91 | if pushCredSid != PUSH_CRED_SID { 92 | t.Errorf("Expected payload %s to be %s, got %s\n", keyPushCredSid, PUSH_CRED_SID, pushCredSid) 93 | } 94 | } 95 | 96 | func TestVideoGrant(t *testing.T) { 97 | t.Parallel() 98 | vdGrnt := NewVideoGrant(PROFILE_SID) 99 | 100 | if vdGrnt.Key() != videoGrant { 101 | t.Errorf("key expected to be %s, got %s\n", videoGrant, vdGrnt.Key()) 102 | } 103 | 104 | if vdGrnt.ToPayload()[keyConfProfSid] != PROFILE_SID { 105 | t.Errorf("%s expected to be %s, got %s\n", keyConfProfSid, PROFILE_SID, vdGrnt.ToPayload()[keyConfProfSid]) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /token/access_token_test.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | jwt "github.com/dgrijalva/jwt-go" 8 | ) 9 | 10 | const ( 11 | ACC_SID = "123456" 12 | API_KEY = "abcdef" 13 | API_SECRET = "asdfghjklqwertyuiopzxcvbnm" 14 | IDENTITY = "johnsmith" 15 | APP_SID = "asdfghjkl" 16 | ) 17 | 18 | func TestJWT(t *testing.T) { 19 | t.Parallel() 20 | 21 | accTkn := New(ACC_SID, API_KEY, API_SECRET, IDENTITY, time.Hour) 22 | accTkn.NotBefore = time.Now() 23 | convGrant := NewConversationsGrant(APP_SID) 24 | 25 | accTkn.AddGrant(convGrant) 26 | jwtString, err := accTkn.JWT() 27 | 28 | if err != nil { 29 | t.Error("Unexpected error when generating the token", err) 30 | } 31 | if jwtString == "" { 32 | t.Error("token returned is empty") 33 | } 34 | 35 | token, err := jwt.ParseWithClaims(jwtString, &myCustomClaims{}, func(tkn *jwt.Token) (interface{}, error) { 36 | return []byte(API_SECRET), nil 37 | }) 38 | if err != nil { 39 | t.Error("Unexpected error when generating the token", err) 40 | } 41 | 42 | claims := token.Claims.(*myCustomClaims) 43 | 44 | if &claims.StandardClaims == nil { 45 | t.Error("Claim doesn't conaint a standard claims struct") 46 | } 47 | 48 | if claims.StandardClaims.ExpiresAt == 0 { 49 | t.Error("ExpiredAt is not set") 50 | } 51 | 52 | if claims.StandardClaims.Id == "" { 53 | t.Error("ID is not set") 54 | } 55 | 56 | if claims.StandardClaims.IssuedAt == 0 { 57 | t.Error("IssuedAt is not set") 58 | } 59 | 60 | if claims.StandardClaims.NotBefore == 0 { 61 | t.Error("NotBefore is not set") 62 | } 63 | 64 | if claims.StandardClaims.Issuer != API_KEY { 65 | t.Errorf("Issuer expected to be: %s, got %s\n", API_KEY, claims.StandardClaims.Issuer) 66 | } 67 | 68 | if claims.StandardClaims.Subject != ACC_SID { 69 | t.Errorf("Subject expected to be: %s, got %s\n", ACC_SID, claims.StandardClaims.Subject) 70 | } 71 | 72 | if claims.Grants == nil { 73 | t.Error("Expected Grants to exist") 74 | } 75 | 76 | if claims.Grants["identity"] != IDENTITY { 77 | t.Errorf("Grants identity expected to be %s, got %s\n", IDENTITY, claims.Grants["identity"]) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /token/example_test.go: -------------------------------------------------------------------------------- 1 | package token_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/saintpete/twilio-go/token" 8 | ) 9 | 10 | func Example() { 11 | t := token.New("AC123", "456bef", "secretkey", "test@example.com", time.Hour) 12 | grant := token.NewConversationsGrant("a-conversation-sid") 13 | t.AddGrant(grant) 14 | jwt, _ := t.JWT() 15 | fmt.Println(jwt) // A string encoded with the given values. 16 | } 17 | -------------------------------------------------------------------------------- /transcription.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | 6 | "golang.org/x/net/context" 7 | ) 8 | 9 | const transcriptionPathPart = "Transcriptions" 10 | 11 | type TranscriptionService struct { 12 | client *Client 13 | } 14 | 15 | type Transcription struct { 16 | Sid string `json:"sid"` 17 | TranscriptionText string `json:"transcription_text"` 18 | DateCreated TwilioTime `json:"date_created"` 19 | DateUpdated TwilioTime `json:"date_updated"` 20 | Duration TwilioDuration `json:"duration"` 21 | Price string `json:"price"` 22 | PriceUnit string `json:"price_unit"` 23 | RecordingSid string `json:"recording_sid"` 24 | Status Status `json:"status"` 25 | Type string `json:"type"` 26 | AccountSid string `json:"account_sid"` 27 | APIVersion string `json:"api_version"` 28 | URI string `json:"uri"` 29 | } 30 | 31 | type TranscriptionPage struct { 32 | Page 33 | Transcriptions []*Transcription 34 | } 35 | 36 | // FriendlyPrice flips the sign of the Price (which is usually reported from 37 | // the API as a negative number) and adds an appropriate currency symbol in 38 | // front of it. For example, a PriceUnit of "USD" and a Price of "-1.25" is 39 | // reported as "$1.25". 40 | func (t *Transcription) FriendlyPrice() string { 41 | if t == nil { 42 | return "" 43 | } 44 | return price(t.PriceUnit, t.Price) 45 | } 46 | 47 | // Get returns a single Transcription or an error. 48 | func (c *TranscriptionService) Get(ctx context.Context, sid string) (*Transcription, error) { 49 | transcription := new(Transcription) 50 | err := c.client.GetResource(ctx, transcriptionPathPart, sid, transcription) 51 | return transcription, err 52 | } 53 | 54 | // Delete the Transcription with the given sid. If the Transcription has 55 | // already been deleted, or does not exist, Delete returns nil. If another 56 | // error or a timeout occurs, the error is returned. 57 | func (c *TranscriptionService) Delete(ctx context.Context, sid string) error { 58 | return c.client.DeleteResource(ctx, transcriptionPathPart, sid) 59 | } 60 | 61 | func (c *TranscriptionService) GetPage(ctx context.Context, data url.Values) (*TranscriptionPage, error) { 62 | iter := c.GetPageIterator(data) 63 | return iter.Next(ctx) 64 | } 65 | 66 | type TranscriptionPageIterator struct { 67 | p *PageIterator 68 | } 69 | 70 | // GetPageIterator returns a TranscriptionPageIterator with the given page 71 | // filters. Call iterator.Next() to get the first page of resources (and again to 72 | // retrieve subsequent pages). 73 | func (c *TranscriptionService) GetPageIterator(data url.Values) *TranscriptionPageIterator { 74 | iter := NewPageIterator(c.client, data, transcriptionPathPart) 75 | return &TranscriptionPageIterator{ 76 | p: iter, 77 | } 78 | } 79 | 80 | // Next returns the next page of resources. If there are no more resources, 81 | // NoMoreResults is returned. 82 | func (c *TranscriptionPageIterator) Next(ctx context.Context) (*TranscriptionPage, error) { 83 | cp := new(TranscriptionPage) 84 | err := c.p.Next(ctx, cp) 85 | if err != nil { 86 | return nil, err 87 | } 88 | c.p.SetNextPageURI(cp.NextPageURI) 89 | return cp, nil 90 | } 91 | -------------------------------------------------------------------------------- /transcription_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | func TestTranscriptionDelete(t *testing.T) { 13 | t.Parallel() 14 | called := false 15 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | called = true 17 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 18 | w.WriteHeader(204) 19 | })) 20 | defer s.Close() 21 | client := NewClient("AC123", "123", nil) 22 | client.Base = s.URL 23 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 24 | defer cancel() 25 | err := client.Transcriptions.Delete(ctx, "TR4c7f9a71f19b7509cb1e8344af78fc82") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | if called == false { 30 | t.Error("never hit server") 31 | } 32 | } 33 | 34 | func TestTranscriptionDeleteTwice(t *testing.T) { 35 | t.Parallel() 36 | client, server := getServerCode(transcriptionDeletedTwice, 404) 37 | defer server.Close() 38 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 39 | defer cancel() 40 | err := client.Transcriptions.Delete(ctx, "TR4c7f9a71f19b7509cb1e8344af78fc82") 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /twilioclient/capabilities.go: -------------------------------------------------------------------------------- 1 | package twilioclient 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | jwt "github.com/dgrijalva/jwt-go" 10 | ) 11 | 12 | type Capability struct { 13 | accountSid string 14 | authToken []byte 15 | capabilities []string 16 | 17 | incomingClientName string 18 | shouldBuildIncomingScope bool 19 | 20 | shouldBuildOutgoingScope bool 21 | outgoingParams map[string]string 22 | appSid string 23 | mu sync.Mutex 24 | } 25 | 26 | func NewCapability(sid, token string) *Capability { 27 | return &Capability{ 28 | accountSid: sid, 29 | authToken: []byte(token), 30 | } 31 | } 32 | 33 | // Registers this client to accept incoming calls by the given `clientName`. 34 | // If your app TwiML s `clientName`, this client will receive the call. 35 | func (c *Capability) AllowClientIncoming(clientName string) { 36 | c.shouldBuildIncomingScope = true 37 | c.incomingClientName = clientName 38 | } 39 | 40 | // Allows this client to call your application with id `appSid` (See https://www.twilio.com/user/account/apps). 41 | // When the call connects, Twilio will call your voiceUrl REST endpoint. 42 | // The `appParams` argument will get passed through to your voiceUrl REST endpoint as GET or POST parameters. 43 | func (c *Capability) AllowClientOutgoing(appSid string, appParams map[string]string) { 44 | c.shouldBuildOutgoingScope = true 45 | c.appSid = appSid 46 | c.outgoingParams = appParams 47 | } 48 | 49 | func (c *Capability) AllowEventStream(filters map[string]string) { 50 | params := map[string]string{ 51 | "path": "/2010-04-01/Events", 52 | } 53 | if len(filters) > 0 { 54 | params["params"] = url.QueryEscape(generateParamString(filters)) 55 | } 56 | c.addCapability("stream", "subscribe", params) 57 | } 58 | 59 | type customClaim struct { 60 | *jwt.StandardClaims 61 | Scope string 62 | } 63 | 64 | // Generate the twilio capability token. Deliver this token to your 65 | // JS/iOS/Android Twilio client. 66 | func (c *Capability) GenerateToken(ttl time.Duration) (string, error) { 67 | c.doBuildIncomingScope() 68 | c.doBuildOutgoingScope() 69 | now := time.Now().UTC() 70 | cc := &customClaim{ 71 | Scope: strings.Join(c.capabilities, " "), 72 | StandardClaims: &jwt.StandardClaims{ 73 | ExpiresAt: now.Add(ttl).Unix(), 74 | Issuer: c.accountSid, 75 | IssuedAt: now.Unix(), 76 | }, 77 | } 78 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, cc) 79 | return token.SignedString([]byte(c.authToken)) 80 | } 81 | 82 | func (c *Capability) doBuildOutgoingScope() { 83 | if c.shouldBuildOutgoingScope { 84 | values := map[string]string{} 85 | values["appSid"] = c.appSid 86 | if c.incomingClientName != "" { 87 | values["clientName"] = c.incomingClientName 88 | } 89 | 90 | if c.outgoingParams != nil { 91 | values["appParams"] = generateParamString(c.outgoingParams) 92 | } 93 | 94 | c.addCapability("client", "outgoing", values) 95 | } 96 | } 97 | 98 | func (c *Capability) doBuildIncomingScope() { 99 | if c.shouldBuildIncomingScope { 100 | values := map[string]string{} 101 | values["clientName"] = c.incomingClientName 102 | c.addCapability("client", "incoming", values) 103 | } 104 | } 105 | 106 | func (c *Capability) addCapability(service, privelege string, params map[string]string) { 107 | c.capabilities = append(c.capabilities, scopeUriFor(service, privelege, params)) 108 | } 109 | 110 | func scopeUriFor(service, privelege string, params map[string]string) string { 111 | scopeUri := "scope:" + service + ":" + privelege 112 | if len(params) > 0 { 113 | scopeUri += "?" + generateParamString(params) 114 | } 115 | return scopeUri 116 | } 117 | 118 | func generateParamString(params map[string]string) string { 119 | values := make(url.Values) 120 | for key, val := range params { 121 | values.Add(key, val) 122 | } 123 | return values.Encode() 124 | } 125 | -------------------------------------------------------------------------------- /twilioclient/capabilities_test.go: -------------------------------------------------------------------------------- 1 | package twilioclient 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | jwt "github.com/dgrijalva/jwt-go" 9 | ) 10 | 11 | func TestCapability(t *testing.T) { 12 | t.Parallel() 13 | cap := NewCapability("AC123", "123") 14 | cap.AllowClientIncoming("client-name") 15 | tok, err := cap.GenerateToken(time.Hour) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | fmt.Println(tok) 20 | cc := new(customClaim) 21 | _, err = jwt.ParseWithClaims(tok, cc, func(tkn *jwt.Token) (interface{}, error) { 22 | return []byte("123"), nil 23 | }) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | if cc.StandardClaims.Issuer != "AC123" { 28 | t.Errorf("bad Issuer") 29 | } 30 | if cc.Scope != "scope:client:incoming?clientName=client-name" { 31 | t.Errorf("bad Scope") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /twilioclient/example_test.go: -------------------------------------------------------------------------------- 1 | package twilioclient_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/saintpete/twilio-go/twilioclient" 9 | ) 10 | 11 | func Example() { 12 | cap := twilioclient.NewCapability("AC123", "123") 13 | cap.AllowClientIncoming("client-name") 14 | tok, err := cap.GenerateToken(time.Hour) 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | fmt.Println(tok) 19 | } 20 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | "unicode" 12 | "unicode/utf8" 13 | 14 | "github.com/ttacon/libphonenumber" 15 | ) 16 | 17 | type PhoneNumber string 18 | 19 | var ErrEmptyNumber = errors.New("twilio: The provided phone number was empty") 20 | 21 | // NewPhoneNumber parses the given value as a phone number or returns an error 22 | // if it cannot be parsed as one. If a phone number does not begin with a plus 23 | // sign, we assume it's a US national number. Numbers are stored in E.164 24 | // format. 25 | func NewPhoneNumber(pn string) (PhoneNumber, error) { 26 | if len(pn) == 0 { 27 | return "", ErrEmptyNumber 28 | } 29 | num, err := libphonenumber.Parse(pn, "US") 30 | // Add some better error messages - the ones in libphonenumber are generic 31 | switch { 32 | case err == libphonenumber.ErrNotANumber: 33 | return "", fmt.Errorf("twilio: Invalid phone number: %s", pn) 34 | case err == libphonenumber.ErrInvalidCountryCode: 35 | return "", fmt.Errorf("twilio: Invalid country code for number: %s", pn) 36 | case err != nil: 37 | return "", err 38 | } 39 | return PhoneNumber(libphonenumber.Format(num, libphonenumber.E164)), nil 40 | } 41 | 42 | // Friendly returns a friendly international representation of the phone 43 | // number, for example, "+14105554092" is returned as "+1 410-555-4092". If the 44 | // phone number is not in E.164 format, we try to parse it as a US number. If 45 | // we cannot parse it as a US number, it is returned as is. 46 | func (pn PhoneNumber) Friendly() string { 47 | num, err := libphonenumber.Parse(string(pn), "US") 48 | if err != nil { 49 | return string(pn) 50 | } 51 | return libphonenumber.Format(num, libphonenumber.INTERNATIONAL) 52 | } 53 | 54 | // Local returns a friendly national representation of the phone number, for 55 | // example, "+14105554092" is returned as "(410) 555-4092". If the phone number 56 | // is not in E.164 format, we try to parse it as a US number. If we cannot 57 | // parse it as a US number, it is returned as is. 58 | func (pn PhoneNumber) Local() string { 59 | num, err := libphonenumber.Parse(string(pn), "US") 60 | if err != nil { 61 | return string(pn) 62 | } 63 | return libphonenumber.Format(num, libphonenumber.NATIONAL) 64 | } 65 | 66 | // A uintStr is sent back from Twilio as a str, but should be parsed as a uint. 67 | type uintStr uint 68 | 69 | type Segments uintStr 70 | type NumMedia uintStr 71 | 72 | func (seg *uintStr) UnmarshalJSON(b []byte) error { 73 | s := new(string) 74 | if err := json.Unmarshal(b, s); err != nil { 75 | return err 76 | } 77 | u, err := strconv.ParseUint(*s, 10, 64) 78 | if err != nil { 79 | return err 80 | } 81 | *seg = uintStr(u) 82 | return nil 83 | } 84 | 85 | func (seg *Segments) UnmarshalJSON(b []byte) (err error) { 86 | u := new(uintStr) 87 | if err = json.Unmarshal(b, u); err != nil { 88 | return 89 | } 90 | *seg = Segments(*u) 91 | return 92 | } 93 | 94 | func (n *NumMedia) UnmarshalJSON(b []byte) (err error) { 95 | u := new(uintStr) 96 | if err = json.Unmarshal(b, u); err != nil { 97 | return 98 | } 99 | *n = NumMedia(*u) 100 | return 101 | } 102 | 103 | // TwilioTime can parse a timestamp returned in the Twilio API and turn it into 104 | // a valid Go Time struct. 105 | type TwilioTime struct { 106 | Time time.Time 107 | Valid bool 108 | } 109 | 110 | // NewTwilioTime returns a TwilioTime instance. val should be formatted using 111 | // the TimeLayout. 112 | func NewTwilioTime(val string) *TwilioTime { 113 | t, err := time.Parse(TimeLayout, val) 114 | if err == nil { 115 | return &TwilioTime{Time: t, Valid: true} 116 | } else { 117 | return &TwilioTime{} 118 | } 119 | } 120 | 121 | // Epoch is a time that predates the formation of the company (January 1, 122 | // 2005). Use this for start filters when you don't want to filter old results. 123 | var Epoch = time.Date(2005, 1, 1, 0, 0, 0, 0, time.UTC) 124 | 125 | // HeatDeath is a sentinel time that should outdate the extinction of the 126 | // company. Use this with GetXInRange calls when you don't want to specify an 127 | // end date. Feel free to adjust this number in the year 5960 or so. 128 | var HeatDeath = time.Date(6000, 1, 1, 0, 0, 0, 0, time.UTC) 129 | 130 | // The reference time, as it appears in the Twilio API. 131 | const TimeLayout = "Mon, 2 Jan 2006 15:04:05 -0700" 132 | 133 | // Format expected by Twilio for searching date ranges. Monitor and other API's 134 | // offer better date search filters 135 | const APISearchLayout = "2006-01-02" 136 | 137 | func (t *TwilioTime) UnmarshalJSON(b []byte) error { 138 | s := new(string) 139 | if err := json.Unmarshal(b, s); err != nil { 140 | return err 141 | } 142 | if s == nil || *s == "null" || *s == "" { 143 | t.Valid = false 144 | return nil 145 | } 146 | tim, err := time.Parse(time.RFC3339, *s) 147 | if err != nil { 148 | tim, err = time.Parse(TimeLayout, *s) 149 | if err != nil { 150 | return err 151 | } 152 | } 153 | *t = TwilioTime{Time: tim, Valid: true} 154 | return nil 155 | } 156 | 157 | func (tt *TwilioTime) MarshalJSON() ([]byte, error) { 158 | if tt.Valid == false { 159 | return []byte("null"), nil 160 | } 161 | b, err := json.Marshal(tt.Time) 162 | if err != nil { 163 | return []byte{}, err 164 | } 165 | return b, nil 166 | } 167 | 168 | var symbols = map[string]string{ 169 | "USD": "$", 170 | "GBP": "£", 171 | "JPY": "¥", 172 | "MXN": "$", 173 | "CHF": "CHF", 174 | "CAD": "$", 175 | "CNY": "¥", 176 | "SGD": "$", 177 | "EUR": "€", 178 | } 179 | 180 | // Price flips the sign of the amount and prints it with a currency symbol for 181 | // the given unit. 182 | func price(unit string, amount string) string { 183 | if len(amount) == 0 { 184 | return amount 185 | } 186 | if amount[0] == '-' { 187 | amount = amount[1:] 188 | } else { 189 | amount = "-" + amount 190 | } 191 | for strings.Contains(amount, ".") && strings.HasSuffix(amount, "0") { 192 | amount = amount[:len(amount)-1] 193 | } 194 | if strings.HasSuffix(amount, ".") { 195 | amount = amount[:len(amount)-1] 196 | } 197 | unit = strings.ToUpper(unit) 198 | if sym, ok := symbols[unit]; ok { 199 | return sym + amount 200 | } else { 201 | if unit == "" { 202 | return amount 203 | } 204 | return unit + " " + amount 205 | } 206 | } 207 | 208 | type TwilioDuration time.Duration 209 | 210 | func (td *TwilioDuration) UnmarshalJSON(b []byte) error { 211 | s := new(string) 212 | if err := json.Unmarshal(b, s); err != nil { 213 | return err 214 | } 215 | if *s == "null" || *s == "" { 216 | *td = 0 217 | return nil 218 | } 219 | i, err := strconv.ParseInt(*s, 10, 64) 220 | if err != nil { 221 | return err 222 | } 223 | *td = TwilioDuration(i) * TwilioDuration(time.Second) 224 | return nil 225 | } 226 | 227 | func (td TwilioDuration) String() string { 228 | return time.Duration(td).String() 229 | } 230 | 231 | type AnsweredBy string 232 | 233 | const AnsweredByHuman = AnsweredBy("human") 234 | const AnsweredByMachine = AnsweredBy("machine") 235 | 236 | type NullAnsweredBy struct { 237 | Valid bool 238 | AnsweredBy AnsweredBy 239 | } 240 | 241 | // The status of the message (accepted, queued, etc). 242 | // For more information , see https://www.twilio.com/docs/api/rest/message 243 | type Status string 244 | 245 | func (s Status) Friendly() string { 246 | switch s { 247 | case StatusInProgress: 248 | return "In Progress" 249 | case StatusNoAnswer: 250 | return "No Answer" 251 | default: 252 | return strings.Title(string(s)) 253 | } 254 | } 255 | 256 | // Values has the methods of url.Values, but can decode JSON from the 257 | // response_headers field of an Alert. 258 | type Values struct { 259 | url.Values 260 | } 261 | 262 | func (h *Values) UnmarshalJSON(b []byte) error { 263 | s := new(string) 264 | if err := json.Unmarshal(b, s); err != nil { 265 | return err 266 | } 267 | vals, err := url.ParseQuery(*s) 268 | if err != nil { 269 | return err 270 | } 271 | *h = Values{url.Values{}} 272 | for k, arr := range vals { 273 | for _, val := range arr { 274 | h.Add(k, val) 275 | } 276 | } 277 | return nil 278 | } 279 | 280 | const StatusAccepted = Status("accepted") 281 | const StatusDelivered = Status("delivered") 282 | const StatusReceiving = Status("receiving") 283 | const StatusReceived = Status("received") 284 | const StatusSending = Status("sending") 285 | const StatusSent = Status("sent") 286 | const StatusUndelivered = Status("undelivered") 287 | 288 | // Call statuses 289 | 290 | const StatusBusy = Status("busy") 291 | const StatusCanceled = Status("canceled") 292 | const StatusCompleted = Status("completed") 293 | const StatusInProgress = Status("in-progress") 294 | const StatusNoAnswer = Status("no-answer") 295 | const StatusRinging = Status("ringing") 296 | 297 | // Shared 298 | const StatusFailed = Status("failed") 299 | const StatusQueued = Status("queued") 300 | 301 | const StatusActive = Status("active") 302 | const StatusSuspended = Status("suspended") 303 | const StatusClosed = Status("closed") 304 | 305 | // A log level returned for an Alert. 306 | type LogLevel string 307 | 308 | const LogLevelError = LogLevel("error") 309 | const LogLevelWarning = LogLevel("warning") 310 | const LogLevelNotice = LogLevel("notice") 311 | const LogLevelDebug = LogLevel("debug") 312 | 313 | func (l LogLevel) Friendly() string { 314 | return capitalize(string(l)) 315 | } 316 | 317 | // capitalize the first letter in s 318 | func capitalize(s string) string { 319 | r, l := utf8.DecodeRuneInString(s) 320 | b := make([]byte, l) 321 | utf8.EncodeRune(b, unicode.ToTitle(r)) 322 | return strings.Join([]string{string(b), s[l:]}, "") 323 | } 324 | -------------------------------------------------------------------------------- /types_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | var pnTestCases = []struct { 12 | in PhoneNumber 13 | expected string 14 | }{ 15 | {PhoneNumber("+41446681800"), "+41 44 668 18 00"}, 16 | {PhoneNumber("+14105554092"), "+1 410-555-4092"}, 17 | {PhoneNumber("blah"), "blah"}, 18 | } 19 | 20 | func TestPhoneNumberFriendly(t *testing.T) { 21 | t.Parallel() 22 | for _, tt := range pnTestCases { 23 | if f := tt.in.Friendly(); f != tt.expected { 24 | t.Errorf("Friendly(%v): got %s, want %s", tt.in, f, tt.expected) 25 | } 26 | } 27 | } 28 | 29 | var pnParseTestCases = []struct { 30 | in string 31 | out PhoneNumber 32 | err error 33 | }{ 34 | {"+14105551234", PhoneNumber("+14105551234"), nil}, 35 | {"410 555 1234", PhoneNumber("+14105551234"), nil}, 36 | {"(410) 555-1234", PhoneNumber("+14105551234"), nil}, 37 | {"+41 44 6681800", PhoneNumber("+41446681800"), nil}, 38 | {"foobarbang", PhoneNumber(""), errors.New("twilio: Invalid phone number: foobarbang")}, 39 | {"22", PhoneNumber("+122"), nil}, 40 | {"", PhoneNumber(""), ErrEmptyNumber}, 41 | } 42 | 43 | func TestNewPhoneNumber(t *testing.T) { 44 | t.Parallel() 45 | for _, tt := range pnParseTestCases { 46 | pn, err := NewPhoneNumber(tt.in) 47 | name := fmt.Sprintf("ParsePhoneNumber(%v)", tt.in) 48 | if tt.err != nil { 49 | if err == nil { 50 | t.Errorf("%s: expected %v, got nil", name, tt.err) 51 | continue 52 | } 53 | if err.Error() != tt.err.Error() { 54 | t.Errorf("%s: expected error %v, got %v", name, tt.err, err) 55 | } 56 | } else if pn != tt.out { 57 | t.Errorf("%s: expected %v, got %v", name, tt.out, pn) 58 | } 59 | } 60 | } 61 | 62 | var timeTests = []struct { 63 | in string 64 | valid bool 65 | time time.Time 66 | }{ 67 | {`"Tue, 20 Sep 2016 22:59:57 +0000"`, true, time.Date(2016, 9, 20, 22, 59, 57, 0, time.UTC)}, 68 | {`"2016-10-27T02:34:21Z"`, true, time.Date(2016, 10, 27, 2, 34, 21, 0, time.UTC)}, 69 | {`null`, false, time.Time{}}, 70 | } 71 | 72 | func TestUnmarshalTime(t *testing.T) { 73 | t.Parallel() 74 | for _, test := range timeTests { 75 | in := []byte(test.in) 76 | var tt TwilioTime 77 | if err := json.Unmarshal(in, &tt); err != nil { 78 | t.Errorf("json.Unmarshal(%v): got error %v", test.in, err) 79 | } 80 | if tt.Valid != test.valid { 81 | t.Errorf("json.Unmarshal(%v): expected Valid=%t, got %t", test.in, test.valid, tt.Valid) 82 | } 83 | if !tt.Time.Equal(test.time) { 84 | t.Errorf("json.Unmarshal(%v): expected time=%v, got %v", test.in, test.time, tt.Time) 85 | } 86 | } 87 | } 88 | 89 | func TestNewTwilioTime(t *testing.T) { 90 | t.Parallel() 91 | v := NewTwilioTime("foo") 92 | if v.Valid == true { 93 | t.Errorf("expected time to be invalid, got true") 94 | } 95 | in := "Tue, 20 Sep 2016 22:59:57 +0000" 96 | v = NewTwilioTime(in) 97 | if v.Valid == false { 98 | t.Errorf("expected %s to be valid time, got false", in) 99 | } 100 | if v.Time.Minute() != 59 { 101 | t.Errorf("wrong minute") 102 | } 103 | expected := "2016-09-20T22:59:57Z" 104 | if f := v.Time.Format(time.RFC3339); f != expected { 105 | t.Errorf("wrong format: got %v, want %v", f, expected) 106 | } 107 | } 108 | 109 | var priceTests = []struct { 110 | unit string 111 | amount string 112 | expected string 113 | }{ 114 | {"USD", "-0.0075", "$0.0075"}, 115 | {"usd", "-0.0075", "$0.0075"}, 116 | {"EUR", "0.0075", "€-0.0075"}, 117 | {"UNK", "2.45", "UNK -2.45"}, 118 | {"", "2.45", "-2.45"}, 119 | {"USD", "-0.75000000", "$0.75"}, 120 | {"USD", "-0.750", "$0.75"}, 121 | {"USD", "-5000.00", "$5000"}, 122 | {"USD", "-5000.", "$5000"}, 123 | {"USD", "-5000", "$5000"}, 124 | } 125 | 126 | func TestPrice(t *testing.T) { 127 | t.Parallel() 128 | for _, tt := range priceTests { 129 | out := price(tt.unit, tt.amount) 130 | if out != tt.expected { 131 | t.Errorf("price(%v, %v): got %v, want %v", tt.unit, tt.amount, out, tt.expected) 132 | } 133 | } 134 | } 135 | 136 | func TestTwilioDuration(t *testing.T) { 137 | t.Parallel() 138 | in := []byte(`"88"`) 139 | var td TwilioDuration 140 | if err := json.Unmarshal(in, &td); err != nil { 141 | t.Fatal(err) 142 | } 143 | if td != TwilioDuration(88*time.Second) { 144 | t.Errorf("got wrong duration: %v, wanted 88 seconds", td) 145 | } 146 | } 147 | 148 | var hdr = `"Transfer-Encoding=chunked&Server=cloudflare-nginx&CF-RAY=2f82bf9cb8102204-EWR&Set-Cookie=__cfduid%3Dd46f1cfd57d664c3038ae66f1c1de9e751477535661%3B+expires%3DFri%2C+27-Oct-17+02%3A34%3A21+GMT%3B+path%3D%2F%3B+domain%3D.inburke.com%3B+HttpOnly&Date=Thu%2C+27+Oct+2016+02%3A34%3A21+GMT&Content-Type=text%2Fhtml&CF-RAY=two"` 149 | 150 | func TestUnmarshalHeader(t *testing.T) { 151 | t.Parallel() 152 | h := new(Values) 153 | if err := json.Unmarshal([]byte(hdr), h); err != nil { 154 | t.Fatal(err) 155 | } 156 | if h == nil { 157 | t.Fatal("nil h") 158 | } 159 | if val := h.Get("Transfer-Encoding"); val != "chunked" { 160 | t.Errorf("expected Transfer-Encoding: chunked header, got %s", val) 161 | } 162 | if vals := h.Values["CF-RAY"]; len(vals) < 2 { 163 | t.Errorf("expected to parse two CF-RAY headers, got less than 2") 164 | } 165 | if vals := h.Values["CF-RAY"]; vals[1] != "two" { 166 | t.Errorf("expected second header to be 'two', got %v", vals[1]) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /validation.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/base64" 7 | "errors" 8 | "net/http" 9 | "net/url" 10 | "sort" 11 | ) 12 | 13 | // ValidateIncomingRequest returns an error if the incoming req could not be 14 | // validated as coming from Twilio. 15 | // 16 | // This process is frequently error prone, especially if you are running behind 17 | // a proxy, or Twilio is making requests with a port in the URL. 18 | // See https://www.twilio.com/docs/security#validating-requests for more information 19 | func ValidateIncomingRequest(host string, authToken string, req *http.Request) (err error) { 20 | err = req.ParseForm() 21 | if err != nil { 22 | return 23 | } 24 | err = validateIncomingRequest(host, authToken, req.URL.String(), req.Form, req.Header.Get("X-Twilio-Signature")) 25 | if err != nil { 26 | return 27 | } 28 | 29 | return 30 | } 31 | 32 | func validateIncomingRequest(host string, authToken string, URL string, postForm url.Values, xTwilioSignature string) (err error) { 33 | expectedTwilioSignature := GetExpectedTwilioSignature(host, authToken, URL, postForm) 34 | if xTwilioSignature != expectedTwilioSignature { 35 | err = errors.New("Bad X-Twilio-Signature") 36 | return 37 | } 38 | 39 | return 40 | } 41 | 42 | func GetExpectedTwilioSignature(host string, authToken string, URL string, postForm url.Values) (expectedTwilioSignature string) { 43 | // Take the full URL of the request URL you specify for your 44 | // phone number or app, from the protocol (https...) through 45 | // the end of the query string (everything after the ?). 46 | str := host + URL 47 | 48 | // If the request is a POST, sort all of the POST parameters 49 | // alphabetically (using Unix-style case-sensitive sorting order). 50 | keys := make([]string, 0, len(postForm)) 51 | for key := range postForm { 52 | keys = append(keys, key) 53 | } 54 | sort.Strings(keys) 55 | 56 | // Iterate through the sorted list of POST parameters, and append 57 | // the variable name and value (with no delimiters) to the end 58 | // of the URL string. 59 | for _, key := range keys { 60 | str += key + postForm[key][0] 61 | } 62 | 63 | // Sign the resulting string with HMAC-SHA1 using your AuthToken 64 | // as the key (remember, your AuthToken's case matters!). 65 | mac := hmac.New(sha1.New, []byte(authToken)) 66 | mac.Write([]byte(str)) 67 | expectedMac := mac.Sum(nil) 68 | 69 | // Base64 encode the resulting hash value. 70 | expectedTwilioSignature = base64.StdEncoding.EncodeToString(expectedMac) 71 | 72 | // Compare your hash to ours, submitted in the X-Twilio-Signature header. 73 | // If they match, then you're good to go. 74 | return expectedTwilioSignature 75 | } 76 | -------------------------------------------------------------------------------- /validation_test.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func TestClientValidateIncomingRequest(t *testing.T) { 9 | t.Parallel() 10 | // Based on example at https://www.twilio.com/docs/security#validating-requests 11 | authToken := "12345" 12 | host := "https://mycompany.com" 13 | URL := "/myapp.php?foo=1&bar=2" 14 | xTwilioSignature := "RSOYDt4T1cUTdK1PDd93/VVr8B8=" 15 | postForm := url.Values{ 16 | "Digits": {"1234"}, 17 | "To": {"+18005551212"}, 18 | "From": {"+14158675309"}, 19 | "Caller": {"+14158675309"}, 20 | "CallSid": {"CA1234567890ABCDE"}, 21 | } 22 | 23 | err := validateIncomingRequest(host, authToken, URL, postForm, xTwilioSignature) 24 | if err != nil { 25 | t.Fatal("Unexpected error:", err) 26 | } 27 | 28 | URL += "&cat=3" 29 | err = validateIncomingRequest(host, authToken, URL, postForm, xTwilioSignature) 30 | if err == nil { 31 | t.Fatal("Expected an error but got none") 32 | } 33 | } 34 | --------------------------------------------------------------------------------