commit 8a380f4377c643a173f0169c116a11ca8d6b9b40 Author: Brian Buller Date: Mon Dec 5 22:20:14 2016 -0600 Initial Commit diff --git a/aocbot.go b/aocbot.go new file mode 100644 index 0000000..39ef23f --- /dev/null +++ b/aocbot.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "gogs.bullercodeworks.com/brian/boltease" +) + +const programName = "aocbot" + +// Message Processors are interfaces for interacting. +type messageProcessor interface { + GetName() string + GetHelp() string + ProcessMessage(s *Slack, m *Message) + ProcessAdminMessage(s *Slack, m *Message) + ProcessBotMessage(s *Slack, m *Message) + ProcessUserMessage(s *Slack, m *Message) + ProcessAdminUserMessage(s *Slack, m *Message) + ProcessBotUserMessage(s *Slack, m *Message) + ProcessChannelMessage(s *Slack, m *Message) + ProcessAdminChannelMessage(s *Slack, m *Message) + ProcessBotChannelMessage(s *Slack, m *Message) +} + +var messageProcessors []messageProcessor + +// Message Processors are interfaces for accumulating statistics. +type statProcessor interface { + GetName() string + GetStatKeys() []string + Initialize() + ProcessMessage(m *Message) + ProcessBotMessage(m *Message) + ProcessUserMessage(m *Message) + ProcessBotUserMessage(m *Message) + ProcessChannelMessage(m *Message) + ProcessBotChannelMessage(m *Message) +} + +var statProcessors []statProcessor +var db *boltease.DB + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "usage: "+programName+" \n") + os.Exit(1) + } + + var err error + var slack *Slack + + if db, err = getDatabase(); err != nil { + panic(err) + } + + if slack, err = CreateSlack(os.Args[1]); err != nil { + panic(err) + } + + statBotMain(slack) +} + +// This is the main function for the statbot +func statBotMain(slack *Slack) { + // start a websocket-based Real Time API session + registerMessageProcessor(new(generalProcessor)) + + fmt.Println("aoc-bot ready, ^C exits") + + writeToLog("== " + time.Now().Format(time.RFC3339) + " - Bot Started ==\n") + for { + // read each incoming message + m, err := slack.getMessage() + if err == nil { + writeToLog(" " + time.Now().Format(time.RFC3339) + " - Received Message\n") + processMessage(slack, &m) + } + } + writeToLog("== " + time.Now().Format(time.RFC3339) + " - Bot Stopped ==\n\n") +} + +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") + } + + if m.Type == "message" || m.Type == "reaction_added" { + var err error + var usr *User + + // Check if we know who the user is + usr, err = getUserInfo(m.User) + // If the user information hasn't been updated in the last 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) + } + } + + for _, stats := range statProcessors { + if usr.IsBot { + stats.ProcessBotMessage(m) + } else { + stats.ProcessMessage(m) + } + } + + for _, proc := range messageProcessors { + if isAdmin(m.User) { + proc.ProcessAdminMessage(slack, m) + } + if usr.IsBot { + proc.ProcessBotMessage(slack, m) + } else { + proc.ProcessMessage(slack, m) + } + } + + if m.Channel != "" { + // Check if we know what the channel is + chnl, err := getChannelInfo(m.Channel) + var isDirectMessage bool + + // If the channel information hasn't been updated in the last 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 { + // Save channel info + saveChannelInfo(c) + } else { + isDirectMessage = true + } + } + if isDirectMessage { + for _, proc := range messageProcessors { + if isAdmin(m.User) { + proc.ProcessAdminUserMessage(slack, m) + } + proc.ProcessUserMessage(slack, m) + } + } else { + for _, proc := range messageProcessors { + proc.ProcessChannelMessage(slack, m) + } + } + } + } +} + +func registerMessageProcessor(b messageProcessor) { + // Register a Message Processor + // Make sure that we haven't already registered it + for _, proc := range messageProcessors { + if proc.GetName() == b.GetName() { + panic(fmt.Errorf("Attempted to Re-register Message Processor %s", b.GetName())) + } + } + messageProcessors = append(messageProcessors, b) +} + +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/model.go b/model.go new file mode 100644 index 0000000..a653850 --- /dev/null +++ b/model.go @@ -0,0 +1,106 @@ +package main + +import ( + "time" + + "gogs.bullercodeworks.com/brian/boltease" +) + +func getDatabase() (*boltease.DB, error) { + db, err := boltease.Create("aocbot.db", 0644, nil) + if err != nil { + return err + } + // Make sure that we have a 'users' bucket + db.MkBucketPath([]string{"users"}) + // Make sure that we have a 'channels' bucket + db.MkBucketPath([]string{"channels"}) + + return db, nil +} + +func (db *boltease.DB) saveUser(usr *User) error { + var err error + if err = db.OpenDB(); err != nil { + return err + } + defer db.CloseDB() + + usr := []string{"users", usr.ID} + db.SetValue(append(usr, "Name"), usr.Name) + db.SetBool(append(usr, "Deleted"), usr.Deleted) + db.SetValue(append(usr, "Status"), usr.Status) + db.SetValue(append(usr, "Color"), usr.Color) + db.SetValue(append(usr, "RealName"), usr.RealName) + db.SetValue(append(usr, "TZ"), usr.TZ) + db.SetValue(append(usr, "TZLabel"), usr.TZLabel) + db.SetInt(append(usr, "TZOffset"), usr.TZOffset) + db.SetBool(append(usr, "IsAdmin"), usr.IsAdmin) + db.SetBool(append(usr, "IsOwner"), usr.IsOwner) + db.SetBool(append(usr, "IsPrimaryOwner"), usr.IsPrimaryOwner) + db.SetBool(append(usr, "IsRestricted"), usr.IsRestricted) + db.SetBool(append(usr, "IsUltraRestricted"), usr.IsUltraRestricted) + db.SetBool(append(usr, "IsBot"), usr.IsBot) + db.SetBool(append(usr, "HasFiles"), usr.HasFiles) + db.SetTimestamp(append(usr, "LastUpdated"), time.Now()) + return err +} + +func (db *boltease.DB) getUser(usrId string) (*User, error) { + usr := new(User) + var err error + if err = db.OpenDB(); err != nil { + return usr, err + } + defer db.CloseDB() + + upath := []string{"users", usrID} + if usr.Name, err = db.GetValue(append(upath, "Name")); err != nil { + return usr, err + } + if usr.Deleted, err = db.GetBool(append(upath, "Deleted")); err != nil { + return usr, err + } + if usr.Status, err = db.GetValue(append(upath, "Status")); err != nil { + return usr, err + } + if usr.Color, err = db.GetValue(append(upath, "Color")); err != nil { + return usr, err + } + if usr.RealName, err = db.GetValue(append(upath, "RealName")); err != nil { + return usr, err + } + if usr.TZ, err = db.GetValue(append(upath, "TZ")); err != nil { + return usr, err + } + if usr.TZLabel, err = db.GetValue(append(upath, "TZLabel")); err != nil { + return usr, err + } + if usr.TZOffset, err = db.GetInt(append(upath, "TZOffset")); err != nil { + return usr, err + } + if usr.IsAdmin, err = db.GetBool(append(upath, "IsAdmin")); err != nil { + return usr, err + } + if usr.IsOwner, err = db.GetBool(append(upath, "IsOwner")); err != nil { + return usr, err + } + if usr.IsPrimaryOwner, err = db.GetBool(append(upath, "IsPrimaryOwner")); err != nil { + return usr, err + } + if usr.IsRestricted, err = db.GetBool(append(upath, "IsRestricted")); err != nil { + return usr, err + } + if usr.IsUltraRestricted, err = db.GetBool(append(upath, "IsUltraRestricted")); err != nil { + return usr, err + } + if usr.IsBot, err = db.GetBool(append(upath, "IsBot")); err != nil { + return usr, err + } + if usr.HasFiles, err = db.GetBool(append(upath, "HasFiles")); err != nil { + return usr, err + } + usr.LastUpdated, err = db.GetTimestamp(append(upath, "LastUpdated")) + + return usr, err +} diff --git a/slack.go b/slack.go new file mode 100644 index 0000000..0e88d95 --- /dev/null +++ b/slack.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "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) + + m.Time = convertSlackTimestamp(m.Ts) + + 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() + 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() + 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 + } + + respObj.Channel.LastRead = convertSlackTimestamp(respObj.Channel.LastReadRaw) + + return respObj.Channel, nil +} + +func (s *Slack) joinChannel(c *Channel) error { + url := fmt.Sprintf("https://slack.com/api/channels.join?token=%s&name=%s", s.apiToken, c.Name) + resp, err := http.Get(url) + if err != nil { + return err + } + if resp.StatusCode != 200 { + err = fmt.Errorf("API request failed with code %d", resp.StatusCode) + return err + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + var respObj responseChannelLookup + err = json.Unmarshal(body, &respObj) + if err != nil { + return err + } + + if !respObj.Ok { + err = fmt.Errorf("Slack error: %s", respObj.Error) + return err + } + return err +} + +// 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/structs.go b/structs.go new file mode 100644 index 0000000..a05f9d5 --- /dev/null +++ b/structs.go @@ -0,0 +1,63 @@ +package main + +import "time" + +// 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 +} + +// 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 string `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 +} + +// 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 +}