From 12ae39399800d13546330d37dd8da73c89e3b720 Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Wed, 28 Oct 2015 11:46:45 -0500 Subject: [PATCH] ADDING THE FILES FOR THAT PROGRESS! --- README.md | 10 + slack.go | 188 +++++++++++++ statbot.go | 107 ++++++++ statbot_model.go | 680 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 985 insertions(+) create mode 100644 README.md create mode 100644 slack.go create mode 100644 statbot.go create mode 100644 statbot_model.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..b756684 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ + +# mybot + +`mybot` is an working Slack bot written in Go. Fork it and use it to build +your very own cool Slack bot! + +Check the [blog post](https://www.opsdash.com/blog/slack-bot-in-golang.html) +for a description of mybot internals. + +Follow us on Twitter today! [@therapidloop](https://twitter.com/therapidloop) diff --git a/slack.go b/slack.go new file mode 100644 index 0000000..10326bb --- /dev/null +++ b/slack.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + "golang.org/x/net/websocket" +) + +// Slack manages the slack connection and implements API calls +type Slack struct { + apiToken string + id string + socket *websocket.Conn + alive bool +} + +// CreateSlack creates the slack object, opening a websocket +func CreateSlack(token string) (*Slack, error) { + s := Slack{apiToken: token} + url := fmt.Sprintf("https://slack.com/api/rtm.start?token=%s", token) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + err = fmt.Errorf("API request failed with code %d", resp.StatusCode) + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + var respObj responseRtmStart + err = json.Unmarshal(body, &respObj) + if err != nil { + return nil, err + } + + if !respObj.Ok { + err = fmt.Errorf("Slack error: %s", respObj.Error) + return nil, err + } + + wsurl := respObj.URL + s.id = respObj.Self.ID + + ws, err := websocket.Dial(wsurl, "", "https://api.slack.com/") + s.socket = ws + s.keepAlive() + + return &s, nil +} + +func (s *Slack) keepAlive() { + s.alive = true + go func() { + for { + if s.alive { + s.ping() + } + time.Sleep(time.Second * 30) + } + }() +} + +var counter uint64 + +func (s *Slack) ping() error { + return s.postMessage(Message{Type: "ping"}) +} + +func (s *Slack) postMessage(m Message) error { + m.ID = atomic.AddUint64(&counter, 1) + return websocket.JSON.Send(s.socket, m) +} + +func (s *Slack) getMessage() (Message, error) { + var m Message + err := websocket.JSON.Receive(s.socket, &m) + + txtArr := strings.Split(m.Ts, ".") + if t, err := strconv.Atoi(txtArr[0]); err == nil { + rawts := int64(t) + m.Time = time.Unix(0, rawts*1000000000) + } + + return m, err +} + +func (s *Slack) sendMessageToUser(uid string, msg string) error { + // TODO: Actually send a direct message to uid + return nil +} + +func (s *Slack) getUserInfo(uid string) (*User, error) { + url := fmt.Sprintf("https://slack.com/api/users.info?token=%s&user=%s", s.apiToken, uid) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + err = fmt.Errorf("API request failed with code %d", resp.StatusCode) + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + writeToLog("User Lookup: \n") + writeToLog(string(body)) + writeToLog("\n\n") + if err != nil { + return nil, err + } + var respObj responseUserLookup + err = json.Unmarshal(body, &respObj) + if err != nil { + return nil, err + } + + if !respObj.Ok { + err = fmt.Errorf("Slack error: %s", respObj.Error) + return nil, err + } + return respObj.User, nil +} + +func (s *Slack) getChannelInfo(cid string) (*Channel, error) { + url := fmt.Sprintf("https://slack.com/api/channels.info?token=%s&channel=%s", s.apiToken, cid) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + err = fmt.Errorf("API request failed with code %d", resp.StatusCode) + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + writeToLog("Channel Lookup: \n") + writeToLog(string(body)) + writeToLog("\n\n") + if err != nil { + return nil, err + } + var respObj responseChannelLookup + err = json.Unmarshal(body, &respObj) + if err != nil { + return nil, err + } + + if !respObj.Ok { + err = fmt.Errorf("Slack error: %s", respObj.Error) + return nil, err + } + return respObj.Channel, nil +} + +// These structures represent the response of the Slack API events +// Only some fields are included. The rest are ignored by json.Unmarshal. +type responseRtmStart struct { + Ok bool `json:"ok"` + Error string `json:"error"` + URL string `json:"url"` + Self responseSelf `json:"self"` +} + +type responseSelf struct { + ID string `json:"id"` +} + +type responseUserLookup struct { + Ok bool `json:"ok"` + Error string `json:"error"` + User *User `json:"user"` +} + +type responseChannelLookup struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Channel *Channel `json:"channel"` +} diff --git a/statbot.go b/statbot.go new file mode 100644 index 0000000..3aa4c54 --- /dev/null +++ b/statbot.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "time" +) + +const programName = "statbot" + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "usage: statbot slack-bot-token\n") + os.Exit(1) + } + writeToLog("\n\n\n== " + time.Now().Format(time.RFC3339) + " ==\n") + + // start a websocket-based Real Time API session + var slack *Slack + var err error + + if err = initDatabase(); err != nil { + panic(err) + } + + if slack, err = CreateSlack(os.Args[1]); err != nil { + panic(err) + } + + fmt.Println("statbot ready, ^C exits") + + for { + // read each incoming message + m, err := slack.getMessage() + if err != nil { + log.Fatal(err) + } + processMessage(slack, &m) + } +} + +func processMessage(slack *Slack, m *Message) { + if mb, me := json.Marshal(m); me == nil { + // Write the JSON representation to the log + writeToLog(string(mb) + "\n") + } + // TODO: Handle reaction_added messages + // TODO: Handle reaction_removed messages + + if m.Type == "message" { + // Check if we know who the user is + usr, err := getUserInfo(m.User) + // If the user information hasn't been updated in the past day, update it. + if err != nil || usr.LastUpdated.IsZero() || time.Since(usr.LastUpdated) > (time.Hour*24) { + if u, ue := slack.getUserInfo(m.User); ue == nil { + saveUserInfo(u) + } + } + // If 'channel' is defined, save the message to the 'channels' bucket + if m.Channel != "" { + // Check if we know what the channel is + chnl, err := getChannelInfo(m.Channel) + // If the channel information hasn't been updated in the past day, update it. + if err != nil || chnl.LastUpdated.IsZero() || time.Since(chnl.LastUpdated) > (time.Hour*24) { + // Either we don't have this channel, or it's a direct message + if c, ce := slack.getChannelInfo(m.Channel); ce != nil { + // Invalid channel, save as a direct message + saveUserMessage(m.User, m) + } else { + // Save channel info + saveChannelInfo(c) + // And save the channel message + saveChannelMessage(m.Channel, m) + } + } + } + // check if we're mentioned + /* + if m.Type == "message" && strings.HasPrefix(m.Text, "<@"+slack.id+">") { + parts := strings.Fields(m.Text) + if len(parts) == 3 && parts[1] == "stock" { + // looks good, get the quote and reply with the result + go func(m Message) { + m.Text = getQuote(parts[2]) + postMessage(ws, m) + }(m) + // NOTE: the Message object is copied, this is intentional + } else { + // huh? + m.Text = fmt.Sprintf("Beep boop beep, that does not compute\n") + postMessage(ws, m) + } + } + */ + } +} + +func writeToLog(d string) { + f, err := os.OpenFile("statbot.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0664) + if err != nil { + panic(err) + } + f.WriteString(d) + f.Close() +} diff --git a/statbot_model.go b/statbot_model.go new file mode 100644 index 0000000..c79300b --- /dev/null +++ b/statbot_model.go @@ -0,0 +1,680 @@ +package main + +import ( + "fmt" + "strconv" + "time" + + "github.com/boltdb/bolt" +) + +const databaseFile = programName + ".db" + +var db *bolt.DB + +func openDatabase() error { + var err error + db, err = bolt.Open(databaseFile, 0600, nil) + if err != nil { + return err + } + + return nil +} + +func closeDatabase() error { + return db.Close() +} + +func initDatabase() error { + openDatabase() + + // Make sure the needed buckets exists + err := db.Update(func(tx *bolt.Tx) error { + + // The config bucket holds config info for statbot + _, err := tx.CreateBucketIfNotExists([]byte("config")) + if err != nil { + return err + } + + // The 'users' bucket holds user info, including direct messages + _, err = tx.CreateBucketIfNotExists([]byte("users")) + if err != nil { + return err + } + + // The 'channels' bucket holds channel info, including channel messages + _, err = tx.CreateBucketIfNotExists([]byte("channels")) + if err != nil { + return err + } + + return nil + }) + + closeDatabase() + return err +} + +// Channel object +type Channel struct { + ID string `json:"id"` + Name string `json:"name"` + IsChannel bool `json:"is_channel"` + CreatedRaw int `json:"created"` + Created time.Time + Creator string `json:"creator"` + IsArchived bool `json:"is_archived"` + IsGeneral bool `json:"is_general"` + IsMember bool `json:"is_member"` + LastReadRaw int `json:"last_read"` + LastRead time.Time + Latest *Message `json:"latest"` + UnreadCount int `json:"unread_count"` + UnreadCountDisplay int `json:"unread_count_display"` + Members []string `json:"members"` + Topic *ChannelTopic `json:"topic"` + Purpose *ChannelTopic `json:"purpose"` + + // LastUpdated is the last time we updated the info in the DB + LastUpdated time.Time +} + +// ChannelTopic A simple 'Channel Topic' object +// used for several things in the slack api (channel topic/purpose, etc. +type ChannelTopic struct { + Value string `json:"value"` + Creator string `json:"creator"` + LastSetRaw int `json:"last_set"` + LastSet time.Time +} + +func getChannelInfo(chnl string) (*Channel, error) { + openDatabase() + ret := Channel{ID: chnl} + err := db.View(func(tx *bolt.Tx) error { + var b, chB, chIB *bolt.Bucket + var err error + + b = tx.Bucket([]byte("channels")) + if b == nil { + return fmt.Errorf("Error opening 'channels' bucket") + } + if chB = b.Bucket([]byte(chnl)); chB == nil { + return fmt.Errorf("Error opening channel bucket (%s)", chnl) + } + if chIB = chB.Bucket([]byte("info")); chIB == nil { + return fmt.Errorf("Error opening channel info bucket (%s/info)", chnl) + } + if ret.Name, err = bktGetString(chIB, "name"); err != nil { + return err + } + if ret.IsChannel, err = bktGetBool(chIB, "is_channel"); err != nil { + return err + } + if ret.Created, err = bktGetTime(chIB, "created"); err != nil { + return err + } + if ret.Creator, err = bktGetString(chIB, "creator"); err != nil { + return err + } + if ret.IsArchived, err = bktGetBool(chIB, "is_archived"); err != nil { + return err + } + if ret.IsGeneral, err = bktGetBool(chIB, "is_general"); err != nil { + return err + } + if ret.IsMember, err = bktGetBool(chIB, "is_member"); err != nil { + return err + } + if ret.LastRead, err = bktGetTime(chIB, "last_read"); err != nil { + return err + } + if ret.UnreadCount, err = bktGetInt(chIB, "unread_count"); err != nil { + return err + } + if ret.UnreadCountDisplay, err = bktGetInt(chIB, "unread_count_display"); err != nil { + return err + } + // Get all of the members into a []string + chMembersB := chIB.Bucket([]byte("members")) + if chMembersB != nil { + chMembersB.ForEach(func(k, v []byte) error { + ret.Members = append(ret.Members, string(v)) + return nil + }) + } + ret.Topic = new(ChannelTopic) + if ret.Topic.Value, err = bktGetString(chIB, "topic_value"); err != nil { + return err + } + if ret.Topic.Creator, err = bktGetString(chIB, "topic_creator"); err != nil { + return err + } + if ret.Topic.LastSet, err = bktGetTime(chIB, "topic_last_set"); err != nil { + return err + } + ret.Purpose = new(ChannelTopic) + if ret.Purpose.Value, err = bktGetString(chIB, "purpose_value"); err != nil { + return err + } + if ret.Purpose.Creator, err = bktGetString(chIB, "purpose_creator"); err != nil { + return err + } + if ret.Purpose.LastSet, err = bktGetTime(chIB, "purpose_last_set"); err != nil { + return err + } + return nil + }) + closeDatabase() + return &ret, err +} + +func saveChannelInfo(chnl *Channel) error { + openDatabase() + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("channel")) + var err error + var chB, chIB *bolt.Bucket + if chB, err = b.CreateBucketIfNotExists([]byte(chnl.ID)); err != nil { + return err + } + if chIB, err = chB.CreateBucketIfNotExists([]byte("info")); err != nil { + return err + } + if err = bktPutString(chIB, "id", chnl.ID); err != nil { + return err + } + if err = bktPutString(chIB, "name", chnl.Name); err != nil { + return err + } + if err = bktPutBool(chIB, "is_channel", chnl.IsChannel); err != nil { + return err + } + if err = bktPutTime(chIB, "created", chnl.Created); err != nil { + return err + } + if err = bktPutString(chIB, "creator", chnl.Creator); err != nil { + return err + } + if err = bktPutBool(chIB, "is_archived", chnl.IsArchived); err != nil { + return err + } + if err = bktPutBool(chIB, "is_general", chnl.IsGeneral); err != nil { + return err + } + if err = bktPutBool(chIB, "is_member", chnl.IsMember); err != nil { + return err + } + if err = bktPutTime(chIB, "last_read", chnl.LastRead); err != nil { + return err + } + if err = bktPutInt(chIB, "unread_count", chnl.UnreadCount); err != nil { + return err + } + if err = bktPutInt(chIB, "unread_count_display", chnl.UnreadCountDisplay); err != nil { + return err + } + chMembersB := chIB.Bucket([]byte("members")) + if chMembersB != nil { + // Recreate the Members bucket + chIB.DeleteBucket([]byte("members")) + } + if chMembersB, err = chIB.CreateBucket([]byte("members")); err != nil { + return err + } + mbIdx := 0 + for _, mm := range chnl.Members { + idxKey := strconv.Itoa(mbIdx) + if err = bktPutString(chMembersB, idxKey, mm); err != nil { + return err + } + } + if chnl.Topic != nil { + if err = bktPutString(chIB, "topic_value", chnl.Topic.Value); err != nil { + return err + } + if err = bktPutString(chIB, "topic_creator", chnl.Topic.Creator); err != nil { + return err + } + if err = bktPutTime(chIB, "topic_last_set", chnl.Topic.LastSet); err != nil { + return err + } + } + if chnl.Purpose != nil { + if err = bktPutString(chIB, "purpose_value", chnl.Topic.Value); err != nil { + return err + } + if err = bktPutString(chIB, "purpose_creator", chnl.Topic.Creator); err != nil { + return err + } + if err = bktPutTime(chIB, "purpose_last_set", chnl.Topic.LastSet); err != nil { + return err + } + } + err = bktPutTime(chIB, "last_updated", chnl.LastUpdated) + return err + }) + closeDatabase() + return err +} + +func saveChannelStat(channel string, key string, val string) error { + openDatabase() + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("channels")) + if chB, err := b.CreateBucketIfNotExists([]byte(channel)); err == nil { + chB.Put([]byte(key), []byte(val)) + } + return nil + }) + closeDatabase() + return err +} + +func saveChannelMessage(channel string, message *Message) error { + openDatabase() + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("channels")) + var err error + var chB, chMB, msgBkt *bolt.Bucket + if chB, err = b.CreateBucketIfNotExists([]byte(channel)); err != nil { + return err + } + if chMB, err = chB.CreateBucketIfNotExists([]byte("messages")); err != nil { + return err + } + idx, _ := chMB.NextSequence() + idxKey := []byte(strconv.FormatUint(idx, 10)) + if msgBkt, err = chMB.CreateBucketIfNotExists(idxKey); err != nil { + return err + } + if err = bktPutString(msgBkt, "type", message.Type); err != nil { + return err + } + if err = bktPutString(msgBkt, "user", message.User); err != nil { + return err + } + if err = bktPutString(msgBkt, "text", message.Text); err != nil { + return err + } + if err = bktPutString(msgBkt, "name", message.Name); err != nil { + return err + } + if err = bktPutTime(msgBkt, "time", message.Time); err != nil { + return err + } + return nil + }) + closeDatabase() + return err +} + +func incrementChannelStat(channel string, key string) error { + openDatabase() + strRet, err := getChannelStat(channel, key) + if err == nil { + if i, err := strconv.Atoi(strRet); err == nil { + i++ + return saveChannelStat(channel, key, fmt.Sprintf("%d", i)) + } + return err + } + closeDatabase() + return err +} + +func decrementChannelStat(channel string, key string) error { + openDatabase() + strRet, err := getChannelStat(channel, key) + if err == nil { + if i, err := strconv.Atoi(strRet); err == nil { + i-- + return saveChannelStat(channel, key, fmt.Sprintf("%d", i)) + } + return err + } + closeDatabase() + return err +} + +func getChannelStat(channel string, key string) (string, error) { + openDatabase() + var ret string + err := db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("channels")) + if chB := b.Bucket([]byte(channel)); chB != nil { + ret = string(chB.Get([]byte(key))) + } + return nil + }) + closeDatabase() + return ret, err +} + +// Message We save the message struct into the db, +// it's also what we send/receive from slack +type Message struct { + ID uint64 `json:"id"` + Type string `json:"type"` + Channel string `json:"channel"` + User string `json:"user"` + Name string `json:"name"` + Text string `json:"text"` + Ts string `json:"ts"` + Time time.Time +} + +// User object +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Status string `json:"status"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsBot bool `json:"is_bot"` + HasFiles bool `json:"has_files"` + + // LastUpdated is the last time we updated the info in the DB + LastUpdated time.Time +} + +func getUserInfo(usr string) (*User, error) { + openDatabase() + ret := User{ID: usr} + err := db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("users")) + if uB := b.Bucket([]byte(usr)); uB != nil { + var err error + if ret.Name, err = bktGetString(uB, "name"); err != nil { + return err + } + if ret.Deleted, err = bktGetBool(uB, "deleted"); err != nil { + return err + } + if ret.Status, err = bktGetString(uB, "status"); err != nil { + return err + } + if ret.Color, err = bktGetString(uB, "color"); err != nil { + return err + } + if ret.RealName, err = bktGetString(uB, "real_name"); err != nil { + return err + } + if ret.TZ, err = bktGetString(uB, "tz"); err != nil { + return err + } + if ret.TZLabel, err = bktGetString(uB, "tz_label"); err != nil { + return err + } + if ret.TZOffset, err = bktGetInt(uB, "tz_offset"); err != nil { + return err + } + if ret.IsAdmin, err = bktGetBool(uB, "is_admin"); err != nil { + return err + } + if ret.IsOwner, err = bktGetBool(uB, "is_owner"); err != nil { + return err + } + if ret.IsPrimaryOwner, err = bktGetBool(uB, "is_primary_owner"); err != nil { + return err + } + if ret.IsRestricted, err = bktGetBool(uB, "is_restricted"); err != nil { + return err + } + if ret.IsUltraRestricted, err = bktGetBool(uB, "is_ultra_restricted"); err != nil { + return err + } + if ret.IsBot, err = bktGetBool(uB, "is_bot"); err != nil { + return err + } + if ret.HasFiles, err = bktGetBool(uB, "has_files"); err != nil { + return err + } + if ret.LastUpdated, err = bktGetTime(uB, "last_updated"); err != nil { + return err + } + return nil + } + return fmt.Errorf("User (%s) bucket not found.", usr) + }) + closeDatabase() + return &ret, err +} + +func saveUserInfo(usr *User) error { + openDatabase() + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("users")) + var err error + var uB, uIB *bolt.Bucket + if uB, err = b.CreateBucketIfNotExists([]byte(usr.ID)); err != nil { + return err + } + if uIB, err = uB.CreateBucketIfNotExists([]byte("info")); err != nil { + return err + } + if err = bktPutString(uIB, "id", usr.ID); err != nil { + return err + } + if err = bktPutString(uIB, "name", usr.Name); err != nil { + return err + } + if err = bktPutBool(uIB, "deleted", usr.Deleted); err != nil { + return err + } + if err = bktPutString(uIB, "status", usr.Status); err != nil { + return err + } + if err = bktPutString(uIB, "color", usr.Color); err != nil { + return err + } + if err = bktPutString(uIB, "real_name", usr.RealName); err != nil { + return err + } + if err = bktPutString(uIB, "tz", usr.TZ); err != nil { + return err + } + if err = bktPutString(uIB, "tz_label", usr.TZLabel); err != nil { + return err + } + if err = bktPutInt(uIB, "tz_offset", usr.TZOffset); err != nil { + return err + } + if err = bktPutBool(uIB, "is_admin", usr.IsAdmin); err != nil { + return err + } + if err = bktPutBool(uIB, "is_owner", usr.IsOwner); err != nil { + return err + } + if err = bktPutBool(uIB, "is_primary_owner", usr.IsPrimaryOwner); err != nil { + return err + } + if err = bktPutBool(uIB, "is_restricted", usr.IsRestricted); err != nil { + return err + } + if err = bktPutBool(uIB, "is_ultra_restricted", usr.IsUltraRestricted); err != nil { + return err + } + if err = bktPutBool(uIB, "is_bot", usr.IsBot); err != nil { + return err + } + if err = bktPutBool(uIB, "has_files", usr.HasFiles); err != nil { + return err + } + err = bktPutTime(uIB, "last_udpated", usr.LastUpdated) + return err + }) + closeDatabase() + return err +} + +func saveUserMessage(user string, message *Message) error { + openDatabase() + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("users")) + var err error + var uB, uMB, msgBkt *bolt.Bucket + if uB, err = b.CreateBucketIfNotExists([]byte(user)); err != nil { + return err + } + if uMB, err = uB.CreateBucketIfNotExists([]byte("messages")); err != nil { + return err + } + idx, _ := uMB.NextSequence() + idxKey := []byte(strconv.FormatUint(idx, 10)) + if msgBkt, err = uMB.CreateBucketIfNotExists(idxKey); err != nil { + return err + } + if err = bktPutString(msgBkt, "type", message.Type); err != nil { + return err + } + if err = bktPutString(msgBkt, "text", message.Text); err != nil { + return err + } + if err = bktPutString(msgBkt, "name", message.Name); err != nil { + return err + } + if err = bktPutTime(msgBkt, "time", message.Time); err != nil { + return err + } + return nil + }) + closeDatabase() + return err +} + +func bktGetBucket(b *bolt.Bucket, key string) (*bolt.Bucket, error) { + bkt := b.Bucket([]byte(key)) + if bkt != nil { + return nil, fmt.Errorf("Bucket (%s) not found.", key) + } + return bkt, nil +} + +func bktGetBytes(b *bolt.Bucket, key string) ([]byte, error) { + bb := b.Get([]byte(key)) + if bb == nil { + return nil, fmt.Errorf("Error reading key (%s)", key) + } + return bb, nil +} + +func bktGetString(b *bolt.Bucket, key string) (string, error) { + bb, err := bktGetBytes(b, key) + return string(bb), err +} + +func bktPutString(b *bolt.Bucket, key string, val string) error { + return b.Put([]byte(key), []byte(val)) +} + +func bktGetBool(b *bolt.Bucket, key string) (bool, error) { + bb, err := bktGetBytes(b, key) + return (string(bb) == "true"), err +} + +func bktPutBool(b *bolt.Bucket, key string, val bool) error { + if val { + return b.Put([]byte(key), []byte("true")) + } + return b.Put([]byte(key), []byte("false")) +} + +func bktGetInt(b *bolt.Bucket, key string) (int, error) { + bb, err := bktGetBytes(b, key) + if err != nil { + return 0, err + } + return strconv.Atoi(string(bb)) +} + +func bktPutInt(b *bolt.Bucket, key string, val int) error { + return b.Put([]byte(key), []byte(fmt.Sprintf("%d", val))) +} + +func bktGetTime(b *bolt.Bucket, key string) (time.Time, error) { + bs, err := bktGetString(b, key) + if err != nil { + return time.Time{}, err + } + return time.Parse(time.RFC3339, bs) +} + +func bktPutTime(b *bolt.Bucket, key string, val time.Time) error { + bs := val.Format(time.RFC3339) + return b.Put([]byte(key), []byte(bs)) +} + +// All user stats are saved in the db like so: +// users (bucket) +// |- username1 (bucket) +// | |-info (bucket) +// | | |-userinfo1 (pair) +// | | \-userinfo2 (pair) +// | | +// | |-messages (bucket) +// | | |-message1 (message bucket) +// | | \-message2 (message bucket) +// | | +// | \-stats (bucket) +// | |-stat1 (pair) +// | \-stat2 (pair) +// | +// \- username2 (bucket) +// |-info (bucket) +// | |-userinfo1 (pair) +// | \-userinfo2 (pair) +// | +// |-messages (bucket) +// | |-message1 (message bucket) +// | \-message2 (message bucket) +// | +// \-stats (bucket) +// |-stat1 (pair) +// \-stat2 (pair) +// +// And channel stats are saved in the db like so: +// channels (bucket) +// |- channel1 (bucket) +// | |-info (bucket) +// | | |-chaninfo1 (pair) +// | | \-chaninfo2 (pair) +// | | +// | |-messages (bucket) +// | | |-message1 (message bucket) +// | | \-message2 (message bucket) +// | | +// | \-stats (bucket) +// | |-stat1 (pair) +// | \-stat2 (pair) +// | +// \- channel2 (bucket) +// |-info (bucket) +// | |-chaninfo1 (pair) +// | \-chaninfo2 (pair) +// | +// |-messages (bucket) +// | |-message1 (message bucket) +// | \-message2 (message bucket) +// | +// \-stats (bucket) +// |-stat1 (pair) +// \-stat2 (pair) +// +// A (message bucket) looks like this: +// messageid (unique to parent bucket) +// |-Type (slack event type) +// |-User (slack user ID) +// |-Text (Text of the message) +// \-Time RFC3339 format timestamp