From 8e468c4ce97d25f3a0dcd0d11ad1ff017c414c24 Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Tue, 6 Dec 2016 17:31:22 -0600 Subject: [PATCH] Functioning with leaderboard updates --- .gitignore | 7 + aoc_structs.go | 18 +++ aocbot.go | 253 +++++++++++++++++++++++---------- model.go | 196 ++++++++++++++++++++----- slack.go | 91 ++++++++---- structs.go => slack_structs.go | 9 ++ 6 files changed, 436 insertions(+), 138 deletions(-) create mode 100644 .gitignore create mode 100644 aoc_structs.go rename structs.go => slack_structs.go (88%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cdb34b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +slack-api-key +cookies +aoc_leaderboard.json +aocbot +aocbot.db +runbot.sh +*.log diff --git a/aoc_structs.go b/aoc_structs.go new file mode 100644 index 0000000..50ea6ec --- /dev/null +++ b/aoc_structs.go @@ -0,0 +1,18 @@ +package main + +import "time" + +type Leaderboard struct { + OwnerID string `json:"owner_id"` + Event string `json:"event"` + Members map[string]Member `json:"members"` +} + +type Member struct { + ID string `json:"id"` + Stars int `json:"stars"` + RawStarTs string `json:"last_star_ts"` + LastStarTs time.Time + Name string `json:"name"` + SlackID string +} diff --git a/aocbot.go b/aocbot.go index 39ef23f..95287d2 100644 --- a/aocbot.go +++ b/aocbot.go @@ -3,7 +3,12 @@ package main import ( "encoding/json" "fmt" + "io/ioutil" + "net/http" "os" + "regexp" + "strconv" + "strings" "time" "gogs.bullercodeworks.com/brian/boltease" @@ -11,42 +16,14 @@ import ( 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 +var boardID string + +var slackChannel string func main() { - if len(os.Args) != 2 { - fmt.Fprintf(os.Stderr, "usage: "+programName+" \n") + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "usage: "+programName+" \n") os.Exit(1) } @@ -56,22 +33,68 @@ func main() { if db, err = getDatabase(); err != nil { panic(err) } + slackChannel = "C0G3X1M5K" + + // DevICT Leaderboard: 3549 + boardID = os.Args[2] if slack, err = CreateSlack(os.Args[1]); err != nil { panic(err) } - statBotMain(slack) + aocBotMain(slack) } // This is the main function for the statbot -func statBotMain(slack *Slack) { +func aocBotMain(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") + var lastAoCUpdate time.Time + go func() { + for { + var err error + lastAoCUpdate = time.Now() + var leaderboard *Leaderboard + fmt.Println(lastAoCUpdate.Format(time.RFC3339) + ": Fetching Online Leaderboard") + if leaderboard, err = getAoCLeaderboard(boardID); err != nil { + fmt.Println(err.Error()) + } + + for _, v := range leaderboard.Members { + var mbr *Member + if mbr, err = getAoCUser(v.ID); err != nil { + // Member doesn't exist in db, add it + // Notify br0xen ( U030RD9NU ) + m := new(Message) + m.Type = "message" + m.Channel = "D0D793N5R" + m.Text = "AoC Leaderboard has a new member! " + v.Name + fmt.Println("New Leaderboard Member Found: " + v.Name) + if err = slack.postMessage(*m); err != nil { + fmt.Println(err.Error()) + } + saveAoCUser(&v) + continue + } + if mbr.Stars != v.Stars { + // Number of stars has changed + fmt.Println(v.ID + "(" + v.Name + "): " + v.LastStarTs.Format(time.RFC3339)) + m := new(Message) + m.Type = "message" + m.Channel = slackChannel + m.Text = ":christmas_tree: " + v.Name + " now has " + strconv.Itoa(v.Stars) + " stars! :christmas_tree:" + if err = slack.postMessage(*m); err != nil { + fmt.Println(err.Error()) + } + saveAoCUser(&v) + } + } + time.Sleep(time.Minute * 10) + } + }() + for { // read each incoming message m, err := slack.getMessage() @@ -79,6 +102,7 @@ func statBotMain(slack *Slack) { writeToLog(" " + time.Now().Format(time.RFC3339) + " - Received Message\n") processMessage(slack, &m) } + } writeToLog("== " + time.Now().Format(time.RFC3339) + " - Bot Stopped ==\n\n") } @@ -94,33 +118,15 @@ func processMessage(slack *Slack, m *Message) { var usr *User // Check if we know who the user is - usr, err = getUserInfo(m.User) + usr, err = getUser(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) + saveUser(u) } } + // TODO: Process the message if m.Channel != "" { // Check if we know what the channel is chnl, err := getChannelInfo(m.Channel) @@ -137,30 +143,133 @@ func processMessage(slack *Slack, m *Message) { } } if isDirectMessage { - for _, proc := range messageProcessors { - if isAdmin(m.User) { - proc.ProcessAdminUserMessage(slack, m) + if m.User == "U030RD9NU" { + // message from br0xen + if strings.HasPrefix(m.Text, "!aoc") { + var slackId, aocNm, op string + flds := strings.Fields(m.Text) + if len(flds) < 2 { + // No operation specified + return + } + op = flds[1] + if op == "help" { + // TODO: Send help message + } + if op == "match" { + re := regexp.MustCompile("[<\"]([^>]*)[>\"]") + matchUp := re.FindAllString(m.Text, -1) + + for i := range matchUp { + if matchUp[i][0] == '<' { + slackId = matchUp[i][1 : len(matchUp[i])-1] + } + if matchUp[i][0] == '"' { + aocNm = matchUp[i][1 : len(matchUp[i])-1] + } + } + + var m *Member + if m, err = getAoCUserByName(aocNm); err != nil { + // AoC Member doesn't exist in DB + fmt.Println("AoC User " + aocNm + " doesn't exist in DB") + fmt.Println(err.Error()) + return + } + m.SlackID = slackId + saveAoCUser(m) + } else if op == "ping" { + m := new(Message) + m.Type = "message" + m.Channel = "D0D793N5R" + m.Text = ":christmas_tree: PONG :christmas_tree:" + if err = slack.postMessage(*m); err != nil { + fmt.Println(err.Error()) + } + } } - proc.ProcessUserMessage(slack, m) + /* + ID: 0 + Type: message + Channel: D0D793N5R + User: U030RD9NU + Name: + Text: testing + Ts: 1481044675.000003 + */ } } else { - for _, proc := range messageProcessors { - proc.ProcessChannelMessage(slack, m) + fmt.Println("--- Channel Message Received") + fmt.Println(m.Channel, m.User, m.Text) + if m.Channel == slackChannel { + // In the AoC channel + if strings.HasPrefix(m.Text, "!aoc") { + var op string + flds := strings.Fields(m.Text) + if len(flds) < 2 { + // No operation specified + return + } + op = flds[1] + if op == "ping" { + m := new(Message) + m.Type = "message" + m.Channel = slackChannel + m.Text = ":christmas_tree: PONG :christmas_tree:" + if err = slack.postMessage(*m); err != nil { + fmt.Println(err.Error()) + } + } + } } + // TODO: Process Channel Message + /* + 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())) - } +func getAoCLeaderboard(boardId string) (*Leaderboard, error) { + var err error + var req *http.Request + var content []byte + var resp *http.Response + var body []byte + leaderboard := new(Leaderboard) + + client := &http.Client{} + boardString := "http://adventofcode.com/2016/leaderboard/private/view/" + boardId + ".json" + + req, err = http.NewRequest("GET", boardString, nil) + // Read in cookies + content, err = ioutil.ReadFile("./cookies") + if err != nil { + fmt.Println("Error reading Cookies") + return leaderboard, err } - messageProcessors = append(messageProcessors, b) + line := strings.TrimSpace(string(content)) + req.Header.Add("Cookie", line) + + resp, err = client.Do(req) + if err != nil { + fmt.Println("Error getting leaderboard") + } + defer resp.Body.Close() + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error reading response body.") + return leaderboard, err + } + err = json.Unmarshal(body, &leaderboard) + for k, mbr := range leaderboard.Members { + mbr.LastStarTs, err = time.Parse("2006-01-02T15:04:05-0700", mbr.RawStarTs) + leaderboard.Members[k] = mbr + } + return leaderboard, err } func writeToLog(d string) { diff --git a/model.go b/model.go index a653850..bf4bd2f 100644 --- a/model.go +++ b/model.go @@ -1,6 +1,7 @@ package main import ( + "strings" "time" "gogs.bullercodeworks.com/brian/boltease" @@ -9,44 +10,46 @@ import ( func getDatabase() (*boltease.DB, error) { db, err := boltease.Create("aocbot.db", 0644, nil) if err != nil { - return err + return db, 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"}) + // Make sure that we have a 'aocmembers' bucket + db.MkBucketPath([]string{"AoCMembers"}) return db, nil } -func (db *boltease.DB) saveUser(usr *User) error { +func 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()) + usrPath := []string{"users", usr.ID} + db.SetValue(usrPath, "Name", usr.Name) + db.SetBool(usrPath, "Deleted", usr.Deleted) + db.SetValue(usrPath, "Status", usr.Status) + db.SetValue(usrPath, "Color", usr.Color) + db.SetValue(usrPath, "RealName", usr.RealName) + db.SetValue(usrPath, "TZ", usr.TZ) + db.SetValue(usrPath, "TZLabel", usr.TZLabel) + db.SetInt(usrPath, "TZOffset", usr.TZOffset) + db.SetBool(usrPath, "IsAdmin", usr.IsAdmin) + db.SetBool(usrPath, "IsOwner", usr.IsOwner) + db.SetBool(usrPath, "IsPrimaryOwner", usr.IsPrimaryOwner) + db.SetBool(usrPath, "IsRestricted", usr.IsRestricted) + db.SetBool(usrPath, "IsUltraRestricted", usr.IsUltraRestricted) + db.SetBool(usrPath, "IsBot", usr.IsBot) + db.SetBool(usrPath, "HasFiles", usr.HasFiles) + db.SetTimestamp(usrPath, "LastUpdated", time.Now()) return err } -func (db *boltease.DB) getUser(usrId string) (*User, error) { +func getUser(usrId string) (*User, error) { usr := new(User) var err error if err = db.OpenDB(); err != nil { @@ -54,53 +57,172 @@ func (db *boltease.DB) getUser(usrId string) (*User, error) { } defer db.CloseDB() - upath := []string{"users", usrID} - if usr.Name, err = db.GetValue(append(upath, "Name")); err != nil { + upath := []string{"users", usrId} + if usr.Name, err = db.GetValue(upath, "Name"); err != nil { return usr, err } - if usr.Deleted, err = db.GetBool(append(upath, "Deleted")); err != nil { + if usr.Deleted, err = db.GetBool(upath, "Deleted"); err != nil { return usr, err } - if usr.Status, err = db.GetValue(append(upath, "Status")); err != nil { + if usr.Status, err = db.GetValue(upath, "Status"); err != nil { return usr, err } - if usr.Color, err = db.GetValue(append(upath, "Color")); err != nil { + if usr.Color, err = db.GetValue(upath, "Color"); err != nil { return usr, err } - if usr.RealName, err = db.GetValue(append(upath, "RealName")); err != nil { + if usr.RealName, err = db.GetValue(upath, "RealName"); err != nil { return usr, err } - if usr.TZ, err = db.GetValue(append(upath, "TZ")); err != nil { + if usr.TZ, err = db.GetValue(upath, "TZ"); err != nil { return usr, err } - if usr.TZLabel, err = db.GetValue(append(upath, "TZLabel")); err != nil { + if usr.TZLabel, err = db.GetValue(upath, "TZLabel"); err != nil { return usr, err } - if usr.TZOffset, err = db.GetInt(append(upath, "TZOffset")); err != nil { + if usr.TZOffset, err = db.GetInt(upath, "TZOffset"); err != nil { return usr, err } - if usr.IsAdmin, err = db.GetBool(append(upath, "IsAdmin")); err != nil { + if usr.IsAdmin, err = db.GetBool(upath, "IsAdmin"); err != nil { return usr, err } - if usr.IsOwner, err = db.GetBool(append(upath, "IsOwner")); err != nil { + if usr.IsOwner, err = db.GetBool(upath, "IsOwner"); err != nil { return usr, err } - if usr.IsPrimaryOwner, err = db.GetBool(append(upath, "IsPrimaryOwner")); err != nil { + if usr.IsPrimaryOwner, err = db.GetBool(upath, "IsPrimaryOwner"); err != nil { return usr, err } - if usr.IsRestricted, err = db.GetBool(append(upath, "IsRestricted")); err != nil { + if usr.IsRestricted, err = db.GetBool(upath, "IsRestricted"); err != nil { return usr, err } - if usr.IsUltraRestricted, err = db.GetBool(append(upath, "IsUltraRestricted")); err != nil { + if usr.IsUltraRestricted, err = db.GetBool(upath, "IsUltraRestricted"); err != nil { return usr, err } - if usr.IsBot, err = db.GetBool(append(upath, "IsBot")); err != nil { + if usr.IsBot, err = db.GetBool(upath, "IsBot"); err != nil { return usr, err } - if usr.HasFiles, err = db.GetBool(append(upath, "HasFiles")); err != nil { + if usr.HasFiles, err = db.GetBool(upath, "HasFiles"); err != nil { return usr, err } - usr.LastUpdated, err = db.GetTimestamp(append(upath, "LastUpdated")) + usr.LastUpdated, err = db.GetTimestamp(upath, "LastUpdated") return usr, err } + +func getChannelInfo(chanId string) (*Channel, error) { + ret := new(Channel) + var err error + if err = db.OpenDB(); err != nil { + return ret, err + } + defer db.CloseDB() + + cPath := []string{"channels", chanId} + if ret.ID, err = db.GetValue(cPath, "ID"); err != nil { + return ret, err + } + if ret.Name, err = db.GetValue(cPath, "Name"); err != nil { + return ret, err + } + if ret.IsChannel, err = db.GetBool(cPath, "IsChannel"); err != nil { + return ret, err + } + if ret.IsGeneral, err = db.GetBool(cPath, "IsGeneral"); err != nil { + return ret, err + } + if ret.IsMember, err = db.GetBool(cPath, "IsMember"); err != nil { + return ret, err + } + var mbrString string + if mbrString, err = db.GetValue(cPath, "Members"); err != nil { + return ret, err + } + ret.Members = strings.Split(mbrString, ",") + return ret, err +} + +func saveChannelInfo(c *Channel) error { + var err error + if err = db.OpenDB(); err != nil { + return err + } + defer db.CloseDB() + + cPath := []string{"channels", c.ID} + db.SetValue(cPath, "ID", c.ID) + db.SetValue(cPath, "Name", c.Name) + db.SetBool(cPath, "IsChannel", c.IsChannel) + db.SetBool(cPath, "IsGeneral", c.IsGeneral) + db.SetBool(cPath, "IsMember", c.IsMember) + db.SetValue(cPath, "Members", strings.Join(c.Members, ",")) + return nil +} + +func saveAoCUser(m *Member) error { + var err error + if err = db.OpenDB(); err != nil { + return err + } + defer db.CloseDB() + + mPath := []string{"AoCMembers", m.ID} + db.SetValue(mPath, "ID", m.ID) + db.SetValue(mPath, "Name", m.Name) + db.SetInt(mPath, "Stars", m.Stars) + db.SetTimestamp(mPath, "LastStarTs", m.LastStarTs) + db.SetValue(mPath, "SlackID", m.SlackID) + return nil +} + +func getAoCUser(mbrId string) (*Member, error) { + ret := new(Member) + var err error + if err = db.OpenDB(); err != nil { + return ret, err + } + defer db.CloseDB() + + mPath := []string{"AoCMembers", mbrId} + if ret.ID, err = db.GetValue(mPath, "ID"); err != nil { + return ret, err + } + if ret.Stars, err = db.GetInt(mPath, "Stars"); err != nil { + return ret, err + } + if ret.LastStarTs, err = db.GetTimestamp(mPath, "LastStarTs"); err != nil { + return ret, err + } + if ret.Name, err = db.GetValue(mPath, "Name"); err != nil { + return ret, err + } + ret.SlackID, err = db.GetValue(mPath, "SlackID") + return ret, err +} + +func getAoCUserByName(nm string) (*Member, error) { + var err error + if err = db.OpenDB(); err != nil { + return nil, err + } + defer db.CloseDB() + + mPath := []string{"AoCMembers"} + var bktList []string + if bktList, err = db.GetBucketList(mPath); err != nil { + return nil, err + } + var dbNm, dbId string + for i := range bktList { + dbNm, err = db.GetValue(append(mPath, bktList[i]), "Name") + if err != nil { + return nil, err + } + if nm == dbNm { + dbId, err = db.GetValue(append(mPath, bktList[i]), "ID") + if err != nil { + return nil, err + } + return getAoCUser(dbId) + } + } + return nil, err +} diff --git a/slack.go b/slack.go index 0e88d95..e73faad 100644 --- a/slack.go +++ b/slack.go @@ -5,6 +5,8 @@ import ( "fmt" "io/ioutil" "net/http" + "strconv" + "strings" "sync/atomic" "time" @@ -91,7 +93,15 @@ func (s *Slack) getMessage() (Message, error) { func (s *Slack) sendMessageToUser(uid string, msg string) error { // TODO: Actually send a direct message to uid - return nil + m := new(Message) + m.ID = atomic.AddUint64(&counter, 1) + m.Type = "message" + m.Channel = "" + m.User = uid + m.Name = "" + m.Text = msg + + return websocket.JSON.Send(s.socket, m) } func (s *Slack) getUserInfo(uid string) (*User, error) { @@ -122,6 +132,41 @@ func (s *Slack) getUserInfo(uid string) (*User, error) { return respObj.User, nil } +func (s *Slack) getChannelList() ([]Channel, error) { + url := fmt.Sprintf("https://slack.com/api/channels.list?token=%s", s.apiToken) + 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 + } + fmt.Println(string(body)) + + var respObj responseChannelList + 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 + } + + for i := range respObj.Channels { + respObj.Channels[i].LastRead = convertSlackTimestamp(respObj.Channels[i].LastReadRaw) + } + + return respObj.Channels, 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) @@ -153,34 +198,6 @@ func (s *Slack) getChannelInfo(cid string) (*Channel, error) { 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 { @@ -205,3 +222,19 @@ type responseChannelLookup struct { Error string `json:"error"` Channel *Channel `json:"channel"` } + +type responseChannelList struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Channels []Channel `json:"channels"` +} + +func convertSlackTimestamp(ts string) time.Time { + var ret time.Time + txtArr := strings.Split(ts, ".") + if t, err := strconv.Atoi(txtArr[0]); err == nil { + rawts := int64(t) + ret = time.Unix(0, rawts*1000000000) + } + return ret +} diff --git a/structs.go b/slack_structs.go similarity index 88% rename from structs.go rename to slack_structs.go index a05f9d5..bb1f154 100644 --- a/structs.go +++ b/slack_structs.go @@ -39,6 +39,15 @@ type Channel struct { 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 +} + // User object type User struct { ID string `json:"id"`