├── .gitignore ├── Godeps └── Godeps.json ├── LICENSE ├── README.md ├── boltdb.go ├── boltdb_test.go ├── conf.go ├── conf.yml ├── conf_test.go ├── event ├── api.go ├── api_test.go ├── atnd.go ├── atnd_test.go ├── citymap.go ├── connpass.go ├── connpass_test.go ├── doc.go ├── doorkeeper.go ├── doorkeeper_test.go ├── event.go ├── event_test.go ├── eventbrite.go ├── eventbrite_test.go ├── meetup.go ├── meetup_test.go ├── strtacademy.go ├── strtacademy_test.go ├── zusaar.go └── zusaar_test.go ├── logger.go ├── logger_test.go ├── main.go ├── slack.go └── slack_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | enotify-slack 2 | enotify-slack.log 3 | enotify-slack.db 4 | .coverprofile 5 | event/.coverprofile 6 | gover.coverprofile 7 | Godeps/* 8 | !Godeps.json 9 | -------------------------------------------------------------------------------- /Godeps/Godeps.json: -------------------------------------------------------------------------------- 1 | { 2 | "ImportPath": "github.com/daikikohara/enotify-slack", 3 | "GoVersion": "go1.4.2", 4 | "Deps": [ 5 | { 6 | "ImportPath": "github.com/boltdb/bolt", 7 | "Comment": "data/v1-238-g43a1303", 8 | "Rev": "43a1303c1568c531237c7a55bb1d40413e792b53" 9 | }, 10 | { 11 | "ImportPath": "github.com/mitchellh/mapstructure", 12 | "Rev": "740c764bc6149d3f1806231418adb9f52c11bcbf" 13 | }, 14 | { 15 | "ImportPath": "github.com/stretchr/testify/assert", 16 | "Rev": "8ce79b9f0b77745113f82c17d0756771456ccbd3" 17 | }, 18 | { 19 | "ImportPath": "golang.org/x/net/html", 20 | "Rev": "bb64f4dc73d4ab97978d5e1cb34515dcc570361b" 21 | }, 22 | { 23 | "ImportPath": "gopkg.in/yaml.v1", 24 | "Rev": "9f9df34309c04878acc86042b16630b0f696e1de" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | enotify-slack 2 | ============= 3 | 4 | [![Build Status](https://drone.io/github.com/daikikohara/enotify-slack/status.png)](https://drone.io/github.com/daikikohara/enotify-slack/latest) 5 | [![Coverage Status](https://coveralls.io/repos/daikikohara/enotify-slack/badge.svg?branch=master&service=github)](https://coveralls.io/github/daikikohara/enotify-slack?branch=master) 6 | 7 | 日本語の紹介記事は[こちら](http://qiita.com/kiida/items/373446edd2fb09da82ca)。 8 | 9 | ## Summary 10 | 11 | This is a tool to get event(meetup) information and send the information to a channel in [Slack](https://slack.com/). 12 | The event information is provided by event provider's site such as [Meetup](http://www.meetup.com/) and [Eventbrite](https://www.eventbrite.com/)(the others are mainly only used in Japan). 13 | The event information will be sent to Slack if the title or description contains keyword specified in the configuration file. 14 | 15 | ![screenshot](https://raw.github.com/wiki/daikikohara/enotify-slack/images/capture01.png) 16 | 17 | ## How to use 18 | 19 | * Get binary and conf.yml from [release](https://github.com/daikikohara/enotify-slack/releases) and place them in the same directory. 20 | * Configure conf.yml. Comment in the file is descriptive enough. 21 | * Run the binary with `nohup` like `nohup ./enotify-slack &` or run it in screen/tmux session. 22 | 23 | ## Thanks 24 | 25 | enotify-slack uses api shown below. 26 | 27 | * Event provider's site 28 | * [ATND](http://api.atnd.org/) 29 | * [Connpass](http://connpass.com/about/api/) 30 | * [Doorkeeper](http://www.doorkeeperhq.com/developer/api) 31 | * [Eventbrite](http://developer.eventbrite.com/docs/) 32 | * [Meetup](http://www.meetup.com/meetup_api/) 33 | * [StreetAcademy](https://www.street-academy.com/api.html) 34 | * [Zusaar](http://www.zusaar.com/doc/api.html) 35 | * Slack 36 | * [Slack](https://api.slack.com/) 37 | 38 | ## License 39 | 40 | enotify-slack is under the Apache 2.0 license. See the [LICENSE](LICENSE) file for details. 41 | -------------------------------------------------------------------------------- /boltdb.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/boltdb/bolt" 8 | ) 9 | 10 | // Bolt holds a pointer of bolt.DB and 2 strings for dbfile and bucket name. 11 | // boltdb is used to store event data in the following key-value format. 12 | // key - event-provider-name:event-id 13 | // value - detail of the event 14 | type Bolt struct { 15 | Db *bolt.DB 16 | Dbfile string 17 | Bucket string 18 | } 19 | 20 | // NewBolt opens boltdb to initialize Bolt. 21 | // The path of the db file that stores event data is specified in the configuration file(yaml). 22 | func NewBolt(dbfile, bucket string) *Bolt { 23 | db, err := bolt.Open(dbfile, 0644, nil) 24 | if err != nil { 25 | log.Panicln(err) 26 | } 27 | return &Bolt{db, dbfile, bucket} 28 | } 29 | 30 | // Exists checks if a key passed via the argument already exists in boltdb. 31 | func (self *Bolt) Exists(key []byte) bool { 32 | err := self.Db.View(func(tx *bolt.Tx) error { 33 | bucket := tx.Bucket([]byte(self.Bucket)) 34 | if bucket == nil { 35 | return nil 36 | } 37 | if bucket.Get(key) != nil { 38 | return fmt.Errorf("already registered") 39 | } 40 | return nil 41 | }) 42 | if err != nil { 43 | return true 44 | } 45 | return false 46 | } 47 | 48 | // Put puts a key-value pair of an event data. 49 | func (self *Bolt) Put(key, value []byte) { 50 | self.Db.Update(func(tx *bolt.Tx) error { 51 | bucket, err := tx.CreateBucketIfNotExists([]byte(self.Bucket)) 52 | if err != nil { 53 | log.Panicln(err) 54 | } 55 | err = bucket.Put(key, value) 56 | if err != nil { 57 | log.Panicln(err) 58 | } 59 | return nil 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /boltdb_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | . "github.com/daikikohara/enotify-slack" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | dbfile = "bolt.db" 14 | bucket = "bucket" 15 | key = "key" 16 | value = "value" 17 | nonexist = "/path/to/nonexist" 18 | empty = "" 19 | ) 20 | 21 | func TestNewBolt(t *testing.T) { 22 | // case1: Success 23 | file, err := ioutil.TempFile(os.TempDir(), dbfile) 24 | if err != nil { 25 | t.Error(err.Error()) 26 | } 27 | defer os.Remove(file.Name()) 28 | bolt := NewBolt(file.Name(), bucket) 29 | defer bolt.Db.Close() 30 | 31 | assert := assert.New(t) 32 | assert.Equal(file.Name(), bolt.Dbfile, "boltdb file name is not correct") 33 | assert.Equal(bucket, bolt.Bucket, "boltdb bucket name is not correct") 34 | 35 | // case2: invalid path 36 | fn := func(file, bucket string) { 37 | defer func() { 38 | if r := recover(); r == nil { 39 | t.Fail() 40 | } 41 | }() 42 | NewBolt(file, bucket) 43 | } 44 | fn(nonexist, bucket) 45 | } 46 | 47 | func TestPut(t *testing.T) { 48 | // case1: bucket name is empty 49 | file, err := ioutil.TempFile(os.TempDir(), dbfile) 50 | if err != nil { 51 | t.Error(err.Error()) 52 | } 53 | defer os.Remove(file.Name()) 54 | bolt := NewBolt(file.Name(), empty) 55 | defer bolt.Db.Close() 56 | 57 | fn := func(k, v string, b *Bolt) { 58 | defer func() { 59 | if r := recover(); r == nil { 60 | t.Fail() 61 | } 62 | }() 63 | b.Put([]byte(k), []byte(v)) 64 | } 65 | fn(key, value, bolt) 66 | 67 | // case2: key is empty 68 | file2, err := ioutil.TempFile(os.TempDir(), dbfile+"2") 69 | if err != nil { 70 | t.Error(err.Error()) 71 | } 72 | defer os.Remove(file2.Name()) 73 | bolt2 := NewBolt(file2.Name(), bucket) 74 | defer bolt2.Db.Close() 75 | fn(empty, value, bolt2) 76 | 77 | // case3: success 78 | // success case can be tested in TestExists 79 | } 80 | 81 | func TestExists(t *testing.T) { 82 | // case1: 83 | file, err := ioutil.TempFile(os.TempDir(), dbfile) 84 | if err != nil { 85 | t.Error(err.Error()) 86 | } 87 | defer os.Remove(file.Name()) 88 | bolt := NewBolt(file.Name(), bucket) 89 | defer bolt.Db.Close() 90 | 91 | assert := assert.New(t) 92 | assert.False(bolt.Exists([]byte(key)), "There has to be NO bucket in boltdb") 93 | bolt.Put([]byte(key), []byte(value)) 94 | assert.False(bolt.Exists([]byte(nonexist)), "There has to be NO key in boltdb") 95 | assert.True(bolt.Exists([]byte(key)), "There has to be NO key in boltdb") 96 | 97 | } 98 | -------------------------------------------------------------------------------- /conf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | 7 | "gopkg.in/yaml.v1" 8 | ) 9 | 10 | // Config represents configurations defined in a yaml file. 11 | type Config struct { 12 | Keyword string 13 | Nickname string 14 | Taboo string 15 | Place []string 16 | Provider map[string]*struct { 17 | Url string 18 | Color string 19 | Interval uint32 20 | Token string 21 | } 22 | Slack struct { 23 | Pretext string 24 | Url string 25 | Channel string 26 | Short bool 27 | Interval uint32 28 | } 29 | Boltdb struct { 30 | Dbfile string 31 | Bucketname string 32 | } 33 | Logfile string 34 | Timezone string 35 | ErrorToSlack bool `yaml:"error_to_slack"` 36 | } 37 | 38 | // defConf holds default values for Config 39 | var defConf = Config{ 40 | Provider: map[string]*struct { 41 | Url string 42 | Color string 43 | Interval uint32 44 | Token string 45 | }{ 46 | "atnd": { 47 | Url: "https://api.atnd.org/events/?count=50&format=json&", 48 | Color: "#000000", 49 | Interval: 3600, 50 | }, 51 | "connpass": { 52 | Url: "http://connpass.com/api/v1/event/?order=3&count=50&", 53 | Color: "#D00000", 54 | Interval: 3600, 55 | }, 56 | "doorkeeper": { 57 | Url: "http://api.doorkeeper.jp/events/?sort=published_at&locale=ja&", 58 | Color: "#00D0A0", 59 | Interval: 3600, 60 | }, 61 | "eventbrite": { 62 | Url: "https://www.eventbriteapi.com/v3/events/search/?token=", 63 | Color: "#FFaa33", 64 | Interval: 3600, 65 | }, 66 | "meetup": { 67 | Url: "https://api.meetup.com/2/open_events?key=", 68 | Color: "#F00000", 69 | Interval: 3600, 70 | }, 71 | "strtacademy": { 72 | Url: "http://www.street-academy.com/api/v1/events?page=", 73 | Color: "#009900", 74 | Interval: 3600, 75 | }, 76 | "zusaar": { 77 | Url: "http://www.zusaar.com/api/event/?count=50&", 78 | Color: "#0000FF", 79 | Interval: 3600, 80 | }, 81 | }, 82 | Slack: struct { 83 | Pretext string 84 | Url string 85 | Channel string 86 | Short bool 87 | Interval uint32 88 | }{ 89 | Pretext: "New Event Arrived!", 90 | Url: "", 91 | Channel: "#event-notify", 92 | Short: false, 93 | Interval: 3, 94 | }, 95 | Boltdb: struct { 96 | Dbfile string 97 | Bucketname string 98 | }{ 99 | Dbfile: "enotify-slack.db", 100 | Bucketname: "enotify-slack", 101 | }, 102 | Logfile: "enotify-slack.log", 103 | Timezone: "America/Los_Angeles", 104 | ErrorToSlack: false, 105 | } 106 | 107 | // NewConfig constructs Config using a yaml file passed as the argument. 108 | func NewConfig(yml string) *Config { 109 | buf, err := ioutil.ReadFile(yml) 110 | if err != nil { 111 | log.Panicln(err) 112 | } 113 | var config Config 114 | err = yaml.Unmarshal(buf, &config) 115 | if err != nil { 116 | log.Panicln(err) 117 | } 118 | if !config.isValid() { 119 | log.Panicln("conf file contains invalid value.") 120 | } 121 | return &config 122 | } 123 | 124 | // isValid checks if Config is valid. 125 | // It currently only checks the interval to avoid overloading api providers. 126 | func (config *Config) isValid() bool { 127 | if config.Slack.Interval < 1 { 128 | config.Slack.Interval = defConf.Slack.Interval 129 | } 130 | if config.Slack.Url == "" { 131 | log.Println("Slack webhook URL is empty.") 132 | return false 133 | } 134 | if config.Slack.Pretext == "" { 135 | config.Slack.Pretext = defConf.Slack.Pretext 136 | } 137 | if config.Slack.Channel == "" { 138 | log.Println("Slack channel name is empty.") 139 | return false 140 | } 141 | if config.Boltdb.Dbfile == "" { 142 | config.Boltdb.Dbfile = defConf.Boltdb.Dbfile 143 | } 144 | if config.Boltdb.Bucketname == "" { 145 | config.Boltdb.Bucketname = defConf.Boltdb.Bucketname 146 | } 147 | if config.Logfile == "" { 148 | config.Logfile = defConf.Logfile 149 | } 150 | if config.Timezone == "" { 151 | config.Timezone = defConf.Timezone 152 | } 153 | for name, provider := range config.Provider { 154 | if provider == nil { 155 | provider = &struct { 156 | Url string 157 | Color string 158 | Interval uint32 159 | Token string 160 | }{ 161 | Url: defConf.Provider[name].Url, 162 | Color: defConf.Provider[name].Color, 163 | Interval: defConf.Provider[name].Interval, 164 | Token: "", 165 | } 166 | } 167 | if provider.Interval == 0 { 168 | provider.Interval = defConf.Provider[name].Interval 169 | } else if provider.Interval < 60 { 170 | log.Println("event check interval must be 60 or bigger. Do NOT overload providers.") 171 | return false 172 | } 173 | if provider.Url == "" { 174 | provider.Url = defConf.Provider[name].Url 175 | } 176 | if provider.Color == "" { 177 | provider.Color = defConf.Provider[name].Color 178 | } 179 | if name == "meetup" || name == "eventbrite" { 180 | if provider.Token == "" { 181 | log.Println("token is empty for" + name) 182 | return false 183 | } 184 | provider.Url = provider.Url + provider.Token + "&" 185 | } 186 | config.Provider[name] = provider 187 | } 188 | return true 189 | } 190 | -------------------------------------------------------------------------------- /conf.yml: -------------------------------------------------------------------------------- 1 | # Optional settings and/or insignificant settings are commented out. Please modify the others. 2 | 3 | 4 | # If event title/description contains 'keyword', the event will be sent to Slack. 5 | # 6 | keyword: golang,slack,anotherkeyword,etc 7 | 8 | # If the owner or participants contains 'nickname', the event will be sent to Slack. 9 | # 'nickname' is OR'd with keyword. 10 | # 11 | # NOTE: 'nickname' only works for Atnd, Connpass and Zusaar. 12 | # 13 | nickname: nickname1,nickname2,etc 14 | 15 | # If event title/description contains 'taboo', the event will Not be sent to Slack even if the other conditions are satisfied. 16 | # 17 | taboo: taboo1,taboo2,etc 18 | 19 | # Event will be sent if 'place' is included in the event address. 20 | # 'place' is AND'd with 'keyword' or 'nickname'. 21 | # NOTE: If you want to get information from Eventbrite, 22 | # - write city name since Eventbrite requires city name. 23 | # - use alphabet name for the city (Among non-alphabet character, only Japanese city(prefecture) name in Kanji character is supported). 24 | # 25 | place: 26 | - "San Jose" 27 | - "Santa Clara" 28 | - "Sunnyvale" 29 | - "Mountain View" 30 | 31 | # Event provider specific configuration. 32 | # Following settings are supported. 33 | # token: (required for eventbrite and meetup) API key for meetup and API token for eventbrite. 34 | # url: (optional) Url for the API. 35 | # default value: see the source file(conf.go) 36 | # color: (optional) color to be displayed on Slack. 37 | # default value: see the source file(conf.go) 38 | # interval: interval in second to call the API. 39 | # Keep the value moderate to avoid overloading the event provider and exceeding the rate limit. 40 | # default value: 3600 41 | # Comment any provider's name out, if you don't want to get event information from the provider. 42 | # 43 | # NOTE: Icon in Slack message is shown as :providername: (like ":meetup:"). 44 | # Please set the icon for your Slack in advance or you'll see the name as text. 45 | # 46 | provider: 47 | atnd: 48 | connpass: 49 | doorkeeper: 50 | strtacademy: 51 | zusaar: 52 | eventbrite: 53 | token: your_token_here 54 | meetup: 55 | token: your_api_key_here 56 | 57 | # Slack configuration. 58 | # Following settings are supported. 59 | # url: (required) Url for your Slack incoming-webhook. Modify the token. 60 | # pretex: (optional) text to be displayed before event details. 61 | # default value: "New Event Arrived!" 62 | # channel: (optional) name of Slack channel that event is sent to. 63 | # default value: "#event-notify" 64 | # short: if true, event details are displayed side-by-side. 65 | # default value: false 66 | # interval: interval in second to send event to Slack. 67 | # This prevent reaching rate limit when many events are registered in a short period. 68 | # default value: 3 69 | # 70 | slack: 71 | url: "https://hooks.slack.com/services/YOUR/TOKEN/HERE" 72 | channel: "#event-notify" 73 | 74 | # timezone 75 | # The time of event sent to Slack is shown as time in this time zone. 76 | # default value: "America/Los_Angeles" 77 | # 78 | timezone: "America/Los_Angeles" 79 | 80 | # db file configuration. 81 | # This usually does not need to be modified. 82 | # default value: "enotify-slack.db" for dbfile, "enotify-slack" for bucketname 83 | # 84 | #boltdb: 85 | # dbfile: enotify-slack.db 86 | # bucketname: enotify-slack 87 | 88 | # log file name. 89 | # This usually does not need to be modified. 90 | # default value: "enotify-slack.log" 91 | # 92 | #logfile: enotify-slack.log 93 | 94 | # If 'error_to_slack' is true, error messages are sent to Slack when error occurs. 95 | # Recommended to leave this false since lots of messages are sent when network error occurs. 96 | # default value: false 97 | # 98 | #error_to_slack: false 99 | -------------------------------------------------------------------------------- /conf_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | . "github.com/daikikohara/enotify-slack" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var ( 13 | validConf = "keyword: keyword1,keyword2\nnickname: person1,person2\nplace:\n - Tokyo\n - Kanagawa\nerror_to_slack: true\nprovider:\n connpass:\n url: http://connpass.com/api/v1/event/?order=3&count=50&\n color: \"#D00000\"\n interval: 300\nslack:\n pretext: slackpretext\n url: https://test.slack.com/services/hooks/incoming-webhook?token=tokenname\n channel: \"#notify-channel\"\n short: false\n interval: 3\nboltdb:\n dbfile: it-event.db\n bucketname: it-event\nlogfile: ./enotify-slack.log\ntimezone: Asia/Tokyo\n" 14 | // slice for invalid conf contents 15 | invalidConf = []string{ 16 | // invalid slack interval data 17 | "keyword: keyword1,keyword2\nnickname: person1,person2\nplace:\n - Tokyo\n - Kanagawa\nerror_to_slack: true\nprovider:\n connpass:\n url: http://connpass.com/api/v1/event/?order=3&count=50&\n color: #D00000\n interval: 0\nslack:\n pretext: slackpretext\n url: https://test.slack.com/services/hooks/incoming-webhook?token=tokenname\n channel: #notify-channel\n short: false\n interval: 3\nboltdb:\n dbfile: it-event.db\n bucketname: it-event\nlogfile: ./enotify-slack.log\n", 18 | // invalid provider interval data 19 | "keyword: keyword1,keyword2\nnickname: person1,person2\nplace:\n - Tokyo\n - Kanagawa\nerror_to_slack: true\nprovider:\n connpass:\n url: http://connpass.com/api/v1/event/?order=3&count=50&\n color: #D00000\n interval: 30\nslack:\n pretext: slackpretext\n url: https://test.slack.com/services/hooks/incoming-webhook?token=tokenname\n channel: #notify-channel\n short: false\n interval: 0\nboltdb:\n dbfile: it-event.db\n bucketname: it-event\nlogfile: ./enotify-slack.log\n", 20 | } 21 | ) 22 | 23 | func TestNewConfig(t *testing.T) { 24 | // initialize conf file 25 | file, err := ioutil.TempFile(os.TempDir(), "enotify-slack-config.yml") 26 | if err != nil { 27 | t.Error(err.Error()) 28 | } 29 | defer os.Remove(file.Name()) 30 | ioutil.WriteFile(file.Name(), []byte(validConf), 0644) 31 | conf := NewConfig(file.Name()) 32 | 33 | // assert contents of valid conf 34 | assert := assert.New(t) 35 | assert.Equal("./enotify-slack.log", conf.Logfile, "conf parse error: logfile") 36 | assert.Equal("it-event", conf.Boltdb.Bucketname, "conf parse error: boltdb bucket name") 37 | assert.Equal("it-event.db", conf.Boltdb.Dbfile, "conf parse error: boltdb dbfile") 38 | assert.Equal(uint(0x3), conf.Slack.Interval, "conf parse error: slack interval") 39 | assert.False(conf.Slack.Short, "conf parse error: slack short") 40 | assert.Equal("#notify-channel", conf.Slack.Channel, "conf parse error: slack channel") 41 | assert.Equal("https://test.slack.com/services/hooks/incoming-webhook?token=tokenname", conf.Slack.Url, "conf parse error: slack url") 42 | assert.Equal("slackpretext", conf.Slack.Pretext, "conf parse error: slack pretext") 43 | assert.Equal(uint(0x12c), conf.Provider["connpass"].Interval, "conf parse error: provider interval") 44 | assert.Equal("#D00000", conf.Provider["connpass"].Color, "conf parse error: provider color") 45 | assert.Equal("http://connpass.com/api/v1/event/?order=3&count=50&", conf.Provider["connpass"].Url, "conf parse error: provider url") 46 | assert.Equal("Tokyo", conf.Place[0], "conf parse error: place[0]") 47 | assert.Equal("Kanagawa", conf.Place[1], "conf parse error: place[1]") 48 | assert.Equal("keyword1,keyword2", conf.Keyword, "conf parse error: keyword") 49 | assert.Equal("person1,person2", conf.Nickname, "conf parse error: nickname") 50 | assert.True(conf.ErrorToSlack, "conf parse error: error_to_slack") 51 | 52 | // function to test invalid config 53 | fn := func(f string) { 54 | defer func() { 55 | if r := recover(); r == nil { 56 | t.Fail() 57 | } 58 | }() 59 | NewConfig(f) 60 | } 61 | for _, s := range invalidConf { 62 | ioutil.WriteFile(file.Name(), []byte(s), 0644) 63 | fn(file.Name()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /event/api.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // Api is an interface to get a slice of Event 12 | type Api interface { 13 | // Get gets a slice of Event using API depending on api providers. 14 | Get(baseurl, keyword, nickname string, places []string) ([]Event, error) 15 | } 16 | 17 | // timeFormat holds time format. 18 | // Currently it only supports "2006-01-02 15:04" style format. 19 | const timeFormat = "2006-01-02 15:04" 20 | 21 | // timezone holds timezone such as "Asia/Tokyo" 22 | // This can be set via configuration file. 23 | var timezone *time.Location 24 | 25 | // SetTimezone sets timezone specified in the configuration file. 26 | func SetTimezone(t string) { 27 | l, err := time.LoadLocation(t) 28 | if err != nil { 29 | panic(err.Error()) 30 | } 31 | timezone = l 32 | } 33 | 34 | // GetApi is a factory function. 35 | // GetApi returns an implementation of Api which gets actual events provided by each event provider. 36 | func GetApi(provider string) Api { 37 | switch provider { 38 | case "doorkeeper": 39 | return new(Doorkeeper) 40 | case "atnd": 41 | return new(Atnd) 42 | case "connpass": 43 | return new(Connpass) 44 | case "zusaar": 45 | return new(Zusaar) 46 | case "strtacademy": 47 | return new(Strtacademy) 48 | case "meetup": 49 | return new(Meetup) 50 | case "eventbrite": 51 | return new(Eventbrite) 52 | default: 53 | log.Panic("Invalid api name:" + provider + "\ncheck conf file.") 54 | } 55 | return nil 56 | } 57 | 58 | // GetJson sends get request to the url passed by the argument and returns json-formatted event data. 59 | func GetJson(url string) (interface{}, error) { 60 | client := &http.Client{ 61 | Timeout: time.Duration(30) * time.Second, 62 | } 63 | req, _ := http.NewRequest("GET", url, nil) 64 | resp, err := client.Do(req) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer resp.Body.Close() 69 | body, err := ioutil.ReadAll(resp.Body) 70 | var result interface{} 71 | err = json.Unmarshal(body, &result) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return result, nil 76 | } 77 | -------------------------------------------------------------------------------- /event/api_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | const ( 15 | keyword = "key1" 16 | nickname = "nickname1" 17 | ) 18 | 19 | var place = []string{"San Jose", "東京", "渋谷"} 20 | 21 | var ( 22 | eventok = Event{ 23 | Id: "123", 24 | Title: "example#1", 25 | Summary: "summary123", 26 | Url: "http://example.connpass.com/event/123/", 27 | Started_at: "2015-09-30 19:00", 28 | Place: "address123\nplace123", 29 | Description: "summary123", 30 | } 31 | eventbriteok = Event{ 32 | Id: "123", 33 | Title: "example#1", 34 | Summary: "summary123", 35 | Url: "http://example.connpass.com/event/123/", 36 | Started_at: "2015-09-30 19:00", 37 | Place: "San Jose", 38 | Description: "summary123", 39 | } 40 | eventoklong = Event{ 41 | Id: "123", 42 | Title: "example#1", 43 | Summary: "summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoo...", 44 | Url: "http://example.connpass.com/event/123/", 45 | Started_at: "2015-09-30 19:00", 46 | Place: "address123\nplace123", 47 | Description: "summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoolong", 48 | } 49 | eventInvalidTime = Event{ 50 | Id: "123", 51 | Title: "example#1", 52 | Summary: "summary123", 53 | Url: "http://example.connpass.com/event/123/", 54 | Started_at: "20150930T190000.0000900", 55 | Place: "address123\nplace123", 56 | Description: "summary123", 57 | } 58 | ) 59 | 60 | var funcNameTable = []struct { 61 | providerName string 62 | functionName string 63 | }{ 64 | {"doorkeeper", "Doorkeeper"}, 65 | {"atnd", "Atnd"}, 66 | {"connpass", "Connpass"}, 67 | {"zusaar", "Zusaar"}, 68 | {"strtacademy", "Strtacademy"}, 69 | {"meetup", "Meetup"}, 70 | {"eventbrite", "Eventbrite"}, 71 | } 72 | 73 | func TestGetApi(t *testing.T) { 74 | assert := assert.New(t) 75 | for _, testcase := range funcNameTable { 76 | assert.Contains(reflect.TypeOf(GetApi(testcase.providerName)).Elem().Name(), testcase.functionName, "GetApi test failed for "+testcase.providerName) 77 | } 78 | defer func() { 79 | if r := recover(); r == nil { 80 | t.Fail() 81 | } 82 | }() 83 | GetApi("other") 84 | } 85 | 86 | func TestGetJson(t *testing.T) { 87 | // case1: fail to get request 88 | if _, err := GetJson(""); err == nil { 89 | t.Error(err.Error()) 90 | } 91 | 92 | // case2: fail to unmarshall json response 93 | tsFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 | w.Header().Set("Content-Type", "application/json") 95 | fmt.Fprintln(w, ``) 96 | })) 97 | defer tsFail.Close() 98 | if _, err := GetJson(tsFail.URL); err == nil { 99 | t.Error(err.Error()) 100 | } 101 | 102 | // case3: success 103 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 104 | w.Header().Set("Content-Type", "application/json") 105 | fmt.Fprintln(w, `{"event":"123"}`) 106 | })) 107 | defer tsSuccess.Close() 108 | result, err := GetJson(tsSuccess.URL) 109 | if err != nil { 110 | t.Error(err.Error()) 111 | } 112 | if result.(map[string]interface{})["event"] != "123" { 113 | t.Fail() 114 | } 115 | } 116 | 117 | func TestSetTimezone(t *testing.T) { 118 | SetTimezone("Asia/Tokyo") 119 | defer func() { 120 | if r := recover(); r == nil { 121 | t.Fail() 122 | } 123 | }() 124 | SetTimezone("ERROR") 125 | } 126 | -------------------------------------------------------------------------------- /event/atnd.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | // Atnd implements Api 11 | // Atnd represents event data returned by ATND API. 12 | type Atnd struct { 13 | Result struct { 14 | Results_returned int 15 | Results_start int 16 | Events []struct { 17 | Event struct { 18 | Event_id string 19 | Title string 20 | Catch string 21 | Event_url string 22 | Started_at string 23 | Address string 24 | Place string 25 | Description string 26 | } 27 | } 28 | } 29 | } 30 | 31 | func (self *Atnd) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 32 | var events []Event 33 | for _, param := range []string{"keyword_or=" + url.QueryEscape(keyword), "owner_nickname=" + nickname, "nickname=" + nickname} { 34 | for t := time.Now().Local(); t.Before(time.Now().Local().AddDate(0, 3, 0)); t = t.AddDate(0, 1, 0) { 35 | query := param + "&ym=" + t.Format("200601") 36 | result, err := GetJson(baseurl + query) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if err = mapstructure.WeakDecode(result, &self.Result); err != nil { 41 | return nil, err 42 | } 43 | for _, atnd := range self.Result.Events { 44 | event := Event{ 45 | Id: atnd.Event.Event_id, 46 | Title: atnd.Event.Title, 47 | Started_at: format(atnd.Event.Started_at), 48 | Url: atnd.Event.Event_url, 49 | Summary: atnd.Event.Catch, 50 | Place: atnd.Event.Address + "\n" + atnd.Event.Place, 51 | Description: parse(atnd.Event.Description), 52 | } 53 | if event.Summary == "" { 54 | event.Summary = trim(event.Description) 55 | } 56 | events = append(events, event) 57 | } 58 | time.Sleep(time.Duration(1 * time.Second)) 59 | } 60 | } 61 | return events, nil 62 | } 63 | -------------------------------------------------------------------------------- /event/atnd_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetAtnd(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"events":[{"event":{"event_id":123,"title":"example#1","catch":"summary123","description":"summary123","event_url":"http://example.connpass.com/event/123/","started_at":"2015-09-30T19:00:00.000+09:00","ended_at":null,"url":"http://not-this-one.com","limit":2147483647,"address":"address123","place":"place123","lat":"0.0","lon":"0.0","owner_id":123,"owner_nickname":"nickname1","owner_twitter_id":"nickname1","accepted":19,"waiting":0,"updated_at":"2018-09-24T13:51:01.000+09:00"}},{"event":{"event_id":123,"title":"example#1","catch":"","description":"summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoolong","event_url":"http://example.connpass.com/event/123/","started_at":"2015-09-30T19:00:00.000+09:00","ended_at":null,"url":"http://not-this-one.com","limit":2147483647,"address":"address123","place":"place123","lat":"0.0","lon":"0.0","owner_id":123,"owner_nickname":"nickname1","owner_twitter_id":"nickname1","accepted":19,"waiting":0,"updated_at":"2018-09-24T13:51:01.000+09:00"}}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | atnd := new(Atnd) 22 | events, err := atnd.Get(tsSuccess.URL+"?", keyword, nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Error("atnd return value is unexpected. expected and actual are") 28 | t.Error(eventok) 29 | t.Error(events[0]) 30 | t.FailNow() 31 | } 32 | if !reflect.DeepEqual(eventoklong, events[1]) { 33 | t.Error("atnd return value is unexpected. expected and actual are") 34 | t.Error(eventoklong) 35 | t.Error(events[1]) 36 | t.FailNow() 37 | } 38 | 39 | // invalid url 40 | events, err = atnd.Get("", keyword, nickname, place) 41 | if err == nil { 42 | t.Error(err.Error()) 43 | } 44 | 45 | // invalid time format 46 | tsInvalidTime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | w.Header().Set("Content-Type", "application/json") 48 | fmt.Fprintln(w, `{"events":[{"event":{"event_id":123,"title":"example#1","catch":"summary123","description":"summary123","event_url":"http://example.connpass.com/event/123/","started_at":"20150930T190000.0000900","ended_at":null,"url":"http://not-this-one.com","limit":2147483647,"address":"address123","place":"place123","lat":"0.0","lon":"0.0","owner_id":123,"owner_nickname":"nickname1","owner_twitter_id":"nickname1","accepted":19,"waiting":0,"updated_at":"2018-09-24T13:51:01.000+09:00"}}]}`) 49 | })) 50 | defer tsInvalidTime.Close() 51 | events, err = atnd.Get(tsInvalidTime.URL+"?", keyword, nickname, place) 52 | if err != nil { 53 | t.FailNow() 54 | } 55 | if !reflect.DeepEqual(eventInvalidTime, events[0]) { 56 | t.Error("atnd return value is unexpected. expected and actual are") 57 | t.Error(eventInvalidTime) 58 | t.Error(events[0]) 59 | t.FailNow() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /event/citymap.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | // cityMap holds pairs of non-alphabet city names and alphabet city names. 4 | // Eventbrite seems to store all places in alphabet, so city name in language with non-alphabet must be converted into alphabet name. 5 | // TODO: move outside go file so that users can add their own city at runtime. 6 | var cityMap = map[string]string{ 7 | "北海道": "hokkaido", 8 | "青森県": "aomori", 9 | "岩手県": "iwate", 10 | "宮城県": "miyagi", 11 | "秋田県": "akita", 12 | "山形県": "yamagata", 13 | "福島県": "fukushima", 14 | "茨城県": "ibaraki", 15 | "栃木県": "tochigi", 16 | "群馬県": "gunma", 17 | "埼玉県": "saitama", 18 | "千葉県": "chiba", 19 | "東京都": "tokyo", 20 | "新潟県": "niigata", 21 | "富山県": "toyama", 22 | "石川県": "ishikawa", 23 | "福井県": "fukui", 24 | "山梨県": "yamanashi", 25 | "長野県": "nagano", 26 | "岐阜県": "gifu", 27 | "静岡県": "shizuoka", 28 | "愛知県": "aichi", 29 | "三重県": "mie", 30 | "滋賀県": "shiga", 31 | "京都府": "kyoto", 32 | "大阪府": "osaka", 33 | "兵庫県": "hyogo", 34 | "奈良県": "nara", 35 | "鳥取県": "tottori", 36 | "島根県": "shimane", 37 | "岡山県": "okayama", 38 | "広島県": "hiroshima", 39 | "山口県": "yamaguchi", 40 | "徳島県": "tokushima", 41 | "香川県": "kagawa", 42 | "愛媛県": "ehime", 43 | "高知県": "kochi", 44 | "福岡県": "fukuoka", 45 | "佐賀県": "saga", 46 | "長崎県": "nagasaki", 47 | "熊本県": "kumamoto", 48 | "大分県": "oita", 49 | "宮崎県": "miyazaki", 50 | "沖縄県": "okinawa", 51 | "神奈川県": "kanagawa", 52 | "鹿児島県": "kagoshima", 53 | "和歌山県": "wakayama", 54 | "青森": "aomori", 55 | "岩手": "iwate", 56 | "宮城": "miyagi", 57 | "秋田": "akita", 58 | "山形": "yamagata", 59 | "福島": "fukushima", 60 | "茨城": "ibaraki", 61 | "栃木": "tochigi", 62 | "群馬": "gunma", 63 | "埼玉": "saitama", 64 | "千葉": "chiba", 65 | "東京": "tokyo", 66 | "新潟": "niigata", 67 | "富山": "toyama", 68 | "石川": "ishikawa", 69 | "福井": "fukui", 70 | "山梨": "yamanashi", 71 | "長野": "nagano", 72 | "岐阜": "gifu", 73 | "静岡": "shizuoka", 74 | "愛知": "aichi", 75 | "三重": "mie", 76 | "滋賀": "shiga", 77 | "京都": "kyoto", 78 | "大阪": "osaka", 79 | "兵庫": "hyogo", 80 | "奈良": "nara", 81 | "鳥取": "tottori", 82 | "島根": "shimane", 83 | "岡山": "okayama", 84 | "広島": "hiroshima", 85 | "山口": "yamaguchi", 86 | "徳島": "tokushima", 87 | "香川": "kagawa", 88 | "愛媛": "ehime", 89 | "高知": "kochi", 90 | "福岡": "fukuoka", 91 | "佐賀": "saga", 92 | "長崎": "nagasaki", 93 | "熊本": "kumamoto", 94 | "大分": "oita", 95 | "宮崎": "miyazaki", 96 | "沖縄": "okinawa", 97 | "神奈川": "kanagawa", 98 | "鹿児島": "kagoshima", 99 | "和歌山": "wakayama", 100 | } 101 | -------------------------------------------------------------------------------- /event/connpass.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | // Connpass implements Api 11 | // Connpass represents event data returned by Connpass. 12 | type Connpass struct { 13 | result struct { 14 | Results_returned int 15 | Events []struct { 16 | Event_id string 17 | Title string 18 | Catch string 19 | Event_url string 20 | Started_at string 21 | Address string 22 | Place string 23 | Description string 24 | } 25 | } 26 | } 27 | 28 | func (self *Connpass) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 29 | var events []Event 30 | for _, param := range []string{"keyword_or=" + url.QueryEscape(keyword), "owner_nickname=" + nickname, "nickname=" + nickname} { 31 | for t := time.Now().Local(); t.Before(time.Now().Local().AddDate(0, 3, 0)); t = t.AddDate(0, 1, 0) { 32 | query := param + "&ym=" + t.Format("200601") 33 | result, err := GetJson(baseurl + query) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 38 | return nil, err 39 | } 40 | for _, e := range self.result.Events { 41 | event := Event{ 42 | Id: e.Event_id, 43 | Title: e.Title, 44 | Started_at: format(e.Started_at), 45 | Url: e.Event_url, 46 | Summary: e.Catch, 47 | Place: e.Address + "\n" + e.Place, 48 | Description: parse(e.Description), 49 | } 50 | if event.Summary == "" { 51 | event.Summary = trim(event.Description) 52 | } 53 | events = append(events, event) 54 | } 55 | time.Sleep(time.Duration(1 * time.Second)) 56 | } 57 | } 58 | return events, nil 59 | } 60 | -------------------------------------------------------------------------------- /event/connpass_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetConnpass(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"events":[{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "2015-09-30T19:00:00+09:00", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": "summary123"},{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "2015-09-30T19:00:00+09:00", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoolong", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": ""}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | connpass := new(Connpass) 22 | events, err := connpass.Get(tsSuccess.URL+"?", keyword, nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Error("connpass return value is unexpected. expected and actual are") 28 | t.Error(eventok) 29 | t.Error(events[0]) 30 | t.FailNow() 31 | } 32 | if !reflect.DeepEqual(eventoklong, events[1]) { 33 | t.Error("connpass return value is unexpected. expected and actual are") 34 | t.Error(eventoklong) 35 | t.Error(events[1]) 36 | t.FailNow() 37 | } 38 | 39 | // invalid url 40 | events, err = connpass.Get("", keyword, nickname, place) 41 | if err == nil { 42 | t.Fail() 43 | } 44 | 45 | // invalid time format 46 | tsInvalidTime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | w.Header().Set("Content-Type", "application/json") 48 | fmt.Fprintln(w, `{"events":[{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "20150930T190000.0000900", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": "summary123"}]}`) 49 | })) 50 | defer tsInvalidTime.Close() 51 | events, err = connpass.Get(tsInvalidTime.URL+"?", keyword, nickname, place) 52 | if err != nil { 53 | t.Fail() 54 | } 55 | if !reflect.DeepEqual(eventInvalidTime, events[0]) { 56 | t.FailNow() 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /event/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Daiki Kohara 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /* 16 | Package event provides implementations of getting events from each event provider. 17 | */ 18 | package event 19 | -------------------------------------------------------------------------------- /event/doorkeeper.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mitchellh/mapstructure" 7 | ) 8 | 9 | // Doorkeeper implements Api 10 | // Doorkeeper represents event data returned by DoorKeeper API. 11 | type Doorkeeper struct { 12 | result []struct { 13 | Event struct { 14 | Id string 15 | Title string 16 | Public_url string 17 | Address string 18 | Venue_name string 19 | Starts_at string 20 | Description string 21 | } 22 | } 23 | } 24 | 25 | func (self *Doorkeeper) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 26 | t := time.Now().Format("2006-01-02") 27 | param := "since=" + t 28 | result, err := GetJson(baseurl + param) 29 | if err != nil { 30 | return nil, err 31 | } 32 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 33 | return nil, err 34 | } 35 | var events []Event 36 | for _, e := range self.result { 37 | event := Event{ 38 | Id: e.Event.Id, 39 | Title: e.Event.Title, 40 | Started_at: format(e.Event.Starts_at), 41 | Url: e.Event.Public_url, 42 | Place: e.Event.Address + "\n" + e.Event.Venue_name, 43 | Description: parse(e.Event.Description), 44 | } 45 | event.Summary = trim(event.Description) 46 | if !event.contains(keyword) { 47 | continue 48 | } 49 | events = append(events, event) 50 | } 51 | return events, nil 52 | } 53 | -------------------------------------------------------------------------------- /event/doorkeeper_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetDoorkeeper(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `[{"event":{"title":"example#1","id":123,"starts_at":"2015-09-30T10:00:00.000Z","ends_at":"2014-12-17T12:00:00.000Z","venue_name":"place123","address":"address123","lat":"34.6985045","long":"135.4902115","ticket_limit":15,"published_at":"2014-12-07T13:52:23.341Z","updated_at":"2014-12-07T13:52:23.343Z","description":"summary123","public_url":"http://example.connpass.com/event/123/","participants":0,"waitlisted":0,"group":{"id":234,"name":"","country_code":"JP","logo":"hogehoge.jpg","description":"hogehoge","public_url":"http://hogehoge/"}}},{"event":{"title":"example#1","id":123,"starts_at":"2015-09-30T10:00:00.000Z","ends_at":"2014-12-17T12:00:00.000Z","venue_name":"place123","address":"address123","lat":"34.6985045","long":"135.4902115","ticket_limit":15,"published_at":"2014-12-07T13:52:23.341Z","updated_at":"2014-12-07T13:52:23.343Z","description":"123","public_url":"http://example.connpass.com/event/123/","participants":0,"waitlisted":0,"group":{"id":234,"name":"","country_code":"JP","logo":"hogehoge.jpg","description":"hogehoge","public_url":"http://hogehoge/"}}}]`) 19 | })) 20 | defer tsSuccess.Close() 21 | dk := new(Doorkeeper) 22 | events, err := dk.Get(tsSuccess.URL+"?", "summary", nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Error("doorkeeper return value is unexpected. expected and actual are") 28 | t.Error(eventok) 29 | t.Error(events[0]) 30 | } 31 | 32 | // invalid url 33 | events, err = dk.Get("", keyword, nickname, place) 34 | if err == nil { 35 | t.Error(err.Error()) 36 | } 37 | 38 | // invalid time format 39 | tsInvalidTime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | w.Header().Set("Content-Type", "application/json") 41 | fmt.Fprintln(w, `[{"event":{"title":"example#1","id":123,"starts_at":"20150930T190000.0000900","ends_at":"2014-12-17T12:00:00.000Z","venue_name":"place123","address":"address123","lat":"34.6985045","long":"135.4902115","ticket_limit":15,"published_at":"2014-12-07T13:52:23.341Z","updated_at":"2014-12-07T13:52:23.343Z","description":"summary123","public_url":"http://example.connpass.com/event/123/","participants":0,"waitlisted":0,"group":{"id":234,"name":"","country_code":"JP","logo":"hogehoge.jpg","description":"hogehoge","public_url":"http://hogehoge/"}}}]`) 42 | })) 43 | defer tsInvalidTime.Close() 44 | events, err = dk.Get(tsInvalidTime.URL+"?", "summary", nickname, place) 45 | if err != nil { 46 | t.FailNow() 47 | } 48 | if !reflect.DeepEqual(eventInvalidTime, events[0]) { 49 | t.FailNow() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "golang.org/x/net/html" 8 | 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // Event represents details of an event. 14 | // Event is sent to Slack in this format. 15 | // Atcual event type depends on event api providers, so provider-specific event type is defined in the each file and converted to this Event. 16 | type Event struct { 17 | Id string 18 | Title string 19 | Summary string 20 | Url string 21 | Started_at string 22 | Place string 23 | Description string 24 | } 25 | 26 | // IsValid checks if the event is valid according to the place and date. 27 | func (event *Event) IsValid(place []string, taboo string) bool { 28 | if !event.isValidPlace(place) { 29 | return false 30 | } 31 | if !event.isValidDate() { 32 | return false 33 | } 34 | if !event.isValidDesc(taboo) { 35 | return false 36 | } 37 | return true 38 | } 39 | 40 | // isValidPlace checks if the place of the event is valid. 41 | // Valid palces are specified in the configuration file(conf.yml by default). 42 | func (event *Event) isValidPlace(place []string) bool { 43 | for _, p := range place { 44 | if strings.Contains(event.Place, p) { 45 | return true 46 | } 47 | } 48 | return false 49 | } 50 | 51 | // isValidDate checks if the date of the event is valid. 52 | func (event *Event) isValidDate() bool { 53 | t, err := time.Parse("2006-01-02 15:04", event.Started_at) 54 | if err != nil { 55 | fmt.Println(err.Error()) 56 | return false 57 | } 58 | if t.After(time.Now()) { 59 | return true 60 | } 61 | return false 62 | } 63 | 64 | // isValidDesc checks if the tile, summary or description is valid. 65 | func (event *Event) isValidDesc(taboo string) bool { 66 | for _, t := range strings.Split(taboo, ",") { 67 | if strings.Contains(strings.ToLower(event.Title), strings.ToLower(t)) { 68 | return false 69 | } 70 | if strings.Contains(strings.ToLower(event.Summary), strings.ToLower(t)) { 71 | return false 72 | } 73 | if strings.Contains(strings.ToLower(event.Description), strings.ToLower(t)) { 74 | return false 75 | } 76 | } 77 | return true 78 | } 79 | 80 | // contains checks if keyword is contained in either title or description. 81 | func (event *Event) contains(keyword string) bool { 82 | for _, q := range strings.Split(keyword, ",") { 83 | if strings.Contains(strings.ToLower(event.Title), strings.ToLower(q)) || 84 | strings.Contains(strings.ToLower(event.Summary), strings.ToLower(q)) || 85 | strings.Contains(strings.ToLower(event.Description), strings.ToLower(q)) { 86 | return true 87 | } 88 | } 89 | return false 90 | } 91 | 92 | // trim returns first 100 characters of string passed by the argument. 93 | func trim(s string) string { 94 | if len([]rune(s)) > 100 { 95 | return string([]rune(s)[:100]) + "..." 96 | } 97 | return s 98 | } 99 | 100 | // format formats date format for the common format used for Slack message. 101 | func format(date string) string { 102 | t, err := time.Parse(time.RFC3339Nano, date) 103 | if err != nil { 104 | return date 105 | } 106 | return t.In(timezone).Format(timeFormat) 107 | } 108 | 109 | // formatEpoch formats time in milliseconds in epoch to the common format used for Slack message. 110 | func formatEpoch(i int64) string { 111 | tm := time.Unix(i/1000, 0) 112 | return tm.In(timezone).Format(timeFormat) 113 | } 114 | 115 | // parse parses html to change it into plain text. 116 | func parse(h string) string { 117 | doc, err := html.Parse(strings.NewReader(h)) 118 | if err != nil { 119 | // just return original html in case of error 120 | return h 121 | } 122 | 123 | var buffer bytes.Buffer 124 | var f func(*html.Node, *bytes.Buffer) 125 | f = func(n *html.Node, buffer *bytes.Buffer) { 126 | if n.Type == html.TextNode { 127 | // this call might fail but ignore. 128 | buffer.WriteString(n.Data) 129 | } 130 | for c := n.FirstChild; c != nil; c = c.NextSibling { 131 | f(c, buffer) 132 | } 133 | } 134 | f(doc, &buffer) 135 | return buffer.String() 136 | } 137 | -------------------------------------------------------------------------------- /event/event_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/daikikohara/enotify-slack/event" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var validTestTable = []struct { 11 | event Event 12 | place []string 13 | taboo string 14 | result bool 15 | }{ 16 | { //OK 17 | Event{ 18 | Id: "id", 19 | Title: "title", 20 | Summary: "summary", 21 | Url: "url", 22 | Started_at: "9999-12-31 23:59", 23 | Place: "Tokyo", 24 | }, 25 | []string{"Tokyo", "place"}, 26 | "taboo", 27 | true, 28 | }, 29 | { //invalid place 30 | Event{ 31 | Id: "id", 32 | Title: "title", 33 | Summary: "summary", 34 | Url: "url", 35 | Started_at: "9999-12-31 23:59", 36 | Place: "Kanagawa", 37 | }, 38 | []string{"Tokyo", "place"}, 39 | "taboo", 40 | false, 41 | }, 42 | { // invalid date 43 | Event{ 44 | Id: "id", 45 | Title: "title", 46 | Summary: "summary", 47 | Url: "url", 48 | Started_at: "2013-12-31 23:59", 49 | Place: "Tokyo", 50 | }, 51 | []string{"Tokyo", "place"}, 52 | "taboo", 53 | false, 54 | }, 55 | { // contains taboo in title 56 | Event{ 57 | Id: "id", 58 | Title: "title,taboo", 59 | Summary: "summary", 60 | Url: "url", 61 | Started_at: "9999-12-31 23:59", 62 | Place: "Tokyo", 63 | }, 64 | []string{"Tokyo", "place"}, 65 | "taboo", 66 | false, 67 | }, 68 | { // contains taboo in summary 69 | Event{ 70 | Id: "id", 71 | Title: "title", 72 | Summary: "summary,taboo", 73 | Url: "url", 74 | Started_at: "9999-12-31 23:59", 75 | Place: "Tokyo", 76 | }, 77 | []string{"Tokyo", "place"}, 78 | "taboo", 79 | false, 80 | }, 81 | { // contains taboo in description 82 | Event{ 83 | Id: "id", 84 | Title: "title", 85 | Summary: "summary", 86 | Url: "url", 87 | Started_at: "9999-12-31 23:59", 88 | Place: "Tokyo", 89 | Description: "description foo", 90 | }, 91 | []string{"Tokyo", "place"}, 92 | "taboo,foo", 93 | false, 94 | }, 95 | { // date format is invalid 96 | Event{ 97 | Id: "id", 98 | Title: "title", 99 | Summary: "summary", 100 | Url: "url", 101 | Started_at: "12-31-9999 23:59", 102 | Place: "Tokyo", 103 | }, 104 | []string{"Tokyo", "place"}, 105 | "taboo", 106 | false, 107 | }, 108 | } 109 | 110 | func TestIsValid(t *testing.T) { 111 | assert := assert.New(t) 112 | for _, testcase := range validTestTable { 113 | assert.Equal(testcase.result, testcase.event.IsValid(testcase.place, testcase.taboo), "Event.IsValid test failed") 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /event/eventbrite.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mitchellh/mapstructure" 10 | ) 11 | 12 | // alphabet is a regex for a string which contains only alphabets and spaces. 13 | var alphabet = regexp.MustCompile(`(?i)^[a-z\s]+$`) 14 | 15 | // Eventbrite implements Api 16 | // Eventbrite represents event data returned by Eventbrite. 17 | type Eventbrite struct { 18 | result struct { 19 | Events []struct { 20 | Id string 21 | Name struct { 22 | Text string 23 | } 24 | Start struct { 25 | Utc string 26 | } 27 | Url string 28 | Description struct { 29 | Text string 30 | } 31 | Venue_id string 32 | } 33 | } 34 | } 35 | 36 | func (self *Eventbrite) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 37 | var events []Event 38 | t := time.Now().Format("2006-01-02T15:04:05Z") 39 | for _, param := range strings.Split(keyword, ",") { 40 | for _, city := range places { 41 | c := city 42 | if !alphabet.MatchString(city) { 43 | c = cityMap[city] 44 | if c == "" { 45 | continue 46 | } 47 | } 48 | query := "q=" + param + "&sort_by=date" + "&start_date.range_start=" + t + "&venue.city=" + url.QueryEscape(c) 49 | result, err := GetJson(baseurl + query) 50 | if err != nil { 51 | return nil, err 52 | } 53 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 54 | return nil, err 55 | } 56 | for _, e := range self.result.Events { 57 | event := Event{ 58 | Id: e.Id, 59 | Title: e.Name.Text, 60 | Started_at: format(e.Start.Utc), 61 | Url: e.Url, 62 | Summary: trim(e.Description.Text), 63 | Place: city, 64 | Description: e.Description.Text, 65 | } 66 | events = append(events, event) 67 | } 68 | time.Sleep(time.Duration(1 * time.Second)) 69 | } 70 | } 71 | return events, nil 72 | } 73 | -------------------------------------------------------------------------------- /event/eventbrite_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetEventbrite(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"events": [{"name":{"text":"example#1","html":"DockerConSFHackathon"},"description":{"text":"summary123","html":""},"id":"123","url":"http://example.connpass.com/event/123/","start":{"timezone":"America/Los_Angeles","local":"2015-06-20T09:30:00","utc":"2015-09-30T10:00:00Z"},"venue":{"address":{"address_1":"address123","address_2":null,"city":"place123","region":"CA","postal_code":"94103","country":"US","latitude":37.7850596,"longitude":-122.40419989999998},"resource_uri":"https://www.eventbriteapi.com/v3/venues/9687712/","id":"9687712","name":"SanFranciscoMarriottMarquis","latitude":"37.7850596","longitude":"-122.40419989999998"}}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | eventbrite := new(Eventbrite) 22 | events, err := eventbrite.Get(tsSuccess.URL+"?", keyword, nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventbriteok, events[0]) { 27 | t.Error("eventbrite event is not ok!") 28 | } 29 | 30 | // different timezone 31 | SetTimezone("America/Los_Angeles") 32 | events, err = eventbrite.Get(tsSuccess.URL+"?", keyword, nickname, place) 33 | if err != nil { 34 | t.Error(err.Error()) 35 | } 36 | e := eventbriteok 37 | e.Started_at = "2015-09-30 02:00" 38 | if !reflect.DeepEqual(e, events[0]) { 39 | // daylight saving 40 | e.Started_at = "2015-09-30 03:00" 41 | if !reflect.DeepEqual(e, events[0]) { 42 | t.Error("eventbrite event is not ok!") 43 | } 44 | } 45 | 46 | // invalid url 47 | events, err = eventbrite.Get("", keyword, nickname, place) 48 | if err == nil { 49 | t.Error(err.Error()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /event/meetup.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | // Meetup implements Api 11 | // Meetup represents event data returned by Meetup. 12 | type Meetup struct { 13 | result struct { 14 | Results []struct { 15 | Id string 16 | Name string 17 | Event_url string 18 | Time int64 19 | Description string 20 | Venue struct { 21 | City string 22 | Address_1 string 23 | } 24 | } 25 | } 26 | } 27 | 28 | func (self *Meetup) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 29 | var events []Event 30 | for _, param := range strings.Split(keyword, ",") { 31 | query := "topic=" + param 32 | result, err := GetJson(baseurl + query) 33 | if err != nil { 34 | return nil, err 35 | } 36 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 37 | return nil, err 38 | } 39 | for _, e := range self.result.Results { 40 | event := Event{ 41 | Id: e.Id, 42 | Title: e.Name, 43 | Started_at: formatEpoch(e.Time), 44 | Url: e.Event_url, 45 | Place: e.Venue.Address_1 + "\n" + e.Venue.City, 46 | Description: parse(e.Description), 47 | } 48 | event.Summary = trim(event.Description) 49 | events = append(events, event) 50 | } 51 | time.Sleep(time.Duration(1 * time.Second)) 52 | } 53 | return events, nil 54 | } 55 | -------------------------------------------------------------------------------- /event/meetup_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetMeetup(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"results":[{"utc_offset":-25200000,"venue":{"country":"us","city":"place123","address_1":"address123","name":"Hacker Dojo (event room)","lon":-122.107498,"id":123,"state":"CA","lat":37.411819,"repinned":false},"rsvp_limit":140,"headcount":0,"visibility":"public","waitlist_count":75,"created":1430850935000,"maybe_rsvp_count":0,"description":"summary123","event_url":"http://example.connpass.com/event/123/","yes_rsvp_count":140,"name":"example#1","id":"123","time":1443607200000,"updated":1431026798000,"group":{"join_mode":"open","created":1413333080000,"name":"Docker Networking","group_lon":-122.1500015258789,"id":17629502,"urlname":"Docker-Networking","group_lat":37.439998626708984,"who":"Members"},"status":"upcoming"}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | meetup := new(Meetup) 22 | events, err := meetup.Get(tsSuccess.URL+"?", keyword, nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Error("meetup event is not ok!") 28 | } 29 | 30 | // different timezone 31 | SetTimezone("America/Los_Angeles") 32 | events, err = meetup.Get(tsSuccess.URL+"?", keyword, nickname, place) 33 | if err != nil { 34 | t.Error(err.Error()) 35 | } 36 | e := eventok 37 | e.Started_at = "2015-09-30 02:00" 38 | if !reflect.DeepEqual(e, events[0]) { 39 | // daylight saving 40 | e.Started_at = "2015-09-30 03:00" 41 | if !reflect.DeepEqual(e, events[0]) { 42 | t.Error("meetup event is not ok!") 43 | } 44 | } 45 | 46 | // invalid url 47 | events, err = meetup.Get("", keyword, nickname, place) 48 | if err == nil { 49 | t.Error(err.Error()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /event/strtacademy.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | // Strtacademy implements Api 11 | // Strtacademy represents event data returned by street academy API. 12 | type Strtacademy struct { 13 | result struct { 14 | Events []struct { 15 | Event_id string 16 | Title string 17 | Details string 18 | Url string 19 | Start_at string 20 | Address string 21 | Venue string 22 | } 23 | } 24 | } 25 | 26 | func (self *Strtacademy) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 27 | var events []Event 28 | for i := 1; i <= 10; i++ { 29 | result, err := GetJson(baseurl + strconv.Itoa(i)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 34 | return nil, err 35 | } 36 | for _, strtacademy := range self.result.Events { 37 | event := Event{ 38 | Id: strtacademy.Event_id, 39 | Title: strtacademy.Title, 40 | Started_at: format(strtacademy.Start_at), 41 | Url: strtacademy.Url, 42 | Summary: trim(strtacademy.Details), 43 | Place: strtacademy.Address + "\n" + strtacademy.Venue, 44 | Description: strtacademy.Details, 45 | } 46 | if !event.contains(keyword) { 47 | continue 48 | } 49 | events = append(events, event) 50 | } 51 | time.Sleep(time.Duration(1 * time.Second)) 52 | } 53 | return events, nil 54 | } 55 | -------------------------------------------------------------------------------- /event/strtacademy_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetStracademy(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"events": [{"title":"example#1","event_id":123,"start_at":"2015-09-30T19:00:00+09:00","end_at":"2014-12-17T12:00:00.000Z","venue":"place123","address":"address123","details":"summary123","url":"http://example.connpass.com/event/123/"},{"title":"example#1","event_id":123,"start_at":"2015-09-30T19:00:00+09:00","end_at":"2014-12-17T12:00:00.000Z","venue":"place123","address":"address123","details":"summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoolong","url":"http://example.connpass.com/event/123/"},{"title":"example#1","event_id":123,"start_at":"2015-09-30T19:00:00+09:00","end_at":"2014-12-17T12:00:00.000Z","venue":"place123","address":"address123","details":"toolong","url":"http://example.connpass.com/event/123/"}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | strt := new(Strtacademy) 22 | events, err := strt.Get(tsSuccess.URL+"?", "summary", nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Error("Strtacademy return value is unexpected. expected and actual are") 28 | t.Error(eventok) 29 | t.Error(events[0]) 30 | } 31 | if !reflect.DeepEqual(eventoklong, events[1]) { 32 | t.Error("Strtacademy return value is unexpected. expected and actual are") 33 | t.Error(eventoklong) 34 | t.Error(events[1]) 35 | } 36 | if len(events) != 20 { 37 | // first 2 elements of tsSuccess. and strt.Get() loops over 10 pages, so 20 in total. 38 | fmt.Println("length does not match!") 39 | t.Fail() 40 | } 41 | 42 | // invalid url 43 | events, err = strt.Get("", keyword, nickname, place) 44 | if err == nil { 45 | t.Error(err.Error()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /event/zusaar.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "net/url" 5 | "time" 6 | 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | // Zusaar implements Api 11 | // Zusaar represents event data returned by Zusaar Api. 12 | type Zusaar struct { 13 | result struct { 14 | Results_returned int 15 | Event []struct { 16 | Event_id string 17 | Title string 18 | Catch string 19 | Event_url string 20 | Started_at string 21 | Address string 22 | Place string 23 | Description string 24 | } 25 | } 26 | } 27 | 28 | func (self *Zusaar) Get(baseurl, keyword, nickname string, places []string) ([]Event, error) { 29 | var events []Event 30 | for _, param := range []string{"keyword_or=" + url.QueryEscape(keyword), "owner_nickname=" + nickname, "nickname=" + nickname} { 31 | for t := time.Now().Local(); t.Before(time.Now().Local().AddDate(0, 3, 0)); t = t.AddDate(0, 1, 0) { 32 | query := param + "&ym=" + t.Format("200601") 33 | result, err := GetJson(baseurl + query) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if err = mapstructure.WeakDecode(result, &self.result); err != nil { 38 | return nil, err 39 | } 40 | for _, e := range self.result.Event { 41 | event := Event{ 42 | Id: e.Event_id, 43 | Title: e.Title, 44 | Started_at: format(e.Started_at), 45 | Url: e.Event_url, 46 | Summary: e.Catch, 47 | Place: e.Address + "\n" + e.Place, 48 | Description: parse(e.Description), 49 | } 50 | if event.Summary == "" { 51 | event.Summary = trim(event.Description) 52 | } 53 | events = append(events, event) 54 | } 55 | time.Sleep(time.Duration(1 * time.Second)) 56 | } 57 | } 58 | return events, nil 59 | } 60 | -------------------------------------------------------------------------------- /event/zusaar_test.go: -------------------------------------------------------------------------------- 1 | package event_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | . "github.com/daikikohara/enotify-slack/event" 11 | ) 12 | 13 | func TestGetZusaar(t *testing.T) { 14 | SetTimezone("Asia/Tokyo") 15 | // success 16 | tsSuccess := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Set("Content-Type", "application/json") 18 | fmt.Fprintln(w, `{"event":[{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "2015-09-30T19:00:00+09:00", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": "summary123"},{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "2015-09-30T19:00:00+09:00", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123toolongtoolongtoolongtoolongtoolongtoolongsummary123toolongtoolongtoolongtoolongtoolongtoolong", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": ""}]}`) 19 | })) 20 | defer tsSuccess.Close() 21 | zusaar := new(Zusaar) 22 | events, err := zusaar.Get(tsSuccess.URL+"?", keyword, nickname, place) 23 | if err != nil { 24 | t.Error(err.Error()) 25 | } 26 | if !reflect.DeepEqual(eventok, events[0]) { 27 | t.Fail() 28 | } 29 | 30 | // invalid url 31 | events, err = zusaar.Get("", keyword, nickname, place) 32 | if err == nil { 33 | t.Fail() 34 | } 35 | 36 | // invalid time format 37 | tsInvalidTime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | w.Header().Set("Content-Type", "application/json") 39 | fmt.Fprintln(w, `{"event":[{"event_url": "http://example.connpass.com/event/123/", "event_type": "advertisement", "owner_nickname": "nickname1", "series": {"url": "http://example.connpass.com/", "id": 1, "title": "example"}, "updated_at": "2014-11-30T10:44:20+09:00", "lat": 35.646470900000, "started_at": "20150930T190000.0000900", "hash_tag": "example", "title": "example#1", "event_id": 123, "lon": 139.706581400000, "waiting": 0, "limit": 60, "owner_id": 8, "owner_display_name": "nickname1", "description": "summary123", "accepted": 0, "ended_at": "2015-09-30T21:00:00+09:00", "place": "place123", "address": "address123", "catch": "summary123"}]}`) 40 | })) 41 | defer tsInvalidTime.Close() 42 | events, err = zusaar.Get(tsInvalidTime.URL+"?", keyword, nickname, place) 43 | if err != nil { 44 | t.Fail() 45 | } 46 | if !reflect.DeepEqual(eventInvalidTime, events[0]) { 47 | t.FailNow() 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // Logger holds log.Logger and os.File 9 | type Logger struct { 10 | logger *log.Logger 11 | file *os.File 12 | } 13 | 14 | // NewLogger constructs Logger and returns it. 15 | // This function opens a log file defined in the configuration file(yaml) and create log.Logger according to the log file. 16 | func NewLogger(path string) *Logger { 17 | file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 18 | if err != nil { 19 | log.Panic("can't open", path) 20 | } 21 | logger := log.New(file, "event-notify-log: ", log.Lshortfile|log.LstdFlags) 22 | return &Logger{logger, file} 23 | } 24 | 25 | // Close closes a file held by Logger. 26 | func (self *Logger) Close() { 27 | err := self.file.Close() 28 | if err != nil { 29 | log.Println(err.Error()) 30 | } 31 | } 32 | 33 | // Println prints a message to console and Logger.file 34 | func (self *Logger) Println(msg string) { 35 | log.Println(msg) 36 | self.logger.Println(msg) 37 | } 38 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | . "github.com/daikikohara/enotify-slack" 10 | ) 11 | 12 | const logMessage = "test message" 13 | 14 | func TestPrintln(t *testing.T) { 15 | logFile, err := ioutil.TempFile(os.TempDir(), "event-notify-test.log") 16 | if err != nil { 17 | t.Error(err.Error()) 18 | } 19 | defer os.Remove(logFile.Name()) 20 | 21 | // success 22 | logger := NewLogger(logFile.Name()) 23 | logger.Println(logMessage) 24 | 25 | b, err := ioutil.ReadFile(logFile.Name()) 26 | if err != nil { 27 | t.Error(err.Error()) 28 | } 29 | if !strings.Contains(string(b), logMessage) { 30 | t.Error("log file doesn't contain log message") 31 | } 32 | 33 | logger.Close() 34 | 35 | // failure 36 | defer func() { 37 | if r := recover(); r == nil { 38 | t.Fail() 39 | } 40 | }() 41 | logger = NewLogger("/path/to/noexist") 42 | } 43 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Daiki Kohara 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "io/ioutil" 21 | "net/http" 22 | "os" 23 | "os/signal" 24 | "runtime" 25 | "strings" 26 | "syscall" 27 | "time" 28 | 29 | . "github.com/daikikohara/enotify-slack/event" 30 | ) 31 | 32 | var ( 33 | config = NewConfig("conf.yml") 34 | logger = NewLogger(config.Logfile) 35 | boltdb = NewBolt(config.Boltdb.Dbfile, config.Boltdb.Bucketname) 36 | ) 37 | 38 | func init() { 39 | runtime.GOMAXPROCS(runtime.NumCPU()) 40 | SetTimezone(config.Timezone) 41 | } 42 | 43 | func main() { 44 | payload := make(chan *Payload) 45 | for provider, _ := range config.Provider { 46 | go getEvent(provider, payload) 47 | } 48 | go sendEvent(payload) 49 | shutdownHook() 50 | } 51 | 52 | // getEvent gets events from event providers. 53 | // API depends on the provider, so the actual implementation to get events is obtained via GetApi(factory method). 54 | func getEvent(provider string, payload chan *Payload) { 55 | api := GetApi(provider) 56 | for { 57 | events, err := api.Get(config.Provider[provider].Url, config.Keyword, config.Nickname, config.Place) 58 | handleError("Error on getting event from "+provider, err, payload) 59 | for _, event := range events { 60 | key := []byte(provider + ":" + fmt.Sprintf("%v", event.Id)) 61 | if event.IsValid(config.Place, config.Taboo) && !boltdb.Exists(key) { 62 | value, _ := json.Marshal(event) 63 | boltdb.Put(key, value) 64 | payload <- ConstructSlackMessage(provider, &event, config) 65 | } 66 | } 67 | time.Sleep(time.Duration(config.Provider[provider].Interval) * time.Second) 68 | } 69 | } 70 | 71 | // handleError handles error. 72 | func handleError(pretext string, err error, errorMsg chan *Payload) { 73 | if err != nil { 74 | msg := pretext + "\n" + err.Error() 75 | logger.Println(msg) 76 | if config.ErrorToSlack { 77 | errorMsg <- ConstructSlackError(msg, config.Slack.Channel) 78 | } 79 | } 80 | } 81 | 82 | // sendEvent sends Payload which contains Event to Slack. 83 | // this function should be called by only one goroutine to avoid the Slack rate limit(1 payload/sec) 84 | func sendEvent(payload chan *Payload) { 85 | for { 86 | p := <-payload 87 | data, err := json.Marshal(p) 88 | resp, err := http.Post(config.Slack.Url, "application/json", strings.NewReader(string(data))) 89 | if err != nil { 90 | logger.Println(err.Error()) 91 | } 92 | defer resp.Body.Close() 93 | _, err = ioutil.ReadAll(resp.Body) 94 | if err != nil { 95 | logger.Println(err.Error()) 96 | } 97 | time.Sleep(time.Duration(config.Slack.Interval) * time.Second) 98 | } 99 | } 100 | 101 | // shutdownHook handles OS signals. 102 | // It catches SIGINT and SIGTERM to close resources and then exits the process. 103 | func shutdownHook() { 104 | sigs := make(chan os.Signal, 1) 105 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 106 | <-sigs 107 | boltdb.Db.Close() 108 | logger.Close() 109 | fmt.Println("bye") 110 | os.Exit(0) 111 | } 112 | -------------------------------------------------------------------------------- /slack.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import . "github.com/daikikohara/enotify-slack/event" 4 | 5 | // Payload represents a payload sent to Slack. 6 | // The values are sent to Slack via incoming-webhook. 7 | // See - https://my.slack.com/services/new/incoming-webhook 8 | type Payload struct { 9 | Channel string `json:"channel"` 10 | Username string `json:"username"` 11 | Text string `json:"text"` 12 | Icon_emoji string `json:"icon_emoji"` 13 | Unfurl_links bool `json:"unfurl_links"` 14 | Attachments []Attachment `json:"attachments"` 15 | } 16 | 17 | // Attachment is an attachment to Payload. 18 | // The format is defined in Slack Api document. 19 | // See - https://api.slack.com/docs/attachments 20 | type Attachment struct { 21 | Fallback string `json:"fallback"` 22 | Pretext string `json:"pretext"` 23 | Color string `json:"color"` 24 | Fields []Field `json:"fields"` 25 | } 26 | 27 | // Field is a field to Attachment. 28 | // Like Attachment, the format is defined in Slack Api document. 29 | // see - https://api.slack.com/docs/attachments 30 | type Field struct { 31 | Title string `json:"title"` 32 | Value string `json:"value"` 33 | Short bool `json:"short"` 34 | } 35 | 36 | // ConstructSlackMessage constructs a message sent to Slack. 37 | // Each message contains a detail of an event gotten by GetEvent function. 38 | func ConstructSlackMessage(provider string, event *Event, config *Config) *Payload { 39 | payload := Payload{} 40 | fields := []Field{ 41 | Field{ 42 | Title: "Title", 43 | Value: event.Title, 44 | Short: config.Slack.Short, 45 | }, 46 | Field{ 47 | Title: "Time", 48 | Value: event.Started_at, 49 | Short: config.Slack.Short, 50 | }, 51 | Field{ 52 | Title: "Place", 53 | Value: event.Place, 54 | Short: config.Slack.Short, 55 | }, 56 | Field{ 57 | Title: "Url", 58 | Value: event.Url, 59 | Short: config.Slack.Short, 60 | }, 61 | Field{ 62 | Title: "Summary", 63 | Value: event.Summary, 64 | Short: config.Slack.Short, 65 | }, 66 | } 67 | 68 | attachment := Attachment{ 69 | Fallback: "New event arrived from " + provider, 70 | Pretext: config.Slack.Pretext, 71 | Color: config.Provider[provider].Color, 72 | Fields: fields, 73 | } 74 | 75 | payload.Channel = config.Slack.Channel 76 | payload.Username = provider + "-bot" 77 | payload.Icon_emoji = ":" + provider + ":" 78 | payload.Unfurl_links = true 79 | payload.Text = "" 80 | payload.Attachments = []Attachment{attachment} 81 | return &payload 82 | } 83 | 84 | // ConstructSlackError constructs an error message sent to Slack. 85 | func ConstructSlackError(msg string, channel string) *Payload { 86 | fields := []Field{ 87 | Field{ 88 | Title: "Detail", 89 | Value: msg, 90 | }, 91 | } 92 | 93 | attachment := Attachment{ 94 | Fallback: "Error occured on enotify-slack", 95 | Pretext: "Error occured on enotify-slack", 96 | Color: "#FF0000", 97 | Fields: fields, 98 | } 99 | 100 | payload := Payload{} 101 | payload.Channel = channel 102 | payload.Username = "notify-error" 103 | payload.Icon_emoji = ":persevere:" 104 | payload.Unfurl_links = true 105 | payload.Attachments = []Attachment{attachment} 106 | payload.Text = "" 107 | 108 | return &payload 109 | } 110 | -------------------------------------------------------------------------------- /slack_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | . "github.com/daikikohara/enotify-slack" 10 | . "github.com/daikikohara/enotify-slack/event" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var event = &Event{ 15 | Id: "id", 16 | Title: "title", 17 | Summary: "summary", 18 | Url: "url", 19 | Started_at: "9999-12-31 23:59", 20 | Place: "Tokyo", 21 | } 22 | 23 | var payloadok = &Payload{ 24 | Channel: "#notify-channel", 25 | Username: "connpass-bot", 26 | Text: "", 27 | Icon_emoji: ":connpass:", 28 | Unfurl_links: true, 29 | Attachments: []Attachment{ 30 | Attachment{ 31 | Fallback: "New event arrived from connpass", 32 | Pretext: "slackpretext", 33 | Color: "#D00000", 34 | Fields: []Field{ 35 | Field{ 36 | Title: "Title", 37 | Value: "title", 38 | Short: false, 39 | }, 40 | Field{ 41 | Title: "Time", 42 | Value: "9999-12-31 23:59", 43 | Short: false, 44 | }, 45 | Field{ 46 | Title: "Place", 47 | Value: "Tokyo", 48 | Short: false, 49 | }, 50 | Field{ 51 | Title: "Url", 52 | Value: "url", 53 | Short: false, 54 | }, 55 | Field{ 56 | Title: "Summary", 57 | Value: "summary", 58 | Short: false, 59 | }, 60 | }, 61 | }, 62 | }, 63 | } 64 | 65 | func TestConstructSlackMessage(t *testing.T) { 66 | // initialize conf file 67 | file, err := ioutil.TempFile(os.TempDir(), "enotify-slack-config.yml") 68 | if err != nil { 69 | t.Error(err.Error()) 70 | } 71 | defer os.Remove(file.Name()) 72 | ioutil.WriteFile(file.Name(), []byte(validConf), 0644) 73 | conf := NewConfig(file.Name()) 74 | payload := ConstructSlackMessage("connpass", event, conf) 75 | 76 | if !reflect.DeepEqual(payload, payloadok) { 77 | t.Log("payload doesn't match") 78 | t.Log(payload) 79 | t.Log(payloadok) 80 | t.Fail() 81 | } 82 | } 83 | 84 | func TestConstructSlackError(t *testing.T) { 85 | 86 | payload := ConstructSlackError("error message", "#test-channel") 87 | 88 | // assert contents of slack payload 89 | assert := assert.New(t) 90 | assert.Equal("#test-channel", payload.Channel, "ConstructSlackError parse error: channel") 91 | assert.Equal(":persevere:", payload.Icon_emoji, "ConstructSlackError parse error: icon_emoji") 92 | assert.Equal("", payload.Text, "ConstructSlackError parse error: text") 93 | assert.True(payload.Unfurl_links, "ConstructSlackError parse error: unfurl_links") 94 | assert.Equal("notify-error", payload.Username, "ConstructSlackError parse error: user name") 95 | assert.Equal("Detail", payload.Attachments[0].Fields[0].Title, "ConstructSlackError parse error: attachment/field/title") 96 | assert.Equal("error message", payload.Attachments[0].Fields[0].Value, "ConstructSlackError parse error: attachment/field/value") 97 | } 98 | --------------------------------------------------------------------------------