├── Procfile ├── .gitattributes ├── .cfignore ├── .gitignore ├── .codeclimate.yml ├── manifest-production.yml ├── Gopkg.toml ├── safeDict ├── methods.go └── structs.go ├── botserver.go ├── messages ├── messages_test.go ├── messages.go └── messages.yaml ├── CONTRIBUTING.md ├── Gopkg.lock ├── LICENSE.md ├── slack └── slack.go ├── helpers └── helpers.go ├── README.md ├── tock ├── tock_test.go └── tock.go └── bot ├── message_processor.go └── bot.go /Procfile: -------------------------------------------------------------------------------- 1 | web: angrytock 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | Gopkg.lock binary 2 | -------------------------------------------------------------------------------- /.cfignore: -------------------------------------------------------------------------------- 1 | *.env 2 | *.db 3 | vendor/ 4 | tmp/ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.env 2 | *.db 3 | vendor/ 4 | tmp/ 5 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | fixme: 3 | enabled: true 4 | ratings: 5 | paths: [] 6 | exclude_paths: [] 7 | -------------------------------------------------------------------------------- /manifest-production.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: angrytock 4 | memory: 64M 5 | disk_quota: 64M 6 | buildpack: go_buildpack 7 | services: 8 | - angrytock-credentials 9 | env: 10 | GOPACKAGENAME: github.com/18F/angrytock 11 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | 7 | [[constraint]] 8 | name = "github.com/nlopes/slack" 9 | version = "0.2.0" 10 | 11 | [[constraint]] 12 | name = "github.com/robfig/cron" 13 | version = "1.0" 14 | 15 | [[constraint]] 16 | name = "gopkg.in/yaml.v2" 17 | version = "2.1.1" 18 | 19 | [prune] 20 | go-tests = true 21 | unused-packages = true 22 | 23 | [[constraint]] 24 | name = "github.com/cloudfoundry-community/go-cfenv" 25 | version = "1.17.0" 26 | -------------------------------------------------------------------------------- /safeDict/methods.go: -------------------------------------------------------------------------------- 1 | package safeDict 2 | 3 | // Get returns the value given a specific key 4 | func (dict *SafeDict) Get(key string) string { 5 | dict.readChannel <- key 6 | return <-dict.readChannel 7 | } 8 | 9 | // Update sets a key to a specific value 10 | func (dict *SafeDict) Update(key string, value string) { 11 | dict.updateChannel <- &keyValue{key: key, value: value} 12 | } 13 | 14 | // Delete removes a key-value pair given a key 15 | func (dict *SafeDict) Delete(key string) { 16 | dict.deleteChannel <- key 17 | } 18 | 19 | // Replace replaces the internal hashmap 20 | func (dict *SafeDict) Replace(newDict map[string]string) { 21 | dict.replaceChannel <- newDict 22 | } 23 | -------------------------------------------------------------------------------- /botserver.go: -------------------------------------------------------------------------------- 1 | /* 2 | Script for starting the bot server 3 | */ 4 | 5 | package main 6 | 7 | import ( 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/18F/angrytock/bot" 13 | "github.com/robfig/cron" 14 | ) 15 | 16 | func main() { 17 | 18 | bot := bot.InitBot() 19 | 20 | bot.StoreSlackUsers() 21 | 22 | // Update the list of stored slack users weekly 23 | c := cron.New() 24 | c.AddFunc("@weekly", func() { 25 | go bot.StoreSlackUsers() 26 | }) 27 | c.Start() 28 | 29 | // Start go routine to listen to tock users 30 | go bot.ListenToSlackUsers() 31 | 32 | // Start server 33 | log.Print("Starting server on port :" + os.Getenv("PORT")) 34 | http.ListenAndServe(":"+os.Getenv("PORT"), nil) 35 | 36 | } 37 | -------------------------------------------------------------------------------- /messages/messages_test.go: -------------------------------------------------------------------------------- 1 | package messagesPackage 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | var messageRepo = InitMessageRepository() 9 | 10 | // Check that messages render properly with user name 11 | func TestAngryMessagesMessages(t *testing.T) { 12 | for _, message := range messageRepo.Angry.Messages { 13 | if !strings.Contains(message, "<@%s>") { 14 | t.Errorf(message) 15 | } 16 | } 17 | } 18 | 19 | // Check that messages render properly with user name 20 | func TestNiceMessagesMessages(t *testing.T) { 21 | for _, message := range messageRepo.Nice.Messages { 22 | if !strings.Contains(message, "<@%s>") { 23 | t.Errorf(message) 24 | } 25 | } 26 | } 27 | 28 | // Check that messages include a %s 29 | func TestReminderMessagesMessages(t *testing.T) { 30 | for _, message := range messageRepo.Reminder.Messages { 31 | if !strings.Contains(message, "%s") { 32 | t.Errorf(message) 33 | } 34 | } 35 | } 36 | 37 | // Check that randomly generated messages work 38 | func TestRandomMessages(t *testing.T) { 39 | message := messageRepo.Angry.fetchRandomMessage() 40 | if !strings.Contains(message, "<@%s>") { 41 | t.Errorf(message) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're so glad you're thinking about contributing to an 18F open source project! If you're unsure about anything, just ask -- or submit the issue or pull request anyway. The worst that can happen is you'll be politely asked to change something. We love all friendly contributions. 4 | 5 | We want to ensure a welcoming environment for all of our projects. Our staff follow the [18F Code of Conduct](https://github.com/18F/code-of-conduct/blob/master/code-of-conduct.md) and all contributors should do the same. 6 | 7 | We encourage you to read this project's CONTRIBUTING policy (you are here), its [LICENSE](LICENSE.md), and its [README](README.md). 8 | 9 | If you have any questions or want to read more, check out the [18F Open Source Policy GitHub repository]( https://github.com/18f/open-source-policy), or just [shoot us an email](mailto:18f@gsa.gov). 10 | 11 | ## Public domain 12 | 13 | This project is in the public domain within the United States, and 14 | copyright and related rights in the work worldwide are waived through 15 | the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 16 | 17 | All contributions to this project will be released under the CC0 18 | dedication. By submitting a pull request, you are agreeing to comply 19 | with this waiver of copyright interest. 20 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/cloudfoundry-community/go-cfenv" 6 | packages = ["."] 7 | revision = "f920e9562d5f951cbf11785728f67258c38a10d0" 8 | version = "v1.17.0" 9 | 10 | [[projects]] 11 | name = "github.com/gorilla/websocket" 12 | packages = ["."] 13 | revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" 14 | version = "v1.2.0" 15 | 16 | [[projects]] 17 | branch = "master" 18 | name = "github.com/mitchellh/mapstructure" 19 | packages = ["."] 20 | revision = "00c29f56e2386353d58c599509e8dc3801b0d716" 21 | 22 | [[projects]] 23 | name = "github.com/nlopes/slack" 24 | packages = ["."] 25 | revision = "8ab4d0b364ef1e9af5d102531da20d5ec902b6c4" 26 | version = "v0.2.0" 27 | 28 | [[projects]] 29 | name = "github.com/robfig/cron" 30 | packages = ["."] 31 | revision = "b024fc5ea0e34bc3f83d9941c8d60b0622bfaca4" 32 | version = "v1" 33 | 34 | [[projects]] 35 | name = "gopkg.in/yaml.v2" 36 | packages = ["."] 37 | revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" 38 | version = "v2.1.1" 39 | 40 | [solve-meta] 41 | analyzer-name = "dep" 42 | analyzer-version = 1 43 | inputs-digest = "764286e0bc97f718142be38766009238896308c30e5e9fdc9d09e07184eaea71" 44 | solver-name = "gps-cdcl" 45 | solver-version = 1 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal Summary 8 | 9 | This is a human-readable summary of the 10 | [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 11 | 12 | ### No Copyright 13 | 14 | The person who associated a work with this deed has dedicated the work to 15 | the public domain by waiving all of his or her rights to the work worldwide 16 | under copyright law, including all related and neighboring rights, to the 17 | extent allowed by law. 18 | 19 | You can copy, modify, distribute and perform the work, even for commercial 20 | purposes, all without asking permission. 21 | 22 | ### Other Information 23 | 24 | In no way are the patent or trademark rights of any person affected by CC0, 25 | nor are the rights that other persons may have in the work or in how the 26 | work is used, such as publicity or privacy rights. 27 | 28 | Unless expressly stated otherwise, the person who associated a work with 29 | this deed makes no warranties about the work, and disclaims liability for 30 | all uses of the work, to the fullest extent permitted by applicable law. 31 | When using or citing the work, you should not imply endorsement by the 32 | author or the affirmer. 33 | -------------------------------------------------------------------------------- /slack/slack.go: -------------------------------------------------------------------------------- 1 | package slackPackage 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/cloudfoundry-community/go-cfenv" 8 | "github.com/nlopes/slack" 9 | ) 10 | 11 | // Slack sturct extend the slackRTM method 12 | type Slack struct { 13 | *slack.RTM 14 | } 15 | 16 | // InitSlack initalizes the struct object 17 | func InitSlack() *Slack { 18 | appEnv, _ := cfenv.Current() 19 | appService, _ := appEnv.Services.WithName("angrytock-credentials") 20 | // Collect the slack key 21 | key := fmt.Sprint(appService.Credentials["SLACK_KEY"]) 22 | if key == "" { 23 | log.Fatal("SLACK_KEY environment variable not found") 24 | } 25 | rtm := slack.New(key).NewRTM() 26 | return &Slack{rtm} 27 | } 28 | 29 | // FetchSlackUsers fetches a list of slack users and saves thier user ids by 30 | // this method could use the GetInfo() 31 | func (api *Slack) FetchSlackUsers() []slack.User { 32 | users, err := api.GetUsers() 33 | if err != nil { 34 | log.Println(err.Error()) 35 | } 36 | return users 37 | } 38 | 39 | // GetSelfID returns the ID of the slack bot 40 | func (api *Slack) GetSelfID() string { 41 | return api.GetInfo().User.ID 42 | } 43 | 44 | // MessageUser opens a channel to a user if it doesn't exist and messages the user 45 | func (api *Slack) MessageUser(user string, message string) { 46 | _, _, channelID, err := api.Client.OpenIMChannel(user) 47 | if err != nil { 48 | log.Println("Unable to open channel") 49 | } 50 | // Can insert images an other things here 51 | postParams := slack.PostMessageParameters{} 52 | api.Client.PostMessage(channelID, message, postParams) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/cloudfoundry-community/go-cfenv" 10 | ) 11 | 12 | // FetchData opens urls and return the body of request 13 | func FetchData(URL string) []byte { 14 | appEnv, _ := cfenv.Current() 15 | appService, _ := appEnv.Services.WithName("angrytock-credentials") 16 | 17 | apiAuthToken := fmt.Sprintf("Token %s", appService.Credentials["TOCK_API_TOKEN"]) 18 | if apiAuthToken == "" { 19 | log.Fatal("TOCK_API_TOKEN environment variable not found") 20 | } 21 | 22 | client := &http.Client{} 23 | // Get url 24 | req, _ := http.NewRequest("GET", URL, nil) 25 | req.Header.Set("Authorization", apiAuthToken) 26 | res, err := client.Do(req) 27 | if err != nil { 28 | log.Print("Failed to make request") 29 | } 30 | defer res.Body.Close() 31 | // Read body 32 | body, err := ioutil.ReadAll(res.Body) 33 | if err != nil { 34 | log.Print("Failed to read response") 35 | } 36 | 37 | return body 38 | 39 | } 40 | 41 | // GenericDataFetcher is a generic function that takes a url string and returns 42 | // a bytes 43 | type GenericDataFetcher func(url string) []byte 44 | 45 | // DataFetcher is a struct that holds a function that of type DataFetcher 46 | type DataFetcher struct { 47 | GenericDataFetcherHolder GenericDataFetcher 48 | } 49 | 50 | // NewDataFetcher get 51 | func NewDataFetcher(dataFetcher GenericDataFetcher) *DataFetcher { 52 | return &DataFetcher{GenericDataFetcherHolder: dataFetcher} 53 | } 54 | 55 | // FetchData get data 56 | func (d *DataFetcher) FetchData(URL string) []byte { 57 | return d.GenericDataFetcherHolder(URL) 58 | } 59 | -------------------------------------------------------------------------------- /messages/messages.go: -------------------------------------------------------------------------------- 1 | package messagesPackage 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "math/rand" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | // MessageArray is a torage for one type of message includes methods for choosing a 16 | // messages from the bunch 17 | type MessageArray struct { 18 | Messages []string `yaml:"responses"` 19 | } 20 | 21 | // fetchRandomMessage method for selecting a random message from the created messages 22 | func (msgs MessageArray) fetchRandomMessage() string { 23 | return msgs.Messages[rand.Intn(len(msgs.Messages))] 24 | } 25 | 26 | func (msgs MessageArray) GenerateMessage(filler string) string { 27 | return fmt.Sprintf( 28 | msgs.fetchRandomMessage(), 29 | filler, 30 | ) 31 | } 32 | 33 | // MessageRepository Contains the messages and methods for generating 34 | // responses for the bot 35 | type MessageRepository struct { 36 | Angry *MessageArray `yaml:"AngryMessages"` 37 | Nice *MessageArray `yaml:"NiceMessages"` 38 | Reminder *MessageArray `yaml:"ReminderMessages"` 39 | } 40 | 41 | // InitMessageRepository loads all of the data into a MessageRepository 42 | // and a MessageRepository struct 43 | func InitMessageRepository() *MessageRepository { 44 | var mrep MessageRepository 45 | messageFile := "messages.yaml" 46 | workingDir, _ := os.Getwd() 47 | if !strings.HasSuffix(workingDir, "messages") { 48 | messageFile = filepath.Join("messages", messageFile) 49 | } 50 | messageFile = filepath.Join(workingDir, messageFile) 51 | data, err := ioutil.ReadFile(messageFile) 52 | if err != nil { 53 | log.Fatalf("error: %v", err) 54 | } 55 | if yaml.Unmarshal(data, &mrep) != nil { 56 | log.Fatalf("error: %v", err) 57 | } 58 | return &mrep 59 | } 60 | -------------------------------------------------------------------------------- /safeDict/structs.go: -------------------------------------------------------------------------------- 1 | // Package safeDict contains the structs and methods for creates a thread saft map 2 | package safeDict 3 | 4 | // keyValue struct stores the a key-value pair for use in a channel 5 | type keyValue struct { 6 | key string 7 | value string 8 | } 9 | 10 | // SafeDict struct is a data type that contains a map along with channels 11 | // that allow users to preform CRUD operations. The main difference between 12 | // the SafeDict and a map is that all operations are serialized and thus thread safe. 13 | type SafeDict struct { 14 | storage map[string]string 15 | readChannel chan string 16 | updateChannel chan *keyValue 17 | deleteChannel chan string 18 | replaceChannel chan map[string]string 19 | } 20 | 21 | // newSafeDict initalizes a new SafeDict and opens all the channels. 22 | func newSafeDict() *SafeDict { 23 | return &SafeDict{ 24 | make(map[string]string), 25 | make(chan string), 26 | make(chan *keyValue), 27 | make(chan string), 28 | make(chan map[string]string), 29 | } 30 | } 31 | 32 | // InitSafeDict initalizes a new SafeDict and creates for loop that listens for 33 | // new operations. 34 | func InitSafeDict() *SafeDict { 35 | safeDict := newSafeDict() 36 | go func() { 37 | for { 38 | select { 39 | case key := <-safeDict.readChannel: 40 | safeDict.readChannel <- safeDict.storage[key] 41 | case keyValuePair := <-safeDict.updateChannel: 42 | safeDict.storage[keyValuePair.key] = keyValuePair.value 43 | case key := <-safeDict.deleteChannel: 44 | delete(safeDict.storage, key) 45 | case newDict := <-safeDict.replaceChannel: 46 | safeDict.storage = newDict 47 | } 48 | } 49 | }() 50 | return safeDict 51 | } 52 | 53 | // DestorySaftDict closes all the dictionary channels 54 | func DestorySaftDict(dict *SafeDict) { 55 | close(dict.readChannel) 56 | close(dict.updateChannel) 57 | close(dict.deleteChannel) 58 | close(dict.replaceChannel) 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AngryTock 2 | 3 | [![Go Report Card](http://goreportcard.com/badge/18F/angrytock)](http://goreportcard.com/report/18F/angrytock) 4 | 5 | A slack bot for "reminding" [Tock](https://github.com/18F/tock) users who are late filling out their timecards. 6 | 7 | This bot is dependent on Tock. Please refer to the appropriate documentation for 8 | both projects. 9 | 10 | - [Tock documentation](https://github.com/18F/tock/tree/master/docs) 11 | - [AngryTock documentation](https://github.com/18F/angrytock/tree/master/docs) 12 | 13 | ## Bot Master User Commands 14 | `@botname: slap users!` : Reminds users to fill in their time sheets one time. 15 | `@botname: bother users!` : Searches for users writing in Slack and tells them to fill in their time sheets. Will only bother user 1 time and is only active for 30 minutes. 16 | `@botname: who is late?` : Returns a list of users who are late. 17 | 18 | 19 | ## Regular interactions 20 | `@botname: status` : Will check the Tock API and tell the user if they have filled out their timesheet. 21 | `@botname: say something` : Will respond to the use with a message about time. 22 | 23 | ## Running tests 24 | `go test ./... -cover ` 25 | 26 | ## Deployment 27 | 28 | ### Env Variables 29 | Set the following env variables 30 | ``` 31 | export SLACK_KEY="<>" 32 | export TOCK_URL="https://tock.18f.gov" 33 | export USER_TOCK_URL="https://tock.18f.gov/employees" 34 | export MASTER_LIST=<>,<> 35 | export PORT=5000 # will be set automatically by Cloud Foundry 36 | ``` 37 | 38 | ### Deploying to Cloud Foundry 39 | `cf push` 40 | 41 | ## Contributing 42 | 43 | See [CONTRIBUTING](CONTRIBUTING.md) for additional information. 44 | 45 | ### Branch flow 46 | 47 | - Main branch: `master` 48 | 49 | ## Public domain 50 | 51 | This project is in the worldwide [public domain](LICENSE.md). As stated in [CONTRIBUTING](CONTRIBUTING.md): 52 | 53 | > This project is in the public domain within the United States, and copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 54 | > 55 | > All contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest. 56 | -------------------------------------------------------------------------------- /messages/messages.yaml: -------------------------------------------------------------------------------- 1 | # Messages must contain a <@%s> to indicate the user the bot is messaging 2 | # AngryMessages are messages that the bot responds for being late on Tock 3 | AngryMessages: 4 | responses: 5 | - <@%s>! So you have time for Slack but not Tock? 6 | - <@%s>! "I wish it need not have happened in my time," said Frodo. "So do I," said Gandalf, "and so do all who live to see such times. But that is not for them to decide. All we have to decide is how to Tock the time that is given us." 7 | - Do or do not fill out Tock. There is no try, <@%s>. —Yoda 8 | - As Dr. Seuss once said, "How did it get so late so soon? It's night before it's afternoon." Don't forget to Tock, <@%s>. 9 | - <@%s>! Time may be a human construct, but timesheets are not! 10 | - I confess I do not believe in time, but I still complete my timesheet, <@%s>. ―Vladimir Nabokov 11 | - Yesterday is gone. Tomorrow has not yet come. We have only today. Let us Tock, <@%s>. ―Mother Teresa 12 | - <@%s>! شو عم تعمل؟؟؟؟ 13 | - | 14 | "I brought to mind the inquisitorial proceedings, and attempted from that point to deduce my real condition. The sentence had passed; and it appeared to me that a very long interval of time had since elapsed, so I logged it in Tock, <@%s>." The Pit and the Pendulum - Edgar Allen Poe 15 | - <@%s>! "Time is a gift, given to you, given to give you the time you need, the time you need to fill out your timesheet." -Norton Juster 16 | - <@%s>! "A time to gain, a time to lose! A time to rend, a time to sew! A time for love, a time for hate! A time for Tock, I swear it's not too late." Turn! Turn! Turn! - The Byrds 17 | - <@%s>! "Tell it to me slowly! Tell me what, I really want to know! It's that time of the week for Tocking." Time of the Season - The Zombies 18 | - <@%s>! "Well I Tock about it, Tock about it, Tock about it, Tock about it, Tock about, Tock about, Tock about workin'!" Funkytown - Lipps Inc. 19 | # NiceMessages are generic messages the bot respondes with when people write to it 20 | NiceMessages: 21 | responses: 22 | - <@%s> I'm actually a nice robot! :'( 23 | - The wisest are the most annoyed at the loss of time, <@%s>. —Dante Alighieri 24 | - Hold on, <@%s>, I'm busy filling out my timesheet. 25 | - Time is what we want most but use worst, <@%s>. —William Penn 26 | - <@%s>, the strongest of all warriors are these two — Time and Patience. ―Leo Tolstoy 27 | - Inelegantly, and without my consent, time passed, <@%s>. ―Miranda July 28 | - Unfortunately, the clock is ticking, the hours are going by. The past increases, the future recedes, <@%s>. —Haruki Murakami 29 | - الحب هو ما حدث بيننا، وعدم سجل الدوام هو كل ما لم يحدث <@%s> يا 30 | - <@%s>, People assume that time is a strict progression of cause to effect, but *actually* from a non-linear, non-subjective viewpoint - it's more like a big ball of wibbly wobbly... time-y wimey... stuff that I log in tock. - Doctor Who 31 | # ReminderMessages are messages the bot sends to a private channel 32 | # to remind users to fill out their timesheets 33 | ReminderMessages: 34 | responses: 35 | - Please fill out your timesheet ^_^ , %s 36 | - Just a reminder :) to fill out your timesheet, %s 37 | - Do me a favor and fill out your timesheets, %s 38 | -------------------------------------------------------------------------------- /tock/tock_test.go: -------------------------------------------------------------------------------- 1 | package tockPackage 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/18F/angrytock/helpers" 9 | ) 10 | 11 | var test = []struct { 12 | Input ReportingPeriodAuditList 13 | Output string 14 | }{ 15 | // Check with dates that already happend 16 | { 17 | ReportingPeriodAuditList{ 18 | ReportingPeriods: []ReportingPeriod{ 19 | ReportingPeriod{StartDate: "2014-01-07", EndDate: "2014-01-12"}, 20 | ReportingPeriod{StartDate: "2014-01-01", EndDate: "2014-01-05"}, 21 | }, 22 | }, 23 | "2014-01-07", 24 | }, 25 | // Check with last date that hasn't occured 26 | { 27 | ReportingPeriodAuditList{ 28 | ReportingPeriods: []ReportingPeriod{ 29 | ReportingPeriod{ 30 | StartDate: time.Now().Add(time.Hour * 24 * 2).Format("2006-01-02"), 31 | EndDate: time.Now().Add(time.Hour * 24 * 7).Format("2006-01-02"), 32 | }, 33 | ReportingPeriod{StartDate: "2014-01-07", EndDate: "2014-01-12"}, 34 | ReportingPeriod{StartDate: "2014-01-01", EndDate: "2014-01-05"}, 35 | }, 36 | }, 37 | "2014-01-07", 38 | }, 39 | // Check with last date that has occured 40 | { 41 | ReportingPeriodAuditList{ 42 | ReportingPeriods: []ReportingPeriod{ 43 | ReportingPeriod{ 44 | StartDate: time.Now().Add(time.Hour * -24 * 2).Format("2006-01-02"), 45 | EndDate: time.Now().Add(time.Hour * -24 * 7).Format("2006-01-02"), 46 | }, 47 | ReportingPeriod{StartDate: "2014-01-07", EndDate: "2014-01-12"}, 48 | ReportingPeriod{StartDate: "2014-01-01", EndDate: "2014-01-05"}, 49 | }, 50 | }, 51 | time.Now().Add(time.Hour * -48).Format("2006-01-02"), 52 | }, 53 | } 54 | 55 | // Check that the most recent reporting period is returned 56 | // without being a reporting period in the future 57 | func TestFetchCurrentReportingPeriod(t *testing.T) { 58 | for _, test := range test { 59 | currentPeriod := fetchCurrentReportingPeriod(&test.Input) 60 | if currentPeriod != test.Output { 61 | t.Error(currentPeriod) 62 | } 63 | } 64 | } 65 | 66 | func mockDataFetcher(url string) []byte { 67 | if url == "AuditEndpoint" { 68 | return []byte(`{ 69 | "count":62, 70 | "next":null, 71 | "previous":null, 72 | "results":[ 73 | {"start_date":"2014-11-22","end_date":"2014-11-28","working_hours":40}, 74 | {"start_date":"2014-11-15","end_date":"2014-11-21","working_hours":40}] 75 | }`) 76 | } else { 77 | return []byte(`{ 78 | "count":2, 79 | "next":null, 80 | "previous":null, 81 | "results":[ 82 | { 83 | "id":1, 84 | "username":"user.one", 85 | "first_name":"user", 86 | "last_name":"one", 87 | "email":"user.one@gsa.gov"}, 88 | { 89 | "id":2, 90 | "username":"user.two", 91 | "first_name":"user", 92 | "last_name":"two", 93 | "email":"user.two@gsa.gov" 94 | } 95 | ] 96 | }`) 97 | } 98 | } 99 | 100 | var tock = Tock{ 101 | "TockURL", 102 | "UserTockURL", 103 | "AuditEndpoint", 104 | helpers.NewDataFetcher(mockDataFetcher), 105 | } 106 | 107 | func TestFetchTockReportingPeriods(t *testing.T) { 108 | reportingPeriod := tock.fetchReportingPeriod() 109 | if reportingPeriod != "2014-11-22" { 110 | t.Errorf(reportingPeriod) 111 | } 112 | } 113 | 114 | func TestFetchTockUsers(t *testing.T) { 115 | reportingPeriod := tock.fetchReportingPeriod() 116 | baseEndpoint := fmt.Sprintf("%s%s", tock.AuditEndpoint, reportingPeriod) 117 | userData := tock.FetchTockUsers(baseEndpoint) 118 | if len(userData.Users) != 2 { 119 | t.Error(userData) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /bot/message_processor.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/nlopes/slack" 10 | ) 11 | 12 | const panopticon = "'The [tockers] must never know whether [they are] looked at at any one moment; but [they] must be sure that [they] may always be so' - Foucault, Discipline 201" 13 | 14 | // processMessage handles incomming messages 15 | func (bot *Bot) processMessage(message *slack.MessageEvent) { 16 | user := message.User 17 | botID := bot.Slack.GetSelfID() 18 | // Handle Violators 19 | userID := bot.violatorUserMap.Get(user) 20 | if userID != "" { 21 | bot.violatorMessage(message, user) 22 | } 23 | 24 | botCalled := strings.HasPrefix( 25 | message.Text, 26 | fmt.Sprintf("<@%s>", botID), 27 | ) 28 | if botCalled { // Messages made directly to bot 29 | switch { 30 | case bot.isMasterUser(user): 31 | { 32 | bot.masterMessages(message) 33 | } 34 | default: 35 | { 36 | bot.niceMessage(message, user) 37 | } 38 | } 39 | } else { 40 | switch { 41 | // Messages that contain the word tick 42 | case strings.Contains(message.Text, " tick "): 43 | { 44 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage( 45 | "tock", message.Channel, 46 | )) 47 | } 48 | // Messages that references the bot will be send out 30% of the time. See: Foucault, Discipline 201 49 | case strings.Contains(message.Text, fmt.Sprintf("<@%s>", botID)): 50 | { 51 | var returnMessage string 52 | randomInt := rand.Intn(100) 53 | if randomInt >= 70 { 54 | returnMessage = bot.MessageRepo.Nice.GenerateMessage(user) 55 | } else if randomInt <= 3 { 56 | returnMessage = panopticon 57 | } 58 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage( 59 | returnMessage, 60 | message.Channel, 61 | )) 62 | } 63 | } 64 | } 65 | } 66 | 67 | // violatorMessage has the message for a late user 68 | func (bot *Bot) violatorMessage(message *slack.MessageEvent, user string) { 69 | var returnMessage string 70 | // Check if user is still late 71 | if bot.isLateUser(user) { 72 | returnMessage = bot.MessageRepo.Angry.GenerateMessage(user) 73 | } else { 74 | returnMessage = fmt.Sprintf( 75 | "<@%s>, I was about to yell at you, but then I realized you actually filled out your timesheet. Thanks! ^_^", 76 | user, 77 | ) 78 | } 79 | bot.violatorUserMap.Delete(user) 80 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage(returnMessage, message.Channel)) 81 | } 82 | 83 | // masterMessages contains the commands for admins 84 | func (bot *Bot) masterMessages(message *slack.MessageEvent) { 85 | var returnMessage string 86 | botID := bot.Slack.GetSelfID() 87 | switch { 88 | case strings.Contains(message.Text, "slap users"): 89 | { 90 | go bot.SlapLateUsers() 91 | returnMessage = "Slapping Users!" 92 | } 93 | case strings.Contains(message.Text, "remind users"): 94 | { 95 | braketFinder := regexp.MustCompile("{{.*?}}") 96 | foundMessages := braketFinder.FindAllString(message.Text, 1) 97 | if len(foundMessages) == 0 { 98 | returnMessage = "Error: no message to send or message not formatted correctly" 99 | } else { 100 | messageToSend := strings.Trim(foundMessages[0], "{}") 101 | go bot.RemindUsers(messageToSend) 102 | returnMessage = fmt.Sprintf("Reminding users with `%s`", messageToSend) 103 | } 104 | } 105 | case strings.Contains(message.Text, "bother users"): 106 | { 107 | bot.startviolatorUserMapUpdater() 108 | returnMessage = "Starting to bother users!" 109 | } 110 | case strings.Contains(message.Text, "who is late?"): 111 | { 112 | lateList, total := bot.fetchLateUsers() 113 | returnMessage = fmt.Sprintf("%s are late! %d people total.", lateList, total) 114 | } 115 | default: 116 | { 117 | returnMessage = fmt.Sprintf( 118 | "Commands:\n Message tardy users `<@%s>: slap users!`\n Remind users nicely `<@%s>: remind users {{Text of message here}}`\nBother tardy users `<@%s>: bother users!`\nFind out who is late `<@%s>: who is late?`", 119 | botID, 120 | botID, 121 | botID, 122 | botID, 123 | ) 124 | } 125 | } 126 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage( 127 | returnMessage, message.Channel, 128 | )) 129 | } 130 | 131 | // niceMessage are commands for user who are not late 132 | func (bot *Bot) niceMessage(message *slack.MessageEvent, user string) { 133 | var returnMessage string 134 | switch { 135 | case strings.Contains(message.Text, "hello"): 136 | { 137 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage( 138 | bot.MessageRepo.Nice.GenerateMessage(user), 139 | message.Channel, 140 | )) 141 | } 142 | case strings.Contains(message.Text, "status"): 143 | { 144 | go func() { 145 | if bot.isLateUser(user) { 146 | returnMessage = fmt.Sprintf("<@%s>, you're late -_-", user) 147 | } else { 148 | returnMessage = fmt.Sprintf("<@%s>, you're on time! ^_^", user) 149 | } 150 | bot.Slack.SendMessage(bot.Slack.NewOutgoingMessage( 151 | returnMessage, message.Channel, 152 | )) 153 | }() 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tock/tock.go: -------------------------------------------------------------------------------- 1 | package tockPackage 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/18F/angrytock/helpers" 10 | "github.com/cloudfoundry-community/go-cfenv" 11 | ) 12 | 13 | // User is a struct representation of the user JSON object from tock 14 | type User struct { 15 | ID int `json:"id"` 16 | Username string `json:"username"` 17 | FirstName string `json:"first_name"` 18 | LastName string `json:"last_name"` 19 | Email string `json:"email"` 20 | } 21 | 22 | // ReportingPeriod is a struct representation of the reporting_period JSON object from tock 23 | type ReportingPeriod struct { 24 | StartDate string `json:"start_date"` 25 | EndDate string `json:"end_date"` 26 | ExactWorkingHours int `json:"exact_working_hours"` 27 | MinWorkingHours int `json:"min_working_hours"` 28 | MaxWorkingHours int `json:"max_working_hours"` 29 | } 30 | 31 | // APIPages is a struct representation of a API page response from tock 32 | type APIPages struct { 33 | Count int `json:"count"` 34 | NextURL string `json:"next"` 35 | PrevURL string `json:"previous"` 36 | } 37 | 38 | // ReportingPeriodAuditList is a struct representation of an API response from 39 | //the Reporting Period Audit list endpoint 40 | type ReportingPeriodAuditList struct { 41 | APIPages 42 | ReportingPeriods []ReportingPeriod 43 | } 44 | 45 | // ReportingPeriodAuditDetails is a struct representation of an API response 46 | //from the Reporting Period Audit details endpoint 47 | type ReportingPeriodAuditDetails struct { 48 | APIPages 49 | Users []User `json:"results"` 50 | } 51 | 52 | // Tock struct contains the audit endpoint and methods associated with Tock 53 | type Tock struct { 54 | // Get Audit endpoint 55 | TockURL string 56 | UserTockURL string 57 | AuditEndpoint string 58 | DataFetcher *helpers.DataFetcher 59 | } 60 | 61 | // InitTock initalizes the tock struct 62 | func InitTock() *Tock { 63 | appEnv, _ := cfenv.Current() 64 | appService, _ := appEnv.Services.WithName("angrytock-credentials") 65 | 66 | // Get the tock url 67 | tockURL := fmt.Sprint(appService.Credentials["TOCK_URL"]) 68 | if tockURL == "" { 69 | log.Fatal("TOCK_URL environment variable not found") 70 | } 71 | userTockURL := fmt.Sprint(appService.Credentials["USER_TOCK_URL"]) 72 | if userTockURL == "" { 73 | log.Fatal("USER_TOCK_URL environment variable not found") 74 | } 75 | auditEndpoint := tockURL + "/api/reporting_period_audit" 76 | // Initalize a new data fetcher 77 | dataFetcher := helpers.NewDataFetcher(helpers.FetchData) 78 | return &Tock{tockURL, userTockURL, auditEndpoint, dataFetcher} 79 | } 80 | 81 | // fetchCurrentReportingPeriod gets the latest reporting time period that 82 | // has happend 83 | func fetchCurrentReportingPeriod(data *ReportingPeriodAuditList) string { 84 | currentPeriodIndex := 0 85 | for idx, period := range data.ReportingPeriods { 86 | endDate, _ := time.Parse("2006-01-02", period.EndDate) 87 | if endDate.Before(time.Now()) { 88 | currentPeriodIndex = idx 89 | break 90 | } 91 | } 92 | return data.ReportingPeriods[currentPeriodIndex].StartDate 93 | } 94 | 95 | // fetchReportingPeriod collects the current reporting period 96 | func (tock *Tock) fetchReportingPeriod() string { 97 | var data ReportingPeriodAuditList 98 | URL := fmt.Sprintf("%s.json", tock.AuditEndpoint) 99 | body := tock.DataFetcher.FetchData(URL) 100 | err := json.Unmarshal(body, &data.ReportingPeriods) 101 | if err != nil { 102 | log.Print(err) 103 | } 104 | return fetchCurrentReportingPeriod(&data) 105 | } 106 | 107 | // FetchTockUsers is a function for collecting all the users who have not 108 | // filled out thier time sheet for the current period 109 | func (tock *Tock) FetchTockUsers(endpoint string) *ReportingPeriodAuditDetails { 110 | var data ReportingPeriodAuditDetails 111 | body := tock.DataFetcher.FetchData(endpoint) 112 | err := json.Unmarshal(body, &data.Users) 113 | if err != nil { 114 | log.Print(err) 115 | } 116 | return &data 117 | } 118 | 119 | // TockUserGen returns a generator that returns a steram 120 | // of user data by paging through the api 121 | func (tock *Tock) TockUserGen() func() *ReportingPeriodAuditDetails { 122 | timePeriod := tock.fetchReportingPeriod() 123 | baseEndpoint := fmt.Sprintf("%s/%s.json", tock.AuditEndpoint, timePeriod) 124 | currentPage := 1 125 | newEndpoint := baseEndpoint + fmt.Sprintf("?page=%d", currentPage) 126 | return func() *ReportingPeriodAuditDetails { 127 | usersResponse := tock.FetchTockUsers(newEndpoint) 128 | currentPage++ 129 | newEndpoint = baseEndpoint + fmt.Sprintf("?page=%d", currentPage) 130 | return usersResponse 131 | } 132 | } 133 | 134 | // UserApplier loops through users and applies a anonymous function to a list 135 | // of late tock users 136 | func (tock *Tock) UserApplier(applyFunc func(user User)) { 137 | // user Generator 138 | userGen := tock.TockUserGen() 139 | // get event indefinitely 140 | for { 141 | apiResponse := userGen() 142 | for _, user := range apiResponse.Users { 143 | applyFunc(user) 144 | } 145 | // Break loop if there are no more urls 146 | if apiResponse.NextURL == "" { 147 | break 148 | } 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /bot/bot.go: -------------------------------------------------------------------------------- 1 | // Package bot provides an interface accessing the tock and slack apis 2 | // The primary purpose of this packages is to collect users from tock 3 | // who have not filled out thier time forms and use the slack api to message them. 4 | package bot 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "strings" 10 | "time" 11 | 12 | "github.com/18F/angrytock/messages" 13 | "github.com/18F/angrytock/safeDict" 14 | "github.com/18F/angrytock/slack" 15 | "github.com/18F/angrytock/tock" 16 | "github.com/cloudfoundry-community/go-cfenv" 17 | "github.com/nlopes/slack" 18 | ) 19 | 20 | // Bot struct serves as the primary entry point for slack and tock api methods 21 | // It stores the slack token string and a database connection for storing 22 | // emails and usernames 23 | type Bot struct { 24 | UserEmailMap *safeDict.SafeDict 25 | Slack *slackPackage.Slack 26 | Tock *tockPackage.Tock 27 | MessageRepo *messagesPackage.MessageRepository 28 | violatorUserMap *safeDict.SafeDict 29 | masterList []string 30 | } 31 | 32 | // InitBot method initalizes a bot 33 | func InitBot() *Bot { 34 | appEnv, _ := cfenv.Current() 35 | appService, _ := appEnv.Services.WithName("angrytock-credentials") 36 | 37 | userEmailMap := safeDict.InitSafeDict() 38 | violatorUserMap := safeDict.InitSafeDict() 39 | masterList := strings.Split(fmt.Sprint(appService.Credentials["MASTER_LIST"]), ",") 40 | slack := slackPackage.InitSlack() 41 | tock := tockPackage.InitTock() 42 | messageRepo := messagesPackage.InitMessageRepository() 43 | 44 | return &Bot{userEmailMap, slack, tock, messageRepo, violatorUserMap, masterList} 45 | } 46 | 47 | // Check if user is in masterList 48 | func (bot *Bot) isMasterUser(user string) bool { 49 | var isMasterUser bool 50 | for _, masterUser := range bot.masterList { 51 | if masterUser == user { 52 | isMasterUser = true 53 | log.Printf("The user %s is a masterUser\n", user) 54 | break 55 | } 56 | } 57 | return isMasterUser 58 | } 59 | 60 | // masterList checks if a user email is in the masterList and 61 | // if it is, it will replace the users email with a slack id 62 | func (bot *Bot) updateMasterList(userEmail string, userSlackID string) { 63 | for idx, masterUserEmail := range bot.masterList { 64 | if masterUserEmail == userEmail { 65 | bot.masterList[idx] = userSlackID 66 | } 67 | } 68 | } 69 | 70 | // StoreSlackUsers is a method for collecting and storing slack users in database 71 | func (bot *Bot) StoreSlackUsers() { 72 | log.Println("Collecting Slack Users") 73 | // Open a write channel to the bot 74 | 75 | slackUsers := bot.Slack.FetchSlackUsers() 76 | for _, user := range slackUsers { 77 | if strings.HasSuffix(user.Profile.Email, ".gov") { 78 | bot.UserEmailMap.Update(user.Profile.Email, user.ID) 79 | bot.updateMasterList(user.Profile.Email, user.ID) 80 | } 81 | } 82 | } 83 | 84 | // updateviolatorUserMap generates a new map containing the slack id and user email 85 | // of late tock users 86 | func (bot *Bot) updateviolatorUserMap() { 87 | violatorUserMap := make(map[string]string) 88 | bot.Tock.UserApplier( 89 | func(user tockPackage.User) { 90 | userID := bot.UserEmailMap.Get(user.Email) 91 | if user.Email != "" && userID != "" { 92 | violatorUserMap[userID] = user.Email 93 | } 94 | }, 95 | ) 96 | bot.violatorUserMap.Replace(violatorUserMap) 97 | } 98 | 99 | // startviolatorUserMapUpdater begins a ticker that only keeps the 100 | // violator list full for 30 minutes 101 | func (bot *Bot) startviolatorUserMapUpdater() { 102 | // Collect user data 103 | bot.updateviolatorUserMap() 104 | // Create a ticker to renew the cache of tock users 105 | ticker := time.NewTicker(30 * time.Minute) 106 | // Start the go channel 107 | go func() { 108 | for { 109 | select { 110 | case <-ticker.C: 111 | bot.violatorUserMap.Replace(make(map[string]string)) 112 | ticker.Stop() 113 | return 114 | } 115 | } 116 | }() 117 | } 118 | 119 | // SlapLateUsers collects users from tock and looks for thier slack ids in a database 120 | func (bot *Bot) SlapLateUsers() { 121 | log.Println("Slapping Tock Users") 122 | bot.Tock.UserApplier( 123 | func(user tockPackage.User) { 124 | userID := bot.UserEmailMap.Get(user.Email) 125 | if userID != "" { 126 | bot.Slack.MessageUser( 127 | userID, 128 | bot.MessageRepo.Reminder.GenerateMessage(bot.Tock.UserTockURL), 129 | ) 130 | } 131 | }, 132 | ) 133 | } 134 | 135 | // RemindUsers collects users from tock and looks for thier slack ids in a database 136 | func (bot *Bot) RemindUsers(message string) { 137 | log.Printf("Reminding Tock Users with `%s`", message) 138 | bot.Tock.UserApplier( 139 | func(user tockPackage.User) { 140 | userID := bot.UserEmailMap.Get(user.Email) 141 | if userID != "" { 142 | bot.Slack.MessageUser( 143 | userID, message, 144 | ) 145 | } 146 | }, 147 | ) 148 | } 149 | 150 | // ListenToSlackUsers starts a loop that listens to tock users 151 | func (bot *Bot) ListenToSlackUsers() { 152 | log.Println("Listening to slack") 153 | go bot.Slack.ManageConnection() 154 | // Creating a for loop to catch channel messages from slack 155 | for { 156 | select { 157 | case rtmEvent := <-bot.Slack.IncomingEvents: 158 | switch event := rtmEvent.Data.(type) { 159 | case *slack.HelloEvent: 160 | // Ignore hello 161 | case *slack.ConnectedEvent: 162 | // Ignore PresenceChangeEvent 163 | case *slack.MessageEvent: 164 | bot.processMessage(event) 165 | case *slack.PresenceChangeEvent: 166 | // Ignore PresenceChangeEvent 167 | case *slack.LatencyReport: 168 | // Ignore LatencyReport 169 | case *slack.RTMError: 170 | // Show errors 171 | fmt.Printf("Error: %s\n", event.Error()) 172 | case *slack.InvalidAuthEvent: 173 | fmt.Printf("Invalid credentials") 174 | break 175 | 176 | default: 177 | // Do nothing 178 | } 179 | } 180 | } 181 | } 182 | 183 | // isLateUser returns if the user is late. 184 | func (bot *Bot) isLateUser(slackUserID string) bool { 185 | found := false 186 | bot.Tock.UserApplier( 187 | func(user tockPackage.User) { 188 | userID := bot.UserEmailMap.Get(user.Email) 189 | if slackUserID == userID { 190 | found = true 191 | } 192 | }, 193 | ) 194 | return found 195 | } 196 | 197 | // fetchLateUsers returns a list of late users 198 | func (bot *Bot) fetchLateUsers() (string, int) { 199 | var lateList string 200 | var counter int 201 | 202 | bot.Tock.UserApplier( 203 | func(user tockPackage.User) { 204 | slackUserID := bot.UserEmailMap.Get(user.Email) 205 | if slackUserID != "" { 206 | lateList += fmt.Sprintf("<@%s>, ", slackUserID) 207 | counter++ 208 | } 209 | }, 210 | ) 211 | if lateList == "" { 212 | lateList = "No people" 213 | } 214 | return lateList, counter 215 | } 216 | --------------------------------------------------------------------------------