├── .gitignore ├── 9gag └── 9gag.go ├── LICENCE ├── README.md ├── bitly └── bitly.go ├── cachet ├── README.md └── cachet.go ├── catfacts ├── catfacts.go └── catfacts_test.go ├── catgif └── catgif.go ├── chucknorris ├── chucknorris.go └── chucknorris_test.go ├── circle.yml ├── cmd ├── README.md └── cmd.go ├── crypto ├── crypto.go └── crypto_test.go ├── dedup ├── README.md └── dedup.go ├── encoding ├── decode.go ├── decode_test.go ├── encode.go └── encode_test.go ├── example ├── goodmorning_command.go ├── helloworld_command.go ├── helloworld_command_test.go ├── reverse_command.go └── reverse_command_test.go ├── gif └── gif.go ├── godoc ├── godoc.go └── godoc_test.go ├── guid ├── guid.go └── guid_test.go ├── jira ├── README.md ├── example_config.json ├── example_config_thread.json ├── jira.go ├── jira_test.go └── mocks │ ├── JENKINS-3314.json │ ├── JENKINS-33149.json │ ├── config1.json │ ├── config2.json │ ├── config3.json │ ├── config4.json │ └── config5.json ├── puppet ├── puppet_command.go └── puppet_command_test.go ├── silence └── silence.go ├── treta ├── treta.go └── treta_test.go ├── twitter ├── README.md ├── twitter.go └── twitter_test.go ├── uptime └── uptime.go ├── url ├── url.go └── url_test.go └── web └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | go-bot 2 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 3 | *.o 4 | *.a 5 | *.so 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | 26 | # Sublime Text 27 | 28 | *.sublime-* 29 | 30 | # vim swap files 31 | *.swp 32 | -------------------------------------------------------------------------------- /9gag/9gag.go: -------------------------------------------------------------------------------- 1 | package gag 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/go-chat-bot/bot" 9 | ) 10 | 11 | const ( 12 | randomURL = "http://9gag.com/random" 13 | ) 14 | 15 | func randomPage(command *bot.Cmd) (string, error) { 16 | var redirectNotAllowed = errors.New("redirect") 17 | redirectedURL := "" 18 | 19 | client := http.Client{} 20 | client.CheckRedirect = func(req *http.Request, via []*http.Request) error { 21 | redirectedURL = req.URL.String() 22 | return redirectNotAllowed 23 | } 24 | 25 | _, err := client.Get(randomURL) 26 | if urlError, ok := err.(*url.Error); ok && urlError.Err == redirectNotAllowed { 27 | return redirectedURL, nil 28 | } 29 | return "", err 30 | } 31 | 32 | func init() { 33 | bot.RegisterCommand( 34 | "9gag", 35 | "Returns a random 9gag page.", 36 | "", 37 | randomPage) 38 | } 39 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Fábio Gomes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Circle CI](https://circleci.com/gh/go-chat-bot/plugins.svg?style=svg)](https://circleci.com/gh/go-chat-bot/plugins) 2 | 3 | ### Active 4 | 5 | * **gif**: Posts a random gif url from [giphy.com][giphy.com]. Try it with: **!gif cat** 6 | * **catgif**: Posts a random cat gif url from [thecatapi.com][thecatapi.com] 7 | * **godoc**: Searches packages in godoc.org. Try it with: **!godoc net/http** 8 | * **puppet**: Allows you to send messages through the bot: Try it with: **!puppet say #go-bot Hello!** 9 | * **guid**: Generates a new guid 10 | * **crypto**: Encrypts the input data using sha1 or md5 11 | * **encode**: Encodes a string, currently only to base64 12 | * **decode**: Decodes a string. currently ony from base64 13 | * **treta**: Use it to sow discord on a channel 14 | 15 | Tip: Use `!help ` to obtaing more info about these commands. 16 | 17 | ### Passive (triggers) 18 | 19 | Passive commands receive all the text sent to the bot or the channels that the bot is in and can process it and reply. 20 | 21 | These commands differ from the active commands as they are executed for every text that the bot receives. Ex: The Chuck Norris command, replies with a Chuck Norris fact every time the words "chuck" or "norris" are mentioned on a channel. 22 | 23 | * **url**: Detects url and gets it's title (very naive implementation, works sometimes) 24 | * **catfacts**: Tells a random cat fact based on some cat keywords 25 | * **jira**: Detects jira issue numbers and posts information about it. Necessary 26 | to configure. See README.md in jira subdirectory for details 27 | * **chucknorris**: Shows a random chuck norris quote every time the word "chuck" is mentioned 28 | * **bitly**: Shortens URLs appearing in output of other plugins before they are sent to channels 29 | 30 | ### Periodic (triggers) 31 | 32 | Periodic commands are run based on a [cron 33 | specification](https://godoc.org/github.com/robfig/cron) passed to the 34 | config. These commands are runned periodically, outputting a message to 35 | the configured channel(s). 36 | 37 | Look into the good morning [example 38 | command](https://github.com/go-chat-bot/plugins/blob/master/example/goodmorning_command.go) for guidance on how to write and configure periodic commands. 39 | 40 | * **cachet**: Notifies of service outages based on Cachet data 41 | 42 | ### Wish to write a new plugin? 43 | 44 | Start with the example commands in the [example directory](https://github.com/go-chat-bot/plugins/tree/master/example). 45 | 46 | It's dead simple, you just need to write a go function and register it on the bot. 47 | 48 | Here's a Hello World plugin example: 49 | 50 | ```Go 51 | package example 52 | 53 | import ( 54 | "fmt" 55 | 56 | "github.com/go-chat-bot/bot" 57 | ) 58 | 59 | func hello(command *bot.Cmd) (msg string, err error) { 60 | msg = fmt.Sprintf("Hello %s", command.User.RealName) 61 | return 62 | } 63 | 64 | func init() { 65 | bot.RegisterCommand( 66 | "hello", 67 | "Sends a 'Hello' message to you on the channel.", 68 | "", 69 | hello) 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /bitly/bitly.go: -------------------------------------------------------------------------------- 1 | package bitly 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/go-chat-bot/bot" 8 | "io/ioutil" 9 | "log" 10 | "mvdan.cc/xurls/v2" 11 | "net/http" 12 | "os" 13 | "strings" 14 | ) 15 | 16 | type shortenRequest struct { 17 | LongURL string `json:"long_url"` 18 | } 19 | 20 | type shortenReply struct { 21 | Link string `json:"link"` 22 | } 23 | 24 | const ( 25 | bitlyTokenEnv = "BITLY_TOKEN" 26 | shortenURLAPI = "https://api-ssl.bitly.com/v4/shorten" 27 | ) 28 | 29 | var ( 30 | urlRegex = xurls.Strict() 31 | ) 32 | 33 | func shorten(longurl string) (string, error) { 34 | sr := shortenRequest{longurl} 35 | body, err := json.Marshal(sr) 36 | if err != nil { 37 | return "", err 38 | } 39 | req, err := http.NewRequest("POST", shortenURLAPI, bytes.NewBuffer(body)) 40 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", 41 | os.Getenv(bitlyTokenEnv))) 42 | resp, err := http.DefaultClient.Do(req) 43 | if err != nil { 44 | return "", err 45 | } 46 | defer resp.Body.Close() 47 | if resp.StatusCode != 200 && resp.StatusCode != 201 { 48 | return "", fmt.Errorf("bitly API request returned non-20x code: %d", resp.StatusCode) 49 | } 50 | body, err = ioutil.ReadAll(resp.Body) 51 | if err != nil { 52 | log.Printf("Error reading response body. ", err) 53 | } 54 | shortReply := shortenReply{} 55 | err = json.Unmarshal(body, &shortReply) 56 | if err != nil { 57 | return "", err 58 | } 59 | return shortReply.Link, nil 60 | } 61 | 62 | func bitlyFilter(cmd *bot.FilterCmd) (string, error) { 63 | urls := urlRegex.FindAllString(cmd.Message, -1) 64 | if urls == nil { 65 | // no urls to shorten 66 | return cmd.Message, nil 67 | } 68 | 69 | for _, url := range urls { 70 | shortURL, err := shorten(url) 71 | if err != nil { 72 | log.Printf("Failed to shorten URL (%s): %s", url, err.Error()) 73 | continue 74 | } 75 | log.Printf("Succesfully shortened URL (%s) to %s", url, shortURL) 76 | cmd.Message = strings.Replace(cmd.Message, 77 | url, shortURL, -1) 78 | } 79 | 80 | return cmd.Message, nil 81 | } 82 | 83 | func init() { 84 | bot.RegisterFilterCommand( 85 | "bitly", 86 | bitlyFilter) 87 | } 88 | -------------------------------------------------------------------------------- /cachet/README.md: -------------------------------------------------------------------------------- 1 | # Cachet plugin 2 | 3 | This plugin can provide notifications for services that are failed in 4 | [Cachet](https://cachethq.io/). 5 | 6 | ## Configuration 7 | 8 | There are two environment variables that this plugin needs: 9 | 10 | * CACHET_API - URL of your Cachet top-level API endpoint. 11 | Example API URL `https://status.company.com/api` 12 | * CACHET_ALERT_CONFIG - Path to file with notification configuration 13 | 14 | Alert configuration is a JSON file which can be edited using bot commands. You 15 | can also edit it manually (remember to restart the bot) 16 | 17 | JSON file is a list of objects which have following keys: 18 | 19 | * `channel` - name of channels the configuration is for 20 | * `repeatGap` - number of minutes between repeated outage notifications 21 | * `services` - cachet component names which will be notified (or `all` for any outage) 22 | 23 | Example: 24 | 25 | ```json 26 | [ 27 | { 28 | "channel": "#outages", 29 | "services": [ 30 | "all" 31 | ], 32 | "repeatGap": 120 33 | }, 34 | { 35 | "channel": "#service1", 36 | "services": [ 37 | "service" 38 | ], 39 | "repeatGap": 15 40 | }, 41 | { 42 | "channel": "#team", 43 | "services": [ 44 | "service2", 45 | "service3" 46 | ], 47 | "repeatGap": 60 48 | } 49 | ] 50 | ``` 51 | 52 | Above configuration would sent alerts to `#outage` for any outage every 2 53 | hours. It would send alerts to `#service1` every 15 minutes for `service` 54 | outages. And it would also send alerts to `#team` every 60 minutes if either 55 | `service2` or `service3` are in outage. 56 | 57 | ## Commands 58 | 59 | Bot recognizes following commands: 60 | 61 | * `services` - list all services known to cachet 62 | * `subscriptions` - list all active subscriptions for this channel 63 | * `subscribe ` - subscribe to receive outage notification for `` 64 | * `unsubscribe ` - unsubscribe from outage notification for `` 65 | * `repeatgap ` - set how often alerts will be repeated (in minutes) 66 | 67 | Configuration is automatically saved on each change through bot commands 68 | -------------------------------------------------------------------------------- /cachet/cachet.go: -------------------------------------------------------------------------------- 1 | package cachet 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/go-chat-bot/bot" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | const ( 18 | statusFailed = 4 19 | ) 20 | 21 | var ( 22 | cachetAPI = os.Getenv("CACHET_API") 23 | configFilePath = os.Getenv("CACHET_ALERT_CONFIG") 24 | outageReportConfig []ChannelConfig 25 | pastOutageNotifications map[string]time.Time 26 | pastOutageMutex = sync.RWMutex{} 27 | ) 28 | 29 | // cachetComponents is Go representation of https://docs.cachethq.io/reference#get-components 30 | type cachetComponents struct { 31 | Meta struct { 32 | Pagination struct { 33 | Total int `json:"total"` 34 | Count int `json:"count"` 35 | PerPage int `json:"per_page"` 36 | CurrentPage int `json:"current_page"` 37 | TotalPages int `json:"total_pages"` 38 | Links struct { 39 | NextPage string `json:"next_page"` 40 | PreviousPage string `json:"previous_page"` 41 | } `json:"links"` 42 | } `json:"pagination"` 43 | } `json:"meta"` 44 | Data []struct { 45 | ID int `json:"id"` 46 | Name string `json:"name"` 47 | Description string `json:"description"` 48 | Link string `json:"link"` 49 | Status int `json:"status"` 50 | Order int `json:"order"` 51 | GroupID int `json:"group_id"` 52 | Enabled bool `json:"enabled"` 53 | Meta interface{} `json:"meta"` 54 | CreatedAt string `json:"created_at"` 55 | UpdatedAt string `json:"updated_at"` 56 | DeletedAt interface{} `json:"deleted_at"` 57 | StatusName string `json:"status_name"` 58 | Tags []interface{} `json:"tags"` 59 | } `json:"data"` 60 | } 61 | 62 | // ChannelConfig is representation of alert configuration for single channel 63 | type ChannelConfig struct { 64 | Channel string `json:"channel"` 65 | Services []string `json:"services"` 66 | RepeatGap int `json:"repeatGap"` 67 | } 68 | 69 | func cachetGetComponentsFromURL(url string) (components cachetComponents, err error) { 70 | err = nil 71 | log.Printf("Getting components from Cachet URL %s", url) 72 | resp, err := http.Get(url) 73 | 74 | if err != nil { 75 | log.Printf("Cachet API call failed: %v", err) 76 | return 77 | } 78 | 79 | if resp.StatusCode != 200 { 80 | log.Printf("Cachet API call failed with: %d", resp.StatusCode) 81 | err = fmt.Errorf("Cachet API call failed with code: %d", resp.StatusCode) 82 | return 83 | } 84 | 85 | defer resp.Body.Close() 86 | body, err := ioutil.ReadAll(resp.Body) 87 | if err != nil { 88 | log.Printf("Failed reading cachet response body: %v", err) 89 | return 90 | } 91 | err = json.Unmarshal(body, &components) 92 | if err != nil { 93 | log.Printf("Failed to unmarshal JSON response: %v", err) 94 | return 95 | } 96 | return 97 | } 98 | 99 | func cachetGetComponentNames(params string) (names []string, err error) { 100 | url := fmt.Sprintf("%s/v1/components?%s", cachetAPI, params) 101 | var components cachetComponents 102 | for { 103 | components, err = cachetGetComponentsFromURL(url) 104 | if err != nil { 105 | return 106 | } 107 | 108 | for _, component := range components.Data { 109 | names = append(names, component.Name) 110 | } 111 | 112 | url = components.Meta.Pagination.Links.NextPage 113 | if url == "" { 114 | // end of paging 115 | break 116 | } 117 | } 118 | 119 | return 120 | } 121 | 122 | func getChannelNamesForServiceNotification(service string) (ret []string) { 123 | for _, channelConfig := range outageReportConfig { 124 | for _, serviceName := range channelConfig.Services { 125 | if service == serviceName { 126 | ret = append(ret, channelConfig.Channel) 127 | } 128 | } 129 | } 130 | return 131 | } 132 | 133 | func getChannelConfig(channel string) (ret *ChannelConfig) { 134 | for i := range outageReportConfig { 135 | if outageReportConfig[i].Channel == channel { 136 | return &outageReportConfig[i] 137 | } 138 | } 139 | return nil 140 | } 141 | 142 | func recordOutage(channel string, service string) { 143 | cc := getChannelConfig(channel) 144 | if cc == nil { 145 | log.Printf("Could not find channel config for %s", channel) 146 | return 147 | } 148 | until := time.Now().UTC().Add(time.Duration(cc.RepeatGap) * time.Minute) 149 | key := fmt.Sprintf("%s-%s", channel, service) 150 | pastOutageMutex.Lock() 151 | pastOutageNotifications[key] = until 152 | pastOutageMutex.Unlock() 153 | 154 | go func() { 155 | time.Sleep(time.Duration(cc.RepeatGap) * time.Minute) 156 | pastOutageMutex.Lock() 157 | delete(pastOutageNotifications, key) 158 | pastOutageMutex.Unlock() 159 | }() 160 | } 161 | 162 | func checkCachet() (ret []bot.CmdResult, err error) { 163 | failedNames, err := cachetGetComponentNames("status=4") 164 | if err != nil { 165 | log.Printf("Failure while getting failed components: %v", err) 166 | return 167 | } 168 | anyChannels := getChannelNamesForServiceNotification("any") 169 | 170 | for _, failedService := range failedNames { 171 | notifyChannels := []string{} 172 | notifyChannels = append(notifyChannels, anyChannels...) 173 | notifyChannels = append(notifyChannels, 174 | getChannelNamesForServiceNotification(failedService)...) 175 | log.Printf("Reporting alerts for %s to %s", failedService, notifyChannels) 176 | for _, notifyChannel := range notifyChannels { 177 | key := fmt.Sprintf("%s-%s", notifyChannel, failedService) 178 | pastOutageMutex.RLock() 179 | until, found := pastOutageNotifications[key] 180 | pastOutageMutex.RUnlock() 181 | if found { 182 | log.Printf("Skipping notification for %s in %s (until %v)", 183 | failedService, notifyChannel, until) 184 | continue 185 | } 186 | recordOutage(notifyChannel, failedService) 187 | log.Printf("Alerting about %s outage in %s", failedService, notifyChannel) 188 | ret = append(ret, bot.CmdResult{ 189 | Message: fmt.Sprintf("Service '%s' is in outage as per %s", 190 | failedService, cachetAPI), 191 | Channel: notifyChannel, 192 | }) 193 | } 194 | } 195 | return 196 | } 197 | 198 | func reloadConfig() { 199 | configFile, err := os.Open(configFilePath) 200 | if err != nil { 201 | log.Printf("Failed to open config file: %v", err) 202 | return 203 | } 204 | defer configFile.Close() 205 | decoder := json.NewDecoder(configFile) 206 | err = decoder.Decode(&outageReportConfig) 207 | if err != nil { 208 | log.Printf("Failed to parse config file: %v", err) 209 | return 210 | } 211 | log.Printf("Loaded config: %v", outageReportConfig) 212 | } 213 | 214 | func saveConfig() { 215 | log.Printf("Config before save: %v", outageReportConfig) 216 | configFile, err := os.Create(configFilePath) 217 | if err != nil { 218 | log.Printf("Failed to open/write config file: %v", err) 219 | return 220 | } 221 | defer configFile.Close() 222 | encoder := json.NewEncoder(configFile) 223 | err = encoder.Encode(&outageReportConfig) 224 | if err != nil { 225 | log.Printf("Failed to encode config file: %v", err) 226 | return 227 | } 228 | } 229 | 230 | func getChannelKey(cmd *bot.Cmd) string { 231 | if cmd.ChannelData.IsPrivate { 232 | return cmd.User.Nick 233 | } 234 | return cmd.Channel 235 | } 236 | 237 | func listComponents(cmd *bot.Cmd) (bot.CmdResultV3, error) { 238 | componentNames, err := cachetGetComponentNames("") 239 | log.Printf("Listing services in %s", getChannelKey(cmd)) 240 | result := bot.CmdResultV3{ 241 | Channel: getChannelKey(cmd), 242 | Message: make(chan string), 243 | Done: make(chan bool, 1)} 244 | if err != nil { 245 | log.Printf("Failed getting components from cachet: %v", err) 246 | result.Message <- fmt.Sprintf("Failed getting components from cachet: %v", err) 247 | result.Done <- true 248 | return result, err 249 | } 250 | go func() { 251 | result.Message <- "Services known in cachet:" 252 | curMsgLen := 0 253 | curComponents := []string{} 254 | for _, componentName := range componentNames { 255 | if curMsgLen > 80 { 256 | log.Printf("Returning partial list of components: %v", curComponents) 257 | result.Message <- strings.Join(curComponents, ", ") 258 | curMsgLen = 0 259 | curComponents = []string{} 260 | } 261 | curMsgLen = curMsgLen + len(componentName) 262 | curComponents = append(curComponents, componentName) 263 | } 264 | log.Printf("Returning last part of components: %v", curComponents) 265 | result.Message <- strings.Join(curComponents, ", ") 266 | result.Done <- true 267 | }() 268 | return result, err 269 | } 270 | 271 | func listSubscriptions(cmd *bot.Cmd) (string, error) { 272 | channelKey := getChannelKey(cmd) 273 | channelConfig := getChannelConfig(channelKey) 274 | if channelConfig != nil && channelConfig.Channel == channelKey { 275 | return fmt.Sprintf("This channel is subscribed to notifications for: %v", 276 | channelConfig.Services), nil 277 | } 278 | return "This channel has no subscriptions", nil 279 | } 280 | 281 | func subscribeChannel(cmd *bot.Cmd) (ret string, err error) { 282 | if len(cmd.Args) != 1 { 283 | return "Expecting 1 argument: ", nil 284 | } 285 | channelKey := getChannelKey(cmd) 286 | newService := cmd.Args[0] 287 | channelConfig := getChannelConfig(channelKey) 288 | ret = fmt.Sprintf("Succesfully subscribed channel %s to outage notifications for '%s'", 289 | channelKey, newService) 290 | defer saveConfig() 291 | if channelConfig == nil { 292 | log.Printf("Channel %s has no config yet. Adding new one", channelKey) 293 | outageReportConfig = append(outageReportConfig, ChannelConfig{ 294 | Channel: channelKey, 295 | Services: []string{newService}, 296 | RepeatGap: 5, 297 | }) 298 | return 299 | } 300 | 301 | for _, service := range channelConfig.Services { 302 | if service == newService { 303 | return fmt.Sprintf( 304 | "This channel is already subscribed to '%s' outage notifications", 305 | service), nil 306 | } 307 | } 308 | log.Printf("Channel already has a config. Appending new service notification") 309 | channelConfig.Services = append(channelConfig.Services, newService) 310 | log.Printf("New notifications: %s", channelConfig.Services) 311 | return 312 | } 313 | 314 | func unsubscribeChannel(cmd *bot.Cmd) (string, error) { 315 | if len(cmd.Args) != 1 { 316 | return "Expecting 1 argument: ", nil 317 | } 318 | channelKey := getChannelKey(cmd) 319 | channelConfig := getChannelConfig(channelKey) 320 | newService := cmd.Args[0] 321 | if channelConfig == nil { 322 | return "Channel is not subscribed to anything", nil 323 | } 324 | 325 | newServices := []string{} 326 | for _, service := range channelConfig.Services { 327 | if service == newService { 328 | continue 329 | } 330 | newServices = append(newServices, service) 331 | } 332 | 333 | log.Printf("Channel already has a config. Appending new service notification") 334 | channelConfig.Services = newServices 335 | log.Printf("New notifications: %s", channelConfig.Services) 336 | saveConfig() 337 | return fmt.Sprintf( 338 | "Succesfully unsubscribed channel %s from outage notifications for %s", 339 | channelKey, newService), nil 340 | } 341 | 342 | func outageRepeatGap(cmd *bot.Cmd) (ret string, err error) { 343 | if len(cmd.Args) != 1 { 344 | return "Expecting 1 argument: ", nil 345 | } 346 | min, err := strconv.Atoi(cmd.Args[0]) 347 | if err != nil { 348 | return "Argument must be exactly 1 number (of minutes between notifications)", nil 349 | } 350 | channelKey := getChannelKey(cmd) 351 | channelConfig := getChannelConfig(channelKey) 352 | defer saveConfig() 353 | ret = fmt.Sprintf("Succesfully configured notification gap to be %d minutes", min) 354 | if channelConfig == nil { 355 | log.Printf("Channel has no config yet. Adding new one") 356 | outageReportConfig = append(outageReportConfig, ChannelConfig{ 357 | Channel: cmd.Channel, 358 | Services: []string{}, 359 | RepeatGap: min, 360 | }) 361 | return 362 | } 363 | channelConfig.RepeatGap = min 364 | return 365 | } 366 | 367 | func init() { 368 | pastOutageNotifications = make(map[string]time.Time) 369 | reloadConfig() 370 | 371 | bot.RegisterPeriodicCommandV2( 372 | "systemStatusCheck", 373 | bot.PeriodicConfig{ 374 | CronSpec: "@every 1m", 375 | CmdFuncV2: checkCachet, 376 | }) 377 | bot.RegisterCommandV3( 378 | "services", 379 | "List services available for subscriptions", 380 | "", 381 | listComponents) 382 | bot.RegisterCommand( 383 | "subscriptions", 384 | "Lists active outage subscriptions", 385 | "", 386 | listSubscriptions) 387 | bot.RegisterCommand( 388 | "subscribe", 389 | "Subscribes this channel to outage notifications of specific service (or 'any' for all outages)", 390 | "", 391 | subscribeChannel) 392 | bot.RegisterCommand( 393 | "unsubscribe", 394 | "Unsubscribes this channel from outage notifications of specific service", 395 | "", 396 | unsubscribeChannel) 397 | bot.RegisterCommand( 398 | "repeatgap", 399 | "Sets number of minutes between notification of specific service outage", 400 | "60", 401 | outageRepeatGap) 402 | } 403 | -------------------------------------------------------------------------------- /catfacts/catfacts.go: -------------------------------------------------------------------------------- 1 | package catfacts 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | "github.com/go-chat-bot/plugins/web" 7 | "regexp" 8 | ) 9 | 10 | const ( 11 | pattern = "(?i)\\b(cat|gato|miau|meow|garfield|lolcat)[s|z]{0,1}\\b" 12 | msgPrefix = "I love cats! Here's a fact: %s" 13 | ) 14 | 15 | type catFact struct { 16 | Fact string `json:"fact"` 17 | Length int `json:"length"` 18 | } 19 | 20 | var ( 21 | re = regexp.MustCompile(pattern) 22 | catFactsURL = "http://catfact.ninja/fact" 23 | ) 24 | 25 | func catFacts(command *bot.PassiveCmd) (string, error) { 26 | if !re.MatchString(command.Raw) { 27 | return "", nil 28 | } 29 | data := &catFact{} 30 | err := web.GetJSON(catFactsURL, data) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | if len(data.Fact) == 0 { 36 | return "", nil 37 | } 38 | 39 | return fmt.Sprintf(msgPrefix, data.Fact), nil} 40 | 41 | func init() { 42 | bot.RegisterPassiveCommand( 43 | "catfacts", 44 | catFacts) 45 | } 46 | -------------------------------------------------------------------------------- /catfacts/catfacts_test.go: -------------------------------------------------------------------------------- 1 | package catfacts 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | . "github.com/smartystreets/goconvey/convey" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | const ( 13 | validResult = `{"facts": [ 14 | "Catz FTW!" 15 | ], 16 | "success": "true"}` 17 | 18 | emptyResult = `{"facts": [], "success": "true"}` 19 | ) 20 | 21 | func TestCatFacts(t *testing.T) { 22 | apiResult := "" 23 | ts := httptest.NewServer( 24 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | fmt.Fprintln(w, apiResult) 26 | })) 27 | 28 | catFactsURL = ts.URL 29 | 30 | cmd := &bot.PassiveCmd{} 31 | 32 | Convey("Given a text", t, func() { 33 | 34 | Reset(func() { 35 | cmd.Raw = "" 36 | apiResult = "" 37 | }) 38 | 39 | Convey("When the text does not have cat", func() { 40 | cmd.Raw = "My name is Catarina." 41 | s, err := catFacts(cmd) 42 | 43 | So(err, ShouldBeNil) 44 | So(s, ShouldEqual, "") 45 | }) 46 | 47 | Convey("When the api returns 0 results", func() { 48 | apiResult = emptyResult 49 | cmd.Raw = "I love Catz!" 50 | 51 | s, err := catFacts(cmd) 52 | 53 | So(err, ShouldBeNil) 54 | So(s, ShouldEqual, "") 55 | }) 56 | 57 | Convey("When the text has cat in the end of the sentence", func() { 58 | cmd.Raw = "I love Catz!" 59 | apiResult = validResult 60 | 61 | s, err := catFacts(cmd) 62 | 63 | So(err, ShouldBeNil) 64 | So(s, ShouldEqual, fmt.Sprintf(msgPrefix, "Catz FTW!")) 65 | }) 66 | 67 | Convey("When the text does not end with the world or puntuation", func() { 68 | cmd.Raw = "My name is Catzarina" 69 | 70 | s, err := catFacts(cmd) 71 | 72 | So(err, ShouldBeNil) 73 | So(s, ShouldEqual, "") 74 | }) 75 | 76 | Convey("When the text has cat in the middle of a word", func() { 77 | cmd.Raw = "My name is aCats" 78 | 79 | s, err := catFacts(cmd) 80 | 81 | So(err, ShouldBeNil) 82 | So(s, ShouldEqual, "") 83 | }) 84 | 85 | Convey("when the text have gato in the middle of the sentence", func() { 86 | cmd.Raw = "Eu tenho 2 gatos gordos." 87 | apiResult = validResult 88 | 89 | s, err := catFacts(cmd) 90 | 91 | So(err, ShouldBeNil) 92 | So(s, ShouldEqual, fmt.Sprintf(msgPrefix, "Catz FTW!")) 93 | }) 94 | 95 | Convey("When the api is unreachable", func() { 96 | cmd.Raw = "cat" 97 | catFactsURL = "127.0.0.1:0" 98 | 99 | _, err := catFacts(cmd) 100 | 101 | So(err, ShouldNotBeNil) 102 | }) 103 | 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /catgif/catgif.go: -------------------------------------------------------------------------------- 1 | package catgif 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | "net/http" 6 | ) 7 | 8 | func gif(command *bot.Cmd) (msg string, err error) { 9 | res, err := http.Get("http://thecatapi.com/api/images/get?format=src&type=gif") 10 | if err != nil { 11 | return "", err 12 | } 13 | return res.Request.URL.String(), nil 14 | } 15 | 16 | func init() { 17 | bot.RegisterCommand( 18 | "catgif", 19 | "Returns a random cat gif.", 20 | "", 21 | gif) 22 | } 23 | -------------------------------------------------------------------------------- /chucknorris/chucknorris.go: -------------------------------------------------------------------------------- 1 | package chucknorris 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | "math/rand" 6 | "regexp" 7 | ) 8 | 9 | const ( 10 | pattern = "(?i)\\b(chuck|norris)\\b" 11 | ) 12 | 13 | var ( 14 | re = regexp.MustCompile(pattern) 15 | chuckFacts = []string{ 16 | "All arrays Chuck Norris declares are of infinite size, because Chuck Norris knows no bounds.", 17 | "Chuck Norris doesn't have disk latency because the hard drive knows to hurry the hell up.", 18 | "All browsers support the hex definitions #chuck and #norris for the colors black and blue.", 19 | "Chuck Norris can't test for equality because he has no equal.", 20 | "Chuck Norris doesn't need garbage collection because he doesn't call .Dispose(), he calls .DropKick().", 21 | "Chuck Norris's first program was kill -9.", 22 | "Chuck Norris burst the dot com bubble.", 23 | "Chuck Norris writes code that optimizes itself.", 24 | "Chuck Norris can write infinite recursion functions... and have them return.", 25 | "Chuck Norris can solve the Towers of Hanoi in one move.", 26 | "The only pattern Chuck Norris knows is God Object.", 27 | "Chuck Norris finished World of Warcraft.", 28 | "Project managers never ask Chuck Norris for estimations... ever.", 29 | "Chuck Norris doesn't use web standards as the web will conform to him.", 30 | "\"It works on my machine\" always holds true for Chuck Norris.", 31 | "Whiteboards are white because Chuck Norris scared them that way.", 32 | "Chuck Norris's beard can type 140 wpm.", 33 | "Chuck Norris can unit test an entire application with a single assert.", 34 | "Chuck Norris doesn't bug hunt as that signifies a probability of failure, he goes bug killing.", 35 | "Chuck Norris's keyboard doesn't have a Ctrl key because nothing controls Chuck Norris.", 36 | "Chuck Norris doesn't need a debugger, he just stares down the bug until the code confesses.", 37 | "Chuck Norris can access private methods.", 38 | "Chuck Norris can instantiate an abstract class.", 39 | "Chuck Norris doesn't need to know about class factory pattern. He can instantiate interfaces.", 40 | "The class object inherits from Chuck Norris.", 41 | "For Chuck Norris, NP-Hard = O(1).", 42 | "Chuck Norris knows the last digit of PI.", 43 | "Chuck Norris can divide by zero.", 44 | "Chuck Norris doesn't get compiler errors, the language changes itself to accommodate Chuck Norris.", 45 | "The programs that Chuck Norris writes don't have version numbers because he only writes them once. If a user reports a bug or has a feature request they don't live to see the sun set.", 46 | "Chuck Norris doesn't believe in floating point numbers because they can't be typed on his binary keyboard.", 47 | "Chuck Norris solved the Travelling Salesman problem in O(1) time.", 48 | "Chuck Norris never gets a syntax error. Instead, The language gets a DoesNotConformToChuck error.", 49 | "No statement can catch the ChuckNorrisException.", 50 | "Chuck Norris doesn't program with a keyboard. He stares the computer down until it does what he wants.", 51 | "Chuck Norris doesn't pair program.", 52 | "Chuck Norris can write multi-threaded applications with a single thread.", 53 | "There is no Esc key on Chuck Norris' keyboard, because no one escapes Chuck Norris.", 54 | "Chuck Norris doesn't delete files, he blows them away.", 55 | "Chuck Norris can binary search unsorted data.", 56 | "Chuck Norris breaks RSA 128-bit encrypted codes in milliseconds.", 57 | "Chuck Norris went out of an infinite loop.", 58 | "Chuck Norris can read all encrypted data, because nothing can hide from Chuck Norris.", 59 | "Chuck Norris hosting is 101% uptime guaranteed.", 60 | "When a bug sees Chuck Norris, it flees screaming in terror, and then immediately self-destructs to avoid being roundhouse-kicked.", 61 | "Chuck Norris rewrote the Google search engine from scratch.", 62 | "Chuck Norris doesn't need the cloud to scale his applications, he uses his laptop.", 63 | "Chuck Norris can access the DB from the UI.", 64 | "Chuck Norris protocol design method has no status, requests or responses, only commands.", 65 | "Chuck Norris programs occupy 150% of CPU, even when they are not executing.", 66 | "Chuck Norris can spawn threads that complete before they are started.", 67 | "Chuck Norris programs do not accept input.", 68 | "Chuck Norris doesn't need an OS.", 69 | "Chuck Norris can compile syntax errors.", 70 | "Chuck Norris compresses his files by doing a flying round house kick to the hard drive.", 71 | "Chuck Norris doesn't use a computer because a computer does everything slower than Chuck Norris.", 72 | "You don't disable the Chuck Norris plug-in, it disables you.", 73 | "Chuck Norris doesn't need a java compiler, he goes straight to .war", 74 | "Chuck Norris can use GOTO as much as he wants to. Telling him otherwise is considered harmful.", 75 | "There is nothing regular about Chuck Norris' expressions.", 76 | "Quantum cryptography does not work on Chuck Norris. When something is being observed by Chuck it stays in the same state until he's finished. ", 77 | "There is no need to try catching Chuck Norris' exceptions for recovery; every single throw he does is fatal.", 78 | "Chuck Norris' beard is immutable.", 79 | "Chuck Norris' preferred IDE is hexedit.", 80 | } 81 | ) 82 | 83 | func chucknorris(command *bot.PassiveCmd) (string, error) { 84 | if re.MatchString(command.Raw) { 85 | return chuckFacts[rand.Intn(len(chuckFacts))], nil 86 | } 87 | return "", nil 88 | } 89 | 90 | func init() { 91 | bot.RegisterPassiveCommand( 92 | "chucknorris", 93 | chucknorris) 94 | } 95 | -------------------------------------------------------------------------------- /chucknorris/chucknorris_test.go: -------------------------------------------------------------------------------- 1 | package chucknorris 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | . "github.com/smartystreets/goconvey/convey" 6 | "testing" 7 | ) 8 | 9 | func TestChuckNorris(t *testing.T) { 10 | Convey("Given a text", t, func() { 11 | cmd := &bot.PassiveCmd{} 12 | Convey("When the text does not match a chuck norris name", func() { 13 | cmd.Raw = "My name is go-bot, I am awesome." 14 | s, err := chucknorris(cmd) 15 | 16 | So(err, ShouldBeNil) 17 | So(s, ShouldEqual, "") 18 | }) 19 | 20 | Convey("When the text match a chuck name", func() { 21 | cmd.Raw = "My name is chuck" 22 | 23 | s, err := chucknorris(cmd) 24 | 25 | So(err, ShouldBeNil) 26 | So(s, ShouldNotEqual, "") 27 | }) 28 | 29 | Convey("When the text match norris", func() { 30 | cmd.Raw = "Hi, I'm Mr. Norris" 31 | 32 | s, err := chucknorris(cmd) 33 | 34 | So(err, ShouldBeNil) 35 | So(s, ShouldNotEqual, "") 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | test: 2 | pre: 3 | - go get -d -t ./... 4 | override: 5 | - go test ./... -race 6 | -------------------------------------------------------------------------------- /cmd/README.md: -------------------------------------------------------------------------------- 1 | ### WARNING 2 | 3 | This plugin enables *anyone* to run almost any command on your system as the 4 | user running the bot. Depending on your environment this can be considered a big 5 | security hole. If in doubt - do *NOT* use this plugin. 6 | 7 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os/exec" 7 | 8 | "github.com/go-chat-bot/bot" 9 | ) 10 | 11 | var ( 12 | disableCmds = map[string]bool{"shutdown": true, "reboot": true, "init": true, "rm": true, "top": true, "htop": true, "iotop": true} 13 | errDisableCmd = errors.New("command is disabled") 14 | ) 15 | 16 | func cmd(command *bot.Cmd) (string, error) { 17 | if _, ok := disableCmds[command.Args[0]]; ok { 18 | return "", errDisableCmd 19 | } 20 | cmd := exec.Command("/bin/bash", "-c", command.RawArgs) 21 | data, err := cmd.CombinedOutput() 22 | return string(data), err 23 | } 24 | 25 | func cmdV3(command *bot.Cmd) (result bot.CmdResultV3, err error) { 26 | result = bot.CmdResultV3{Message: make(chan string), Done: make(chan bool)} 27 | if _, ok := disableCmds[command.Args[0]]; ok { 28 | err = errDisableCmd 29 | return 30 | } 31 | 32 | cmd := exec.Command("/bin/bash", "-c", command.RawArgs) 33 | var b bytes.Buffer 34 | cmd.Stdout = &b 35 | cmd.Stderr = &b 36 | err = cmd.Start() 37 | if err != nil { 38 | return 39 | } 40 | done := false 41 | go func() { 42 | cmd.Wait() 43 | done = true 44 | result.Done <- true 45 | }() 46 | go func() { 47 | for { 48 | line, _ := b.ReadString('\n') 49 | if line != "" { 50 | result.Message <- line 51 | } 52 | if done { 53 | break 54 | } 55 | 56 | } 57 | }() 58 | return 59 | } 60 | 61 | func init() { 62 | bot.RegisterCommand( 63 | "cmd", 64 | "run cmd on system", 65 | "pwd", 66 | cmd) 67 | bot.RegisterCommandV3( 68 | "cmdv3", 69 | "run cmd on system", 70 | "pwd", 71 | cmdV3) 72 | } 73 | -------------------------------------------------------------------------------- /crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/sha1" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/go-chat-bot/bot" 10 | ) 11 | 12 | const ( 13 | invalidAmountOfParams = "Invalid amount of parameters" 14 | invalidParams = "Invalid parameters" 15 | ) 16 | 17 | func crypto(command *bot.Cmd) (string, error) { 18 | 19 | if len(command.Args) < 2 { 20 | return invalidAmountOfParams, nil 21 | } 22 | 23 | inputData := []byte(strings.Join(command.Args[1:], " ")) 24 | switch strings.ToUpper(command.Args[0]) { 25 | case "MD5": 26 | return encryptMD5(inputData), nil 27 | case "SHA1", "SHA-1": 28 | return encryptSHA1(inputData), nil 29 | default: 30 | return invalidParams, nil 31 | } 32 | } 33 | 34 | func encryptMD5(data []byte) string { 35 | return fmt.Sprintf("%x", md5.Sum(data)) 36 | } 37 | 38 | func encryptSHA1(data []byte) string { 39 | return fmt.Sprintf("%x", sha1.Sum(data)) 40 | } 41 | 42 | func init() { 43 | bot.RegisterCommand( 44 | "crypto", 45 | "Encrypts the input data from its hash value", 46 | "md5|sha-1 enter here text to encrypt", 47 | crypto) 48 | } 49 | -------------------------------------------------------------------------------- /crypto/crypto_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-chat-bot/bot" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestCrypto(t *testing.T) { 11 | Convey("Crypto", t, func() { 12 | bot := &bot.Cmd{ 13 | Command: "crypto", 14 | } 15 | 16 | Convey("Should return a error message when don't pass any params", func() { 17 | got, error := crypto(bot) 18 | 19 | So(error, ShouldBeNil) 20 | So(got, ShouldEqual, invalidAmountOfParams) 21 | }) 22 | 23 | Convey("Should return a error message when pass an invalid algorithm", func() { 24 | bot.Args = []string{"invalidAlgorithm", "input data"} 25 | got, error := crypto(bot) 26 | 27 | So(error, ShouldBeNil) 28 | So(got, ShouldEqual, invalidParams) 29 | }) 30 | 31 | Convey("using MD5 algorithm", func() { 32 | 33 | Convey("Should encrypt a value", func() { 34 | bot.Args = []string{"md5", "go-chat-bot"} 35 | got, error := crypto(bot) 36 | want := "1120d1df84fec8a0557e8737ac021651" 37 | 38 | So(error, ShouldBeNil) 39 | So(got, ShouldEqual, want) 40 | }) 41 | 42 | Convey("Should encrypt multiple words", func() { 43 | bot.Args = []string{"md5", "The", "Go", "Programming", "Language"} 44 | got, error := crypto(bot) 45 | want := "adb505803d3502f2f00c88365ab85bf0" 46 | 47 | So(error, ShouldBeNil) 48 | So(got, ShouldEqual, want) 49 | }) 50 | }) 51 | 52 | Convey("using SHA-1 algorithm", func() { 53 | 54 | Convey("Should encrypt a value", func() { 55 | bot.Args = []string{"sha1", "go-chat-bot"} 56 | got, error := crypto(bot) 57 | want := "385ca248ffebb5ed7f62d1ea2b0545cff80ac18e" 58 | 59 | So(error, ShouldBeNil) 60 | So(got, ShouldEqual, want) 61 | }) 62 | 63 | Convey("Should encrypt multiple words", func() { 64 | bot.Args = []string{"sha-1", "The", "Go", "Programming", "Language"} 65 | got, error := crypto(bot) 66 | want := "88a93e668044877a845097aaf620532a232bfd34" 67 | 68 | So(error, ShouldBeNil) 69 | So(got, ShouldEqual, want) 70 | }) 71 | }) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /dedup/README.md: -------------------------------------------------------------------------------- 1 | ### Overview 2 | 3 | This plugin de-duplicates messages sent by the bot. This can be useful to 4 | prevent bot from repeating the same answer repeatedly within a period of time. 5 | 6 | ### Setup 7 | Set up DEDUP_TIMEOUT env variable to number of minutes which should be 8 | de-duplicated. Plugin defaults to 5 minutes of de-duplication unless configured 9 | otherwise 10 | -------------------------------------------------------------------------------- /dedup/dedup.go: -------------------------------------------------------------------------------- 1 | package dedup 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | "hash/fnv" 6 | "log" 7 | "os" 8 | "strconv" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | const ( 14 | dedupConfigEnv = "DEDUP_TIMEOUT" 15 | defaultDedupTime = "5" 16 | ) 17 | 18 | var ( 19 | pastMessageMap map[uint32]time.Time 20 | pastMessageMapLock = sync.RWMutex{} 21 | dedupConfig time.Duration 22 | fnvHash = fnv.New32a() 23 | ) 24 | 25 | func messageHash(msg, target string) uint32 { 26 | fnvHash.Write([]byte(msg)) 27 | fnvHash.Write([]byte(target)) 28 | defer fnvHash.Reset() 29 | return fnvHash.Sum32() 30 | } 31 | 32 | func recordMessage(msgHash uint32, target string) { 33 | until := time.Now().UTC().Add(dedupConfig) 34 | pastMessageMapLock.Lock() 35 | pastMessageMap[msgHash] = until 36 | pastMessageMapLock.Unlock() 37 | go func() { 38 | time.Sleep(dedupConfig) 39 | pastMessageMapLock.Lock() 40 | delete(pastMessageMap, msgHash) 41 | pastMessageMapLock.Unlock() 42 | }() 43 | } 44 | 45 | func dedupFilter(cmd *bot.FilterCmd) (string, error) { 46 | msgHash := messageHash(cmd.Message, cmd.Target) 47 | pastMessageMapLock.RLock() 48 | _, found := pastMessageMap[msgHash] 49 | pastMessageMapLock.RUnlock() 50 | if !found { 51 | // No past message like this, record and send 52 | recordMessage(msgHash, cmd.Target) 53 | return cmd.Message, nil 54 | } 55 | 56 | // Past message found, filter out! 57 | log.Printf("Deduplicating message in %s\n", cmd.Target) 58 | return "", nil 59 | } 60 | 61 | func init() { 62 | pastMessageMap = make(map[uint32]time.Time) 63 | dedupVar := os.Getenv(dedupConfigEnv) 64 | if dedupVar == "" { 65 | dedupVar = defaultDedupTime 66 | } 67 | min, err := strconv.Atoi(dedupVar) 68 | if err != nil { 69 | log.Printf("Failed to load dedup configuration. Falling back to default") 70 | min, _ = strconv.Atoi(defaultDedupTime) 71 | } 72 | dedupConfig = time.Duration(min) * time.Minute 73 | 74 | bot.RegisterFilterCommand( 75 | "dedup", 76 | dedupFilter) 77 | } 78 | -------------------------------------------------------------------------------- /encoding/decode.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/go-chat-bot/bot" 9 | ) 10 | 11 | func decode(command *bot.Cmd) (string, error) { 12 | 13 | if len(command.Args) < 2 { 14 | return invalidAmountOfParams, nil 15 | } 16 | 17 | var str string 18 | var err error 19 | switch command.Args[0] { 20 | case "base64": 21 | s := strings.Join(command.Args[1:], " ") 22 | str, err = decodeBase64(s) 23 | default: 24 | return invalidParams, nil 25 | } 26 | 27 | if err != nil { 28 | return fmt.Sprintf("Error: %s", err), nil 29 | } 30 | 31 | return str, nil 32 | } 33 | 34 | func decodeBase64(str string) (string, error) { 35 | data, err := base64.StdEncoding.DecodeString(str) 36 | if err != nil { 37 | return "", err 38 | } 39 | return string(data), nil 40 | } 41 | 42 | func init() { 43 | bot.RegisterCommand( 44 | "decode", 45 | "Decodes the given string", 46 | "base64 VGhlIEdvIFByb2dyYW1taW5nIExhbmd1YWdl", 47 | decode) 48 | } 49 | -------------------------------------------------------------------------------- /encoding/decode_test.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-chat-bot/bot" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestDecode(t *testing.T) { 11 | Convey("Decode", t, func() { 12 | bot := &bot.Cmd{ 13 | Command: "decode", 14 | } 15 | 16 | Convey("Should decode a value", func() { 17 | bot.Args = []string{"base64", "R28gaXMgYW4gb3BlbiBzb3VyY2UgcHJvZ3JhbW1pbmcgbGFuZ3VhZ2U="} 18 | got, error := decode(bot) 19 | 20 | want := "Go is an open source programming language" 21 | So(error, ShouldBeNil) 22 | So(got, ShouldEqual, want) 23 | }) 24 | 25 | Convey("Should return a error message when pass a invalid hash", func() { 26 | bot.Args = []string{"base64", "R28gaXMgYW4gb3BlbiBzb3VyY2Ugc", "HJvZ3JhbW1pbmcgbGFuZ3VhZ2U="} 27 | got, error := decode(bot) 28 | 29 | So(error, ShouldBeNil) 30 | So(got, ShouldStartWith, "Error: ") 31 | }) 32 | 33 | Convey("Should return a error message when pass correct amount of params but invalid param", func() { 34 | bot.Args = []string{"invalid_code", "R28gaXMgYW4gb3BlbiBzb3VyY2UgcHJvZ3JhbW1pbmcgbGFuZ3VhZ2U="} 35 | got, error := decode(bot) 36 | 37 | So(error, ShouldBeNil) 38 | So(got, ShouldEqual, invalidParams) 39 | }) 40 | 41 | Convey("Should return a error message when don't pass any params", func() { 42 | got, error := decode(bot) 43 | 44 | So(error, ShouldBeNil) 45 | So(got, ShouldEqual, invalidAmountOfParams) 46 | }) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /encoding/encode.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/go-chat-bot/bot" 9 | ) 10 | 11 | const ( 12 | invalidAmountOfParams = "Invalid amount of parameters" 13 | invalidParams = "Invalid parameters" 14 | ) 15 | 16 | func encode(command *bot.Cmd) (string, error) { 17 | if len(command.Args) < 2 { 18 | return invalidAmountOfParams, nil 19 | } 20 | 21 | var str string 22 | var err error 23 | switch command.Args[0] { 24 | case "base64": 25 | s := strings.Join(command.Args[1:], " ") 26 | str, err = encodeBase64(s) 27 | default: 28 | return invalidParams, nil 29 | } 30 | 31 | if err != nil { 32 | return fmt.Sprintf("Error: %s", err), nil 33 | } 34 | 35 | return str, nil 36 | } 37 | 38 | func encodeBase64(str string) (string, error) { 39 | data := []byte(str) 40 | return base64.StdEncoding.EncodeToString(data), nil 41 | } 42 | 43 | func init() { 44 | bot.RegisterCommand( 45 | "encode", 46 | "Allows you encoding a value", 47 | "base64 enter here text to encode", 48 | encode) 49 | } 50 | -------------------------------------------------------------------------------- /encoding/encode_test.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-chat-bot/bot" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestEncode(t *testing.T) { 11 | Convey("Encode", t, func() { 12 | bot := &bot.Cmd{ 13 | Command: "encode", 14 | } 15 | 16 | Convey("Should encode a value", func() { 17 | bot.Args = []string{"base64", "The Go Programming Language"} 18 | got, error := encode(bot) 19 | 20 | want := "VGhlIEdvIFByb2dyYW1taW5nIExhbmd1YWdl" 21 | So(error, ShouldBeNil) 22 | So(got, ShouldEqual, want) 23 | }) 24 | 25 | Convey("Should encode multiple words", func() { 26 | bot.Args = []string{"base64", "The", "Go", "Programming", "Language"} 27 | got, error := encode(bot) 28 | 29 | want := "VGhlIEdvIFByb2dyYW1taW5nIExhbmd1YWdl" 30 | So(error, ShouldBeNil) 31 | So(got, ShouldEqual, want) 32 | }) 33 | 34 | Convey("Should return a error message when pass correct amount of params but invalid param", func() { 35 | bot.Args = []string{"invalid_code", "R28gaXMgYW4gb3BlbiBzb3VyY2UgcHJvZ3JhbW1pbmcgbGFuZ3VhZ2U="} 36 | got, error := encode(bot) 37 | 38 | So(error, ShouldBeNil) 39 | So(got, ShouldEqual, invalidParams) 40 | }) 41 | 42 | Convey("Should return a error message when don't pass any params", func() { 43 | got, error := encode(bot) 44 | 45 | So(error, ShouldBeNil) 46 | So(got, ShouldEqual, invalidAmountOfParams) 47 | }) 48 | 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /example/goodmorning_command.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/go-chat-bot/bot" 9 | ) 10 | 11 | func goodMorning(channel string) (msg string, err error) { 12 | msg = fmt.Sprintf("Good morning, %s!", channel) 13 | return 14 | } 15 | 16 | func init() { 17 | // A comma separated list of channel ids from environment 18 | channels := strings.Split(os.Getenv("CHANNEL_IDS"), ",") 19 | 20 | if len(channels) > 0 { 21 | // Greets channel at 8am every week day 22 | config := bot.PeriodicConfig{ 23 | CronSpec: "0 0 08 * * mon-fri", 24 | Channels: channels, 25 | CmdFunc: goodMorning, 26 | } 27 | 28 | bot.RegisterPeriodicCommand("good_morning", config) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/helloworld_command.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-chat-bot/bot" 7 | ) 8 | 9 | func hello(command *bot.Cmd) (msg string, err error) { 10 | msg = fmt.Sprintf("Hello %s", command.User.RealName) 11 | return 12 | } 13 | 14 | func init() { 15 | bot.RegisterCommand( 16 | "hello", 17 | "Sends a 'Hello' message to you on the channel.", 18 | "", 19 | hello) 20 | } 21 | -------------------------------------------------------------------------------- /example/helloworld_command_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-chat-bot/bot" 7 | ) 8 | 9 | func TestHelloworld(t *testing.T) { 10 | bot := &bot.Cmd{ 11 | Command: "helloworld", 12 | User: &bot.User{ 13 | Nick: "nick", 14 | RealName: "Real Name", 15 | }, 16 | } 17 | want := "Hello Real Name" 18 | got, error := hello(bot) 19 | 20 | if got != want { 21 | t.Errorf("Expected '%v' got '%v'", want, got) 22 | } 23 | 24 | if error != nil { 25 | t.Errorf("Expected '%v' got '%v'", nil, error) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/reverse_command.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | ) 6 | 7 | // From stackoverflow: http://stackoverflow.com/a/10030772 8 | func reverse(command *bot.Cmd) (msg string, err error) { 9 | runes := []rune(command.RawArgs) 10 | for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { 11 | runes[i], runes[j] = runes[j], runes[i] 12 | } 13 | msg = string(runes) 14 | return 15 | } 16 | 17 | func init() { 18 | bot.RegisterCommand( 19 | "reverse", "Reverses a string", 20 | "string to be reversed", 21 | reverse) 22 | } 23 | -------------------------------------------------------------------------------- /example/reverse_command_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | "testing" 6 | ) 7 | 8 | func TestReverseString(t *testing.T) { 9 | arg := "Hello world" 10 | want := "dlrow olleH" 11 | bot := &bot.Cmd{ 12 | Command: "reverse", 13 | RawArgs: arg, 14 | } 15 | 16 | got, error := reverse(bot) 17 | 18 | if got != want { 19 | t.Errorf("Expected '%v' got '%v'", want, got) 20 | } 21 | 22 | if error != nil { 23 | t.Errorf("Expected '%v' got '%v'", nil, error) 24 | } 25 | } 26 | 27 | func TestReverseEmptyString(t *testing.T) { 28 | arg := "" 29 | want := "" 30 | bot := &bot.Cmd{ 31 | Command: "reverse", 32 | RawArgs: arg, 33 | } 34 | got, error := reverse(bot) 35 | 36 | if got != want { 37 | t.Errorf("Expected '%v' got '%v'", want, got) 38 | } 39 | 40 | if error != nil { 41 | t.Errorf("Expected '%v' got '%v'", nil, error) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /gif/gif.go: -------------------------------------------------------------------------------- 1 | package gif 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | "github.com/go-chat-bot/plugins/web" 7 | "math/rand" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | const ( 13 | giphyURL = "http://api.giphy.com/v1/gifs/search?q=%s&api_key=dc6zaTOxFJmzC&limit=50" 14 | ) 15 | 16 | type giphy struct { 17 | Data []struct { 18 | BitlyURL string `json:"bitly_url"` 19 | Images struct { 20 | FixedHeight struct { 21 | Height string `json:"height"` 22 | URL string `json:"url"` 23 | Width string `json:"width"` 24 | } `json:"fixed_height"` 25 | FixedHeightDownsampled struct { 26 | Height string `json:"height"` 27 | URL string `json:"url"` 28 | Width string `json:"width"` 29 | } `json:"fixed_height_downsampled"` 30 | FixedHeightStill struct { 31 | Height string `json:"height"` 32 | URL string `json:"url"` 33 | Width string `json:"width"` 34 | } `json:"fixed_height_still"` 35 | FixedWidth struct { 36 | Height string `json:"height"` 37 | URL string `json:"url"` 38 | Width string `json:"width"` 39 | } `json:"fixed_width"` 40 | FixedWidthDownsampled struct { 41 | Height string `json:"height"` 42 | URL string `json:"url"` 43 | Width string `json:"width"` 44 | } `json:"fixed_width_downsampled"` 45 | FixedWidthStill struct { 46 | Height string `json:"height"` 47 | URL string `json:"url"` 48 | Width string `json:"width"` 49 | } `json:"fixed_width_still"` 50 | Original struct { 51 | Frames string `json:"frames"` 52 | Height string `json:"height"` 53 | Size string `json:"size"` 54 | URL string `json:"url"` 55 | Width string `json:"width"` 56 | } `json:"original"` 57 | } `json:"images"` 58 | Type string `json:"type"` 59 | Username string `json:"username"` 60 | BitlyGifURL string `json:"bitly_gif_url"` 61 | EmbedURL string `json:"embed_url"` 62 | ID string `json:"id"` 63 | Rating string `json:"rating"` 64 | Source string `json:"source"` 65 | URL string `json:"url"` 66 | } `json:"data"` 67 | Meta struct { 68 | Msg string `json:"msg"` 69 | Status int64 `json:"status"` 70 | } `json:"meta"` 71 | Pagination struct { 72 | Count int64 `json:"count"` 73 | Offset int64 `json:"offset"` 74 | TotalCount int64 `json:"total_count"` 75 | } `json:"pagination"` 76 | } 77 | 78 | func gif(command *bot.Cmd) (msg string, err error) { 79 | data := &giphy{} 80 | err = web.GetJSON(fmt.Sprintf(giphyURL, url.QueryEscape(command.RawArgs)), data) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | if len(data.Data) == 0 { 86 | return "No gifs found. try: !gif cat", nil 87 | } 88 | 89 | index := rand.Intn(len(data.Data)) 90 | return fmt.Sprintf(data.Data[index].Images.FixedHeight.URL), nil 91 | } 92 | 93 | func init() { 94 | rand.Seed(time.Now().UnixNano()) 95 | bot.RegisterCommand( 96 | "gif", 97 | "Searchs and posts a random gif url from Giphy.", 98 | "cat", 99 | gif) 100 | } 101 | -------------------------------------------------------------------------------- /godoc/godoc.go: -------------------------------------------------------------------------------- 1 | package godoc 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | "github.com/go-chat-bot/plugins/web" 7 | "net/url" 8 | ) 9 | 10 | const ( 11 | godocSiteURL = "http://godoc.org" 12 | noPackagesFound = "No packages found." 13 | ) 14 | 15 | var ( 16 | godocSearchURL = "http://api.godoc.org/search" 17 | ) 18 | 19 | type godocResults struct { 20 | Results []struct { 21 | Path string `json:"path"` 22 | Synopsis string `json:"synopsis"` 23 | } `json:"results"` 24 | } 25 | 26 | func search(cmd *bot.Cmd) (string, error) { 27 | if cmd.RawArgs == "" { 28 | return "", nil 29 | } 30 | 31 | data := &godocResults{} 32 | 33 | url, _ := url.Parse(godocSearchURL) 34 | q := url.Query() 35 | q.Set("q", cmd.RawArgs) 36 | url.RawQuery = q.Encode() 37 | 38 | err := web.GetJSON(url.String(), data) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | if len(data.Results) == 0 { 44 | return noPackagesFound, nil 45 | } 46 | 47 | return fmt.Sprintf("%s %s/%s", data.Results[0].Synopsis, godocSiteURL, data.Results[0].Path), nil 48 | } 49 | 50 | func init() { 51 | bot.RegisterCommand( 52 | "godoc", 53 | "Searchs godoc.org and displays the first result.", 54 | "package name", 55 | search) 56 | } 57 | -------------------------------------------------------------------------------- /godoc/godoc_test.go: -------------------------------------------------------------------------------- 1 | package godoc 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | . "github.com/smartystreets/goconvey/convey" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | const ( 13 | validResults = `{ 14 | "results": [ 15 | { 16 | "path": "github.com/go-chat-bot/bot", 17 | "synopsis": "IRC bot written in go" 18 | } 19 | ] 20 | }` 21 | 22 | emptyResults = `{"results":[]}` 23 | ) 24 | 25 | func TestGoDoc(t *testing.T) { 26 | cmd := &bot.Cmd{} 27 | apiResult := "" 28 | 29 | ts := httptest.NewServer( 30 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | fmt.Fprintln(w, apiResult) 32 | })) 33 | 34 | godocSearchURL = ts.URL 35 | 36 | Convey("Given a search query text", t, func() { 37 | 38 | Reset(func() { 39 | cmd.RawArgs = "" 40 | apiResult = "" 41 | }) 42 | 43 | Convey("When the result is empty", func() { 44 | cmd.RawArgs = "non existant package" 45 | apiResult = emptyResults 46 | 47 | s, err := search(cmd) 48 | 49 | So(err, ShouldBeNil) 50 | So(s, ShouldEqual, noPackagesFound) 51 | }) 52 | 53 | Convey("When the result is ok", func() { 54 | cmd.RawArgs = "go-bot" 55 | apiResult = validResults 56 | 57 | s, err := search(cmd) 58 | 59 | So(err, ShouldBeNil) 60 | So(s, ShouldEqual, "IRC bot written in go http://godoc.org/github.com/go-chat-bot/bot") 61 | }) 62 | 63 | Convey("When the query is empty", func() { 64 | cmd.RawArgs = "" 65 | 66 | s, err := search(cmd) 67 | 68 | So(err, ShouldBeNil) 69 | So(s, ShouldEqual, "") 70 | }) 71 | 72 | Convey("When the api is unreachable", func() { 73 | godocSearchURL = "127.0.0.1:0" 74 | cmd.RawArgs = "go-bot" 75 | 76 | _, err := search(cmd) 77 | 78 | So(err, ShouldNotBeNil) 79 | }) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /guid/guid.go: -------------------------------------------------------------------------------- 1 | package guid 2 | 3 | import ( 4 | "strings" 5 | 6 | uuid "github.com/beevik/guid" 7 | "github.com/go-chat-bot/bot" 8 | ) 9 | 10 | const ( 11 | msgInvalidAmountOfParams = "Invalid amount of parameters" 12 | msgInvalidParam = "Invalid parameter" 13 | ) 14 | 15 | func guid(command *bot.Cmd) (string, error) { 16 | 17 | if len(command.Args) > 1 { 18 | return msgInvalidAmountOfParams, nil 19 | } 20 | 21 | if len(command.Args) == 1 { 22 | if command.Args[0] == "upper" { 23 | return strings.ToUpper(uuid.NewString()), nil 24 | } 25 | return msgInvalidParam, nil 26 | } 27 | return uuid.NewString(), nil 28 | } 29 | 30 | func init() { 31 | bot.RegisterCommand( 32 | "guid", 33 | "Generates GUID", 34 | "", 35 | guid) 36 | } 37 | -------------------------------------------------------------------------------- /guid/guid_test.go: -------------------------------------------------------------------------------- 1 | package guid 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/go-chat-bot/bot" 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | const ( 12 | guidSize = 36 13 | ) 14 | 15 | func TestGUID(t *testing.T) { 16 | Convey("GUID", t, func() { 17 | bot := &bot.Cmd{ 18 | Command: "guid", 19 | } 20 | 21 | Convey("Should return a valid GUID", func() { 22 | got, error := guid(bot) 23 | 24 | So(error, ShouldBeNil) 25 | So(len(got), ShouldEqual, guidSize) 26 | }) 27 | 28 | Convey("Should return a GUID version 4", func() { 29 | got, error := guid(bot) 30 | 31 | So(error, ShouldBeNil) 32 | So(strings.Split(got, "")[14], ShouldEqual, "4") 33 | }) 34 | 35 | Convey("Should return a upper GUID", func() { 36 | bot.Args = []string{"upper"} 37 | got, error := guid(bot) 38 | 39 | So(error, ShouldBeNil) 40 | So(got, ShouldEqual, strings.ToUpper(got)) 41 | }) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /jira/README.md: -------------------------------------------------------------------------------- 1 | ### Overview 2 | 3 | This is a plugin for [Atlassian JIRA](https://www.atlassian.com/software/jira) 4 | issue tracking system. 5 | 6 | ### Features 7 | * Simple authentication support for JIRA 8 | * Outputs some of the issue details in the channels not just links 9 | * Optional per-channel configuration of issue message format 10 | 11 | ### Setup 12 | * Set up JIRA_BASE_URL env variable to your JIRA server URL. For example 13 | https://issues.jenkins-ci.org 14 | * Set up JIRA_USER env variable to JIRA username for the bot account 15 | * Set up JIRA_PASS env variable to JIRA password for the bot account 16 | * Optional: set up JIRA_TOKEN env variable, if your instance requires 17 | using personal access tokens (user/pass no longer need to be defined). 18 | 19 | In addition to the above channel-specific configuration variables can be defined 20 | in a separate JSON configuration file loaded from path specified by environment 21 | variable `JIRA_CONFIG_FILE`. Example file can be seen in 22 | `example_config.json`. It is an array of channel configurations with each 23 | configuration having: 24 | * `channel` for which the configuration is intended 25 | * `template` to override default issue template (see Issue Formatting) 26 | * `templateNew` to override default issue template for new issue notifications 27 | * `templateResolved` to override default issue template for resolved issue notifications 28 | * `notifyNew` is array of JIRA project keys to watch for new issues 29 | * `notifyResolved` is array of JIRA project keys to watch for resolved issues 30 | * `components` (optional) is array of the specific JIRA project components to watch 31 | 32 | ### Issue Formatting 33 | 34 | By default the plugin will output issues in the following format: 35 | ``` 36 | (, ): - 37 | ``` 38 | To see which values are available for use in templates see 39 | [go-jira](https://github.com/andygrunwald/go-jira/blob/master/issue.go). 40 | 41 | The format used is go template notation on the issue object. If you want to just 42 | post URL to the issue itself you can configure it by setting the template to 43 | `{{.Self}}` for given channel in the configuration file. 44 | 45 | Default template looks like this: 46 | ``` 47 | {{.Key}} ({{.Fields.Assignee.Key}}, {{.Fields.Status.Name}}): {{.Fields.Summary}} - {{.Self}} 48 | ``` 49 | 50 | `JIRA_NOTIFY_INTERVAL` environment variable can be used to control how often the 51 | notification methods will be run. It defaults to be run every minute. 52 | 53 | ### Threaded notifications 54 | **NOTE:** This feature has only been tested in Google Chat. The person who wrote this code 55 | does not use this bot in any other platform. Feel free to contribute to make it 56 | work in your prefered platform (in case it supports threads). 57 | 58 | In Google Chat, each notification will create a new thread by default. In some cases, it might 59 | be desirable to restrict to a single thread, for cleaningness. Due to a limitation in the API, 60 | the thread must exist first. Once a thread is created, you must fetch the full URL. There are 61 | many different methods for this, so use whatever is better for you, but a thread URL 62 | should look similar to one of these examples: 63 | * `https://chat.google.com/room//` 64 | * `https://mail.google.com/chat/u/0/#chat/space//` 65 | 66 | Once you have that information, your `JIRA_CONFIG_FILE` should look like 67 | [example_config_thread.json](example_config_thread.json). 68 | 69 | Also you, need to start the bot with `JIRA_THREAD=true` environment variable defined. 70 | 71 | ### Verbose log 72 | If JIRA_VERBOSE variable is defined (any value) the bot generates a log 73 | every time it queries JIRA. 74 | -------------------------------------------------------------------------------- /jira/example_config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#gojirabot", 4 | "notifyNew": ["JENKINS"], 5 | "notifyResolved": ["JENKINS"], 6 | "templateNew": "New {{.Fields.Type.Name}} reported by {{.Fields.Reporter.Key}}: {.Key}" 7 | }, 8 | { 9 | "channel": "#gojira-test", 10 | "template": "{{.Self}}" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /jira/example_config_thread.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "spaces/AXYZIF4-ABC", 4 | "thread": "threads/LMNOPQRggSE", 5 | "notifyNew": ["JENKINS"], 6 | "notifyResolved": ["JENKINS"] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /jira/jira.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "text/template" 13 | 14 | gojira "github.com/andygrunwald/go-jira" 15 | "github.com/davecgh/go-spew/spew" 16 | "github.com/go-chat-bot/bot" 17 | ) 18 | 19 | const ( 20 | pattern = ".*?([A-Z]+)-([0-9]+)\\b" 21 | userEnv = "JIRA_USER" 22 | passEnv = "JIRA_PASS" 23 | tokenEnv = "JIRA_TOKEN" 24 | baseURLEnv = "JIRA_BASE_URL" 25 | channelConfigEnv = "JIRA_CONFIG_FILE" 26 | notifyIntervalEnv = "JIRA_NOTIFY_INTERVAL" 27 | defaultTemplate = "{{.Key}} ({{.Fields.Assignee.Key}}, {{.Fields.Status.Name}}): " + 28 | "{{.Fields.Summary}} - {{.Self}}" 29 | defaultTemplateNew = "New {{.Fields.Type.Name}}: {{.Key}} " + 30 | "({{.Fields.Assignee.Key}}, {{.Fields.Status.Name}}): " + 31 | "{{.Fields.Summary}} - {{.Self}}" 32 | defaultTemplateResolved = "Resolved {{.Fields.Type.Name}}: {{.Key}} " + 33 | "({{.Fields.Assignee.Key}}, {{.Fields.Status.Name}}): " + 34 | "{{.Fields.Summary}} - {{.Self}}" 35 | verboseEnv = "JIRA_VERBOSE" 36 | threadEnv = "JIRA_THREAD" 37 | ) 38 | 39 | var ( 40 | url string 41 | projects map[string]gojira.Project // project.Key -> project map 42 | channelConfigs map[string]channelConfig // channel -> channelConfig map 43 | notifyNewConfig map[string][]string // project.Key -> slice of channel names 44 | notifyResConfig map[string][]string // project.Key -> slice of channel names 45 | componentsConfig map[string][]string // project.key -> slice of component names 46 | client *gojira.Client 47 | re = regexp.MustCompile(pattern) 48 | projectJQL = "project in (%s) " 49 | componentJQL = "AND component in (%s) " 50 | newJQL = "AND resolution = Unresolved " + 51 | "AND created > '-%dm' " + 52 | "ORDER BY key ASC" 53 | resolvedJQL = "AND resolved > '-%dm' " + 54 | "ORDER BY key ASC" 55 | notifyInterval int 56 | verbose bool 57 | thread bool 58 | ) 59 | 60 | type channelConfig struct { 61 | Channel string `json:"channel"` 62 | Thread string `json:"thread,omitempty"` 63 | Template string `json:"template,omitempty"` // template format for issues being posted 64 | TemplateNew string `json:"templateNew,omitempty"` // template format for newly created issues 65 | TemplateResolved string `json:"templateResolved,omitempty"` // template format for resolved issues 66 | NotifyNew []string `json:"notifyNew,omitempty"` // list of JIRA projects to watch for new issues 67 | NotifyResolved []string `json:"notifyResolved,omitempty"` // list of JIRA projects to watch for resolved issues 68 | Components []string `json:"components,omitempty"` // list of JIRA project components to watch for 69 | } 70 | 71 | func getProjects() (map[string]gojira.Project, error) { 72 | req, err := client.NewRequest("GET", "rest/api/2/project", nil) 73 | if err != nil { 74 | return projects, fmt.Errorf("Error creating request object: %v", err) 75 | } 76 | 77 | projectObjects := new([]gojira.Project) 78 | projects = make(map[string]gojira.Project) 79 | _, err = client.Do(req, projectObjects) 80 | if err != nil { 81 | return projects, fmt.Errorf("Failed getting JIRA projects: %v", err) 82 | } 83 | for _, project := range *projectObjects { 84 | projects[project.Key] = project 85 | } 86 | return projects, nil 87 | } 88 | 89 | func getIssuesFromString(text string) [][2]string { 90 | matches := re.FindAllStringSubmatch(text, -1) 91 | var data [][2]string 92 | for _, match := range matches { 93 | // match[1] == project key 94 | // match[2] == issue number 95 | data = append(data, [2]string{match[1], match[2]}) 96 | } 97 | return data 98 | } 99 | 100 | func provideDefaultValues(issue *gojira.Issue) { 101 | if issue.Fields.Assignee == nil { 102 | issue.Fields.Assignee = &gojira.User{Key: "no assignee"} 103 | } 104 | // we use Self as the web URL in template 105 | issue.Self = url + issue.Key 106 | } 107 | 108 | func formatIssue(issue *gojira.Issue, channel string, templ string) string { 109 | defaultRet := url + issue.Key 110 | provideDefaultValues(issue) 111 | 112 | tmpl, err := template.New("default").Parse(templ) 113 | if err != nil { 114 | log.Printf("Failed formatting for %s: %v\n", issue.Key, err) 115 | return defaultRet 116 | } 117 | 118 | buf := &bytes.Buffer{} 119 | err = tmpl.Execute(buf, issue) 120 | if err != nil { 121 | log.Printf("Failed formatting for %s: %s\n", issue.Key, err.Error()) 122 | return defaultRet 123 | } 124 | return buf.String() 125 | } 126 | 127 | func jira(cmd *bot.PassiveCmd) (bot.CmdResultV3, error) { 128 | result := bot.CmdResultV3{ 129 | Message: make(chan string), 130 | Done: make(chan bool, 1)} 131 | result.Channel = cmd.Channel 132 | issues := getIssuesFromString(cmd.Raw) 133 | if issues != nil { 134 | go func() { 135 | for _, issue := range issues { 136 | project, num := issue[0], issue[1] 137 | key := project + "-" + num 138 | _, found := projects[project] 139 | if found { 140 | issue, _, err := client.Issue.Get(key, nil) 141 | if err != nil { 142 | log.Printf("Failed getting issue %s info: %v\n", 143 | key, err) 144 | continue 145 | } 146 | if verbose { 147 | log.Printf("Replying to %s about issue %s\n", cmd.Channel, 148 | key) 149 | } 150 | template := defaultTemplate 151 | config, found := channelConfigs[cmd.Channel] 152 | if found { 153 | template = config.Template 154 | } 155 | result.Message <- formatIssue(issue, cmd.Channel, template) 156 | } 157 | } 158 | result.Done <- true 159 | }() 160 | } else { 161 | result.Done <- true 162 | } 163 | 164 | return result, nil 165 | } 166 | 167 | func containsComponent(fromJira []*gojira.Component, fromConf []string) bool { 168 | for _, i := range fromConf { 169 | for _, j := range fromJira { 170 | if i == j.Name { 171 | return true 172 | } 173 | } 174 | } 175 | return false 176 | } 177 | 178 | func periodicJIRANotifyNew() (ret []bot.CmdResult, err error) { 179 | newProjectKeys := make([]string, 0, len(notifyNewConfig)) 180 | for k := range notifyNewConfig { 181 | newProjectKeys = append(newProjectKeys, k) 182 | } 183 | componentsKeys := make([]string, 0, len(componentsConfig)) 184 | for k := range componentsConfig { 185 | componentsKeys = append(componentsKeys, k) 186 | } 187 | 188 | query := fmt.Sprintf(projectJQL, strings.Join(newProjectKeys, ",")) 189 | query = query + fmt.Sprintf(newJQL, notifyInterval) 190 | if verbose { 191 | log.Printf("New issues query: %s", query) 192 | } 193 | newIssues, _, err := client.Issue.Search(query, nil) 194 | if err != nil { 195 | log.Printf("Error querying JIRA for new issues: %v\n", err) 196 | return nil, err 197 | } 198 | for _, issue := range newIssues { 199 | channels := notifyNewConfig[issue.Fields.Project.Key] 200 | for _, notifyChan := range channels { 201 | if len(channelConfigs[notifyChan].Components) == 0 || 202 | (len(channelConfigs[notifyChan].Components) > 0 && 203 | containsComponent(issue.Fields.Components, channelConfigs[notifyChan].Components)) { 204 | // displays only if Components are not defined OR Components exist in Jira output 205 | threadName := channelConfigs[notifyChan].Thread 206 | if thread && (len(threadName) > 0) { 207 | notifyChan += ":" + notifyChan + "/" + threadName 208 | } 209 | if verbose { 210 | log.Printf("Notifying %s about new %s %s", notifyChan, 211 | issue.Fields.Type.Name, 212 | issue.Key) 213 | } 214 | template := defaultTemplateNew 215 | config, found := channelConfigs[notifyChan] 216 | if found { 217 | template = config.TemplateNew 218 | } 219 | ret = append(ret, bot.CmdResult{ 220 | Message: formatIssue(&issue, notifyChan, template), 221 | Channel: notifyChan, 222 | }) 223 | } 224 | } 225 | } 226 | 227 | return ret, nil 228 | } 229 | 230 | func periodicJIRANotifyResolved() (ret []bot.CmdResult, err error) { 231 | resolvedProjectKeys := make([]string, 0, len(notifyResConfig)) 232 | for k := range notifyResConfig { 233 | resolvedProjectKeys = append(resolvedProjectKeys, k) 234 | } 235 | componentsKeys := make([]string, 0, len(componentsConfig)) 236 | for k := range componentsConfig { 237 | componentsKeys = append(componentsKeys, k) 238 | } 239 | 240 | query := fmt.Sprintf(projectJQL, strings.Join(resolvedProjectKeys, ",")) 241 | query = query + fmt.Sprintf(resolvedJQL, notifyInterval) 242 | if verbose { 243 | log.Printf("Resolved issues query: %s", query) 244 | } 245 | resolvedIssues, _, err := client.Issue.Search(query, nil) 246 | if err != nil { 247 | log.Printf("Error querying JIRA for resolved issues: %v\n", err) 248 | return nil, err 249 | } 250 | for _, issue := range resolvedIssues { 251 | channels := notifyResConfig[issue.Fields.Project.Key] 252 | if verbose { 253 | log.Printf("Resolved issues result: %s", spew.Sdump(issue.Fields.Components)) 254 | } 255 | for _, notifyChan := range channels { 256 | if len(channelConfigs[notifyChan].Components) == 0 || 257 | (len(channelConfigs[notifyChan].Components) > 0 && 258 | containsComponent(issue.Fields.Components, channelConfigs[notifyChan].Components)) { 259 | // displays only if Components are not defined OR Components exist in Jira output 260 | threadName := channelConfigs[notifyChan].Thread 261 | if thread && (len(threadName) > 0) { 262 | notifyChan += ":" + notifyChan + "/" + threadName 263 | } 264 | if verbose { 265 | log.Printf("Notifying %s about resolved %s %s", notifyChan, 266 | issue.Fields.Type.Name, 267 | issue.Key) 268 | } 269 | template := defaultTemplateResolved 270 | config, found := channelConfigs[notifyChan] 271 | if found { 272 | template = config.TemplateResolved 273 | } 274 | ret = append(ret, bot.CmdResult{ 275 | Message: formatIssue(&issue, notifyChan, template), 276 | Channel: notifyChan, 277 | }) 278 | } 279 | } 280 | } 281 | 282 | return ret, nil 283 | } 284 | 285 | func initJIRAClient(baseURL, jiraUser, jiraPass, jiraToken string) error { 286 | var err error 287 | 288 | if len(jiraToken) > 0 { 289 | tpPATA := gojira.PATAuthTransport{ 290 | Token: jiraToken, 291 | } 292 | client, err = gojira.NewClient(tpPATA.Client(), baseURL) 293 | } else { 294 | tpBA := gojira.BasicAuthTransport{ 295 | Username: jiraUser, 296 | Password: jiraPass, 297 | } 298 | client, err = gojira.NewClient(tpBA.Client(), baseURL) 299 | } 300 | if err != nil { 301 | log.Printf("Error initializing JIRA client: %v\n", err) 302 | return err 303 | } 304 | return nil 305 | } 306 | 307 | func loadChannelConfigs(filename string) error { 308 | channelConfigs = make(map[string]channelConfig) 309 | notifyNewConfig = make(map[string][]string) 310 | notifyResConfig = make(map[string][]string) 311 | componentsConfig = make(map[string][]string) 312 | 313 | file, err := os.Open(filename) 314 | if err != nil { 315 | log.Printf("Failed opening configuration file %s: %v\n", filename, err) 316 | return err 317 | } 318 | defer file.Close() 319 | decoder := json.NewDecoder(file) 320 | configs := make([]channelConfig, 0) 321 | err = decoder.Decode(&configs) 322 | if err != nil { 323 | log.Printf("Error loading configuration: %v\n", err) 324 | return err 325 | } 326 | for _, chanConf := range configs { 327 | if chanConf.Channel == "" { 328 | log.Println("Configuration without channel found. Skipping") 329 | continue 330 | } 331 | if chanConf.Template == "" { 332 | chanConf.Template = defaultTemplate 333 | } 334 | if chanConf.TemplateNew == "" { 335 | chanConf.TemplateNew = defaultTemplateNew 336 | } 337 | if chanConf.TemplateResolved == "" { 338 | chanConf.TemplateResolved = defaultTemplateResolved 339 | } 340 | channelConfigs[chanConf.Channel] = chanConf 341 | for _, project := range chanConf.NotifyNew { 342 | notifyNewConfig[project] = append(notifyNewConfig[project], 343 | chanConf.Channel) 344 | } 345 | for _, project := range chanConf.NotifyResolved { 346 | notifyResConfig[project] = append(notifyResConfig[project], 347 | chanConf.Channel) 348 | } 349 | for _, project := range chanConf.Components { 350 | componentsConfig[project] = append(componentsConfig[project], 351 | chanConf.Channel) 352 | } 353 | } 354 | return nil 355 | } 356 | 357 | func init() { 358 | _, verbose = os.LookupEnv(verboseEnv) 359 | _, thread = os.LookupEnv(threadEnv) 360 | 361 | jiraUser := os.Getenv(userEnv) 362 | jiraPass := os.Getenv(passEnv) 363 | jiraToken := os.Getenv(tokenEnv) 364 | baseURL := os.Getenv(baseURLEnv) 365 | confFile := os.Getenv(channelConfigEnv) 366 | url = baseURL + "/browse/" 367 | 368 | err := initJIRAClient(baseURL, jiraUser, jiraPass, jiraToken) 369 | if err != nil { 370 | log.Printf("Error querying JIRA for projects: %v\n", err) 371 | return 372 | } 373 | 374 | if confFile != "" { 375 | err = loadChannelConfigs(confFile) 376 | if err != nil { 377 | log.Printf("Error loading channel configuration (non-fatal): %v\n", err) 378 | } 379 | } 380 | 381 | _, err = getProjects() 382 | if err != nil { 383 | log.Printf("Error querying JIRA for projects: %v\n", err) 384 | return 385 | } 386 | 387 | interval := os.Getenv(notifyIntervalEnv) 388 | if interval == "" { 389 | interval = "1" 390 | } 391 | notifyInterval, err = strconv.Atoi(interval) 392 | if err != nil { 393 | log.Printf("Error parsing interval from %s. Using default", 394 | interval) 395 | notifyInterval = 1 396 | } 397 | 398 | bot.RegisterPassiveCommandV2( 399 | "jira", 400 | jira) 401 | 402 | if len(notifyNewConfig) > 0 { 403 | bot.RegisterPeriodicCommandV2( 404 | "periodicJIRANotifyNew", 405 | bot.PeriodicConfig{ 406 | CronSpec: fmt.Sprintf("*/%d * * * *", notifyInterval), 407 | CmdFuncV2: periodicJIRANotifyNew, 408 | }) 409 | } 410 | log.Printf("New issue notifications set up for %d JIRA projects", len(notifyNewConfig)) 411 | if len(notifyResConfig) > 0 { 412 | bot.RegisterPeriodicCommandV2( 413 | "periodicJIRANotifyResolved", 414 | bot.PeriodicConfig{ 415 | CronSpec: fmt.Sprintf("*/%d * * * *", notifyInterval), 416 | CmdFuncV2: periodicJIRANotifyResolved, 417 | }) 418 | } 419 | log.Printf("Resolved issue notifications set up for %d JIRA projects", len(notifyResConfig)) 420 | log.Printf("JIRA plugin initialization successful") 421 | } 422 | -------------------------------------------------------------------------------- /jira/jira_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "fmt" 5 | gojira "github.com/andygrunwald/go-jira" 6 | "github.com/go-chat-bot/bot" 7 | . "github.com/smartystreets/goconvey/convey" 8 | "io/ioutil" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func setup() *httptest.Server { 16 | ts := httptest.NewServer(http.HandlerFunc( 17 | func(w http.ResponseWriter, r *http.Request) { 18 | parts := strings.Split(r.URL.String(), "/") 19 | fname := parts[len(parts)-1] 20 | dat, err := ioutil.ReadFile("mocks/" + fname + ".json") 21 | if err != nil { 22 | fmt.Printf("No mock file %s.json", fname) 23 | // provide empty data when file does not exist 24 | return 25 | } 26 | 27 | fmt.Fprintf(w, "%s", dat) 28 | }, 29 | )) 30 | baseURL := ts.URL 31 | err := initJIRAClient(baseURL, "", "") 32 | if err != nil { 33 | fmt.Print(err.Error()) 34 | } 35 | return ts 36 | } 37 | 38 | func TestJira(t *testing.T) { 39 | ts := setup() 40 | defer ts.Close() 41 | url = "https://example.atlassian.net/browse/" 42 | projects["BOT"] = gojira.Project{} 43 | projects["JENKINS"] = gojira.Project{} 44 | projects["MON"] = gojira.Project{} 45 | Convey("Given a text", t, func() { 46 | cmd := &bot.PassiveCmd{} 47 | Convey("When the text does not match a jira issue syntax", func() { 48 | cmd.Raw = "My name is go-bot, I am awesome." 49 | s, err := jira(cmd) 50 | 51 | So(err, ShouldBeNil) 52 | So(<-s.Done, ShouldEqual, true) 53 | So(s.Message, ShouldBeEmpty) 54 | }) 55 | 56 | Convey("When the text match a jira issue syntax", func() { 57 | cmd.Raw = "My name is go-bot, I am awesome. JENKINS-33149" 58 | 59 | s, err := jira(cmd) 60 | 61 | So(err, ShouldBeNil) 62 | So(<-s.Message, ShouldEqual, 63 | "JENKINS-33149 (ndeloof, Closed): Images that specify an "+ 64 | "entrypoint can not be used as a build environment - "+ 65 | "https://example.atlassian.net/browse/JENKINS-33149") 66 | So(<-s.Done, ShouldEqual, true) 67 | So(s.Message, ShouldBeEmpty) 68 | }) 69 | 70 | Convey("When the text has a jira issue in the midle of a word", func() { 71 | cmd.Raw = "My name is goJENKINS-3314" 72 | s, err := jira(cmd) 73 | 74 | So(err, ShouldBeNil) 75 | So(<-s.Message, ShouldEqual, 76 | "JENKINS-3314 (no assignee, Closed): "+ 77 | " to inherit portions of configurations - "+ 78 | "https://example.atlassian.net/browse/JENKINS-3314") 79 | So(<-s.Done, ShouldEqual, true) 80 | So(s.Message, ShouldBeEmpty) 81 | }) 82 | 83 | Convey("When multiple jiras are referenced", func() { 84 | cmd.Raw = "::JENKINS-3314,JENKINS-33149 and BOT-321" 85 | s, err := jira(cmd) 86 | 87 | So(err, ShouldBeNil) 88 | So(<-s.Message, ShouldEqual, 89 | "JENKINS-3314 (no assignee, Closed): "+ 90 | " to inherit portions of configurations - "+ 91 | "https://example.atlassian.net/browse/JENKINS-3314") 92 | So(<-s.Message, ShouldEqual, 93 | "JENKINS-33149 (ndeloof, Closed): Images that specify an "+ 94 | "entrypoint can not be used as a build environment - "+ 95 | "https://example.atlassian.net/browse/JENKINS-33149") 96 | So(s.Message, ShouldBeEmpty) 97 | So(<-s.Done, ShouldEqual, true) 98 | }) 99 | 100 | Convey("When jira from non-existing project is mentioned", func() { 101 | cmd.Raw = "I saw this NON-123 issue once!" 102 | s, err := jira(cmd) 103 | 104 | So(err, ShouldBeNil) 105 | So(s.Message, ShouldBeEmpty) 106 | So(<-s.Done, ShouldEqual, true) 107 | }) 108 | }) 109 | } 110 | 111 | func TestChannelConfig(t *testing.T) { 112 | 113 | Convey("Given environment variables", t, func() { 114 | Convey("When there is correct channel template config", func() { 115 | loadChannelConfigs("mocks/config1.json") 116 | 117 | So(len(channelConfigs), ShouldEqual, 1) 118 | 119 | conf, ok := channelConfigs["#chan1"] 120 | So(ok, ShouldEqual, true) 121 | So(conf.Template, ShouldEqual, "{{.Self}}") 122 | }) 123 | 124 | Convey("When there are more channel configurations", func() { 125 | loadChannelConfigs("mocks/config2.json") 126 | 127 | So(len(channelConfigs), ShouldEqual, 2) 128 | 129 | conf, ok := channelConfigs["#chan1"] 130 | So(ok, ShouldEqual, true) 131 | So(conf.Template, ShouldEqual, "{{.Self}} - 1") 132 | 133 | conf, ok = channelConfigs["#chan2"] 134 | So(ok, ShouldEqual, true) 135 | So(conf.Template, ShouldEqual, "{{.Self}} - 2") 136 | }) 137 | 138 | Convey("When there is channel notification config", func() { 139 | loadChannelConfigs("mocks/config3.json") 140 | 141 | So(notifyNewConfig, ShouldHaveLength, 1) 142 | 143 | conf, ok := notifyNewConfig["PROJ1"] 144 | So(ok, ShouldEqual, true) 145 | So(conf, ShouldHaveLength, 1) 146 | So(conf, ShouldContain, "#chan1") 147 | }) 148 | 149 | Convey("When there is channel notification config with many projects", func() { 150 | loadChannelConfigs("mocks/config4.json") 151 | 152 | So(notifyNewConfig, ShouldHaveLength, 2) 153 | 154 | conf, ok := notifyNewConfig["PROJ1"] 155 | So(ok, ShouldEqual, true) 156 | So(conf, ShouldHaveLength, 1) 157 | So(conf, ShouldContain, "#chan1") 158 | 159 | conf, ok = notifyNewConfig["PROJ2"] 160 | So(ok, ShouldEqual, true) 161 | So(conf, ShouldHaveLength, 1) 162 | So(conf, ShouldContain, "#chan1") 163 | }) 164 | 165 | Convey("When there is multiple channel notifications with many projects", func() { 166 | loadChannelConfigs("mocks/config5.json") 167 | 168 | So(notifyNewConfig, ShouldHaveLength, 3) 169 | 170 | conf, ok := notifyNewConfig["PROJ1"] 171 | So(ok, ShouldEqual, true) 172 | So(conf, ShouldHaveLength, 2) 173 | So(conf, ShouldContain, "#chan1") 174 | So(conf, ShouldContain, "#chan2") 175 | 176 | conf, ok = notifyNewConfig["PROJ2"] 177 | So(ok, ShouldEqual, true) 178 | So(conf, ShouldHaveLength, 1) 179 | So(conf, ShouldContain, "#chan1") 180 | 181 | conf, ok = notifyNewConfig["PROJ3"] 182 | So(ok, ShouldEqual, true) 183 | So(conf, ShouldHaveLength, 1) 184 | So(conf, ShouldContain, "#chan2") 185 | 186 | So(notifyResConfig, ShouldHaveLength, 3) 187 | 188 | conf, ok = notifyResConfig["PROJ1"] 189 | So(ok, ShouldEqual, true) 190 | So(conf, ShouldHaveLength, 1) 191 | So(conf, ShouldContain, "#chan1") 192 | 193 | conf, ok = notifyResConfig["PROJ2"] 194 | So(ok, ShouldEqual, true) 195 | So(conf, ShouldHaveLength, 1) 196 | So(conf, ShouldContain, "#chan2") 197 | 198 | conf, ok = notifyResConfig["PROJ3"] 199 | So(ok, ShouldEqual, true) 200 | So(conf, ShouldHaveLength, 1) 201 | So(conf, ShouldContain, "#chan2") 202 | 203 | }) 204 | }) 205 | 206 | } 207 | -------------------------------------------------------------------------------- /jira/mocks/JENKINS-3314.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", 3 | "id": "133387", 4 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133387", 5 | "key": "JENKINS-3314", 6 | "fields": { 7 | "issuetype": { 8 | "self": "https://issues.jenkins-ci.org/rest/api/2/issuetype/2", 9 | "id": "2", 10 | "description": "A new feature of the product, which has yet to be developed.", 11 | "iconUrl": "https://issues.jenkins-ci.org/secure/viewavatar?size=xsmall&avatarId=14681&avatarType=issuetype", 12 | "name": "New Feature", 13 | "subtask": false, 14 | "avatarId": 14681 15 | }, 16 | "project": { 17 | "self": "https://issues.jenkins-ci.org/rest/api/2/project/10172", 18 | "id": "10172", 19 | "key": "JENKINS", 20 | "name": "Jenkins", 21 | "avatarUrls": { 22 | "48x48": "https://issues.jenkins-ci.org/secure/projectavatar?pid=10172&avatarId=10152", 23 | "24x24": "https://issues.jenkins-ci.org/secure/projectavatar?size=small&pid=10172&avatarId=10152", 24 | "16x16": "https://issues.jenkins-ci.org/secure/projectavatar?size=xsmall&pid=10172&avatarId=10152", 25 | "32x32": "https://issues.jenkins-ci.org/secure/projectavatar?size=medium&pid=10172&avatarId=10152" 26 | } 27 | }, 28 | "resolution": { 29 | "self": "https://issues.jenkins-ci.org/rest/api/2/resolution/3", 30 | "id": "3", 31 | "description": "The problem is a duplicate of an existing issue.", 32 | "name": "Duplicate" 33 | }, 34 | "resolutiondate": "2009-03-28T14:25:25.000+0000", 35 | "workratio": -1, 36 | "lastViewed": null, 37 | "watches": { 38 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/JENKINS-3314/watchers", 39 | "watchCount": 0, 40 | "isWatching": false 41 | }, 42 | "created": "2009-03-18T14:06:42.000+0000", 43 | "priority": { 44 | "self": "https://issues.jenkins-ci.org/rest/api/2/priority/2", 45 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/priorities/critical.svg", 46 | "name": "Critical", 47 | "id": "2" 48 | }, 49 | "labels": [], 50 | "issuelinks": [ 51 | { 52 | "id": "14674", 53 | "self": "https://issues.jenkins-ci.org/rest/api/2/issueLink/14674", 54 | "type": { 55 | "id": "10000", 56 | "name": "Duplicate", 57 | "inward": "is duplicated by", 58 | "outward": "duplicates", 59 | "self": "https://issues.jenkins-ci.org/rest/api/2/issueLinkType/10000" 60 | }, 61 | "outwardIssue": { 62 | "id": "133230", 63 | "key": "JENKINS-3157", 64 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133230", 65 | "fields": { 66 | "summary": "Feature request: cascading project settings", 67 | "status": { 68 | "self": "https://issues.jenkins-ci.org/rest/api/2/status/1", 69 | "description": "The issue is open and ready for the assignee to start work on it.", 70 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/statuses/open.png", 71 | "name": "Open", 72 | "id": "1", 73 | "statusCategory": { 74 | "self": "https://issues.jenkins-ci.org/rest/api/2/statuscategory/2", 75 | "id": 2, 76 | "key": "new", 77 | "colorName": "blue-gray", 78 | "name": "To Do" 79 | } 80 | }, 81 | "priority": { 82 | "self": "https://issues.jenkins-ci.org/rest/api/2/priority/3", 83 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/priorities/major.svg", 84 | "name": "Major", 85 | "id": "3" 86 | }, 87 | "issuetype": { 88 | "self": "https://issues.jenkins-ci.org/rest/api/2/issuetype/2", 89 | "id": "2", 90 | "description": "A new feature of the product, which has yet to be developed.", 91 | "iconUrl": "https://issues.jenkins-ci.org/secure/viewavatar?size=xsmall&avatarId=14681&avatarType=issuetype", 92 | "name": "New Feature", 93 | "subtask": false, 94 | "avatarId": 14681 95 | } 96 | } 97 | } 98 | } 99 | ], 100 | "assignee": null, 101 | "updated": "2011-02-10T19:13:54.000+0000", 102 | "status": { 103 | "self": "https://issues.jenkins-ci.org/rest/api/2/status/6", 104 | "description": "The issue is considered finished, the resolution is correct. Issues which are closed can be reopened.", 105 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/statuses/closed.png", 106 | "name": "Closed", 107 | "id": "6", 108 | "statusCategory": { 109 | "self": "https://issues.jenkins-ci.org/rest/api/2/statuscategory/3", 110 | "id": 3, 111 | "key": "done", 112 | "colorName": "green", 113 | "name": "Done" 114 | } 115 | }, 116 | "components": [ 117 | { 118 | "self": "https://issues.jenkins-ci.org/rest/api/2/component/15593", 119 | "id": "15593", 120 | "name": "core", 121 | "description": "Jenkins core" 122 | } 123 | ], 124 | "description": "I'm trying to realize a way to unify some of the configurations in multiple \nproject environment (hundreds of them). \nI was thinking about storing common configuration in common_config.xml and then \nimport it into the config.xml of the specific project. So it could look like:\n\n...\n\n\nWhen the common_config.xml can be something like:\n\n....\nC:\\Ivy\\apache-ivy-2.0.0\\src\\example\\multi-\nproject\\projects\\${projectname}\n\n\nBut it seems that neither import or property usage are working.\n\nWhen trying to import I'm getting:\nMar 18, 2009 4:30:59 PM hudson.util.RobustReflectionConverter doUnmarshal\nWARNING: Skipping a non-existent field import\ncom.thoughtworks.xstream.converters.reflection.NonExistentFieldException: No suc\nh field hudson.model.FreeStyleProject.import\n at com.thoughtworks.xstream.converters.reflection.FieldDictionary.field(\nFieldDictionary.java:106)\n at com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProv\nider.getFieldType(PureJavaReflectionProvider.java:151)\n at hudson.util.RobustReflectionConverter.determineType(RobustReflectionC\nonverter.java:327)\n at hudson.util.RobustReflectionConverter.doUnmarshal(RobustReflectionCon\nverter.java:218)\n at hudson.util.RobustReflectionConverter.unmarshal(RobustReflectionConve\nrter.java:173)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convert(TreeUnmarshall\ner.java:81)\n at com.thoughtworks.xstream.core.AbstractReferenceUnmarshaller.convert(A\nbstractReferenceUnmarshaller.java:55)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convertAnother(TreeUnm\narshaller.java:75)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convertAnother(TreeUnm\narshaller.java:59)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.start(TreeUnmarshaller\n.java:142)\n at com.thoughtworks.xstream.core.AbstractTreeMarshallingStrategy.unmarsh\nal(AbstractTreeMarshallingStrategy.java:33)\n at com.thoughtworks.xstream.XStream.unmarshal(XStream.java:931)\n at hudson.util.XStream2.unmarshal(XStream2.java:65)\n at com.thoughtworks.xstream.XStream.unmarshal(XStream.java:917)\n at com.thoughtworks.xstream.XStream.fromXML(XStream.java:861)\n at hudson.XmlFile.read(XmlFile.java:126)\n at hudson.model.Items.load(Items.java:109)\n at hudson.model.Hudson.load(Hudson.java:1837)\n at hudson.model.Hudson.access$500(Hudson.java:191)\n at hudson.model.Hudson$10.run(Hudson.java:2439)\nMar 18, 2009 4:30:59 PM hudson.model.Hudson load\nWARNING: Failed to load C:\\Documents and Settings\\username\\.hudson\\jobs\\version\njava.lang.NullPointerException\n at hudson.model.Project.updateTransientActions(Project.java:198)\n at hudson.model.AbstractProject.onLoad(AbstractProject.java:199)\n at hudson.model.Project.onLoad(Project.java:85)\n at hudson.model.Items.load(Items.java:110)\n at hudson.model.Hudson.load(Hudson.java:1837)\n at hudson.model.Hudson.access$500(Hudson.java:191)\n at hudson.model.Hudson$10.run(Hudson.java:2439)\n******************************************************************\n\nWhen i was testing the property usage I've got:\n\nWARNING: Skipping a non-existent field property\ncom.thoughtworks.xstream.converters.reflection.NonExistentFieldException: No suc\nh field hudson.model.FreeStyleProject.property\n at com.thoughtworks.xstream.converters.reflection.FieldDictionary.field(\nFieldDictionary.java:106)\n at com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProv\nider.getFieldType(PureJavaReflectionProvider.java:151)\n at hudson.util.RobustReflectionConverter.determineType(RobustReflectionC\nonverter.java:327)\n at hudson.util.RobustReflectionConverter.doUnmarshal(RobustReflectionCon\nverter.java:218)\n at hudson.util.RobustReflectionConverter.unmarshal(RobustReflectionConve\nrter.java:173)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convert(TreeUnmarshall\ner.java:81)\n at com.thoughtworks.xstream.core.AbstractReferenceUnmarshaller.convert(A\nbstractReferenceUnmarshaller.java:55)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convertAnother(TreeUnm\narshaller.java:75)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.convertAnother(TreeUnm\narshaller.java:59)\n at com.thoughtworks.xstream.core.TreeUnmarshaller.start(TreeUnmarshaller\n.java:142)\n at com.thoughtworks.xstream.core.AbstractTreeMarshallingStrategy.unmarsh\nal(AbstractTreeMarshallingStrategy.java:33)\n at com.thoughtworks.xstream.XStream.unmarshal(XStream.java:931)\n at hudson.util.XStream2.unmarshal(XStream2.java:65)\n at com.thoughtworks.xstream.XStream.unmarshal(XStream.java:917)\n at com.thoughtworks.xstream.XStream.fromXML(XStream.java:861)\n at hudson.XmlFile.read(XmlFile.java:126)\n at hudson.model.Items.load(Items.java:109)\n at hudson.model.Hudson.load(Hudson.java:1837)\n at hudson.model.Hudson.(Hudson.java:513)\n at hudson.WebAppMain$2.run(WebAppMain.java:190)\nMar 18, 2009 4:51:17 PM hudson.ivy.IvyBuildTrigger$1 doInIvyContext\n\nWhich causes:\n\nWARNING: Parsing error while reading the ivy file C:\\Ivy\\apache-ivy-2.0.0\\src\\ex\nample\\multi-project\\projects\\${projectname}\\ivy.xml", 125 | "attachment": [], 126 | "summary": " to inherit portions of configurations", 127 | "creator": { 128 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=eguess74", 129 | "name": "eguess74", 130 | "key": "eguess74", 131 | "avatarUrls": { 132 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 133 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 134 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 135 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 136 | }, 137 | "displayName": "eguess74", 138 | "active": true, 139 | "timeZone": "GMT" 140 | }, 141 | "reporter": { 142 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=eguess74", 143 | "name": "eguess74", 144 | "key": "eguess74", 145 | "avatarUrls": { 146 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 147 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 148 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 149 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 150 | }, 151 | "displayName": "eguess74", 152 | "active": true, 153 | "timeZone": "GMT" 154 | }, 155 | "environment": "Platform: PC, OS: All", 156 | "comment": { 157 | "comments": [ 158 | { 159 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133387/comment/129671", 160 | "id": "129671", 161 | "author": { 162 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=jglick", 163 | "name": "jglick", 164 | "key": "jglick", 165 | "avatarUrls": { 166 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=jglick&avatarId=15877", 167 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=jglick&avatarId=15877", 168 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=jglick&avatarId=15877", 169 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=jglick&avatarId=15877" 170 | }, 171 | "displayName": "Jesse Glick", 172 | "active": true, 173 | "timeZone": "America/New_York" 174 | }, 175 | "body": "Do you have some reason to believe that\n\n\n\nwas ever intended to work? Ant has some similar syntax, but XML files in general\ndo not.\n\nAFAIK there is no support for inheriting settings in Hudson projects. There was\nsome RFE open for this, I think, but I cannot find it now.", 176 | "updateAuthor": { 177 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=jglick", 178 | "name": "jglick", 179 | "key": "jglick", 180 | "avatarUrls": { 181 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=jglick&avatarId=15877", 182 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=jglick&avatarId=15877", 183 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=jglick&avatarId=15877", 184 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=jglick&avatarId=15877" 185 | }, 186 | "displayName": "Jesse Glick", 187 | "active": true, 188 | "timeZone": "America/New_York" 189 | }, 190 | "created": "2009-03-18T14:57:30.000+0000", 191 | "updated": "2009-03-18T14:57:30.000+0000" 192 | }, 193 | { 194 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133387/comment/129672", 195 | "id": "129672", 196 | "author": { 197 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=eguess74", 198 | "name": "eguess74", 199 | "key": "eguess74", 200 | "avatarUrls": { 201 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 202 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 203 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 204 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 205 | }, 206 | "displayName": "eguess74", 207 | "active": true, 208 | "timeZone": "GMT" 209 | }, 210 | "body": "i'm so used to such syntax in ant (both import and variables), so I probably do \nexpect it to work everywhere;) I also saw the same functionality supported by \nCruiseControl. \nIf it was not intended from the beginning then this should be transformed into \nfeature request. \nI think this will add a lot to Hudson usability for large scale environments.", 211 | "updateAuthor": { 212 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=eguess74", 213 | "name": "eguess74", 214 | "key": "eguess74", 215 | "avatarUrls": { 216 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 217 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 218 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 219 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 220 | }, 221 | "displayName": "eguess74", 222 | "active": true, 223 | "timeZone": "GMT" 224 | }, 225 | "created": "2009-03-18T15:40:29.000+0000", 226 | "updated": "2009-03-18T15:40:29.000+0000" 227 | }, 228 | { 229 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133387/comment/129673", 230 | "id": "129673", 231 | "author": { 232 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=jglick", 233 | "name": "jglick", 234 | "key": "jglick", 235 | "avatarUrls": { 236 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=jglick&avatarId=15877", 237 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=jglick&avatarId=15877", 238 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=jglick&avatarId=15877", 239 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=jglick&avatarId=15877" 240 | }, 241 | "displayName": "Jesse Glick", 242 | "active": true, 243 | "timeZone": "America/New_York" 244 | }, 245 | "body": "Like I said, probably a duplicate, I just am not sure where to find the original\nnow.", 246 | "updateAuthor": { 247 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=jglick", 248 | "name": "jglick", 249 | "key": "jglick", 250 | "avatarUrls": { 251 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=jglick&avatarId=15877", 252 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=jglick&avatarId=15877", 253 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=jglick&avatarId=15877", 254 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=jglick&avatarId=15877" 255 | }, 256 | "displayName": "Jesse Glick", 257 | "active": true, 258 | "timeZone": "America/New_York" 259 | }, 260 | "created": "2009-03-18T15:55:03.000+0000", 261 | "updated": "2009-03-18T15:55:03.000+0000" 262 | }, 263 | { 264 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/133387/comment/129674", 265 | "id": "129674", 266 | "author": { 267 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=mdonohue", 268 | "name": "mdonohue", 269 | "key": "mdonohue", 270 | "avatarUrls": { 271 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 272 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 273 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 274 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 275 | }, 276 | "displayName": "mdonohue", 277 | "active": true, 278 | "timeZone": "America/Los_Angeles" 279 | }, 280 | "body": "Duplicate of issue 3157 - it's in the top 10 vote getters.\n\n*** This issue has been marked as a duplicate of 3157 ***", 281 | "updateAuthor": { 282 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=mdonohue", 283 | "name": "mdonohue", 284 | "key": "mdonohue", 285 | "avatarUrls": { 286 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 287 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 288 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 289 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 290 | }, 291 | "displayName": "mdonohue", 292 | "active": true, 293 | "timeZone": "America/Los_Angeles" 294 | }, 295 | "created": "2009-03-28T14:25:25.000+0000", 296 | "updated": "2009-03-28T14:25:25.000+0000" 297 | } 298 | ], 299 | "maxResults": 4, 300 | "total": 4, 301 | "startAt": 0 302 | }, 303 | "votes": { 304 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/JENKINS-3314/votes", 305 | "votes": 0, 306 | "hasVoted": false 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /jira/mocks/JENKINS-33149.json: -------------------------------------------------------------------------------- 1 | { 2 | "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", 3 | "id": "168520", 4 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/168520", 5 | "key": "JENKINS-33149", 6 | "fields": { 7 | "issuetype": { 8 | "self": "https://issues.jenkins-ci.org/rest/api/2/issuetype/1", 9 | "id": "1", 10 | "description": "A problem which impairs or prevents the functions of the product.", 11 | "iconUrl": "https://issues.jenkins-ci.org/secure/viewavatar?size=xsmall&avatarId=14673&avatarType=issuetype", 12 | "name": "Bug", 13 | "subtask": false, 14 | "avatarId": 14673 15 | }, 16 | "project": { 17 | "self": "https://issues.jenkins-ci.org/rest/api/2/project/10172", 18 | "id": "10172", 19 | "key": "JENKINS", 20 | "name": "Jenkins", 21 | "avatarUrls": { 22 | "48x48": "https://issues.jenkins-ci.org/secure/projectavatar?pid=10172&avatarId=10152", 23 | "24x24": "https://issues.jenkins-ci.org/secure/projectavatar?size=small&pid=10172&avatarId=10152", 24 | "16x16": "https://issues.jenkins-ci.org/secure/projectavatar?size=xsmall&pid=10172&avatarId=10152", 25 | "32x32": "https://issues.jenkins-ci.org/secure/projectavatar?size=medium&pid=10172&avatarId=10152" 26 | } 27 | }, 28 | "resolution": { 29 | "self": "https://issues.jenkins-ci.org/rest/api/2/resolution/2", 30 | "id": "2", 31 | "description": "The problem described is an issue which will never be fixed.", 32 | "name": "Won't Fix" 33 | }, 34 | "resolutiondate": "2016-02-29T15:02:11.000+0000", 35 | "workratio": -1, 36 | "lastViewed": "2018-10-01T19:34:30.708+0000", 37 | "watches": { 38 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/JENKINS-33149/watchers", 39 | "watchCount": 3, 40 | "isWatching": false 41 | }, 42 | "created": "2016-02-25T09:06:06.000+0000", 43 | "priority": { 44 | "self": "https://issues.jenkins-ci.org/rest/api/2/priority/4", 45 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/priorities/minor.svg", 46 | "name": "Minor", 47 | "id": "4" 48 | }, 49 | "labels": [], 50 | "issuelinks": [], 51 | "assignee": { 52 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 53 | "name": "ndeloof", 54 | "key": "ndeloof", 55 | "avatarUrls": { 56 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 57 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 58 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 59 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 60 | }, 61 | "displayName": "Nicolas De Loof", 62 | "active": true, 63 | "timeZone": "Europe/Paris" 64 | }, 65 | "updated": "2016-05-30T11:33:31.000+0000", 66 | "status": { 67 | "self": "https://issues.jenkins-ci.org/rest/api/2/status/6", 68 | "description": "The issue is considered finished, the resolution is correct. Issues which are closed can be reopened.", 69 | "iconUrl": "https://issues.jenkins-ci.org/images/icons/statuses/closed.png", 70 | "name": "Closed", 71 | "id": "6", 72 | "statusCategory": { 73 | "self": "https://issues.jenkins-ci.org/rest/api/2/statuscategory/3", 74 | "id": 3, 75 | "key": "done", 76 | "colorName": "green", 77 | "name": "Done" 78 | } 79 | }, 80 | "components": [ 81 | { 82 | "self": "https://issues.jenkins-ci.org/rest/api/2/component/20634", 83 | "id": "20634", 84 | "name": "docker-custom-build-environment-plugin", 85 | "description": "docker-custom-build-environment-plugin plugin" 86 | } 87 | ], 88 | "description": "Steps:\r\n\r\n1. Create an image that specifies an entrypoint in the Dockerfile, e.g.:\r\n{{ENTRYPOINT [ \"/usr/share/maven/bin/mvn\" ]}}\r\n2. Create a build job that uses the image as a Build Environment\r\n\r\nWhen the environment is started docker run passes \"cat\" as a command but does not override the entry point. The result is that \"/usr/share/maven/bin/mvn cat\" is invoked in the container which typically fails causing the whole build to fail with an unexpected error.\r\n\r\nThere is no configuration option to specify the entrypoint for the Build Environment.", 89 | "attachment": [], 90 | "summary": "Images that specify an entrypoint can not be used as a build environment", 91 | "creator": { 92 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=spingel", 93 | "name": "spingel", 94 | "key": "spingel", 95 | "avatarUrls": { 96 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 97 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 98 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 99 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 100 | }, 101 | "displayName": "Steffen Pingel", 102 | "active": true, 103 | "timeZone": "Europe/Berlin" 104 | }, 105 | "reporter": { 106 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=spingel", 107 | "name": "spingel", 108 | "key": "spingel", 109 | "avatarUrls": { 110 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?avatarId=10292", 111 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&avatarId=10292", 112 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&avatarId=10292", 113 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&avatarId=10292" 114 | }, 115 | "displayName": "Steffen Pingel", 116 | "active": true, 117 | "timeZone": "Europe/Berlin" 118 | }, 119 | "environment": null, 120 | "comment": { 121 | "comments": [ 122 | { 123 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/168520/comment/249294", 124 | "id": "249294", 125 | "author": { 126 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 127 | "name": "ndeloof", 128 | "key": "ndeloof", 129 | "avatarUrls": { 130 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 131 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 132 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 133 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 134 | }, 135 | "displayName": "Nicolas De Loof", 136 | "active": true, 137 | "timeZone": "Europe/Paris" 138 | }, 139 | "body": "According to Docker best practices, entrypoint should be designed to detect it is used to run a single command, and not just pass additional arguments.\r\nIf you use Entrypoint this way, then your Docker image is designed to run a standalone service, not to be used as a build environment. Just adjust your Docker image to your needs.", 140 | "updateAuthor": { 141 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 142 | "name": "ndeloof", 143 | "key": "ndeloof", 144 | "avatarUrls": { 145 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 146 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 147 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 148 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 149 | }, 150 | "displayName": "Nicolas De Loof", 151 | "active": true, 152 | "timeZone": "Europe/Paris" 153 | }, 154 | "created": "2016-02-29T15:02:04.000+0000", 155 | "updated": "2016-02-29T15:02:04.000+0000" 156 | }, 157 | { 158 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/168520/comment/259298", 159 | "id": "259298", 160 | "author": { 161 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=mgreau", 162 | "name": "mgreau", 163 | "key": "mgreau", 164 | "avatarUrls": { 165 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=mgreau&avatarId=16261", 166 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=mgreau&avatarId=16261", 167 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=mgreau&avatarId=16261", 168 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=mgreau&avatarId=16261" 169 | }, 170 | "displayName": "Maxime GREAU", 171 | "active": true, 172 | "timeZone": "GMT" 173 | }, 174 | "body": "I'm blocked with the same case and I found this issue, cool.\r\nBut I'm not agree with the resolution :) .\r\n\r\nSo in our case we try to use the simplest (and same) commands for developers and CI environment.\r\n\r\nThat's why we have created some Docker CI images for each stack (JDK8/Maven32, JDK6/Maven30...) with\r\nthe following Dockerfile commands:\r\n\r\n{code}\r\n...\r\nENTRYPOINT [\"mvn\"]\r\nCMD [\"--help\"]\r\n{code}\r\nhttps://github.com/exo-docker/exo-ci/blob/master/jdk8-maven32/Dockerfile#L50-L51\r\n\r\nSo now we can easily build our legacy or newest projects without all Maven and JDK versions installed locally, with the following command:\r\n\r\n{code}\r\n$ cd my-project\r\n$ docker run --name=my-project-build -it -v $(pwd):/opt/ciagent/workspace \\\r\n -v ~/.m2/repository:/opt/ciagent/.m2/repository \\\r\n -v ~/.m2/settings.xml:/opt/ciagent/.m2/settings.xml \\\r\n exoplatform/ci:jdk8-maven32 clean package\r\n{code}\r\n\r\nAccording to the official Docker documentation and best practices, such use of ENTRYPOINT and CMD Dockerfile commands seems to be the\r\ngood one:\r\n* https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#entrypoint\r\n* https://docs.docker.com/engine/reference/builder/#entrypoint\r\n\r\nWDYT? (cc [~ndeloof] )\r\n", 175 | "updateAuthor": { 176 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=mgreau", 177 | "name": "mgreau", 178 | "key": "mgreau", 179 | "avatarUrls": { 180 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=mgreau&avatarId=16261", 181 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=mgreau&avatarId=16261", 182 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=mgreau&avatarId=16261", 183 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=mgreau&avatarId=16261" 184 | }, 185 | "displayName": "Maxime GREAU", 186 | "active": true, 187 | "timeZone": "GMT" 188 | }, 189 | "created": "2016-05-30T08:41:10.000+0000", 190 | "updated": "2016-05-30T08:41:10.000+0000" 191 | }, 192 | { 193 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/168520/comment/259309", 194 | "id": "259309", 195 | "author": { 196 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 197 | "name": "ndeloof", 198 | "key": "ndeloof", 199 | "avatarUrls": { 200 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 201 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 202 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 203 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 204 | }, 205 | "displayName": "Nicolas De Loof", 206 | "active": true, 207 | "timeZone": "Europe/Paris" 208 | }, 209 | "body": "on https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#entrypoint you have sample for postgres entrypoint script, which detect a valid postgres command or switch to plain `$@` execution. This is actually a requirement for official docker image to let user run with `bash` (or any other) command and execute it in replacement for the packaged tool. \r\n\r\nAlso see https://github.com/jenkinsci/docker/blob/master/jenkins.sh#L32\r\nwill be harder with maven as the argument is a maven phase, which can be arbitrary string :-\\\r\n\r\npossible workaround : give up with this plugin and use docker-slaves ! :)\r\n\r\n", 210 | "updateAuthor": { 211 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 212 | "name": "ndeloof", 213 | "key": "ndeloof", 214 | "avatarUrls": { 215 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 216 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 217 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 218 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 219 | }, 220 | "displayName": "Nicolas De Loof", 221 | "active": true, 222 | "timeZone": "Europe/Paris" 223 | }, 224 | "created": "2016-05-30T11:07:04.000+0000", 225 | "updated": "2016-05-30T11:07:04.000+0000" 226 | }, 227 | { 228 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/168520/comment/259312", 229 | "id": "259312", 230 | "author": { 231 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 232 | "name": "ndeloof", 233 | "key": "ndeloof", 234 | "avatarUrls": { 235 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 236 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 237 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 238 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 239 | }, 240 | "displayName": "Nicolas De Loof", 241 | "active": true, 242 | "timeZone": "Europe/Paris" 243 | }, 244 | "body": "[~mgreau] I guess you could detect a command to be executed in replacement to a maven goal if it starts with '/'. \r\nplugin do actually run `/bin/cat`, and this is a reasonable constraint for user to provide full executable path if they want to bypass the entrypoint", 245 | "updateAuthor": { 246 | "self": "https://issues.jenkins-ci.org/rest/api/2/user?username=ndeloof", 247 | "name": "ndeloof", 248 | "key": "ndeloof", 249 | "avatarUrls": { 250 | "48x48": "https://issues.jenkins-ci.org/secure/useravatar?ownerId=ndeloof&avatarId=10232", 251 | "24x24": "https://issues.jenkins-ci.org/secure/useravatar?size=small&ownerId=ndeloof&avatarId=10232", 252 | "16x16": "https://issues.jenkins-ci.org/secure/useravatar?size=xsmall&ownerId=ndeloof&avatarId=10232", 253 | "32x32": "https://issues.jenkins-ci.org/secure/useravatar?size=medium&ownerId=ndeloof&avatarId=10232" 254 | }, 255 | "displayName": "Nicolas De Loof", 256 | "active": true, 257 | "timeZone": "Europe/Paris" 258 | }, 259 | "created": "2016-05-30T11:33:31.000+0000", 260 | "updated": "2016-05-30T11:33:31.000+0000" 261 | } 262 | ], 263 | "maxResults": 4, 264 | "total": 4, 265 | "startAt": 0 266 | }, 267 | "votes": { 268 | "self": "https://issues.jenkins-ci.org/rest/api/2/issue/JENKINS-33149/votes", 269 | "votes": 0, 270 | "hasVoted": false 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /jira/mocks/config1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#chan1", 4 | "template": "{{.Self}}" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /jira/mocks/config2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#chan1", 4 | "template": "{{.Self}} - 1" 5 | }, 6 | { 7 | "channel": "#chan2", 8 | "template": "{{.Self}} - 2" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /jira/mocks/config3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#chan1", 4 | "notifyNew": ["PROJ1"], 5 | "notifyResolved": [] 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /jira/mocks/config4.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#chan1", 4 | "notifyNew": ["PROJ1", "PROJ2"], 5 | "notifyResolved": [] 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /jira/mocks/config5.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "channel": "#chan1", 4 | "notifyNew": ["PROJ1", "PROJ2"], 5 | "notifyResolved": ["PROJ1"] 6 | }, 7 | { 8 | "channel": "#chan2", 9 | "notifyNew": ["PROJ1", "PROJ3"], 10 | "notifyResolved": ["PROJ2", "PROJ3"] 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /puppet/puppet_command.go: -------------------------------------------------------------------------------- 1 | package puppet 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-chat-bot/bot" 7 | ) 8 | 9 | const ( 10 | seeUsage = "Invalid args, see usage with: !help puppet." 11 | ) 12 | 13 | func sendMessage(command *bot.Cmd) (result bot.CmdResult, err error) { 14 | result = bot.CmdResult{} 15 | 16 | if !argsValid(command.Args) { 17 | result.Message = seeUsage 18 | return 19 | } 20 | 21 | result.Channel = command.Args[1] 22 | result.Message = strings.Join(command.Args[2:], " ") 23 | return 24 | } 25 | 26 | func argsValid(args []string) bool { 27 | return len(args) >= 3 && validCommand(args[0]) 28 | } 29 | 30 | func validCommand(cmd string) bool { 31 | return cmd == "say" || cmd == "act" 32 | } 33 | 34 | func init() { 35 | bot.RegisterCommandV2( 36 | "puppet", 37 | "Allows you to send messages through the bot", 38 | "say #channel your message", 39 | sendMessage) 40 | } 41 | -------------------------------------------------------------------------------- /puppet/puppet_command_test.go: -------------------------------------------------------------------------------- 1 | package puppet 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | . "github.com/smartystreets/goconvey/convey" 6 | "testing" 7 | ) 8 | 9 | func TestPuppet(t *testing.T) { 10 | Convey("When say", t, func() { 11 | cmd := &bot.Cmd{} 12 | 13 | Convey("Should return usage if less than 3 arguments", func() { 14 | cmd.Args = []string{ 15 | "say", 16 | "#go-bot", 17 | } 18 | cmd.Channel = "#channel" 19 | result, err := sendMessage(cmd) 20 | 21 | So(err, ShouldBeNil) 22 | So(result.Message, ShouldEqual, seeUsage) 23 | So(result.Channel, ShouldBeEmpty) 24 | }) 25 | 26 | Convey("Should return error if the first argument is not say or act", func() { 27 | cmd.Args = []string{ 28 | "hi", 29 | "#channel", 30 | "go-bot", 31 | } 32 | result, err := sendMessage(cmd) 33 | 34 | So(err, ShouldBeNil) 35 | So(result.Message, ShouldEqual, seeUsage) 36 | So(result.Channel, ShouldBeEmpty) 37 | }) 38 | 39 | Convey("Should send a message to the specific channel", func() { 40 | 41 | cmd.Args = []string{ 42 | "say", 43 | "#channel", 44 | "message with spaces", 45 | } 46 | result, err := sendMessage(cmd) 47 | 48 | So(err, ShouldBeNil) 49 | So(result.Channel, ShouldEqual, "#channel") 50 | So(result.Message, ShouldEqual, "message with spaces") 51 | }) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /silence/silence.go: -------------------------------------------------------------------------------- 1 | package silence 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | "log" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | var ( 12 | silentMap map[string]time.Time 13 | ) 14 | 15 | func silenceFilter(cmd *bot.FilterCmd) (string, error) { 16 | until, found := silentMap[cmd.Target] 17 | if !found || time.Now().After(until) { 18 | return cmd.Message, nil 19 | } 20 | log.Printf("Silencing message in %s\n", cmd.Target) 21 | return "", nil 22 | } 23 | 24 | func silence(cmd *bot.Cmd) (string, error) { 25 | if len(cmd.Args) != 1 { 26 | return "Argument must be exactly 1 number (of minutes to be silent)", nil 27 | } 28 | 29 | min, err := strconv.Atoi(cmd.Args[0]) 30 | if err != nil { 31 | return "Argument must be exactly 1 number (of minutes to be silent)!", nil 32 | } 33 | until := time.Now().UTC().Add(time.Duration(min) * time.Minute) 34 | 35 | go func() { 36 | // delay setting the timeout so plugin reply can go out first 37 | time.Sleep(1 * time.Second) 38 | silentMap[cmd.Channel] = until 39 | }() 40 | // disable map for plugin reply 41 | silentMap[cmd.Channel] = time.Now() 42 | return fmt.Sprintf("OK, I will be silent until %s\n", 43 | until.Format(time.RFC1123)), nil 44 | } 45 | 46 | func init() { 47 | silentMap = make(map[string]time.Time) 48 | bot.RegisterFilterCommand( 49 | "silence", 50 | silenceFilter) 51 | 52 | bot.RegisterCommand( 53 | "silence", 54 | "Makes the bot completely silent for X minutes (0 removes silence)", 55 | "5", 56 | silence) 57 | } 58 | -------------------------------------------------------------------------------- /treta/treta.go: -------------------------------------------------------------------------------- 1 | package treta 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | 7 | "github.com/go-chat-bot/bot" 8 | ) 9 | 10 | const ( 11 | msgInvalidAmountOfParams = "Invalid amount of parameters" 12 | msgInvalidParam = "Invalid parameter" 13 | ) 14 | 15 | var ( 16 | quotes = map[string][]string{ 17 | "APPLE": { 18 | "Apple Inc. is not just a computer/portable device company, but at its inner core a philosophy. It's a philosophy of life, of living, of being alive, of stayin' alive, and of livin' la vida loca. It is a way of thinking and consuming overpriced monochrome technology that's designed with elegance.", 19 | "Apple decided to discontinue the Macbook Air, though there are rumours of its reintroduction, as several enthusiastic customers have threatened suicide if the line is permanently discontinued.", 20 | "Every once in a while, some brave soul dares to ask: \"Why do I keep buying Apple products? They're shit and extremely overpriced, but I just keep buying! WHY?!\" Apple's response is always some along the lines of: \"We own you.\"", 21 | "The iPhone is the culmination of several years of research and development at Apple, in to how they could further extort money from customers while maintaining an almost Big Brother style control over their device.", 22 | "Safari is worse than the fucking Internet Explorer Version 6.", 23 | "OS X in some ways is actually worse than Windows to program for. Their file system is complete and utter crap, which is scary.", 24 | }, 25 | "DELPHI": { 26 | "Delphi. Now there's a name I haven't heard in a long time.", 27 | "Access Violation at address 69696969 in module 'Treta.exe'. Read of address 00000666.", 28 | "Delphi supports the best way to develop iOS applications", 29 | "It’s not difficult to read and listen about the wonders of Embarcadero DataSnap technology around the world.", 30 | "Delphi was, and remains, vastly superior to anyone developer tools, in that users can actually produce working programs with it.", 31 | }, 32 | "GO": { 33 | "Go is the official programming language of the eXtreme Go Horse", 34 | "If you're looking for a language optimized for your problem domain, Golang is not the language for you.", 35 | "I don't know that Golang is a great language.", 36 | "Go don't have classes/constructors, but we have to reinvent them... with much worse practices.", 37 | "Oh Go! You so crazy!", 38 | "Good, Good... Let the Golang flow through you.", 39 | }, 40 | "LINUX": { 41 | "The Linux philosophy is 'Laugh in the face of danger'. Oops. Wrong One. 'Do it yourself'. Yes, that's it.", 42 | "Software is like sex: it's better when it's free.", 43 | "My name is Linus, and I am your God.", 44 | "I think the OpenBSD crowd is a bunch of masturbating monkeys, in that they make such a big deal about concentrating on security to the point where they pretty much admit that nothing else matters to them.", 45 | "Nvidia, fuck you!", 46 | }, 47 | "JAVA": { 48 | "You're using Java? Well there's your problem.", 49 | "I had a problem so I thought to use Java. Now I have a ProblemFactory.", 50 | "Many individual Java programmers claim that it is the very best technology available, particularly when they don't know anything else.", 51 | "Java Performance? You must be joking!", 52 | "It is said that Java was an idea of God to show to Humans how stupid they were", 53 | }, 54 | "JAVASCRIPT": { 55 | "Javascript is not funny at all", 56 | "JavaScript, why don't you work?", 57 | "Brace yourself. A new Javascript framework is coming.", 58 | "JavaScript... Whoops! Maybe you were looking for Java?", 59 | "JavaScript is a computer language for writing ineffectual computer viruses (interruptions to web surfing that will annoy the user without completely ruining his computer)", 60 | }, 61 | "PYTHON": { 62 | "We'll can do cool things... even with Python", 63 | "No one has been able to live programming with Python", 64 | "Python is the best programming language in the world... for kids to play and have fun.", 65 | }, 66 | "RUBY": { 67 | "Can Rails Scale? NOOOOO!", 68 | "Why is Ruby so slow?", 69 | "I hate managing inventory and the game drops more weapon than the rails can handle the requests", 70 | "Ruby on Rails? Pleaaase. Do you even code, bro?", 71 | "The classic Hello, world! program is really easy with Ruby. You just need to know the name of the gem you want to install.", 72 | "Python is known for its clear, readable, and regular syntax. Ruby code is vandalism!", 73 | "Python is better than Ruby", 74 | "even PHP is better than Ruby", 75 | "Ruby may do something completely useless and have infinite ways of doing something completely useless.", 76 | "I've hit this a few times in Ruby and it bugged me like crazy. But then I grew up, learned Python, and dealt with it.", 77 | "Do your best to program, not just uses Ruby.", 78 | }, 79 | "VIM": { 80 | "Emacs > VIM", 81 | "Sublime Text > VIM", 82 | "even Notepad > VIM", 83 | "VIM... Why can't I quit you?!", 84 | "Vim Is Too Mainstream. I'm Switching To Emacs", 85 | }, 86 | "WINDOWS": { 87 | "Why I love Windows: Keyboard not responding. Press any key to continue.", 88 | "Why I love Windows: A system call that should never fail has failed.", 89 | "Why I love Windows: Bluescreen has performed an illegal operation. Bluescreen must be closed.", 90 | "Why I love Windows: An error occurred whilst trying to load the previous error.", 91 | "Help and Support Error: Windows cannot open Help and Support because a system service is not running. To fix this problems, start the service named Help and Support", 92 | "Windows is the collective name for a series of failures that began in 1983 as a means of reversing the stagnation of the computer hardware market.", 93 | "I mean, it's obvious, isn't it? Windows seems perfectly clear and simple to use, but it crashes with the slightest pressure, or sometimes breaks inexplicably.", 94 | "Windows was officially confirmed to work correctly on i386, X86-64, IA64, ARM - it crashes on all of them. Undesired productivity boost when run under VirtualBox on Ubuntu.", 95 | "Microsoft isn't evil, they just make really crappy operating systems.", 96 | "Hoping the problem magically goes away by ignoring it is the “microsoft approach to programming” and should never be allowed.", 97 | }, 98 | } 99 | ) 100 | 101 | func treta(command *bot.Cmd) (string, error) { 102 | var key string 103 | switch len(command.Args) { 104 | case 0: 105 | key = randKey() 106 | case 1: 107 | key = strings.ToUpper(command.Args[0]) 108 | default: 109 | return msgInvalidAmountOfParams, nil 110 | } 111 | 112 | q, found := quotes[key] 113 | if !found { 114 | return msgInvalidParam, nil 115 | } 116 | return q[rand.Intn(len(q))], nil 117 | } 118 | 119 | func randKey() string { 120 | keys := make([]string, 0, len(quotes)) 121 | for k := range quotes { 122 | keys = append(keys, k) 123 | } 124 | return keys[rand.Intn(len(keys))] 125 | } 126 | 127 | func init() { 128 | bot.RegisterCommand( 129 | "treta", 130 | "sowing discord", 131 | "", 132 | treta) 133 | } 134 | -------------------------------------------------------------------------------- /treta/treta_test.go: -------------------------------------------------------------------------------- 1 | package treta 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-chat-bot/bot" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestTreta(t *testing.T) { 11 | Convey("treta", t, func() { 12 | bot := &bot.Cmd{ 13 | Command: "treta", 14 | } 15 | 16 | Convey("Should return a random treta", func() { 17 | got, error := treta(bot) 18 | 19 | So(error, ShouldBeNil) 20 | So(got, ShouldNotBeBlank) 21 | }) 22 | 23 | Convey("Should return a VIM treta", func() { 24 | bot.Args = []string{"vim"} 25 | got, error := treta(bot) 26 | 27 | So(error, ShouldBeNil) 28 | So(quotes["VIM"], ShouldContain, got) 29 | }) 30 | 31 | Convey("Should return a Ruby treta", func() { 32 | bot.Args = []string{"ruby"} 33 | got, error := treta(bot) 34 | 35 | So(error, ShouldBeNil) 36 | So(quotes["RUBY"], ShouldContain, got) 37 | }) 38 | 39 | Convey("Should return a error message when pass a invalid param", func() { 40 | bot.Args = []string{"kkk"} 41 | got, error := treta(bot) 42 | 43 | So(error, ShouldBeNil) 44 | So(got, ShouldEqual, msgInvalidParam) 45 | }) 46 | 47 | Convey("Should return a error message when pass a invalid amount of params", func() { 48 | bot.Args = []string{"1", "2"} 49 | got, error := treta(bot) 50 | 51 | So(error, ShouldBeNil) 52 | So(got, ShouldEqual, msgInvalidAmountOfParams) 53 | }) 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /twitter/README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The Twitter plugin scrapes message text for Twitter URLs, then attempts to fetch the linked Tweet and post it to the channel, like so: 4 | 5 | ``` 6 | 08:29:19 https://twitter.com/simonpierce/status/1265829199115218945 7 | 08:29:21 Tweet from @simonpierce: Gentoo penguins like to exercise their growing chicks by makingthem run around the colony, squawking hysterically, if they want to get fed. I'm not saying it'd be fun to try this with your own kids if you're stuck at home... but I'm not not saying that. https://t.co/2Y0wewRKDw 8 | ``` 9 | 10 | # Setting up Twitter API credentials 11 | 12 | - Request Twitter development access [here](https://developer.twitter.com) (Note: the approval process takes about a week) 13 | - Once your dev access is approved, create an App [here](https://developer.twitter.com/en/apps) 14 | - Visit the App's "Keys and Tokens" tab 15 | - Save the *Consumer API Key* and *Consumer API key secret* in a safe place (it is not necessary to generate *Access token* and *Access token secret* for this plugin) 16 | - Export the required environment variables into the shell in which your go-chat-bot process will run: 17 | 18 | ``` 19 | export TWITTER_CONSUMER_KEY="yourconsumerkeyhere" \ 20 | TWITTER_CONSUMER_SECRET="yourconsumersecrethere" 21 | ``` 22 | -------------------------------------------------------------------------------- /twitter/twitter.go: -------------------------------------------------------------------------------- 1 | // Package twitter provides a plugin that scrapes messages for Twitter links, 2 | // then expands them into chat messages. 3 | package twitter 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "github.com/dghubble/go-twitter/twitter" 9 | "github.com/go-chat-bot/bot" 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/clientcredentials" 12 | "os" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | // findTweetIDs checks a given message string for strings that look like Twitter links, 19 | // then attempts to extract the Tweet ID from the link. 20 | // It returns an array of Tweet IDs. 21 | func findTweetIDs(message string) ([]int64, error) { 22 | re := regexp.MustCompile(`http(?:s)?://(?:mobile.)?twitter.com/(?:.*)/status/([0-9]*)`) 23 | // FIXME this is only returning the LAST match, should return ALL matches 24 | result := re.FindAllStringSubmatch(message, -1) 25 | var ( 26 | tweetIDs []int64 27 | id int64 28 | err error 29 | ) 30 | 31 | for i := range result { 32 | last := len(result[i]) - 1 33 | idStr := result[i][last] 34 | id, err = strconv.ParseInt(idStr, 10, 64) 35 | tweetIDs = append(tweetIDs, id) 36 | } 37 | return tweetIDs, err 38 | } 39 | 40 | // getCredentialsFromEnvironment attempts to extract the Twitter consumer key 41 | // and consumer secret from the current process environment. If either the key 42 | // or the secret is not found, it returns a pair of empty strings and a 43 | // missingAPICredentialsError. 44 | // If successful, it returns the consumer key and consumer secret. 45 | func getCredentialsFromEnvironment() (string, string, error) { 46 | key, keyOk := os.LookupEnv("TWITTER_CONSUMER_KEY") 47 | secret, secretOk := os.LookupEnv("TWITTER_CONSUMER_SECRET") 48 | if !keyOk || !secretOk { 49 | return "", "", errors.New("missing API credentials") 50 | } 51 | return key, secret, nil 52 | } 53 | 54 | // newTwitterClientConfig takes a Twitter consumer key and consumer secret and 55 | // attempts to create a clientcredentials.Config. If either the key or the secret 56 | // is an empty string, no client is returned and a missingAPICredentialsError is returned. 57 | // If successful, it returns a clientcredentials.Config. 58 | func newTwitterClientConfig(twitterConsumerKey, twitterConsumerSecret string) (*clientcredentials.Config, error) { 59 | if twitterConsumerKey == "" || twitterConsumerSecret == "" { 60 | return nil, errors.New("missing API credentials") 61 | } 62 | config := &clientcredentials.Config{ 63 | ClientID: twitterConsumerKey, 64 | ClientSecret: twitterConsumerSecret, 65 | TokenURL: "https://api.twitter.com/oauth2/token", 66 | } 67 | return config, nil 68 | } 69 | 70 | // newAuthenticatedTwitterClient uses a provided consumer key and secret to authenticate 71 | // against Twitter's Oauth2 endpoint, then validates the authentication by checking the 72 | // current RateLimit against the provided account credentials. 73 | // It returns a twitter.Client. 74 | func newAuthenticatedTwitterClient(twitterConsumerKey, twitterConsumerSecret string) (*twitter.Client, error) { 75 | config, err := newTwitterClientConfig(twitterConsumerKey, twitterConsumerSecret) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | httpClient := config.Client(oauth2.NoContext) 81 | client := twitter.NewClient(httpClient) 82 | err = checkTwitterClientRateLimit(client) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return client, nil 88 | } 89 | 90 | // checkTwitterClientRateLimit uses the provided twitter.Client to check the remaining 91 | // RateLimit.Status for that client. 92 | // It returns an error if authentication failed or if the rate limit has been exceeded. 93 | func checkTwitterClientRateLimit(client *twitter.Client) error { 94 | // NOTE: calls to RateLimits apply against the Remaining calls for that endpoint 95 | params := twitter.RateLimitParams{Resources: []string{"statuses"}} 96 | rl, resp, err := client.RateLimits.Status(¶ms) 97 | 98 | // FIXME if i don't return this err at this point and credentials are bad, a panic happens 99 | if err != nil { 100 | return err 101 | } 102 | 103 | remaining := rl.Resources.Statuses["/statuses/show/:id"].Remaining 104 | if resp.StatusCode/200 != 1 { 105 | return errors.New(resp.Status) 106 | } 107 | 108 | if remaining == 0 { 109 | return errors.New("rate limit exceeded") 110 | } 111 | return nil 112 | } 113 | 114 | // fetchTweets takes an array of Tweet IDs and retrieves the corresponding 115 | // Statuses. 116 | // It returns an array of twitter.Tweets. 117 | func fetchTweets(client *twitter.Client, tweetIDs []int64) ([]twitter.Tweet, error) { 118 | var tweets []twitter.Tweet 119 | for _, tweetID := range tweetIDs { 120 | tweet, err := fetchTweet(client, tweetID) 121 | if err != nil { 122 | return nil, err 123 | } 124 | tweets = append(tweets, *tweet) 125 | } 126 | return tweets, nil 127 | } 128 | 129 | // fetchTweet takes a twitter.Client and a single Tweet ID and fetches the 130 | // corresponding Status. 131 | // It returns a twitter.Tweet. 132 | func fetchTweet(client *twitter.Client, tweetID int64) (*twitter.Tweet, error) { 133 | var err error 134 | // TODO get alt text 135 | // params: include_entities=true,include_ext_alt_text=true 136 | 137 | params := twitter.StatusShowParams{ 138 | TweetMode: "extended", // populate FullText field 139 | } 140 | tweet, resp, err := client.Statuses.Show(tweetID, ¶ms) 141 | 142 | // If we return nil instead of tweet, a panic happens 143 | if err != nil { 144 | return tweet, err 145 | } 146 | 147 | if resp.StatusCode/200 != 1 { 148 | err = errors.New(resp.Status) 149 | } 150 | 151 | return tweet, err 152 | } 153 | 154 | // formatTweets takes an array of twitter.Tweets and formats them in preparation for 155 | // sending as a chat message. 156 | // It returns an array of nicely formatted strings. 157 | func formatTweets(tweets []twitter.Tweet) []string { 158 | formatString := "Tweet from @%s: %s" 159 | newlines := regexp.MustCompile(`\r?\n`) 160 | var messages []string 161 | for _, tweet := range tweets { 162 | // TODO get link title, eg: Tweet from @user: look at this cool thing https://thing.cool (Link title: A Cool Thing) 163 | // tweet.Entities.Urls contains []URLEntity 164 | // fetch title from urlEntity.URL 165 | // urls plugin already correctly handles t.co links 166 | username := tweet.User.ScreenName 167 | text := newlines.ReplaceAllString(tweet.FullText, " ") 168 | newMessage := fmt.Sprintf(formatString, username, text) 169 | messages = append(messages, newMessage) 170 | } 171 | return messages 172 | } 173 | 174 | // expandTweets receives a bot.PassiveCmd and performs the full parse-and-fetch 175 | // pipeline. It sets up a client, finds Tweet IDs in the message text, fetches 176 | // the tweets, and formats them. If multiple Tweet IDs were found in the message, 177 | // all formatted Tweets will be joined into a single message. 178 | // It returns a single string suitable for sending as a chat message. 179 | func expandTweets(cmd *bot.PassiveCmd) (string, error) { 180 | var message string 181 | messageText := cmd.MessageData.Text 182 | 183 | twitterConsumerKey, twitterConsumerSecret, err := getCredentialsFromEnvironment() 184 | if err != nil { 185 | return message, err 186 | } 187 | 188 | client, err := newAuthenticatedTwitterClient(twitterConsumerKey, twitterConsumerSecret) 189 | if err != nil { 190 | return message, err 191 | } 192 | 193 | tweetIDs, err := findTweetIDs(messageText) 194 | if err != nil { 195 | return message, err 196 | } 197 | 198 | tweets, err := fetchTweets(client, tweetIDs) 199 | if err != nil { 200 | return message, err 201 | } 202 | 203 | formattedTweets := formatTweets(tweets) 204 | if formattedTweets != nil { 205 | message = strings.Join(formattedTweets, "\n") 206 | } 207 | return message, err 208 | } 209 | 210 | // init initalizes a PassiveCommand for expanding Tweets. 211 | func init() { 212 | bot.RegisterPassiveCommand( 213 | "twitter", 214 | expandTweets) 215 | } 216 | -------------------------------------------------------------------------------- /twitter/twitter_test.go: -------------------------------------------------------------------------------- 1 | package twitter 2 | 3 | import ( 4 | "errors" 5 | "github.com/go-chat-bot/bot" 6 | "regexp" 7 | "testing" 8 | ) 9 | 10 | func TestTwitter(t *testing.T) { 11 | // given a message string, I should get back a response message string 12 | // containing one or more parsed Tweets 13 | jbouieOutput := `Tweet from @jbouie: This falls into one of my favorite genres of tweets, bona fide elites whose pretenses to understanding “common people” instead reveal their cloistered, condescending view of ordinary people. https://t.co/KV8xnG2w48` 14 | sethAbramsonOutput := `Tweet from @SethAbramson: This is the first U.S. presidential election in which "Vote Him Out Before He Kills You and Your Family" is a wholly reasonable slogan for the challenger` 15 | dmackdrwnsOutput := `Tweet from @dmackdrwns: It was pretty fun to try to manifest creatures plucked right from the minds of manic children. #georgiamuseumofart https://t.co/C983t6QjmT` 16 | 17 | var cases = []struct { 18 | input, output string 19 | expectedError error 20 | }{ 21 | { 22 | input: "this message has no links", 23 | output: "", 24 | expectedError: nil, 25 | }, { 26 | input: "http://twitter.com/jbouie/status/1247273759632961537", 27 | output: jbouieOutput, 28 | expectedError: nil, 29 | }, { 30 | input: "https://mobile.twitter.com/jbouie/status/1247273759632961537", 31 | output: jbouieOutput, 32 | expectedError: nil, 33 | }, { 34 | input: "wow check out this tweet https://mobile.twitter.com/jbouie/status/1247273759632961537", 35 | output: jbouieOutput, 36 | expectedError: nil, 37 | }, { 38 | input: "wow check out this tweethttps://mobile.twitter.com/jbouie/status/1247273759632961537", 39 | output: jbouieOutput, 40 | expectedError: nil, 41 | }, { 42 | input: "wow check out this tweet https://mobile.twitter.com/jbouie/status/1247273759632961537super cool right?", 43 | output: jbouieOutput, 44 | expectedError: nil, 45 | }, { 46 | input: "https://twitter.com/dmackdrwns/status/1217830568848764930/photo/1", 47 | output: dmackdrwnsOutput, 48 | expectedError: nil, 49 | }, { 50 | input: "http://twitter.com/notARealUser/status/123456789", 51 | output: "", 52 | expectedError: errors.New("twitter: 144 No status found with that ID."), 53 | }, { 54 | input: "https://twitter.com/SethAbramson/status/1259875673994338305 lol bye", 55 | output: sethAbramsonOutput, 56 | expectedError: nil, 57 | }, 58 | } 59 | for i, c := range cases { 60 | testingUser := bot.User{ 61 | ID: "test", 62 | Nick: "test", 63 | RealName: "test", 64 | IsBot: true, 65 | } 66 | testingMessage := bot.Message{ 67 | Text: c.input, 68 | IsAction: false, 69 | } 70 | testingCmd := bot.PassiveCmd{ 71 | Raw: c.input, 72 | Channel: "test", 73 | User: &testingUser, 74 | MessageData: &testingMessage, 75 | } 76 | t.Run(string(i), func(t *testing.T) { 77 | // these CANNOT run concurrently 78 | // FIXME panic here when no credentials 79 | got, err := expandTweets(&testingCmd) 80 | want := c.output 81 | if err != nil && err.Error() != c.expectedError.Error() { 82 | t.Error(err) 83 | } 84 | if got != want { 85 | t.Errorf("got %+v; want %+v", got, want) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestNewAuthenticatedTwitterClient(t *testing.T) { 92 | // TODO test case for these envvars not being set 93 | key, secret, err := getCredentialsFromEnvironment() 94 | if err != nil { 95 | t.Error(err) 96 | } 97 | var cases = []struct { 98 | key, secret string 99 | expectedError error 100 | }{ 101 | { 102 | key: "", 103 | secret: "", 104 | expectedError: errors.New("missing API credentials"), 105 | }, { 106 | key: "asdf", 107 | secret: "jklmnop", 108 | expectedError: errors.New(`Get https://api.twitter.com/1.1/application/rate_limit_status.json?resources=statuses: oauth2: cannot fetch token: 403 Forbidden Response: {"errors":[{"code":99,"message":"Unable to verify your credentials","label":"authenticity_token_error"}]}`), 109 | }, { 110 | key: key, 111 | secret: secret, 112 | expectedError: nil, 113 | }, 114 | } 115 | newlines := regexp.MustCompile(`\r?\n`) 116 | for i, c := range cases { 117 | t.Run(string(i), func(t *testing.T) { 118 | // these CANNOT run concurrently 119 | _, err := newAuthenticatedTwitterClient(c.key, c.secret) 120 | if err != nil { 121 | // eat newlines because they mess with our tests 122 | got := newlines.ReplaceAllString(err.Error(), " ") 123 | want := c.expectedError.Error() 124 | if got != want { 125 | t.Errorf("got %s; want %s", got, want) 126 | } 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /uptime/uptime.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2012 VMware, Inc. - https://github.com/cloudfoundry/gosigar/blob/master/examples/uptime.go 2 | 3 | package uptime 4 | 5 | import ( 6 | "fmt" 7 | "github.com/cloudfoundry/gosigar" 8 | "time" 9 | "github.com/go-chat-bot/bot" 10 | ) 11 | 12 | func uptime(command *bot.Cmd) (msg string, err error) { 13 | uptime := sigar.Uptime{} 14 | uptime.Get() 15 | avg := sigar.LoadAverage{} 16 | avg.Get() 17 | msg = fmt.Sprintf("%s up %s load average: %.2f, %.2f, %.2f\n", time.Now().Format("15:04:05"), uptime.Format(), avg.One, avg.Five, avg.Fifteen) 18 | return 19 | } 20 | 21 | func init() { 22 | bot.RegisterCommand( 23 | "uptime", 24 | "Sends the uptime of your server to you on the channel.", 25 | "", 26 | uptime) 27 | } 28 | -------------------------------------------------------------------------------- /url/url.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "github.com/go-chat-bot/bot" 5 | "github.com/go-chat-bot/plugins/web" 6 | "html" 7 | "net/url" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | minDomainLength = 3 14 | ) 15 | 16 | var ( 17 | re = regexp.MustCompile("\\n*?(.*?)\\n*?<\\/title>") 18 | ) 19 | 20 | func canBeURLWithoutProtocol(text string) bool { 21 | return len(text) > minDomainLength && 22 | !strings.HasPrefix(text, "http") && 23 | strings.Contains(text, ".") 24 | } 25 | 26 | func extractURL(text string) string { 27 | extractedURL := "" 28 | for _, value := range strings.Split(text, " ") { 29 | if canBeURLWithoutProtocol(value) { 30 | value = "http://" + value 31 | } 32 | 33 | parsedURL, err := url.Parse(value) 34 | if err != nil { 35 | continue 36 | } 37 | if strings.HasPrefix(parsedURL.Scheme, "http") { 38 | extractedURL = parsedURL.String() 39 | break 40 | } 41 | } 42 | return extractedURL 43 | } 44 | 45 | func urlTitle(cmd *bot.PassiveCmd) (string, error) { 46 | URL := extractURL(cmd.Raw) 47 | 48 | if URL == "" { 49 | return "", nil 50 | } 51 | 52 | body, err := web.GetBody(URL) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | title := re.FindString(string(body)) 58 | if title == "" { 59 | return "", nil 60 | } 61 | 62 | title = strings.Replace(title, "\n", "", -1) 63 | title = title[strings.Index(title, ">")+1 : strings.LastIndex(title, "<")] 64 | 65 | return html.UnescapeString(title), nil 66 | } 67 | 68 | func init() { 69 | bot.RegisterPassiveCommand( 70 | "url", 71 | urlTitle) 72 | } 73 | -------------------------------------------------------------------------------- /url/url_test.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-chat-bot/bot" 6 | . "github.com/smartystreets/goconvey/convey" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestURL(t *testing.T) { 13 | cmd := &bot.PassiveCmd{} 14 | getExecuted := false 15 | getResult := "" 16 | 17 | ts := httptest.NewServer( 18 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | getExecuted = true 20 | fmt.Fprintln(w, getResult) 21 | })) 22 | 23 | url := ts.URL 24 | 25 | Convey("Given a text", t, func() { 26 | 27 | Reset(func() { 28 | getExecuted = false 29 | getResult = "" 30 | }) 31 | 32 | Convey("If the text is not a URL", func() { 33 | cmd.Raw = "foo bar" 34 | title, err := urlTitle(cmd) 35 | 36 | So(getExecuted, ShouldBeFalse) 37 | So(err, ShouldBeNil) 38 | So(title, ShouldBeBlank) 39 | }) 40 | 41 | Convey("If the url contains no title", func() { 42 | cmd.Raw = "foo " + url 43 | 44 | title, err := urlTitle(cmd) 45 | 46 | So(getExecuted, ShouldBeTrue) 47 | So(err, ShouldBeNil) 48 | So(title, ShouldBeBlank) 49 | }) 50 | 51 | Convey("If the url contains a title", func() { 52 | getResult = "<title>Google" 53 | cmd.Raw = fmt.Sprintf("foo %s bar", url) 54 | 55 | title, err := urlTitle(cmd) 56 | 57 | So(getExecuted, ShouldBeTrue) 58 | So(err, ShouldBeNil) 59 | So(title, ShouldEqual, "Google") 60 | }) 61 | 62 | Convey("If the text is a https URL", func() { 63 | httpsURL := "https://google.com" 64 | 65 | extractedURL := extractURL(fmt.Sprintf("foo %s bar", httpsURL)) 66 | 67 | So(extractedURL, ShouldEqual, httpsURL) 68 | }) 69 | 70 | Convey("If title starts or ends with a new line", func() { 71 | getResult = "\nGoogle\n" 72 | cmd.Raw = url 73 | 74 | title, err := urlTitle(cmd) 75 | 76 | So(err, ShouldBeNil) 77 | So(title, ShouldEqual, "Google") 78 | }) 79 | 80 | Convey("If an error occurs while fetching the url", func() { 81 | cmd.Raw = "127.0.0.1:0" 82 | 83 | _, err := urlTitle(cmd) 84 | 85 | So(err, ShouldNotBeNil) 86 | }) 87 | 88 | Convey("if the url doesn't have a protocol", func() { 89 | noProtocolURL := "google.com" 90 | 91 | extractedURL := extractURL(fmt.Sprintf("foo %s bar", noProtocolURL)) 92 | 93 | So(extractedURL, ShouldEqual, "http://google.com") 94 | }) 95 | 96 | Convey("if the text has fewer than 4 characters", func() { 97 | So(extractURL("a.a"), ShouldEqual, "") 98 | }) 99 | 100 | Convey("if the url is invalid", func() { 101 | So(extractURL(":googlecom"), ShouldEqual, "") 102 | }) 103 | 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /web/utils.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | func GetBody(url string) ([]byte, error) { 10 | res, err := http.Get(url) 11 | if err != nil { 12 | return nil, err 13 | } 14 | defer res.Body.Close() 15 | 16 | return ioutil.ReadAll(res.Body) 17 | } 18 | 19 | func GetJSON(url string, v interface{}) error { 20 | body, err := GetBody(url) 21 | if err != nil { 22 | return err 23 | } 24 | return json.Unmarshal(body, v) 25 | } 26 | --------------------------------------------------------------------------------