├── .gitignore ├── Procfile ├── README.md ├── accesslogs └── accesslogs.go ├── collector └── collector.go ├── config └── config.go ├── entries └── entries.go ├── present.go ├── slack ├── incomming │ └── incomming.go └── outgoing │ └── outgoing.go ├── tags └── tags.go └── web └── web.go /.gitignore: -------------------------------------------------------------------------------- 1 | !tags 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: present 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Present 2 | 3 | はてなブックマークから集めた人気の新着URLを、定期的にお知らせするSlack用のbotプログラムです。 4 | 5 | - はてなブックマークを検索して指定したタグの新着URLを取得 6 | - 適度に人気で新しいURLを選んで定期的にSlackのチャンネルに発言 7 | - Slackのチャンネルで人間が会話している時には遠慮して発言しない 8 | - Slackのチャンネルで人間が会話していない時には発言しすぎない 9 | 10 | ## 起動 11 | 12 | 1. MySQLに新しいデータベースを作る 13 | 2. botが常駐するチャンネルを選んでSlackのintegrationを設定する 14 | - SlackのIncomming WebHooksを設定する 15 | - "Post to channel"をbotが常駐するチャンネルを設定 16 | - SlackのOutgoing WebHooksを設定する 17 | - "Channel"にbotが常駐するチャンネルを設定 18 | - "URL(s)"にこのbotのhook APIのURLを設定 (例: http://example.com:8080/hook) 19 | 3. 環境変数に設定を与えて起動する 20 | 21 | ```sh 22 | $ go get github.com/hakobe/present 23 | $ PRESENT_SLACK_INCOMMING_URL="https://hooks.slack.com/services/ABCD1234/EFGH5679/abcdefghijk123456" \ 24 | PRESENT_DB_DSN="id:pass@tcp(mysqldhost:3306)/dbname?parseTime=true&charset=utf8" \ 25 | PRESENT_NAME=engineerkun \ # コマンドを実行するときに呼ぶbotの名前 26 | PRESENT_WAIT=900 \ # URLを発言する頻度(秒) 27 | PRESENT_NOOP_LIMIT=3 \ # この回数だけ連続して発言したら一時停止する 28 | PORT="8080" \ # WebHooksを待ち受けるHTTPサーバのポート 29 | $GOPATH/bin/present 30 | ``` 31 | 32 | ## 使い方 33 | 34 | - 予めタグを設定したあと、ほうっておくと設定したタグではてなブックマークを検索し、`PRESENT_WAIT`で設定した秒数ごとにURLを発言します 35 | - `PRESENT_NAME`に設定した文字列のあとにコマンドを続けて発言することで、botに指示を与えることができます 36 | 37 | ### コマンド一覧 38 | `PRESENT_NAME`に`engineerkun`と設定した場合 39 | 40 | - `engineerkun tags` 41 | - 現在検索対象に設定されているタグの一覧を表示する 42 | - `engineerkun add ` 43 | - <tag> を検索対象に追加する 44 | - `engineerkun del ` 45 | - <tag> を検索対象から削除する 46 | - `engineerkun plz` 47 | - URLを強制的に発言させる 48 | - `engineerkun help` 49 | - ヘルプを表示する 50 | 51 | 52 | -------------------------------------------------------------------------------- /accesslogs/accesslogs.go: -------------------------------------------------------------------------------- 1 | package accesslogs 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | func Prepare(db *sql.DB) error { 9 | sql := ` 10 | CREATE TABLE IF NOT EXISTS accesslogs ( 11 | entry_id INT UNSIGNED NOT NULL, 12 | created TIMESTAMP NOT NULL, 13 | KEY(created), 14 | KEY(entry_id) 15 | ) ENGINE=InnoDB DEFAULT CHARSET=binary 16 | ` 17 | _, err := db.Exec(sql) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | return nil 23 | } 24 | 25 | func Access(db *sql.DB, entryID int) error { 26 | sql := ` 27 | INSERT INTO accesslogs 28 | (entry_id, created) VALUES ( ?, ? ) 29 | ` 30 | _, err := db.Exec(sql, entryID, time.Now()) 31 | if err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "encoding/xml" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | func feedUrl(tag string) string { 13 | queries := url.Values{} 14 | queries.Add("safe", "on") 15 | queries.Add("mode", "rss") 16 | queries.Add("users", "5") 17 | queries.Add("q", tag) 18 | return "http://b.hatena.ne.jp/search/tag?" + queries.Encode() 19 | } 20 | 21 | func fetch(url string) ([]byte, error) { 22 | resp, err := http.Get(url) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer resp.Body.Close() 27 | 28 | body, err := ioutil.ReadAll(resp.Body) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return body, nil 34 | } 35 | 36 | type RssFeed struct { 37 | XMLName xml.Name `xml:"RDF"` 38 | Url string 39 | Title string `xml:"channel>title"` 40 | RssEntries []*RssEntry `xml:"item"` 41 | } 42 | 43 | type RssEntry struct { 44 | XMLName xml.Name `xml:"item"` 45 | RawTitle string `xml:"title"` 46 | RawUrl string `xml:"link"` 47 | RawDescription string `xml:"description"` 48 | RawDate string `xml:"http://purl.org/dc/elements/1.1/ date"` 49 | tag string 50 | } 51 | 52 | func (entry *RssEntry) ID() int { 53 | return -1 54 | } 55 | 56 | func (entry *RssEntry) Title() string { 57 | return entry.RawTitle 58 | } 59 | 60 | func (entry *RssEntry) Url() string { 61 | return entry.RawUrl 62 | } 63 | 64 | func (entry *RssEntry) Description() string { 65 | return entry.RawDescription 66 | } 67 | 68 | func (entry *RssEntry) Date() time.Time { 69 | t, err := time.Parse("2006-01-02T15:04:05-07:00", entry.RawDate) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | return t 74 | } 75 | 76 | func (entry *RssEntry) Tag() string { 77 | return entry.tag 78 | } 79 | 80 | func parseRss(data []byte) (*RssFeed, error) { 81 | var v RssFeed 82 | err := xml.Unmarshal(data, &v) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return &v, nil 87 | } 88 | 89 | func fetchRss(url string) (*RssFeed, error) { 90 | data, err := fetch(url) 91 | if err != nil { 92 | return nil, err 93 | } 94 | feed, err := parseRss(data) 95 | if err != nil { 96 | return nil, err 97 | } 98 | feed.Url = url 99 | 100 | return feed, nil 101 | } 102 | 103 | func Start() (<-chan *RssEntry, chan<- []string) { 104 | ticker := time.Tick(10 * time.Minute) 105 | out := make(chan *RssEntry) 106 | newTags := make(chan []string) 107 | 108 | collect := func(tags []string, out chan *RssEntry) { 109 | for _, tag := range tags { 110 | go func(tag string) { 111 | feed, err := fetchRss(feedUrl(tag)) 112 | 113 | if err != nil { 114 | log.Print(err) 115 | return 116 | } 117 | for _, entry := range feed.RssEntries { 118 | log.Printf("Queued entry: %s\n", entry.Title()) 119 | entry.tag = tag 120 | out <- entry 121 | } 122 | }(tag) 123 | } 124 | } 125 | 126 | go func() { 127 | tags := make([]string, 0) 128 | for { 129 | select { 130 | case <-ticker: 131 | collect(tags, out) 132 | case ts := <-newTags: 133 | tags = ts 134 | log.Printf("New tags: %s\n", tags) 135 | collect(tags, out) 136 | } 137 | } 138 | }() 139 | 140 | return out, newTags 141 | } 142 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strconv" 7 | ) 8 | 9 | var DbDsn string = os.Getenv("PRESENT_DB_DSN") 10 | 11 | var SlackIncomingWebhookUrl string = os.Getenv("PRESENT_SLACK_INCOMMING_URL") 12 | 13 | var Names []string = []string{"present"} 14 | var Wait int = 15 * 60 15 | var NoopLimit int = 0 16 | var RankingsHour int = -1 17 | var AccesslogUrlBase string = "" 18 | 19 | func init() { 20 | 21 | names := os.Getenv("PRESENT_NAME") 22 | if names != "" { 23 | Names = regexp.MustCompile(",").Split(names, -1) 24 | } 25 | 26 | wait := os.Getenv("PRESENT_WAIT") 27 | if w, err := strconv.Atoi(wait); err == nil { 28 | Wait = w 29 | } 30 | 31 | noopLimit := os.Getenv("PRESENT_NOOP_LIMIT") 32 | if n, err := strconv.Atoi(noopLimit); err == nil { 33 | NoopLimit = n 34 | } 35 | 36 | rankingsHour := os.Getenv("PRESENT_RANKINGS_HOUR") 37 | if r, err := strconv.Atoi(rankingsHour); err == nil { 38 | RankingsHour = r 39 | } 40 | 41 | AccesslogUrlBase = os.Getenv("PRESENT_ACCESSLOG_URL_BASE") 42 | } 43 | -------------------------------------------------------------------------------- /entries/entries.go: -------------------------------------------------------------------------------- 1 | package entries 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "time" 7 | ) 8 | 9 | type Entry interface { 10 | ID() int 11 | Title() string 12 | Url() string 13 | Description() string 14 | Date() time.Time 15 | Tag() string 16 | } 17 | 18 | type DbEntry struct { 19 | id int 20 | title string 21 | url string 22 | description string 23 | date time.Time 24 | tag string 25 | } 26 | 27 | func (entry *DbEntry) ID() int { 28 | return entry.id 29 | } 30 | 31 | func (entry *DbEntry) Title() string { 32 | return entry.title 33 | } 34 | 35 | func (entry *DbEntry) Url() string { 36 | return entry.url 37 | } 38 | 39 | func (entry *DbEntry) Description() string { 40 | return entry.description 41 | } 42 | 43 | func (entry *DbEntry) Date() time.Time { 44 | return entry.date 45 | } 46 | 47 | func (entry *DbEntry) Tag() string { 48 | return entry.tag 49 | } 50 | 51 | type RankedEntry struct { 52 | entry Entry 53 | accessCount int 54 | } 55 | 56 | func (rankedEntry *RankedEntry) Entry() Entry { 57 | return rankedEntry.entry 58 | } 59 | 60 | func (rankedEntry *RankedEntry) AccessCount() int { 61 | return rankedEntry.accessCount 62 | } 63 | 64 | func Prepare(db *sql.DB) error { 65 | sql := ` 66 | CREATE TABLE IF NOT EXISTS entries ( 67 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 68 | url VARCHAR(1024) NOT NULL, 69 | title VARCHAR(255) NOT NULL, 70 | description TEXT NOT NULL, 71 | date TIMESTAMP NOT NULL, 72 | has_posted BOOL NOT NULL, 73 | tag VARCHAR(255) NOT NULL, 74 | created TIMESTAMP NOT NULL, 75 | PRIMARY KEY(id), 76 | KEY(has_posted, created, date) 77 | ) ENGINE=InnoDB DEFAULT CHARSET=binary 78 | ` 79 | _, err := db.Exec(sql) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func Add(db *sql.DB, entry Entry) error { 88 | tx, err := db.Begin() 89 | if err != nil { 90 | return err 91 | } 92 | // XXX maybe this cause table lock 93 | lockSql := ` 94 | SELECT id FROM entries 95 | WHERE 96 | url = ? 97 | FOR UPDATE 98 | ` 99 | var id int 100 | err = tx.QueryRow(lockSql, entry.Url()).Scan(&id) 101 | switch { 102 | case err == sql.ErrNoRows: 103 | // nop, ok 104 | case err != nil: 105 | tx.Rollback() 106 | return err 107 | default: 108 | tx.Rollback() 109 | log.Println("Entry has already fetched.") 110 | return nil 111 | } 112 | 113 | insertSql := ` 114 | INSERT INTO entries 115 | (url, title, description, date, has_posted, tag, created) 116 | VALUES 117 | ( ?, ?, ?, ?, ?, ?, ? ) 118 | ` 119 | _, err = tx.Exec(insertSql, entry.Url(), entry.Title(), entry.Description(), entry.Date(), false, entry.Tag(), time.Now()) 120 | if err != nil { 121 | tx.Rollback() 122 | return err 123 | } 124 | tx.Commit() 125 | return nil 126 | } 127 | 128 | func Next(db *sql.DB) (*DbEntry, error) { 129 | tx, err := db.Begin() 130 | if err != nil { 131 | return nil, err 132 | } 133 | fetchSql := ` 134 | SELECT id, url, title, description, date, tag FROM entries 135 | WHERE 136 | NOT has_posted AND 137 | created > DATE_SUB(NOW(), INTERVAL 3 DAY) 138 | ORDER BY date DESC 139 | LIMIT 1 140 | FOR UPDATE 141 | ` 142 | entry := &DbEntry{} 143 | err = tx.QueryRow(fetchSql).Scan( 144 | &(entry.id), 145 | &(entry.url), 146 | &(entry.title), 147 | &(entry.description), 148 | &(entry.date), 149 | &(entry.tag), 150 | ) 151 | if err != nil { 152 | tx.Rollback() 153 | return nil, err 154 | } 155 | updateSql := ` 156 | UPDATE entries SET has_posted = true 157 | WHERE 158 | id = ? AND has_posted = false 159 | LIMIT 1 160 | ` 161 | _, err = tx.Exec(updateSql, entry.id) 162 | if err != nil { 163 | tx.Rollback() 164 | return nil, err 165 | } 166 | tx.Commit() 167 | return entry, nil 168 | } 169 | 170 | func Find(db *sql.DB, id int) (*DbEntry, error) { 171 | fetchSql := ` 172 | SELECT id, url, title, description, date, tag FROM entries 173 | WHERE 174 | id = ? 175 | LIMIT 1 176 | ` 177 | entry := &DbEntry{} 178 | err := db.QueryRow(fetchSql, id).Scan( 179 | &(entry.id), 180 | &(entry.url), 181 | &(entry.title), 182 | &(entry.description), 183 | &(entry.date), 184 | &(entry.tag), 185 | ) 186 | if err != nil { 187 | return nil, err 188 | } 189 | return entry, nil 190 | } 191 | 192 | func Upcommings(db *sql.DB) ([]*DbEntry, error) { 193 | sql := ` 194 | SELECT id, url, title, description, date, tag FROM entries 195 | WHERE 196 | NOT has_posted AND 197 | created > DATE_SUB(NOW(), INTERVAL 3 DAY) 198 | ORDER BY date DESC 199 | LIMIT 50 200 | ` 201 | 202 | rows, err := db.Query(sql) 203 | if err != nil { 204 | return nil, err 205 | } 206 | defer rows.Close() 207 | 208 | entries := make([]*DbEntry, 0) 209 | for rows.Next() { 210 | entry := &DbEntry{} 211 | if err := rows.Scan( 212 | &(entry.id), 213 | &(entry.url), 214 | &(entry.title), 215 | &(entry.description), 216 | &(entry.date), 217 | &(entry.tag), 218 | ); err != nil { 219 | return nil, err 220 | } 221 | entries = append(entries, entry) 222 | } 223 | if err = rows.Err(); err != nil { 224 | return nil, err 225 | } 226 | 227 | return entries, nil 228 | } 229 | 230 | func Rankings(db *sql.DB) ([]*RankedEntry, error) { 231 | sql := ` 232 | SELECT 233 | count(accesslogs.entry_id) as access_count, 234 | entries.id as id, 235 | entries.url as url, 236 | entries.title as title, 237 | entries.description as description, 238 | entries.date as date, 239 | entries.tag as tag 240 | FROM accesslogs JOIN entries ON accesslogs.entry_id = entries.id 241 | WHERE accesslogs.created > DATE_SUB(NOW(), INTERVAL 1 DAY) 242 | GROUP BY accesslogs.entry_id 243 | ORDER BY access_count DESC 244 | LIMIT 5 245 | ` 246 | 247 | rows, err := db.Query(sql) 248 | if err != nil { 249 | return nil, err 250 | } 251 | defer rows.Close() 252 | 253 | rankedEntries := make([]*RankedEntry, 0) 254 | for rows.Next() { 255 | var accessCount int 256 | entry := &DbEntry{} 257 | if err := rows.Scan( 258 | &accessCount, 259 | &(entry.id), 260 | &(entry.url), 261 | &(entry.title), 262 | &(entry.description), 263 | &(entry.date), 264 | &(entry.tag), 265 | ); err != nil { 266 | return nil, err 267 | } 268 | 269 | rankedEntries = append(rankedEntries, &RankedEntry{entry, accessCount}) 270 | } 271 | if err = rows.Err(); err != nil { 272 | return nil, err 273 | } 274 | 275 | return rankedEntries, nil 276 | } 277 | 278 | func deleteOld(db *sql.DB) error { 279 | sql := ` 280 | DELETE FROM entries 281 | WHERE created < DATE_SUB(NOW(), INTERVAL 7 DAY) 282 | ` 283 | _, err := db.Exec(sql) 284 | if err != nil { 285 | return err 286 | } 287 | return nil 288 | } 289 | 290 | func StartCleaner(db *sql.DB) { 291 | go func() { 292 | ticker := time.Tick(1 * time.Hour) 293 | for _ = range ticker { 294 | err := deleteOld(db) 295 | if err != nil { 296 | log.Println("Failed to delete old entries: %v", err) 297 | } 298 | } 299 | }() 300 | } 301 | -------------------------------------------------------------------------------- /present.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | "strconv" 9 | "strings" 10 | "text/template" 11 | "time" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | 15 | "github.com/hakobe/present/accesslogs" 16 | "github.com/hakobe/present/collector" 17 | "github.com/hakobe/present/config" 18 | "github.com/hakobe/present/entries" 19 | slackIncoming "github.com/hakobe/present/slack/incomming" 20 | "github.com/hakobe/present/tags" 21 | "github.com/hakobe/present/web" 22 | ) 23 | 24 | func trim(s string, l int) string { 25 | r := []rune(s) 26 | res := string(r) 27 | if len(r) > l && l >= 3 { 28 | res = string(r[0:(l-3)]) + "..." 29 | } 30 | return res 31 | } 32 | 33 | func postNextEntry(db *sql.DB) { 34 | entry, err := entries.Next(db) 35 | if err != nil { 36 | log.Printf("No entries can be retrieved: %v\n", err) 37 | return 38 | } 39 | log.Printf("Posting entry: %s\n", entry.Title()) 40 | entryUrl := entry.Url() 41 | if config.AccesslogUrlBase != "" { 42 | entryUrl = config.AccesslogUrlBase + "/entry/" + strconv.Itoa(entry.ID()) 43 | } 44 | err = slackIncoming.Post( 45 | fmt.Sprintf("<%s|%s>", entryUrl, entry.Title()), 46 | trim(entry.Description(), 150), 47 | ) 48 | if err != nil { 49 | log.Printf("%v\n", err) 50 | return 51 | } 52 | log.Printf("Entry posted.\n") 53 | } 54 | 55 | func postTags(db *sql.DB) { 56 | tags, err := tags.All(db) 57 | if err != nil { 58 | log.Printf("Tags retrieve error: %v\n", err) 59 | return 60 | } 61 | log.Printf("Posting tags: %s\n", strings.Join(tags, ", ")) 62 | err = slackIncoming.Post("Watching tags: "+strings.Join(tags, ", "), "") 63 | if err != nil { 64 | log.Printf("%v\n", err) 65 | return 66 | } 67 | log.Printf("Tags posted.\n") 68 | } 69 | 70 | func addTag(db *sql.DB, tag string) { 71 | err := tags.Add(db, tag) 72 | if err != nil { 73 | log.Printf("Adding tag failed: %v\n", err) 74 | return 75 | } 76 | log.Printf("Tag added: %s\n", tag) 77 | postTags(db) 78 | } 79 | 80 | func delTag(db *sql.DB, tag string) { 81 | err := tags.Del(db, tag) 82 | if err != nil { 83 | log.Printf("Deleting tag failed: %v\n", err) 84 | return 85 | } 86 | log.Printf("Tag deleted: %s\n", tag) 87 | postTags(db) 88 | } 89 | 90 | func postRankings(db *sql.DB) { 91 | rankedEntries, err := entries.Rankings(db) 92 | if err != nil { 93 | log.Printf("Rankings retrieve error: %v\n", err) 94 | return 95 | } 96 | 97 | tmpl, err := template.New("rankings").Parse(` 98 | Click rankings in 24 hours 99 | {{ range $i, $e := . }}{{$e.AccessCount}} clicks <{{$e.Entry.Url}}|{{$e.Entry.Title}}> 100 | {{ end }}`) 101 | if err != nil { 102 | log.Printf("Rankings template error: %v\n", err) 103 | return 104 | } 105 | 106 | buf := bytes.NewBufferString("") 107 | tmpl.Execute(buf, rankedEntries) 108 | 109 | err = slackIncoming.Post("", buf.String()) 110 | if err != nil { 111 | log.Printf("%v\n", err) 112 | return 113 | } 114 | 115 | log.Printf("Rankings posted.\n") 116 | } 117 | 118 | func postHelp() { 119 | text := ` 120 | plz: Post a one url. 121 | tags: Show all watching tags. 122 | add : Add a watching tag. 123 | del : Delete a watching tag. 124 | rankings: Show clicks ranking in 24 hours. 125 | help: Show this message. 126 | ` 127 | err := slackIncoming.Post("", text) 128 | if err != nil { 129 | log.Printf("%v\n", err) 130 | return 131 | } 132 | log.Printf("Help posted.\n") 133 | } 134 | 135 | func updateToSavedTags(db *sql.DB, updateTags chan<- []string) { 136 | tags, err := tags.All(db) 137 | if err != nil { 138 | log.Printf("Tags retrieve error: %v\n", err) 139 | return 140 | } 141 | updateTags <- tags 142 | } 143 | 144 | func main() { 145 | db, err := sql.Open("mysql", config.DbDsn) 146 | 147 | err = entries.Prepare(db) 148 | if err != nil { 149 | log.Fatalf("DB(entries) preparation error: %v\n", err) 150 | } 151 | err = tags.Prepare(db) 152 | if err != nil { 153 | log.Fatalf("DB(tags) preparation error: %v\n", err) 154 | } 155 | err = accesslogs.Prepare(db) 156 | if err != nil { 157 | log.Fatalf("DB(accesslogs) preparation error: %v\n", err) 158 | } 159 | 160 | collectedEntries, updateTags := collector.Start() 161 | updateToSavedTags(db, updateTags) 162 | webOp := web.Start(db) 163 | 164 | go func() { 165 | for entry := range collectedEntries { 166 | err = entries.Add(db, entry) 167 | if err != nil { 168 | log.Printf("SQL error: %v\n", err) 169 | continue 170 | } 171 | } 172 | }() 173 | entries.StartCleaner(db) 174 | 175 | go func() { 176 | c := time.Tick(1 * time.Minute) 177 | for now := range c { 178 | if now.Hour() == config.RankingsHour && now.Minute() == 0 { 179 | postRankings(db) 180 | log.Printf("Scheduled rankings are posted.\n") 181 | } 182 | } 183 | }() 184 | 185 | noopCount := 0 186 | wait := config.Wait 187 | for { 188 | var timer <-chan time.Time 189 | if config.NoopLimit == 0 || noopCount < config.NoopLimit { 190 | timer = time.After(time.Duration(wait) * time.Second) 191 | } else { 192 | timer = make(chan time.Time) // block. leak? 193 | log.Printf("NoopLimit(%d) reached, going to long sleep...\n", config.NoopLimit) 194 | } 195 | select { 196 | case <-timer: 197 | postNextEntry(db) 198 | 199 | noopCount += 1 200 | case o := <-webOp: 201 | switch o.Op { 202 | case "humanspeaking": 203 | log.Printf("Humans are speaking. Go to next sleep.\n") 204 | case "plz": 205 | postNextEntry(db) 206 | case "fever": 207 | for i := 0; i < 10; i++ { 208 | postNextEntry(db) 209 | } 210 | case "tags": 211 | postTags(db) 212 | case "add": 213 | addTag(db, o.Args[0]) 214 | updateToSavedTags(db, updateTags) 215 | case "del": 216 | delTag(db, o.Args[0]) 217 | updateToSavedTags(db, updateTags) 218 | case "help": 219 | postHelp() 220 | case "rankings": 221 | postRankings(db) 222 | } 223 | 224 | noopCount = 0 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /slack/incomming/incomming.go: -------------------------------------------------------------------------------- 1 | package incomming 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "os" 8 | 9 | "github.com/hakobe/present/config" 10 | ) 11 | 12 | var webhookUrl string = os.Getenv("PRESENT_SLACK_INCOMMING_URL") 13 | 14 | type field struct { 15 | Title string `json:"title"` 16 | Value string `json:"value"` 17 | Short bool `json:"short"` 18 | } 19 | 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 | type payload struct { 28 | Attachments []*attachment `json:"attachments"` 29 | } 30 | 31 | func Post(title string, description string) error { 32 | p, err := json.Marshal(&payload{ 33 | Attachments: []*attachment{ 34 | &attachment{ 35 | Fallback: title, 36 | Pretext: title, 37 | Fields: []*field{ 38 | &field{ 39 | Title: "", 40 | Value: description, 41 | Short: false, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | _, err = http.PostForm(config.SlackIncomingWebhookUrl, url.Values{ 52 | "payload": []string{string(p)}, 53 | }) 54 | if err != nil { 55 | return err 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /slack/outgoing/outgoing.go: -------------------------------------------------------------------------------- 1 | package outgoing 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/hakobe/present/config" 10 | ) 11 | 12 | type Op struct { 13 | Op string 14 | Args []string 15 | } 16 | 17 | func isNameMatched(name string) bool { 18 | matched := false 19 | for _, n := range config.Names { 20 | if name == n { 21 | matched = true 22 | break 23 | } 24 | } 25 | return matched 26 | } 27 | 28 | func Handle(op chan *Op, rw http.ResponseWriter, r *http.Request) { 29 | if r.Method == "POST" { 30 | userId := r.FormValue("user_id") 31 | text := strings.TrimSpace(r.FormValue("text")) 32 | texts := regexp.MustCompile("\\s+").Split(text, -1) 33 | 34 | if userId != "USLACKBOT" { 35 | if len(texts) > 1 && isNameMatched(texts[0]) { 36 | switch texts[1] { 37 | case "plz", "please": 38 | op <- &Op{"plz", nil} 39 | case "fever": 40 | op <- &Op{"fever", nil} 41 | case "tags", "tag": 42 | op <- &Op{"tags", nil} 43 | case "add": 44 | op <- &Op{"add", []string{strings.Join(texts[2:], " ")}} 45 | case "del": 46 | op <- &Op{"del", []string{strings.Join(texts[2:], " ")}} 47 | case "rankings", "ranking": 48 | op <- &Op{"rankings", nil} 49 | default: 50 | op <- &Op{"help", nil} 51 | } 52 | } else { 53 | op <- &Op{"humanspeaking", nil} 54 | } 55 | } 56 | } 57 | fmt.Fprintf(rw, "ok") 58 | } 59 | -------------------------------------------------------------------------------- /tags/tags.go: -------------------------------------------------------------------------------- 1 | package tags 2 | 3 | import "database/sql" 4 | 5 | func Prepare(db *sql.DB) error { 6 | sql := ` 7 | CREATE TABLE IF NOT EXISTS tags ( 8 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 9 | tag VARCHAR(255) NOT NULL, 10 | PRIMARY KEY(id), 11 | UNIQUE KEY(tag) 12 | ) ENGINE=InnoDB DEFAULT CHARSET=binary 13 | ` 14 | 15 | _, err := db.Exec(sql) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | return nil 21 | } 22 | 23 | func Add(db *sql.DB, tag string) error { 24 | sql := ` 25 | INSERT INTO tags 26 | (tag) VALUES ( ? ) 27 | ` 28 | _, err := db.Exec(sql, tag) 29 | if err != nil { 30 | return err 31 | } 32 | return nil 33 | } 34 | 35 | func Del(db *sql.DB, tag string) error { 36 | sql := ` 37 | DELETE FROM tags 38 | WHERE tag = ? 39 | ` 40 | _, err := db.Exec(sql, tag) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func All(db *sql.DB) ([]string, error) { 48 | sql := ` 49 | SELECT tag FROM tags ORDER BY tag ASC 50 | ` 51 | 52 | rows, err := db.Query(sql) 53 | if err != nil { 54 | return nil, err 55 | } 56 | defer rows.Close() 57 | 58 | tags := make([]string, 0) 59 | for rows.Next() { 60 | var tag string 61 | if err := rows.Scan(&tag); err != nil { 62 | return nil, err 63 | } 64 | tags = append(tags, tag) 65 | } 66 | if err = rows.Err(); err != nil { 67 | return nil, err 68 | } 69 | 70 | return tags, nil 71 | } 72 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "database/sql" 5 | "html/template" 6 | "log" 7 | "net/http" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | 12 | "github.com/hakobe/present/accesslogs" 13 | "github.com/hakobe/present/entries" 14 | slackOutgoing "github.com/hakobe/present/slack/outgoing" 15 | ) 16 | 17 | var bind string = ":" + os.Getenv("PORT") 18 | 19 | func Start(db *sql.DB) chan *slackOutgoing.Op { 20 | op := make(chan *slackOutgoing.Op, 1000) 21 | 22 | http.HandleFunc( 23 | "/hook", 24 | func(rw http.ResponseWriter, r *http.Request) { 25 | slackOutgoing.Handle(op, rw, r) 26 | }, 27 | ) 28 | 29 | http.HandleFunc( 30 | "/upcommings", 31 | func(rw http.ResponseWriter, r *http.Request) { 32 | es, err := entries.Upcommings(db) 33 | if err != nil { 34 | http.Error(rw, err.Error(), http.StatusInternalServerError) 35 | return 36 | } 37 | tmpl, err := template.New("upcommings").Parse(` 38 | 39 | 40 | 41 | 42 | 43 | 44 | {{ range . }} 45 | 46 | 47 | 48 | {{ end }} 49 |
TagTitleUrl
{{.Tag}}{{.Title}}{{.Url}}
50 | 51 | 52 | `) 53 | if err != nil { 54 | http.Error(rw, err.Error(), http.StatusInternalServerError) 55 | return 56 | } 57 | 58 | tmpl.Execute(rw, es) 59 | }, 60 | ) 61 | 62 | http.HandleFunc( 63 | "/entry/", 64 | func(rw http.ResponseWriter, r *http.Request) { 65 | matches := regexp.MustCompile(`/entry/(\d+)`).FindStringSubmatch(r.URL.Path) 66 | if !(matches != nil && matches[1] != "") { 67 | http.Error(rw, "Invalid URL", http.StatusBadRequest) 68 | return 69 | } 70 | 71 | var id int 72 | var err error 73 | if id, err = strconv.Atoi(matches[1]); err != nil { 74 | http.Error(rw, err.Error(), http.StatusNotFound) 75 | return 76 | } 77 | 78 | entry, err := entries.Find(db, id) 79 | if err != nil { 80 | http.Error(rw, err.Error(), http.StatusInternalServerError) 81 | return 82 | } 83 | accesslogs.Access(db, id) 84 | tmpl, err := template.New("entry").Parse(` 85 | 86 | 87 | 88 | Redirector 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | `) 97 | if err != nil { 98 | http.Error(rw, err.Error(), http.StatusInternalServerError) 99 | return 100 | } 101 | 102 | tmpl.Execute(rw, template.HTML(entry.Url())) 103 | }, 104 | ) 105 | 106 | go func() { 107 | log.Printf("Starting slack webhook on \"%s\"\n", bind) 108 | err := http.ListenAndServe(bind, nil) 109 | if err != nil { 110 | log.Fatalf("ListenAndServe: %v", err) 111 | } 112 | }() 113 | 114 | return op 115 | } 116 | --------------------------------------------------------------------------------