├── .gitignore ├── mailgun.go ├── rotation.go ├── email.go ├── config.yaml.sample ├── docs ├── NOTIFICATION_API.md ├── PEOPLE_API.md └── NOTIFICATION_PLAN_API.md ├── LICENSE.txt ├── smtp.go ├── config.go ├── escalation.go ├── victorops.go ├── notification_plan.go ├── people.go ├── api_person_test.go ├── db.go ├── api_notification_plan_test.go ├── chickenlittle.go ├── api_notification_test.go ├── README.md ├── api_notification.go ├── notification.go ├── api_person.go ├── api_notification_plan.go └── twilio.go /.gitignore: -------------------------------------------------------------------------------- 1 | chickenlittle.db 2 | chickenlittle 3 | config.yaml 4 | 5 | stash 6 | stash/* 7 | 8 | config.yaml 9 | -------------------------------------------------------------------------------- /mailgun.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mailgun/mailgun-go" 6 | ) 7 | 8 | // Sends a multipart text and HTML e-mail with a link to the click endpoint for stopping the notification 9 | func SendEmailMailgun(address, subject, plain, html string) { 10 | from := fmt.Sprint("Chicken Little ") 11 | 12 | mg := mailgun.NewMailgun(c.Config.Integrations.Mailgun.Hostname, c.Config.Integrations.Mailgun.APIKey, "") 13 | 14 | m := mg.NewMessage(from, subject, plain) 15 | m.SetHtml(html) 16 | m.AddRecipient(address) 17 | 18 | mg.Send(m) 19 | } 20 | -------------------------------------------------------------------------------- /rotation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | // RotationPolicy defines how shifts are rotated. If the frequency is zero 9 | // no automatic rotations should be attempted. 10 | type RotationPolicy struct { 11 | Description string `yaml:"description" json:"description"` 12 | RotationFrequency time.Duration `yaml:"frequency" json:"frequency"` 13 | RotateTime time.Time `yaml:"time" json:"time"` 14 | } 15 | 16 | func (r *RotationPolicy) Marshal() ([]byte, error) { 17 | jr, err := json.Marshal(&r) 18 | return jr, err 19 | } 20 | 21 | func (r *RotationPolicy) Unmarshal(jr string) error { 22 | err := json.Unmarshal([]byte(jr), &r) 23 | return err 24 | } 25 | -------------------------------------------------------------------------------- /email.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | func SendEmail(address, message, uuid string) { 9 | log.Println("[", uuid, "] Sending email to:", address) 10 | subject := "Chicken Little message received" 11 | plain := fmt.Sprint("You've received a message from the Chicken Little alert system:\n\n", message, 12 | "\n\n", "Stop notifications for this alert: ", c.Config.Service.ClickURLBase, "/", uuid, "/stop") 13 | html := fmt.Sprint("You've received a message from the Chicken Little alert system:

", 14 | message, "

Stop notifications for this alert") 15 | 16 | if c.Config.Integrations.Mailgun.Enabled { 17 | SendEmailMailgun(address, subject, plain, html) 18 | } else { 19 | SendEmailSMTP(address, subject, plain, html) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config.yaml.sample: -------------------------------------------------------------------------------- 1 | service: 2 | api_listen_address: :7072 3 | click_listen_address: :7074 4 | click_url_base: http://some.ngrok.io 5 | callback_listen_address: :7073 6 | callback_url_base: http://some-other.ngrok.io 7 | db_file: ./chickenlittle.db 8 | integrations: 9 | twilio: 10 | account_sid: your-account-sid-goes-here 11 | auth_token: your-auth-token-goes-here 12 | api_base_url: https://api.twilio.com/2010-04-01/Accounts/ 13 | call_from_number: your-twilio-phone-number-goes-here 14 | mailgun: 15 | enabled: true 16 | api_key: your-mailgun-key-goes-here 17 | hostname: yourhostname.mailgun.org 18 | hipchat: 19 | hipchat_auth_token: NotYetImplemented 20 | hipchat_announce_room: NotYetImplemented 21 | victorops: 22 | api_key: your-api-key-goes-here 23 | smtp: 24 | hostname: your-smtp-hostname 25 | port: 587 26 | login: your-smtp-login 27 | password: your-smtp-password 28 | sender: your-smtp-sender-address 29 | -------------------------------------------------------------------------------- /docs/NOTIFICATION_API.md: -------------------------------------------------------------------------------- 1 | # Notification API 2 | 3 | ### Notify a person 4 | 5 | **Request** 6 | ``` 7 | POST /people/USERNAME/notify 8 | 9 | { 10 | "content": "Dinnertime, chickies, lets all eat. Wash your wings and take a seat." 11 | } 12 | ``` 13 | 14 | **Example Response** 15 | ``` 16 | HTTP/1.1 200 OK 17 | ``` 18 | ```json 19 | { 20 | "username": "USERNAME", 21 | "uuid": "d6b65a80-5a58-4334-8f25-c35619998ba5", 22 | "content": "Dinnertime, chickies, lets all eat. Wash your wings and take a seat.", 23 | "message": "Notification initiated", 24 | "error": "" 25 | } 26 | ``` 27 | 28 | ### Stop an in-progress notification 29 | 30 | **Request** 31 | ``` 32 | DELETE /notifications/UUID 33 | ``` 34 | 35 | **Example Response** 36 | ``` 37 | HTTP/1.1 200 OK 38 | ``` 39 | ```json 40 | { 41 | "username": "", 42 | "uuid": "81ce4c82-6e78-4491-9fbe-537bdce4459a", 43 | "content": "", 44 | "message": "Attempting to terminate notification", 45 | "error": "" 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 Christopher Snell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /smtp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jpoehls/gophermail" 6 | "log" 7 | "net/mail" 8 | "net/smtp" 9 | "time" 10 | ) 11 | 12 | func SendEmailSMTP(address, subject, plain, html string) { 13 | // Set up authentication information 14 | auth := smtp.PlainAuth( 15 | "", 16 | c.Config.Integrations.SMTP.Login, 17 | c.Config.Integrations.SMTP.Password, 18 | c.Config.Integrations.SMTP.Hostname, 19 | ) 20 | 21 | from := mail.Address{Address: c.Config.Integrations.SMTP.Sender} 22 | to := mail.Address{Address: address} 23 | headers := mail.Header{} 24 | headers["Date"] = []string{time.Now().Format(time.RFC822Z)} 25 | 26 | message := &gophermail.Message{ 27 | From: from, 28 | To: []mail.Address{to}, 29 | Subject: subject, 30 | Body: plain, 31 | HTMLBody: html, 32 | Headers: headers, 33 | } 34 | 35 | // Connect to the server, auth and send 36 | host := fmt.Sprintf("%s:%d", c.Config.Integrations.SMTP.Hostname, c.Config.Integrations.SMTP.Port) 37 | err := gophermail.SendMail( 38 | host, 39 | auth, 40 | message, 41 | ) 42 | if err != nil { 43 | log.Println("SMTP-Error:", err) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Config struct { 4 | Service ServiceConfig `yaml:"service"` 5 | Integrations Integrations `yaml:"integrations"` 6 | } 7 | 8 | type ServiceConfig struct { 9 | APIListenAddr string `yaml:"api_listen_address"` 10 | ClickListenAddr string `yaml:"click_listen_address"` 11 | ClickURLBase string `yaml:"click_url_base"` 12 | CallbackListenAddr string `yaml:"callback_listen_address"` 13 | CallbackURLBase string `yaml:"callback_url_base"` 14 | DBFile string `yaml:"db_file"` 15 | } 16 | 17 | type Integrations struct { 18 | HipChat HipChat `yaml:"hipchat"` 19 | VictorOps VictorOps `yaml:"victorops"` 20 | Twilio Twilio `yaml:"twilio"` 21 | Mailgun Mailgun `yaml:"mailgun"` 22 | SMTP SMTP `yaml:"smtp"` 23 | } 24 | 25 | type Twilio struct { 26 | AccountSID string `yaml:"account_sid"` 27 | AuthToken string `yaml:"auth_token"` 28 | CallFromNumber string `yaml:"call_from_number"` 29 | APIBaseURL string `yaml:"api_base_url"` 30 | } 31 | 32 | type Mailgun struct { 33 | Enabled bool `yaml:"enabled"` 34 | APIKey string `yaml:"api_key"` 35 | Hostname string `yaml:"hostname"` 36 | } 37 | 38 | type SMTP struct { 39 | Hostname string `yaml:"hostname"` 40 | Port int `yaml:"port"` 41 | Login string `yaml:"login"` 42 | Password string `yaml:"password"` 43 | Sender string `yaml:"sender"` 44 | } 45 | 46 | type VictorOps struct { 47 | APIKey string `yaml:"api_key"` 48 | } 49 | 50 | type HipChat struct { 51 | HipChatAuthToken string `yaml:"hipchat_auth_token"` 52 | HipChatAnnounceRoom string `yaml:"hipchat_announce_room"` 53 | } 54 | -------------------------------------------------------------------------------- /escalation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type EscalationMethod int 9 | 10 | const ( 11 | NotifyOnDuty EscalationMethod = iota // 0 12 | NotifyNextInRotation // 1 13 | NotifyOtherPerson // 2 14 | NotifyWebhook // 3 15 | NotifyEmail // 4 16 | ) 17 | 18 | // EscalationStep defines how alerts are escalated when an contact 19 | // does not respond in time. Target takes a different meaning depending on 20 | // the EscalationMethod. It is ignored on NotifyOnDuty or NotifyNextInRotation. 21 | // For NotifyOtherPerson it's the name of another contact, for CallWebhook it's an URL 22 | // and for SendEmail it's an email address. 23 | type EscalationStep struct { 24 | TimeBeforeEscalation time.Duration `yaml:"timebefore" json:"timebefore"` // How long to try the current step 25 | Method EscalationMethod `yaml:"method" json:"method"` // What action to take during this step 26 | Target string `yaml:"target" json:"target"` // Who or what to do the action with 27 | } 28 | 29 | func (e *EscalationMethod) Marshal() ([]byte, error) { 30 | je, err := json.Marshal(&e) 31 | return je, err 32 | } 33 | 34 | func (e *EscalationMethod) Unmarshal(je string) error { 35 | err := json.Unmarshal([]byte(je), &e) 36 | return err 37 | } 38 | 39 | func (e *EscalationStep) Marshal() ([]byte, error) { 40 | je, err := json.Marshal(&e) 41 | return je, err 42 | } 43 | 44 | func (e *EscalationStep) Unmarshal(je string) error { 45 | err := json.Unmarshal([]byte(je), &e) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /victorops.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/chrissnell/victorops-go" 12 | "github.com/gorilla/mux" 13 | ) 14 | 15 | // Send a notification via VictorOps 16 | func NotifyPersonViaVictorops(w http.ResponseWriter, r *http.Request) { 17 | 18 | var n Notification 19 | var res PeopleResponse 20 | 21 | vars := mux.Vars(r) 22 | username := vars["person"] 23 | 24 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*10)) 25 | // If something went wrong, return an error in the JSON response 26 | if err != nil { 27 | res.Error = err.Error() 28 | json.NewEncoder(w).Encode(res) 29 | return 30 | } 31 | 32 | err = r.Body.Close() 33 | if err != nil { 34 | res.Error = err.Error() 35 | json.NewEncoder(w).Encode(res) 36 | return 37 | } 38 | 39 | err = json.Unmarshal(body, &n) 40 | if err != nil { 41 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 42 | w.WriteHeader(422) // unprocessable entity 43 | res.Error = err.Error() 44 | json.NewEncoder(w).Encode(res) 45 | return 46 | } 47 | 48 | vo := victorops.NewClient(c.Config.Integrations.VictorOps.APIKey) 49 | 50 | p, err := c.GetPerson(username) 51 | if err != nil { 52 | res.Error = err.Error() 53 | log.Println("config.GetPerson() Error:", err) 54 | json.NewEncoder(w).Encode(res) 55 | return 56 | } 57 | 58 | e := &victorops.Event{ 59 | RoutingKey: p.VictorOpsRoutingKey, 60 | MessageType: victorops.Critical, 61 | EntityID: n.Message, 62 | Timestamp: time.Now(), 63 | } 64 | 65 | resp, err := vo.SendAlert(e) 66 | if err != nil { 67 | res.Error = err.Error() 68 | log.Println("Error:", err) 69 | json.NewEncoder(w).Encode(res) 70 | return 71 | } 72 | log.Println("VO Response - Result:", resp.Result, "EntityID:", resp.EntityID, "Message:", resp.EntityID) 73 | return 74 | 75 | } 76 | -------------------------------------------------------------------------------- /docs/PEOPLE_API.md: -------------------------------------------------------------------------------- 1 | # People API 2 | 3 | ## Get list of all people 4 | **Request** 5 | ``` 6 | GET /people 7 | ``` 8 | 9 | **Example Response** 10 | ``` 11 | HTTP/1.1 200 OK 12 | ``` 13 | ```json 14 | { 15 | "people": [ 16 | { 17 | "username": "arthur", 18 | "fullname": "King Arthur" 19 | }, 20 | { 21 | "username": "lancelot", 22 | "fullname": "Sir Lancelot" 23 | } 24 | ], 25 | "message": "", 26 | "error": "" 27 | } 28 | ``` 29 | 30 | ## Fetch details for a person 31 | **Request** 32 | ``` 33 | GET /people/USERNAME 34 | ``` 35 | 36 | **Example Response** 37 | ``` 38 | HTTP/1.1 200 OK 39 | ``` 40 | ```json 41 | { 42 | "people": [ 43 | { 44 | "username": "lancelot", 45 | "fullname": "Sir Lancelot" 46 | } 47 | ], 48 | "message": "", 49 | "error": "" 50 | } 51 | ``` 52 | 53 | ## Create a new person 54 | **Request** 55 | ``` 56 | POST /people 57 | 58 | { 59 | "username": "lancelot", 60 | "fullname": "Sir Lancelot" 61 | } 62 | ``` 63 | 64 | **Example Response** 65 | ``` 66 | HTTP/1.1 200 OK 67 | ``` 68 | ```json 69 | { 70 | "message": "User lancelot created", 71 | "error": "" 72 | } 73 | ``` 74 | 75 | ## Update a person 76 | **Request** 77 | ``` 78 | PUT /people/USERNAME 79 | 80 | { 81 | "fullname": "Sir Lancelot the Brave" 82 | } 83 | ``` 84 | 85 | **Example Response** 86 | ``` 87 | HTTP/1.1 200 OK 88 | ``` 89 | ```json 90 | { 91 | "people": [ 92 | { 93 | "username": "lancelot", 94 | "fullname": "Sir Lancelot the Brave" 95 | } 96 | ], 97 | "message": "User lancelot updated", 98 | "error": "" 99 | } 100 | ``` 101 | 102 | ## Delete a person 103 | **Request** 104 | ``` 105 | DELETE /people/USERNAME 106 | ``` 107 | 108 | **Example Response** 109 | ``` 110 | HTTP/1.1 200 OK 111 | ``` 112 | ```json 113 | { 114 | "message": "User lancelot deleted", 115 | "error": "" 116 | } 117 | ``` 118 | -------------------------------------------------------------------------------- /notification_plan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/twinj/uuid" 9 | ) 10 | 11 | type ( 12 | Method uint8 13 | ) 14 | 15 | type NotificationStep struct { 16 | Method string `json:"method"` 17 | NotifyEveryPeriod time.Duration `json:"notify_every_period"` 18 | NotifyUntilPeriod time.Duration `json:"notify_until_period"` 19 | } 20 | 21 | type NotificationPlan struct { 22 | ID uuid.UUID 23 | Username string `json:"username"` 24 | Steps []NotificationStep `json:"steps,omitempty"` 25 | } 26 | 27 | func (np *NotificationPlan) Marshal() ([]byte, error) { 28 | jnp, err := json.Marshal(np) 29 | return jnp, err 30 | } 31 | 32 | func (np *NotificationPlan) Unmarshal(jnp string) error { 33 | err := json.Unmarshal([]byte(jnp), np) 34 | return err 35 | } 36 | 37 | // Fetch a NotificationPlan from the DB 38 | func (c *ChickenLittle) GetNotificationPlan(username string) (*NotificationPlan, error) { 39 | jp, err := c.DB.Fetch("notificationplans", username) 40 | if err != nil { 41 | return nil, fmt.Errorf("Could not fetch notification plan from DB: plan for %v does not exist", username) 42 | } 43 | 44 | plan := &NotificationPlan{} 45 | 46 | err = plan.Unmarshal(jp) 47 | if err != nil { 48 | return nil, fmt.Errorf("Could not unmarshal notification plan from DB. Err: %v JSON: %v", err, jp) 49 | } 50 | 51 | return plan, nil 52 | } 53 | 54 | // Store a NotificationPlan in the DB 55 | func (c *ChickenLittle) StoreNotificationPlan(p *NotificationPlan) error { 56 | jp, err := p.Marshal() 57 | if err != nil { 58 | return fmt.Errorf("Could not marshal person %+v", p) 59 | } 60 | 61 | err = c.DB.Store("notificationplans", p.Username, string(jp)) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // Delete a NotificationPlan from the DB 70 | func (c *ChickenLittle) DeleteNotificationPlan(username string) error { 71 | err := c.DB.Delete("notificationplans", username) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /people.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | type Person struct { 10 | Username string `yaml:"username" json:"username"` 11 | FullName string `yaml:"full_name" json:"fullname"` 12 | VictorOpsRoutingKey string `yaml:"victorops_routing_key" json:"victorops_routing_key,omitempty"` 13 | } 14 | 15 | func (p *Person) Marshal() ([]byte, error) { 16 | jp, err := json.Marshal(&p) 17 | return jp, err 18 | } 19 | 20 | func (p *Person) Unmarshal(jp string) error { 21 | err := json.Unmarshal([]byte(jp), &p) 22 | return err 23 | } 24 | 25 | // Fetch a Person from the DB 26 | func (c *ChickenLittle) GetPerson(p string) (*Person, error) { 27 | jp, err := c.DB.Fetch("people", p) 28 | if err != nil { 29 | return nil, fmt.Errorf("Could not fetch person %v from DB", p) 30 | } 31 | 32 | peep := &Person{} 33 | 34 | err = peep.Unmarshal(jp) 35 | if err != nil { 36 | return nil, fmt.Errorf("Could not unmarshal person from DB. Err: %v JSON: %v", err, jp) 37 | } 38 | 39 | return peep, nil 40 | } 41 | 42 | // Fetch every Person from the DB 43 | func (c *ChickenLittle) GetAllPeople() ([]*Person, error) { 44 | var peeps []*Person 45 | 46 | jp, err := c.DB.FetchAll("people") 47 | if err != nil { 48 | log.Println("Error fetching all people from DB:", err, "(Have you added any people?)") 49 | return nil, fmt.Errorf("Could not fetch all people from DB") 50 | } 51 | 52 | for _, v := range jp { 53 | peep := &Person{} 54 | 55 | err = peep.Unmarshal(v) 56 | if err != nil { 57 | return nil, fmt.Errorf("Could not unmarshal person from DB. Err: %v JSON: %v", err, jp) 58 | } 59 | 60 | peeps = append(peeps, peep) 61 | } 62 | 63 | return peeps, nil 64 | } 65 | 66 | // Store a Person in the DB 67 | func (c *ChickenLittle) StorePerson(p *Person) error { 68 | jp, err := p.Marshal() 69 | if err != nil { 70 | return fmt.Errorf("Could not marshal person %+v", p) 71 | } 72 | 73 | err = c.DB.Store("people", p.Username, string(jp)) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // Delete a Person from the DB 82 | func (c *ChickenLittle) DeletePerson(p string) error { 83 | err := c.DB.Delete("people", p) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /api_person_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | const testCreatePersonJson = ` 13 | { 14 | "username": "lancelot", 15 | "fullname": "Sir Lancelot" 16 | } 17 | ` 18 | 19 | const testUpdatePersonJson = ` 20 | { 21 | "username": "lancelot", 22 | "fullname": "Sir" 23 | } 24 | ` 25 | 26 | func TestPerson(t *testing.T) { 27 | var w *httptest.ResponseRecorder 28 | var r *http.Request 29 | var p *bytes.Buffer 30 | var err error 31 | 32 | // create tempdir for fs based tests 33 | tempdir, _ := ioutil.TempDir(os.TempDir(), "chickenlittle-tests-") 34 | defer func() { 35 | // remove tempdir 36 | _ = os.RemoveAll(tempdir) 37 | }() 38 | dbfile := tempdir + "/db" 39 | 40 | // open BoldDB handle 41 | c.DB.Open(dbfile) 42 | defer c.DB.Close() 43 | 44 | // prepare the API router 45 | router := apiRouter() 46 | 47 | // Test CreatePerson: POST /people 48 | w = httptest.NewRecorder() 49 | p = bytes.NewBufferString(testCreatePersonJson) 50 | r, err = http.NewRequest("POST", "http://localhost/people", p) 51 | if err != nil { 52 | t.Fatalf("Failed to create new HTTP Request: %s", err) 53 | } 54 | router.ServeHTTP(w, r) 55 | // verify response 56 | if w.Code != 200 { 57 | t.Errorf("CreatePerson request failed") 58 | } 59 | 60 | // Test ListPeople: GET /people 61 | w = httptest.NewRecorder() 62 | r, err = http.NewRequest("GET", "http://localhost/people", nil) 63 | if err != nil { 64 | t.Fatalf("Failed to create new HTTP Request: %s", err) 65 | } 66 | router.ServeHTTP(w, r) 67 | // verify response 68 | if w.Code != 200 { 69 | t.Errorf("ListPeople request failed") 70 | } 71 | 72 | // Test ShowPerson: GET /people/lancelot 73 | w = httptest.NewRecorder() 74 | r, err = http.NewRequest("GET", "http://localhost/people/lancelot", nil) 75 | if err != nil { 76 | t.Fatalf("Failed to create new HTTP Request: %s", err) 77 | } 78 | router.ServeHTTP(w, r) 79 | // verify response 80 | if w.Code != 200 { 81 | t.Errorf("ShowPerson request failed") 82 | } 83 | 84 | // Test UpdatePerson: PUT /people/lancelog 85 | w = httptest.NewRecorder() 86 | p = bytes.NewBufferString(testUpdatePersonJson) 87 | r, err = http.NewRequest("PUT", "http://localhost/people/lancelot", p) 88 | if err != nil { 89 | t.Fatalf("Failed to create new HTTP Request: %s", err) 90 | } 91 | router.ServeHTTP(w, r) 92 | // verify response 93 | if w.Code != 200 { 94 | t.Errorf("UpdatePerson request failed") 95 | } 96 | 97 | // Test DeletePerson: DELETE /people/lancelot 98 | w = httptest.NewRecorder() 99 | r, err = http.NewRequest("DELETE", "http://localhost/people/lancelot", nil) 100 | if err != nil { 101 | t.Fatalf("Failed to create new HTTP Request: %s", err) 102 | } 103 | router.ServeHTTP(w, r) 104 | // verify response 105 | if w.Code != 200 { 106 | t.Errorf("ShowPerson request failed") 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/boltdb/bolt" 6 | "log" 7 | ) 8 | 9 | type DB struct { 10 | Handle *bolt.DB 11 | } 12 | 13 | // Open the BoltDB file 14 | func (db *DB) Open(dbfile string) { 15 | var err error 16 | 17 | db.Handle, err = bolt.Open(dbfile, 0600, nil) 18 | if err != nil { 19 | log.Fatalln(err) 20 | } 21 | 22 | return 23 | } 24 | 25 | // Close the BoltDB file 26 | func (db *DB) Close() { 27 | db.Handle.Close() 28 | return 29 | } 30 | 31 | // Store a key/value in a BoltDB bucket 32 | func (d *DB) Store(bucket, key, value string) error { 33 | 34 | log.Println("Storing:", key) 35 | 36 | err := d.Handle.Update(func(tx *bolt.Tx) error { 37 | bkt, err := tx.CreateBucketIfNotExists([]byte(bucket)) 38 | if err != nil { 39 | return fmt.Errorf("Could not create bucket %q", bucket) 40 | } 41 | 42 | err = bkt.Put([]byte(key), []byte(value)) 43 | if err != nil { 44 | return fmt.Errorf("Could not write key %q to bucket %q: %v", key, bucket, err) 45 | } 46 | 47 | return nil 48 | }) 49 | 50 | return err 51 | } 52 | 53 | // Delete a key from a BoltDB bucket 54 | func (d *DB) Delete(bucket, key string) error { 55 | 56 | log.Println("Deleting", key, "from bucket", bucket) 57 | 58 | err := d.Handle.Update(func(tx *bolt.Tx) error { 59 | bkt := tx.Bucket([]byte(bucket)) 60 | if bkt == nil { 61 | return fmt.Errorf("Could not locate bucket to delete: %q", bucket) 62 | } 63 | 64 | err := bkt.Delete([]byte(key)) 65 | if err != nil { 66 | return fmt.Errorf("Could not delete bucket %q: %v", bucket, err) 67 | } 68 | 69 | return nil 70 | }) 71 | 72 | return err 73 | } 74 | 75 | // Fetch a key from a BoltDB bucket 76 | func (d *DB) Fetch(bucket, key string) (string, error) { 77 | 78 | var val string 79 | 80 | log.Println("Fetching", key, "from bucket", bucket) 81 | 82 | err := d.Handle.View(func(tx *bolt.Tx) error { 83 | bkt := tx.Bucket([]byte(bucket)) 84 | if bkt == nil { 85 | return fmt.Errorf("Bucket %q not found!", bucket) 86 | } 87 | 88 | val = string(bkt.Get([]byte(key))) 89 | 90 | return nil 91 | }) 92 | 93 | if val == "" { 94 | return "", fmt.Errorf("Key %q in bucket %q not found", key, bucket) 95 | } 96 | 97 | return val, err 98 | } 99 | 100 | // Fetch every key from a BoltDB bucket 101 | func (d *DB) FetchAll(bucket string) ([]string, error) { 102 | var vals []string 103 | 104 | log.Println("Fetching all from bucket", bucket) 105 | 106 | err := d.Handle.View(func(tx *bolt.Tx) error { 107 | bkt := tx.Bucket([]byte(bucket)) 108 | if bkt == nil { 109 | return fmt.Errorf("Bucket %q not found!", bucket) 110 | } 111 | 112 | bkt.ForEach(func(k, v []byte) error { 113 | vals = append(vals, string(v)) 114 | return nil 115 | }) 116 | 117 | if len(vals) == 0 { 118 | return fmt.Errorf("There are no items in bucket %v", bucket) 119 | 120 | } 121 | 122 | return nil 123 | }) 124 | 125 | return vals, err 126 | 127 | } 128 | -------------------------------------------------------------------------------- /api_notification_plan_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | const testCreateNotificationPlanJson = ` 13 | [ 14 | { 15 | "method": "noop://2108675309", 16 | "notify_every_period": 0, 17 | "notify_until_period": 300000000000 18 | }, 19 | { 20 | "method": "noop://2105551212", 21 | "notify_every_period": 900000000000, 22 | "notify_until_period": 0 23 | } 24 | ] 25 | ` 26 | 27 | const testUpdateNotificationPlanJson = ` 28 | [ 29 | { 30 | "method": "noop://2108675309", 31 | "notify_every_period": 0, 32 | "notify_until_period": 300000000000 33 | } 34 | ] 35 | ` 36 | 37 | func TestNotificationPlan(t *testing.T) { 38 | var w *httptest.ResponseRecorder 39 | var r *http.Request 40 | var p *bytes.Buffer 41 | var err error 42 | 43 | // create tempdir for fs based tests 44 | tempdir, _ := ioutil.TempDir(os.TempDir(), "chickenlittle-tests-") 45 | defer func() { 46 | // remove tempdir on exit 47 | _ = os.RemoveAll(tempdir) 48 | }() 49 | dbfile := tempdir + "/db" 50 | 51 | // open BoldDB handle 52 | c.DB.Open(dbfile) 53 | defer c.DB.Close() 54 | 55 | // prepare the API router 56 | router := apiRouter() 57 | 58 | // We need a Person to test the notification plans 59 | w = httptest.NewRecorder() 60 | p = bytes.NewBufferString(testCreatePersonJson) 61 | r, err = http.NewRequest("POST", "http://localhost/people", p) 62 | if err != nil { 63 | t.Fatalf("Failed to create new HTTP Request: %s", err) 64 | } 65 | router.ServeHTTP(w, r) 66 | // verify response 67 | if w.Code != 200 { 68 | t.Fatalf("CreatePerson request failed") 69 | } 70 | 71 | // Test CreateNotificationPlan: POST /plan/{{username}} 72 | w = httptest.NewRecorder() 73 | p = bytes.NewBufferString(testCreateNotificationPlanJson) 74 | r, err = http.NewRequest("POST", "http://localhost/plan/lancelot", p) 75 | if err != nil { 76 | t.Fatalf("Failed to create new HTTP Request: %s", err) 77 | } 78 | router.ServeHTTP(w, r) 79 | // verify response 80 | if w.Code != 200 { 81 | t.Errorf("CreateNotificationPlan request failed") 82 | } 83 | 84 | // Test ShowNotificationPlan: GET /plan/lancelot 85 | w = httptest.NewRecorder() 86 | r, err = http.NewRequest("GET", "http://localhost/plan/lancelot", nil) 87 | if err != nil { 88 | t.Fatalf("Failed to create new HTTP Request: %s", err) 89 | } 90 | router.ServeHTTP(w, r) 91 | // verify response 92 | if w.Code != 200 { 93 | t.Errorf("ShowNotificationPlan request failed") 94 | } 95 | 96 | // Test UpdateNotificaitonPlan: PUT /plan/lancelot 97 | w = httptest.NewRecorder() 98 | p = bytes.NewBufferString(testUpdateNotificationPlanJson) 99 | r, err = http.NewRequest("PUT", "http://localhost/plan/lancelot", p) 100 | if err != nil { 101 | t.Fatalf("Failed to create new HTTP Request: %s", err) 102 | } 103 | router.ServeHTTP(w, r) 104 | // verify response 105 | if w.Code != 200 { 106 | t.Errorf("UpdateNotificationPlan request failed") 107 | } 108 | 109 | // Test DeleteNotificationPlan: DELETE /plan/lancelot 110 | w = httptest.NewRecorder() 111 | r, err = http.NewRequest("DELETE", "http://localhost/plan/lancelot", nil) 112 | if err != nil { 113 | t.Fatalf("Failed to create new HTTP Request: %s", err) 114 | } 115 | router.ServeHTTP(w, r) 116 | // verify response 117 | if w.Code != 200 { 118 | t.Errorf("DeleteNotificationPlan request failed") 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /chickenlittle.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "path/filepath" 9 | 10 | "github.com/gorilla/mux" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | var ( 15 | cfgFile *string 16 | c ChickenLittle 17 | NIP NotificationsInProgress 18 | planChan = make(chan *NotificationRequest) 19 | ) 20 | 21 | type ChickenLittle struct { 22 | Config Config 23 | DB DB 24 | } 25 | 26 | func main() { 27 | 28 | cfgFile = flag.String("config", "config.yaml", "Path to config file (default: ./config.yaml)") 29 | flag.Parse() 30 | 31 | // Read our server configuration 32 | filename, _ := filepath.Abs(*cfgFile) 33 | cfgFile, err := ioutil.ReadFile(filename) 34 | if err != nil { 35 | log.Fatalln("Error opening config file. Did you pass the -config flag? Run with -h for help.\n", err) 36 | } 37 | err = yaml.Unmarshal(cfgFile, &c.Config) 38 | if err != nil { 39 | log.Fatalln("Error:", err) 40 | } 41 | 42 | // Open our BoltDB handle 43 | c.DB.Open(c.Config.Service.DBFile) 44 | defer c.DB.Close() 45 | 46 | // Create our stop channel and launch the notification engine 47 | stopChan = make(chan string) 48 | go StartNotificationEngine() 49 | 50 | // Set up our API endpoint router 51 | go func() { 52 | log.Fatal(http.ListenAndServe(c.Config.Service.APIListenAddr, apiRouter())) 53 | }() 54 | 55 | // Set up our Twilio callback endpoint router 56 | go func() { 57 | log.Fatal(http.ListenAndServe(c.Config.Service.CallbackListenAddr, callbackRouter())) 58 | }() 59 | 60 | // Set up our Click endpoint router to handle stop requests from browsers 61 | log.Fatal(http.ListenAndServe(c.Config.Service.ClickListenAddr, clickRouter())) 62 | } 63 | 64 | func apiRouter() *mux.Router { 65 | apiRouter := mux.NewRouter().StrictSlash(true) 66 | 67 | apiRouter.HandleFunc("/people", ListPeople). 68 | Methods("GET") 69 | 70 | apiRouter.HandleFunc("/people", CreatePerson). 71 | Methods("POST") 72 | 73 | apiRouter.HandleFunc("/people/{person}", ShowPerson). 74 | Methods("GET") 75 | 76 | apiRouter.HandleFunc("/people/{person}", DeletePerson). 77 | Methods("DELETE") 78 | 79 | apiRouter.HandleFunc("/people/{person}", UpdatePerson). 80 | Methods("PUT") 81 | 82 | apiRouter.HandleFunc("/plan/{person}", CreateNotificationPlan). 83 | Methods("POST") 84 | 85 | apiRouter.HandleFunc("/plan/{person}", ShowNotificationPlan). 86 | Methods("GET") 87 | 88 | apiRouter.HandleFunc("/plan/{person}", DeleteNotificationPlan). 89 | Methods("DELETE") 90 | 91 | apiRouter.HandleFunc("/plan/{person}", UpdateNotificationPlan). 92 | Methods("PUT") 93 | 94 | apiRouter.HandleFunc("/people/{person}/notify", NotifyPerson). 95 | Methods("POST") 96 | 97 | apiRouter.HandleFunc("/notifications/{uuid}", StopNotification). 98 | Methods("DELETE") 99 | 100 | return apiRouter 101 | } 102 | 103 | func callbackRouter() *mux.Router { 104 | callbackRouter := mux.NewRouter().StrictSlash(true) 105 | 106 | callbackRouter.HandleFunc("/{uuid}/twiml/{action}", GenerateTwiML). 107 | Methods("POST") 108 | 109 | callbackRouter.HandleFunc("/{uuid}/callback", ReceiveCallback). 110 | Methods("POST") 111 | 112 | callbackRouter.HandleFunc("/{uuid}/digits", ReceiveDigits). 113 | Methods("POST") 114 | 115 | callbackRouter.HandleFunc("/sms", ReceiveSMSReply). 116 | Methods("POST") 117 | 118 | return callbackRouter 119 | } 120 | 121 | func clickRouter() *mux.Router { 122 | clickRouter := mux.NewRouter().StrictSlash(true) 123 | 124 | clickRouter.HandleFunc("/{uuid}/stop", StopNotificationClick). 125 | Methods("GET") 126 | 127 | return clickRouter 128 | } 129 | -------------------------------------------------------------------------------- /api_notification_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | const testCreateNotificationJson = ` 15 | { 16 | "content": "Hello World", 17 | } 18 | ` 19 | 20 | func TestNotification(t *testing.T) { 21 | var w *httptest.ResponseRecorder 22 | var r *http.Request 23 | var p *bytes.Buffer 24 | var err error 25 | 26 | // create tempdir for fs based tests 27 | tempdir, _ := ioutil.TempDir(os.TempDir(), "chickenlittle-tests-") 28 | defer func() { 29 | // remove tempdir 30 | _ = os.RemoveAll(tempdir) 31 | }() 32 | dbfile := tempdir + "/db" 33 | 34 | // open BoldDB handle 35 | c.DB.Open(dbfile) 36 | defer c.DB.Close() 37 | 38 | // The notification engine must be running, or we'll run into an deadlock 39 | stopChan = make(chan string) 40 | go StartNotificationEngine() 41 | 42 | // prepare the API router 43 | router := apiRouter() 44 | 45 | // Create a person to test with 46 | w = httptest.NewRecorder() 47 | p = bytes.NewBufferString(testCreatePersonJson) 48 | r, err = http.NewRequest("POST", "http://localhost/people", p) 49 | if err != nil { 50 | t.Fatalf("Failed to create new HTTP Request: %s", err) 51 | } 52 | router.ServeHTTP(w, r) 53 | // verify response 54 | if w.Code != 200 { 55 | t.Fatalf("CreatePerson request failed") 56 | } 57 | 58 | // Create a notification plan 59 | w = httptest.NewRecorder() 60 | p = bytes.NewBufferString(testCreateNotificationPlanJson) 61 | r, err = http.NewRequest("POST", "http://localhost/plan/lancelot", p) 62 | if err != nil { 63 | t.Fatalf("Failed to create new HTTP Request: %s", err) 64 | } 65 | router.ServeHTTP(w, r) 66 | // verify response 67 | if w.Code != 200 { 68 | t.Fatalf("CreateNotificaitonPlan request failed") 69 | } 70 | 71 | // Test NotifyPerson: POST /people/lancelot/notify 72 | w = httptest.NewRecorder() 73 | p = bytes.NewBufferString(testCreateNotificationJson) 74 | r, err = http.NewRequest("POST", "http://localhost/people/lancelot/notify", p) 75 | if err != nil { 76 | t.Fatalf("Failed to create new HTTP Request: %s", err) 77 | } 78 | router.ServeHTTP(w, r) 79 | // verify response 80 | if w.Code != 200 { 81 | t.Fatalf("NotifyPerson request failed") 82 | } 83 | 84 | // decode the response to extract the UUID for the following API calls 85 | resp := &NotifyPersonResponse{} 86 | err = json.Unmarshal(w.Body.Bytes(), &resp) 87 | if err != nil { 88 | t.Fatalf("Failed to unmarshal: %s", err) 89 | } 90 | uuid := resp.UUID 91 | if resp.Error != "" { 92 | t.Fatalf("Notification Error: %s", resp.Error) 93 | } 94 | t.Logf("UUID: %s", uuid) 95 | // we need to give the NotificationEndine some time to pick up the notification job 96 | time.Sleep(time.Millisecond) 97 | 98 | // Test StopNotification: /notificaionts/{{uuid}} 99 | w = httptest.NewRecorder() 100 | r, err = http.NewRequest("DELETE", "http://localhost/notifications/"+uuid, nil) 101 | if err != nil { 102 | t.Fatalf("Failed to create new HTTP Request: %s", err) 103 | } 104 | router.ServeHTTP(w, r) 105 | // verify response 106 | if w.Code != 200 { 107 | t.Errorf("StopNotification request failed: %d", w.Code) 108 | } 109 | 110 | // Test StopNotificationClick: ??? 111 | // args: uuid 112 | w = httptest.NewRecorder() 113 | r, err = http.NewRequest("GET", "http://localhost/"+uuid+"/stop", nil) 114 | if err != nil { 115 | t.Fatalf("Failed to create new HTTP Request: %s", err) 116 | } 117 | clickRouter().ServeHTTP(w, r) 118 | // verify response 119 | if w.Code != 200 { 120 | t.Fatalf("StopNotificationClick request failed: %d", w.Code) 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chicken Little 2 | **A RESTful service to get ahold of people, quickly.** 3 | 4 | - Uses phone calls, SMS, and e-mail to send short messages to people registered with the service. 5 | - Allows for per-user configurable contact plans (e.g., "Send me an SMS. If I don't reply within five minutes, call me on the phone. If I don't answer, keep calling back every ten minutes until I do."). 6 | - Uses Twilio and Mailgun (or your own SMTP server) to handle the contacting. 7 | 8 | # Requirements 9 | You'll need a Twilio account if you want to notify by voice and/or SMS. You'll need a Mailgun account or a SMTP server if you want to notify by e-mail. 10 | 11 | # API 12 | - **[People API](https://github.com/chrissnell/chickenlittle/blob/master/docs/PEOPLE_API.md)** - used for adding and deleting people in the system. 13 | - **[Notification Plan API](https://github.com/chrissnell/chickenlittle/blob/master/docs/NOTIFICATION_PLAN_API.md)** - used to define how people are notified (contact methods, order, and timing) 14 | - **[Notification API](https://github.com/chrissnell/chickenlittle/blob/master/docs/NOTIFICATION_API.md)** - used to send notifications to a person using their notification plan 15 | 16 | # Quick Start 17 | 1. You'll need [Go](http://golang.org/) installed to build the binary. 18 | 19 | 2. Fetch and build Chicken Little: 20 | ``` 21 | % go get github.com/chrissnell/chickenlittle 22 | ``` 23 | 24 | 3. Make a directory for the config file (config.yaml) and the database (chickenlittle.db) to live: 25 | ```sudo mkdir /opt/chickenlittle``` 26 | 27 | 4. Copy the binary you just built into wherever you like to keep third-party software: 28 | ```sudo cp $GOPATH/bin/chickenlittle /usr/local/bin/``` 29 | 30 | 5. Copy the sample config.yaml into the directory you made in step 3: 31 | ```sudo cp $GOPATH/src/github.com/chrissnell/chickenlittle/config.yaml.sample /opt/chickenlittle/config.yaml``` 32 | 33 | 6. Edit the config file and fill in your Twilio and/or Mailgun API keys, endpoint URLs, etc. For the click_url_base and callback_url_base, you can use a service like [ngrok](http://ngrok.com) for testing or you can run Chicken Little on a public network and put the base URL to your server here. 34 | 35 | 7. Start the Chicken Little service: 36 | ```/usr/local/bin/chickenlittle -config PATH_TO_YOUR_CONFIG_YAML``` 37 | 38 | 8. Follow the API instructions to create users and set up notification plans 39 | 40 | # To Do 41 | - Implement on-call rotations for teams of people 42 | - Authentication and role-based access control (RBAC) for various API functions. 43 | - More test coverage 44 | 45 | # Authors 46 | - [Christopher Snell](http://output.chrissnell.com) - Chicken Little author 47 | - [Dominik Schulz](https://github.com/dominikschulz) - Wrote the SMTP server support and the Team API 48 | 49 | 50 | # License 51 | ``` 52 | Copyright (C) 2015 Christopher Snell 53 | 54 | Permission is hereby granted, free of charge, to any person obtaining a copy 55 | of this software and associated documentation files (the "Software"), to deal 56 | in the Software without restriction, including without limitation the rights 57 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 58 | copies of the Software, and to permit persons to whom the Software is 59 | furnished to do so, subject to the following conditions: 60 | 61 | The above copyright notice and this permission notice shall be included in 62 | all copies or substantial portions of the Software. 63 | 64 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 65 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 66 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 67 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 68 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 69 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 70 | THE SOFTWARE. 71 | ``` 72 | -------------------------------------------------------------------------------- /api_notification.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/twinj/uuid" 12 | ) 13 | 14 | type NotificationRequest struct { 15 | Content string `json:"content"` 16 | Plan *NotificationPlan `json:"-"` 17 | } 18 | 19 | type NotifyPersonResponse struct { 20 | Username string `json:"username"` 21 | UUID string `json:"uuid"` 22 | Content string `json:"content"` 23 | Message string `json:"message"` 24 | Error string `json:"error"` 25 | } 26 | 27 | // Notifies a Person by looking up their NotificationPlan and sending it to the notification engine. 28 | func NotifyPerson(w http.ResponseWriter, r *http.Request) { 29 | var res NotifyPersonResponse 30 | var req NotificationRequest 31 | 32 | vars := mux.Vars(r) 33 | username := vars["person"] 34 | 35 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 36 | 37 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*20)) 38 | // If something went wrong, return an error in the JSON response 39 | if err != nil { 40 | res.Error = err.Error() 41 | json.NewEncoder(w).Encode(res) 42 | return 43 | } 44 | 45 | err = r.Body.Close() 46 | if err != nil { 47 | res.Error = err.Error() 48 | json.NewEncoder(w).Encode(res) 49 | return 50 | } 51 | 52 | err = json.Unmarshal(body, &req) 53 | 54 | req.Plan, err = c.GetNotificationPlan(username) 55 | if err != nil { 56 | // res.Error = err.Error() 57 | // errjson, _ := json.Marshal(res) 58 | http.Error(w, err.Error(), http.StatusNotFound) 59 | return 60 | } 61 | 62 | // Assign a UUID to this notification. The UUID is used to track notifications-in-progress (NIP) and to stop 63 | // them when requested. 64 | uuid.SwitchFormat(uuid.CleanHyphen) 65 | req.Plan.ID = uuid.NewV4() 66 | 67 | // Send our NotificationRequest to the notification engine 68 | planChan <- &req 69 | 70 | res = NotifyPersonResponse{ 71 | Message: "Notification initiated", 72 | Content: req.Content, 73 | UUID: req.Plan.ID.String(), 74 | Username: req.Plan.Username, 75 | } 76 | 77 | json.NewEncoder(w).Encode(res) 78 | 79 | } 80 | 81 | // Stop a notification-in-progress (NIP) by sending the UUID to the notification engine 82 | func StopNotification(w http.ResponseWriter, r *http.Request) { 83 | var res NotifyPersonResponse 84 | 85 | vars := mux.Vars(r) 86 | id := vars["uuid"] 87 | 88 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 89 | 90 | if _, exists := NIP.Stoppers[id]; !exists { 91 | res = NotifyPersonResponse{ 92 | Error: "No active notifications for this UUID", 93 | UUID: id, 94 | } 95 | errjson, _ := json.Marshal(res) 96 | http.Error(w, string(errjson), http.StatusNotFound) 97 | return 98 | } 99 | 100 | // Attempt to stop the notification by sending the UUID to the notification engine 101 | stopChan <- id 102 | 103 | // TO DO: make sure that this is a valid UUID and obtain 104 | // confirmation of deletion 105 | 106 | res = NotifyPersonResponse{ 107 | Message: "Attempting to terminate notification", 108 | UUID: id, 109 | } 110 | 111 | json.NewEncoder(w).Encode(res) 112 | } 113 | 114 | // A simple GET-able endpoint to stop notifications when a link is clicked in an email client 115 | func StopNotificationClick(w http.ResponseWriter, r *http.Request) { 116 | 117 | vars := mux.Vars(r) 118 | id := vars["uuid"] 119 | 120 | w.Header().Set("Content-Type", "text/html; charset=UTF-8") 121 | 122 | if _, exists := NIP.Stoppers[id]; !exists { 123 | http.Error(w, fmt.Sprint("UUID not found."), http.StatusNotFound) 124 | return 125 | } 126 | 127 | // Attempt to stop the notification by sending the UUID to the notification engine 128 | stopChan <- id 129 | 130 | fmt.Fprintln(w, "Thank you!

Chicken Little has received your acknowledgement and you will no longer be notified with this message.") 131 | } 132 | -------------------------------------------------------------------------------- /docs/NOTIFICATION_PLAN_API.md: -------------------------------------------------------------------------------- 1 | # Notification Plan API 2 | 3 | ## About the Notification Plan 4 | 5 | The notfication plan is stored as a JSON array of steps to take when notifying a person. The order of the array is the order in which the steps are followed. An example plan looks like this: 6 | 7 | ```json 8 | [ 9 | { 10 | "method": "sms://2108675309", 11 | "notify_every_period": 0, 12 | "notify_until_period": 900000000000 13 | }, 14 | { 15 | "method": "phone://2105551212", 16 | "notify_every_period": 0, 17 | "notify_until_period": 900000000000 18 | }, 19 | { 20 | "method": "email://lancelot@roundtable.org.uk", 21 | "notify_every_period": 300000000000, 22 | "notify_until_period": 0 23 | } 24 | ] 25 | ``` 26 | 27 | The fields of a step are as follows: 28 | 29 | | Field | Description | 30 | |:-------|:-------------| 31 | |```method```| **Method of notification** The following are valid examples: ```phone://2108675309```, ```sms://2105551212```, ```email://lancelot@roundtable.org.uk``` | 32 | |```notify_every_period```|**Period of time in which to repeat a notification** Time is stored in nanoseconds. 1 minute = 60000000000. This is only relevant to the *last* notification step in the array, since the last step is the only one repeated *ad infinitum* until the person responds. A ```0``` value indicates that this step will only be followed once and not repeated. If this field is set for a step that's not the last in the array, it will be ignored. | 33 | |```notify_until_period```|**Period of time in which the service waits for a response before proceeding to the next notification step in the array** Time is stored in nanoseconds. 1 minute = 60000000000. A ```0``` value is not valid for this field and will result in the step being skipped. If this field is set for the very last step in the array, it will be ignored. | 34 | 35 | ## Notification Plan API Methods 36 | 37 | ### Get notification plan for a person 38 | 39 | **Request** 40 | ``` 41 | GET /plan/USERNAME 42 | ``` 43 | 44 | **Example Response** 45 | ``` 46 | HTTP/1.1 200 OK 47 | ``` 48 | ```json 49 | { 50 | "people": { 51 | "username": "lancelot", 52 | "steps": [ 53 | { 54 | "method": "sms://2108675309", 55 | "notify_every_period": 0, 56 | "notify_until_period": 300000000000 57 | }, 58 | { 59 | "method": "phone://2105551212", 60 | "notify_every_period": 900000000000, 61 | "notify_until_period": 0 62 | } 63 | ] 64 | }, 65 | "message": "", 66 | "error": "" 67 | } 68 | ``` 69 | 70 | ### Create a new notification plan for a person 71 | 72 | **Request** 73 | ``` 74 | POST /plan/USERNAME 75 | 76 | [ 77 | { 78 | "method": "sms://2108675309", 79 | "notify_every_period": 0, 80 | "notify_until_period": 300000000000 81 | }, 82 | { 83 | "method": "phone://2105551212", 84 | "notify_every_period": 900000000000, 85 | "notify_until_period": 0 86 | } 87 | ] 88 | ``` 89 | 90 | **Example Response** 91 | ``` 92 | HTTP/1.1 200 OK 93 | ``` 94 | ```json 95 | { 96 | "people": { 97 | "username": "" 98 | }, 99 | "message": "Notification plan for user lancelot created", 100 | "error": "" 101 | } 102 | ``` 103 | 104 | 105 | ### Update an existing notification plan for a person 106 | 107 | **Request** 108 | 109 | **Note:** The API does not support atomic updates of notification plans. You need to post the entire plan even if you're just updating part of it. 110 | ``` 111 | PUT /plan/USERNAME 112 | 113 | [ 114 | { 115 | "method": "phone://2105551212", 116 | "notify_every_period": 0, 117 | "notify_until_period": 300000000000 118 | }, 119 | { 120 | "method": "sms://2108675309", 121 | "notify_every_period": 600000000000, 122 | "notify_until_period": 0 123 | } 124 | ] 125 | ``` 126 | 127 | **Example Response** 128 | ``` 129 | HTTP/1.1 200 OK 130 | ``` 131 | ```json 132 | { 133 | "people": { 134 | "username": "lancelot", 135 | "steps": [ 136 | { 137 | "method": "phone://2105551212", 138 | "notify_every_period": 0, 139 | "notify_until_period": 300000000000 140 | }, 141 | { 142 | "method": "sms://2108675309", 143 | "notify_every_period": 600000000000, 144 | "notify_until_period": 0 145 | } 146 | ] 147 | }, 148 | "message": "Notification plan for user lancelot updated", 149 | "error": "" 150 | } 151 | ``` 152 | 153 | 154 | ### Delete a notification plan for a person 155 | 156 | **Request** 157 | ``` 158 | DELETE /plan/USERNAME 159 | ``` 160 | 161 | **Example Response** 162 | ``` 163 | HTTP/1.1 200 OK 164 | ``` 165 | ```json 166 | { 167 | "people": { 168 | "username": "" 169 | }, 170 | "message": "Notification plan for user lancelot deleted", 171 | "error": "" 172 | } 173 | ``` 174 | -------------------------------------------------------------------------------- /notification.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "strconv" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | var ( 13 | stopChan chan string 14 | ) 15 | 16 | type NotificationsInProgress struct { 17 | Stoppers map[string]chan bool 18 | Messages map[string]string 19 | Conversations map[string]string 20 | Mu sync.Mutex 21 | } 22 | 23 | // Start the main notification loop. This loop receives notifications on planChan and launches the notificationHandler 24 | // to carry out the actual notifications. Also receives requests to stop notifications on stopChan and then stops them. 25 | func StartNotificationEngine() { 26 | // Initialize our map of Stopper channels 27 | // UUID -> channel 28 | NIP.Stoppers = make(map[string]chan bool) 29 | 30 | // Initialize our map of Messages 31 | NIP.Messages = make(map[string]string) 32 | 33 | // Initialize our map of Conversations 34 | NIP.Conversations = make(map[string]string) 35 | 36 | log.Println("StartNotificationEngine()") 37 | 38 | for { 39 | 40 | select { 41 | // IMPROVEMENT: We could implement a close() of planChan to indicate that the service is shutting down 42 | // and instruct all notifications to cease 43 | case nr := <-planChan: 44 | // We've received a new notification plan 45 | 46 | // Get the plan's UUID 47 | id := nr.Plan.ID.String() 48 | 49 | NIP.Mu.Lock() 50 | 51 | // Create a new Stopper channel for this plan 52 | NIP.Stoppers[id] = make(chan bool) 53 | 54 | // Save the message to NIP.Message 55 | NIP.Messages[id] = nr.Content 56 | 57 | // Launch a goroutine to handle plan processing 58 | go notificationHandler(nr, NIP.Stoppers[id]) 59 | 60 | NIP.Mu.Unlock() 61 | case stopUUID := <-stopChan: 62 | // We've received a request to stop a notification plan 63 | NIP.Mu.Lock() 64 | 65 | // Check to see if the requested UUID is actually in progress 66 | _, prs := NIP.Stoppers[stopUUID] 67 | if prs { 68 | 69 | log.Println("[", stopUUID, "]", "Sending a stop notification to the plan processor") 70 | 71 | // It's in progress, so we'll send a message on its Stopper to 72 | // be received by the goroutine executing the plan 73 | NIP.Stoppers[stopUUID] <- true 74 | } 75 | NIP.Mu.Unlock() 76 | } 77 | } 78 | 79 | } 80 | 81 | // Receives notification requests from the notification engine and steps through the plan, making phone calls, 82 | // sending SMS, email, etc., as necessary. 83 | func notificationHandler(nr *NotificationRequest, sc <-chan bool) { 84 | 85 | var timerChan <-chan time.Time 86 | var tickerChan <-chan time.Time 87 | 88 | uuid := nr.Plan.ID.String() 89 | log.Println("[", uuid, "]", "Initiating notification plan") 90 | 91 | // Iterate through each step of the plan 92 | for n, s := range nr.Plan.Steps { 93 | 94 | // TO DO: validate Method here and return an error if it's unsupported 95 | u, err := url.Parse(s.Method) 96 | if err != nil { 97 | log.Println("Error parsing URI:", err) 98 | log.Println("Advancing to next step in plan.") 99 | continue 100 | } 101 | 102 | log.Println("[", uuid, "]", "Method:", s.Method) 103 | 104 | stepLoop: 105 | // This outer loop repeats a notification until it's acknowledged. It can be broken by the expiration of the timer for this step, 106 | // or by a stop request. 107 | for { 108 | 109 | // Take the appropriate action, depending on the type of notification 110 | switch u.Scheme { 111 | case "phone": 112 | MakePhoneCall(u.Host, nr.Content, uuid) 113 | case "sms": 114 | SendSMS(u.Host, nr.Content, uuid, false) 115 | case "email": 116 | SendEmail(fmt.Sprint(u.User, "@", u.Host), nr.Content, uuid) 117 | } 118 | 119 | if n == len(nr.Plan.Steps)-1 { 120 | // We're at the last step of the plan, so this step will repeat until ackknowledged. We use a Ticker and set its period to NotifyEveryPeriod 121 | tickerChan = time.NewTicker(s.NotifyEveryPeriod).C 122 | log.Println("[", uuid, "]", "Scheduling the next retry in", strconv.FormatFloat(s.NotifyEveryPeriod.Minutes(), 'f', 1, 64), "minutes") 123 | 124 | } else { 125 | // We're not at the last step, so we only run this step once. We use Timer set its duration to NotifyUntilPeriod 126 | timerChan = time.NewTimer(s.NotifyUntilPeriod).C 127 | log.Println("[", uuid, "]", "Scheduling the next notification step in", strconv.FormatFloat(s.NotifyUntilPeriod.Minutes(), 'f', 1, 64), "minutes") 128 | } 129 | 130 | timerLoop: 131 | // This inner loop selects over various channels to receive timers and stop requests. 132 | // It can be broken by an expiring Timer (signaling that it's time to proceed to the next step) or a stop request. 133 | for { 134 | select { 135 | case <-timerChan: 136 | // Our timer for this step has expired so we break the outer loop to proceed to the next step. 137 | log.Println("[", uuid, "]", "Step timer expired. Proceeding to next plan step.") 138 | break stepLoop 139 | case <-tickerChan: 140 | // Our ticker for this step expired, so we'll break the inner loop and try this step again. 141 | log.Println("[", uuid, "]", "**Tick** Retry contact method!") 142 | log.Println("[", uuid, "]", "Waiting", strconv.FormatFloat(s.NotifyEveryPeriod.Minutes(), 'f', 1, 64), "minutes]") 143 | break timerLoop 144 | case <-sc: 145 | log.Println("[", uuid, "]", "Stop request received. Terminating notifications.") 146 | NIP.Mu.Lock() 147 | defer NIP.Mu.Unlock() 148 | delete(NIP.Stoppers, uuid) 149 | delete(NIP.Messages, uuid) 150 | return 151 | } 152 | } 153 | 154 | log.Println("Loop broken") 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /api_person.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/chrissnell/victorops-go" 12 | "github.com/gorilla/mux" 13 | ) 14 | 15 | type PeopleResponse struct { 16 | People []Person `json:"people"` 17 | Message string `json:"message"` 18 | Error string `json:"error"` 19 | } 20 | 21 | type Notification struct { 22 | Username string `json:"username"` 23 | Message string `json:"message"` 24 | Priority victorops.MessageType `json:"priority,omitempty"` 25 | } 26 | 27 | // Fetches every person from the DB and returns them as JSON 28 | func ListPeople(w http.ResponseWriter, r *http.Request) { 29 | var res PeopleResponse 30 | 31 | p, err := c.GetAllPeople() 32 | if err != nil { 33 | res.Error = err.Error() 34 | errjson, _ := json.Marshal(res) 35 | http.Error(w, string(errjson), http.StatusInternalServerError) 36 | return 37 | } 38 | 39 | for _, v := range p { 40 | res.People = append(res.People, *v) 41 | } 42 | 43 | json.NewEncoder(w).Encode(res) 44 | } 45 | 46 | // Fetches a single person form the DB and returns them as JSON 47 | func ShowPerson(w http.ResponseWriter, r *http.Request) { 48 | var res PeopleResponse 49 | 50 | vars := mux.Vars(r) 51 | username := vars["person"] 52 | 53 | p, err := c.GetPerson(username) 54 | if err != nil { 55 | res.Error = err.Error() 56 | errjson, _ := json.Marshal(res) 57 | http.Error(w, string(errjson), http.StatusNotFound) 58 | return 59 | } 60 | 61 | res.People = append(res.People, *p) 62 | 63 | json.NewEncoder(w).Encode(res) 64 | } 65 | 66 | // Deletes the specified person from the database 67 | func DeletePerson(w http.ResponseWriter, r *http.Request) { 68 | var res PeopleResponse 69 | 70 | vars := mux.Vars(r) 71 | username := vars["person"] 72 | 73 | // Make sure the user actually exists before updating 74 | p, err := c.GetPerson(username) 75 | if p == nil { 76 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 77 | w.WriteHeader(422) // unprocessable entity 78 | res.Error = fmt.Sprint("User ", username, " does not exist and thus, cannot be deleted") 79 | json.NewEncoder(w).Encode(res) 80 | return 81 | } 82 | if err != nil { 83 | log.Println("GetPerson() failed for", username) 84 | } 85 | 86 | err = c.DeletePerson(username) 87 | if err != nil { 88 | http.Error(w, err.Error(), http.StatusNotFound) 89 | return 90 | } 91 | 92 | res.Message = fmt.Sprint("User ", username, " deleted") 93 | 94 | json.NewEncoder(w).Encode(res) 95 | } 96 | 97 | // Creates a new person in the database 98 | func CreatePerson(w http.ResponseWriter, r *http.Request) { 99 | var res PeopleResponse 100 | var p Person 101 | 102 | // We're getting the details of this new person from the POSTed JSON 103 | // so we first need to read in the body of the POST 104 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*10)) 105 | // If something went wrong, return an error in the JSON response 106 | if err != nil { 107 | res.Error = err.Error() 108 | json.NewEncoder(w).Encode(res) 109 | return 110 | } 111 | 112 | err = r.Body.Close() 113 | if err != nil { 114 | res.Error = err.Error() 115 | json.NewEncoder(w).Encode(res) 116 | return 117 | } 118 | 119 | // Attempt to unmarshall the JSON into our Person struct 120 | err = json.Unmarshal(body, &p) 121 | if err != nil { 122 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 123 | w.WriteHeader(422) // unprocessable entity 124 | res.Error = err.Error() 125 | json.NewEncoder(w).Encode(res) 126 | return 127 | } 128 | 129 | // If a username *and* fullname were not provided, return an error 130 | if p.Username == "" || p.FullName == "" { 131 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 132 | w.WriteHeader(422) // unprocessable entity 133 | res.Error = "Must provide username and fullname" 134 | json.NewEncoder(w).Encode(res) 135 | return 136 | } 137 | 138 | // Make sure that this user doesn't already exist 139 | fp, err := c.GetPerson(p.Username) 140 | if fp != nil && fp.Username != "" { 141 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 142 | w.WriteHeader(422) // unprocessable entity 143 | res.Error = fmt.Sprint("User ", p.Username, " already exists. Use PUT /people/", p.Username, " to update.") 144 | json.NewEncoder(w).Encode(res) 145 | return 146 | } 147 | if err != nil { 148 | log.Println("GetPerson() failed:", err) 149 | } 150 | 151 | // Store our new person in the DB 152 | err = c.StorePerson(&p) 153 | if err != nil { 154 | log.Println("Error storing person:", err) 155 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 156 | w.WriteHeader(422) // unprocessable entity 157 | res.Error = err.Error() 158 | json.NewEncoder(w).Encode(res) 159 | return 160 | } 161 | 162 | res.Message = fmt.Sprint("User ", p.Username, " created") 163 | 164 | json.NewEncoder(w).Encode(res) 165 | } 166 | 167 | // Updates an existing person in the database 168 | func UpdatePerson(w http.ResponseWriter, r *http.Request) { 169 | var res PeopleResponse 170 | var p Person 171 | 172 | vars := mux.Vars(r) 173 | username := vars["person"] 174 | 175 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*10)) 176 | // If something went wrong, return an error in the JSON response 177 | if err != nil { 178 | res.Error = err.Error() 179 | json.NewEncoder(w).Encode(res) 180 | return 181 | } 182 | 183 | err = r.Body.Close() 184 | if err != nil { 185 | res.Error = err.Error() 186 | json.NewEncoder(w).Encode(res) 187 | return 188 | } 189 | 190 | err = json.Unmarshal(body, &p) 191 | 192 | if err != nil { 193 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 194 | w.WriteHeader(422) // unprocessable entity 195 | res.Error = err.Error() 196 | json.NewEncoder(w).Encode(res) 197 | return 198 | } 199 | 200 | // The only field that can be updated currently is fullname, so make sure one was provided 201 | if p.FullName == "" { 202 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 203 | w.WriteHeader(422) // unprocessable entity 204 | res.Error = "Must provide a fullname to update" 205 | json.NewEncoder(w).Encode(res) 206 | return 207 | } 208 | 209 | // Make sure the user actually exists before updating 210 | fp, err := c.GetPerson(username) 211 | if (fp != nil && fp.Username == "") || fp == nil { 212 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 213 | w.WriteHeader(422) // unprocessable entity 214 | res.Error = fmt.Sprint("User ", p.Username, " does not exist. Use POST to create.") 215 | json.NewEncoder(w).Encode(res) 216 | return 217 | } 218 | if err != nil { 219 | log.Println("GetPerson() failed for", username) 220 | } 221 | 222 | // Now that we know our user exists in the DB, copy the username from the URI path and add it to our struct 223 | p.Username = username 224 | 225 | // Store the updated user in the DB 226 | err = c.StorePerson(&p) 227 | if err != nil { 228 | log.Println("Error storing person:", err) 229 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 230 | w.WriteHeader(422) // unprocessable entity 231 | res.Error = err.Error() 232 | json.NewEncoder(w).Encode(res) 233 | return 234 | } 235 | 236 | res.People = append(res.People, p) 237 | res.Message = fmt.Sprint("User ", username, " updated") 238 | 239 | json.NewEncoder(w).Encode(res) 240 | 241 | } 242 | -------------------------------------------------------------------------------- /api_notification_plan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | type NotificationPlanResponse struct { 15 | NotificationPlan NotificationPlan `json:"people"` 16 | Message string `json:"message"` 17 | Error string `json:"error"` 18 | } 19 | 20 | // Return a JSON-formatted NotificationPlan for a Person 21 | func ShowNotificationPlan(w http.ResponseWriter, r *http.Request) { 22 | var res NotificationPlanResponse 23 | 24 | vars := mux.Vars(r) 25 | username := vars["person"] 26 | 27 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 28 | 29 | p, err := c.GetNotificationPlan(username) 30 | if err != nil { 31 | res.Error = err.Error() 32 | errjson, _ := json.Marshal(res) 33 | http.Error(w, string(errjson), http.StatusNotFound) 34 | return 35 | } 36 | 37 | res.NotificationPlan = *p 38 | 39 | json.NewEncoder(w).Encode(res) 40 | } 41 | 42 | // Delete a Person's NotificationPlan 43 | func DeleteNotificationPlan(w http.ResponseWriter, r *http.Request) { 44 | var res NotificationPlanResponse 45 | 46 | vars := mux.Vars(r) 47 | username := vars["person"] 48 | 49 | np, err := c.GetNotificationPlan(username) 50 | if np == nil { 51 | w.WriteHeader(422) // unprocessable entity 52 | res.Error = fmt.Sprint("Notification plan for user ", username, " doesn't exist and thus, cannot be deleted") 53 | json.NewEncoder(w).Encode(res) 54 | return 55 | } 56 | if err != nil { 57 | log.Println("GetNotificationPlan() failed for", username) 58 | } 59 | 60 | err = c.DeleteNotificationPlan(username) 61 | if err != nil { 62 | http.Error(w, err.Error(), http.StatusNotFound) 63 | return 64 | } 65 | 66 | res.Message = fmt.Sprint("Notification plan for user ", username, " deleted") 67 | 68 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 69 | json.NewEncoder(w).Encode(res) 70 | } 71 | 72 | // Create a NotificationPlan for a Person 73 | func CreateNotificationPlan(w http.ResponseWriter, r *http.Request) { 74 | var res NotificationPlanResponse 75 | var p []NotificationStep 76 | 77 | vars := mux.Vars(r) 78 | username := vars["person"] 79 | 80 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 81 | 82 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*15)) 83 | // If something went wrong, return an error in the JSON response 84 | if err != nil { 85 | res.Error = err.Error() 86 | json.NewEncoder(w).Encode(res) 87 | return 88 | } 89 | 90 | err = r.Body.Close() 91 | if err != nil { 92 | res.Error = err.Error() 93 | json.NewEncoder(w).Encode(res) 94 | return 95 | } 96 | 97 | err = json.Unmarshal(body, &p) 98 | 99 | if err != nil { 100 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 101 | w.WriteHeader(422) // unprocessable entity 102 | res.Error = err.Error() 103 | json.NewEncoder(w).Encode(res) 104 | return 105 | } 106 | 107 | if username == "" { 108 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 109 | w.WriteHeader(422) // unprocessable entity 110 | res.Error = "Must provide username in URL" 111 | json.NewEncoder(w).Encode(res) 112 | return 113 | } 114 | 115 | fp, err := c.GetPerson(username) 116 | if fp != nil && fp.Username == "" { 117 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 118 | w.WriteHeader(422) // unprocessable entity 119 | res.Error = fmt.Sprint("User ", username, " does not exist. Create the user first before adding a notification plan for them.") 120 | json.NewEncoder(w).Encode(res) 121 | return 122 | } 123 | if err != nil { 124 | log.Println("GetPerson() failed:", err) 125 | } 126 | 127 | // The NotificationPlan provided must have at least one NotificationStep 128 | if len(p) == 0 { 129 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 130 | w.WriteHeader(422) // unprocessable entity 131 | res.Error = "Must provide at least one notification step in JSON" 132 | json.NewEncoder(w).Encode(res) 133 | return 134 | } 135 | 136 | np, err := c.GetNotificationPlan(username) 137 | if np != nil && np.Username != "" { 138 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 139 | w.WriteHeader(422) // unprocessable entity 140 | res.Error = fmt.Sprint("Notification plan for user ", username, " already exists. Use PUT /plan/", username, " to update..") 141 | json.NewEncoder(w).Encode(res) 142 | return 143 | } 144 | if err != nil { 145 | log.Println("GetNotificationPlan() failed for", username) 146 | } 147 | 148 | plan := NotificationPlan{Username: username, Steps: p} 149 | 150 | err = c.StoreNotificationPlan(&plan) 151 | if err != nil { 152 | log.Println("Error storing notification plan:", err) 153 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 154 | w.WriteHeader(422) // unprocessable entity 155 | res.Error = err.Error() 156 | json.NewEncoder(w).Encode(res) 157 | return 158 | } 159 | 160 | res.Message = fmt.Sprint("Notification plan for user ", username, " created") 161 | 162 | json.NewEncoder(w).Encode(res) 163 | } 164 | 165 | // Updates a NotificationPlan for a Person 166 | func UpdateNotificationPlan(w http.ResponseWriter, r *http.Request) { 167 | var res NotificationPlanResponse 168 | var p []NotificationStep 169 | 170 | vars := mux.Vars(r) 171 | username := vars["person"] 172 | 173 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 174 | 175 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1024*15)) 176 | // If something went wrong, return an error in the JSON response 177 | if err != nil { 178 | res.Error = err.Error() 179 | json.NewEncoder(w).Encode(res) 180 | return 181 | } 182 | 183 | err = r.Body.Close() 184 | if err != nil { 185 | res.Error = err.Error() 186 | json.NewEncoder(w).Encode(res) 187 | return 188 | } 189 | 190 | err = json.Unmarshal(body, &p) 191 | 192 | if err != nil { 193 | w.WriteHeader(422) // unprocessable entity 194 | res.Error = err.Error() 195 | json.NewEncoder(w).Encode(res) 196 | return 197 | } 198 | 199 | if username == "" { 200 | w.WriteHeader(422) // unprocessable entity 201 | res.Error = "Must provide username in URL" 202 | json.NewEncoder(w).Encode(res) 203 | return 204 | } 205 | 206 | fp, err := c.GetPerson(username) 207 | if fp != nil && fp.Username == "" { 208 | w.WriteHeader(422) // unprocessable entity 209 | res.Error = fmt.Sprint("Can't update notification plan for user (", username, ") that does not exist.") 210 | json.NewEncoder(w).Encode(res) 211 | return 212 | } 213 | if err != nil { 214 | log.Println("GetPerson() failed:", err) 215 | } 216 | 217 | // The NotificationPlan provided must have at least one NotificationStep 218 | if len(p) == 0 { 219 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 220 | w.WriteHeader(422) // unprocessable entity 221 | res.Error = "Must provide at least one notification step in JSON" 222 | json.NewEncoder(w).Encode(res) 223 | return 224 | } 225 | 226 | np, err := c.GetNotificationPlan(username) 227 | if (np != nil && np.Username == "") || np == nil { 228 | w.WriteHeader(422) // unprocessable entity 229 | res.Error = fmt.Sprint("Notification plan for user ", username, " doesn't exist. Use POST /plan/", username, " to create one first before attempting to update.") 230 | json.NewEncoder(w).Encode(res) 231 | return 232 | } 233 | if err != nil { 234 | log.Println("GetNotificationPlan() failed for", username) 235 | } 236 | 237 | // Replace the NotificationSteps of the fetched plan with those from this request 238 | np.Steps = p 239 | 240 | err = c.StoreNotificationPlan(np) 241 | if err != nil { 242 | log.Println("Error storing notification plan:", err) 243 | w.WriteHeader(422) // unprocessable entity 244 | res.Error = err.Error() 245 | json.NewEncoder(w).Encode(res) 246 | return 247 | } 248 | 249 | res.NotificationPlan = *np 250 | res.Message = fmt.Sprint("Notification plan for user ", username, " updated") 251 | 252 | json.NewEncoder(w).Encode(res) 253 | } 254 | -------------------------------------------------------------------------------- /twilio.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | 14 | "bitbucket.org/ckvist/twilio/twiml" 15 | "github.com/gorilla/mux" 16 | ) 17 | 18 | type CallbackResponse struct { 19 | UUID string `json:"uuid"` 20 | Message string `json:"message"` 21 | Error string `json:"error"` 22 | } 23 | 24 | type SMSResponse struct { 25 | Sid string 26 | DateCreated string 27 | DateUpdated string 28 | DateSent string 29 | AccountSid string 30 | To string 31 | From string 32 | Body string 33 | NumSegments string 34 | Status string 35 | Direction string 36 | Price string 37 | PriceUnit string 38 | ApiVersion string 39 | Uri string 40 | } 41 | 42 | // Sends an SMS text message to a phone number using the Twilio API, 43 | // optionally including a method for acknowledging receipt of the message. 44 | func SendSMS(phoneNumber, message, uuid string, dontSendAckRequest bool) { 45 | var cr SMSResponse 46 | 47 | if uuid != "" { 48 | log.Println("[", uuid, "]", "Sending SMS to", phoneNumber, "with message:", message) 49 | } else { 50 | log.Println("Sending SMS to", phoneNumber, "with message:", message) 51 | } 52 | 53 | // Generate an int in the range 100 <= n <= 999 54 | ackReply := rand.Intn(899) + 100 55 | 56 | // Builds a form that will be posted to Twilio API 57 | u := url.Values{} 58 | u.Set("From", c.Config.Integrations.Twilio.CallFromNumber) 59 | u.Set("To", phoneNumber) 60 | 61 | // Sometimes we send texts that don't require ACKing. This handles that. 62 | if dontSendAckRequest { 63 | u.Set("Body", message) 64 | } else { 65 | u.Set("Body", fmt.Sprint(message, " - Reply with \"", ackReply, "\" to acknowledge")) 66 | 67 | } 68 | 69 | // If we have a UUID, we can request status callbacks for this SMS 70 | if uuid != "" { 71 | u.Set("StatusCallback", fmt.Sprint(c.Config.Service.CallbackURLBase, "/", uuid, "/callback")) 72 | } 73 | 74 | // Post the request to the Twilio API 75 | body := *strings.NewReader(u.Encode()) 76 | client := &http.Client{} 77 | req, _ := http.NewRequest("POST", fmt.Sprint(c.Config.Integrations.Twilio.APIBaseURL, c.Config.Integrations.Twilio.AccountSID, "/Messages.json"), &body) 78 | req.SetBasicAuth(c.Config.Integrations.Twilio.AccountSID, c.Config.Integrations.Twilio.AuthToken) 79 | req.Header.Add("Accept", "application/json") 80 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 81 | 82 | resp, err := client.Do(req) 83 | if err != nil { 84 | log.Println("SendSMS() Request error:", err) 85 | } 86 | 87 | // Get the response 88 | b, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*20)) 89 | resp.Body.Close() 90 | 91 | err = json.Unmarshal(b, &cr) 92 | if err != nil { 93 | log.Fatalln("SendSMS() Error unmarshalling JSON:", err) 94 | } 95 | 96 | if uuid != "" { 97 | // We create conversation key that's a combination of our recipient's phone number and the random 3-digit key 98 | // that we generated above 99 | conversationKey := fmt.Sprint(cr.To, "::", ackReply) 100 | 101 | NIP.Mu.Lock() 102 | defer NIP.Mu.Unlock() 103 | 104 | NIP.Conversations[conversationKey] = uuid 105 | } 106 | 107 | } 108 | 109 | // Makes a phone call to a phone number using the Twilio API. Sends Twilio a URL for 110 | // retrieving the TwiML that defines the interaction in the call. 111 | func MakePhoneCall(phoneNumber, message, uuid string) { 112 | var cr map[string]interface{} 113 | 114 | log.Println("[", uuid, "] Calling", phoneNumber, "with message:", message) 115 | 116 | // Build a form that we'll POST to the Twilio API to initiate a phone call 117 | u := url.Values{} 118 | u.Set("From", c.Config.Integrations.Twilio.CallFromNumber) 119 | u.Set("To", phoneNumber) 120 | u.Set("Url", fmt.Sprint(c.Config.Service.CallbackURLBase, "/", uuid, "/twiml/notify")) 121 | // Optional status callbacks are enabled below... 122 | // u.Set("StatusCallback", fmt.Sprint(c.Config.Service.CallbackURLBase, "/", uuid, "/callback")) 123 | // u.Add("StatusCallbackEvent", "ringing") 124 | // u.Add("StatusCallbackEvent", "answered") 125 | // u.Add("StatusCallbackEvent", "completed") 126 | u.Set("IfMachine", "Hangup") 127 | u.Set("Timeout", "20") 128 | body := *strings.NewReader(u.Encode()) 129 | 130 | // Send our form to Twilio 131 | client := &http.Client{} 132 | req, _ := http.NewRequest("POST", fmt.Sprint(c.Config.Integrations.Twilio.APIBaseURL, c.Config.Integrations.Twilio.AccountSID, "/Calls.json"), &body) 133 | req.SetBasicAuth(c.Config.Integrations.Twilio.AccountSID, c.Config.Integrations.Twilio.AuthToken) 134 | req.Header.Add("Accept", "application/json") 135 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 136 | 137 | resp, err := client.Do(req) 138 | if err != nil { 139 | log.Println("MakePhoneCall() Request error:", err) 140 | } 141 | 142 | b, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*20)) 143 | resp.Body.Close() 144 | 145 | // We get the response back but don't currently do anything with it. TO DO: implement error handling 146 | err = json.Unmarshal(b, &cr) 147 | if err != nil { 148 | log.Fatalln("MakePhoneCall() Error unmarshalling JSON:", err) 149 | } 150 | 151 | } 152 | 153 | // Receives the SMS reply callback from Twilio and deletes the notification if the 154 | // response text matches the code sent with the original SMS notification 155 | func ReceiveSMSReply(w http.ResponseWriter, r *http.Request) { 156 | err := r.ParseForm() 157 | if err != nil { 158 | log.Println("ReceiveSMSReply() r.ParseForm() error:", err) 159 | } 160 | 161 | // We should have a "From" parameter being passed from Twilio 162 | recipient := r.FormValue("From") 163 | if recipient == "" { 164 | log.Println("ReceiveSMSReply() error: 'From' parameter was not provided in response") 165 | return 166 | } 167 | 168 | NIP.Mu.Lock() 169 | 170 | // Our conversation key is a combination of the recipient's phone number and the 3-digit code 171 | // that they sent in reply 172 | conversationKey := fmt.Sprint(recipient, "::", r.FormValue("Body")) 173 | 174 | // See if this SMS conversation is active. If it is, look up the UUID with the conversation key. 175 | if _, exists := NIP.Conversations[conversationKey]; exists { 176 | uuid := NIP.Conversations[conversationKey] 177 | 178 | log.Println("[", uuid, "]", "Recieved a SMS reply from", recipient, ":", r.FormValue("Body")) 179 | 180 | if _, exists := NIP.Stoppers[uuid]; !exists { 181 | log.Println("ReceiveSMSReply(): No active notifications for this UUID:", uuid) 182 | http.Error(w, "", http.StatusNotFound) 183 | NIP.Mu.Unlock() 184 | return 185 | } 186 | 187 | // Delete the conversation key from the in-progress store 188 | delete(NIP.Conversations, conversationKey) 189 | 190 | // Unlock our mutex so the notification engine can take it 191 | NIP.Mu.Unlock() 192 | 193 | log.Println("[", uuid, "] Attempting to stop notifications") 194 | 195 | // Attempt to stop the notification by sending the UUID to the notification engine 196 | stopChan <- uuid 197 | 198 | SendSMS(recipient, "Chicken Little has received your acknowledgment. Thanks!", uuid, true) 199 | 200 | } else { 201 | SendSMS(recipient, "I'm sorry but I don't recognize that response. Please acknowledge with the three-digit code from the notfication you received.", "", true) 202 | } 203 | 204 | } 205 | 206 | // Receives call progress callbacks from the Twilio API. Not currently used. 207 | // May be used for Websocket interface in the future. 208 | func ReceiveCallback(w http.ResponseWriter, r *http.Request) { 209 | var res CallbackResponse 210 | 211 | vars := mux.Vars(r) 212 | uuid := vars["uuid"] 213 | 214 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 215 | 216 | // Stuff will happen 217 | 218 | res = CallbackResponse{ 219 | Message: "Callback received", 220 | UUID: uuid, 221 | } 222 | 223 | json.NewEncoder(w).Encode(res) 224 | 225 | } 226 | 227 | // Receives digits pressed during a phone call via callback by the Twilio API. 228 | // Stops the notification if the user pressed any keys. 229 | func ReceiveDigits(w http.ResponseWriter, r *http.Request) { 230 | 231 | vars := mux.Vars(r) 232 | uuid := vars["uuid"] 233 | 234 | err := r.ParseForm() 235 | if err != nil { 236 | log.Println("ReceiveDigits() r.ParseForm() error:", err) 237 | } 238 | 239 | // Fetch some form values we'll need from Twilio's request 240 | digits := r.FormValue("Digits") 241 | callSid := r.FormValue("CallSid") 242 | 243 | // If digits has been set, user has answered the phone and pressed (any) key to acknowledge the message 244 | if digits != "" { 245 | 246 | if _, exists := NIP.Stoppers[uuid]; !exists { 247 | log.Println("ReceiveDigits(): No active notifications for this UUID:", uuid) 248 | http.Error(w, "", http.StatusNotFound) 249 | return 250 | } 251 | 252 | // We matched a valid notification-in-progress and the user pressed digits when prompted 253 | // so we'll do a POST to Twilio that points the call at a TwiML routine that confirms 254 | // their acknowledgement and sends them on their way. 255 | u := url.Values{} 256 | u.Set("Url", fmt.Sprint(c.Config.Service.CallbackURLBase, "/", uuid, "/twiml/acknowledged")) 257 | 258 | // Send our POST to Twilio 259 | body := *strings.NewReader(u.Encode()) 260 | client := &http.Client{} 261 | req, _ := http.NewRequest("POST", fmt.Sprint(c.Config.Integrations.Twilio.APIBaseURL, c.Config.Integrations.Twilio.AccountSID, "/Calls/", callSid), &body) 262 | req.SetBasicAuth(c.Config.Integrations.Twilio.AccountSID, c.Config.Integrations.Twilio.AuthToken) 263 | req.Header.Add("Accept", "application/json") 264 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 265 | 266 | // Send the POST request 267 | _, err := client.Do(req) 268 | if err != nil { 269 | log.Println("ReceiveDigits() TwiML POST Request error:", err) 270 | } 271 | 272 | // Attempt to stop the notification by sending the UUID to the notification engine 273 | stopChan <- uuid 274 | } 275 | } 276 | 277 | // This Twilio callback generates TwiML that is used to describe the flow of the phone call. 278 | func GenerateTwiML(w http.ResponseWriter, r *http.Request) { 279 | vars := mux.Vars(r) 280 | uuid := vars["uuid"] 281 | action := vars["action"] 282 | 283 | resp := twiml.NewResponse() 284 | 285 | switch action { 286 | case "notify": 287 | // This is a request for a TwiML script for a standard message notification 288 | if _, exists := NIP.Stoppers[uuid]; !exists { 289 | http.Error(w, "No active notifications for this UUID", http.StatusNotFound) 290 | return 291 | } 292 | 293 | intro := twiml.Say{ 294 | Voice: "woman", 295 | Text: "This is Chicken Little with a message for you.", 296 | } 297 | 298 | gather := twiml.Gather{ 299 | Action: fmt.Sprint(c.Config.Service.CallbackURLBase, "/", uuid, "/digits"), 300 | Timeout: 15, 301 | NumDigits: 1, 302 | } 303 | 304 | theMessage := twiml.Say{ 305 | Voice: "man", 306 | Text: NIP.Messages[uuid], 307 | } 308 | 309 | pressAny := twiml.Say{ 310 | Voice: "woman", 311 | Text: "Press any key to acknowledge receipt of this message", 312 | } 313 | 314 | resp.Action(intro) 315 | resp.Gather(gather, theMessage, pressAny) 316 | 317 | case "acknowledged": 318 | // This is a request for the end-of-call wrap-up message 319 | resp.Action(twiml.Say{ 320 | Voice: "woman", 321 | Text: "Thank you. This message has been acknowledged. Goodbye!", 322 | }) 323 | } 324 | 325 | // Reply to the callback with the TwiML content 326 | resp.Send(w) 327 | } 328 | --------------------------------------------------------------------------------