From c08bd6c05afdad763c4cb137ca4f6105b35c97bc Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Thu, 22 Nov 2018 22:24:33 +0100 Subject: [PATCH] bot: Added channel handler and the actual logging. --- internal/bot/bot.go | 24 ++- internal/bot/channel.go | 267 ++++++++++++++++++++++++++++++ internal/bot/handler.go | 44 ++++- internal/models/log.go | 31 ++++ internal/models/logs/add.go | 67 ++++++++ internal/models/logs/edit.go | 67 ++++++++ internal/models/logs/find-open.go | 66 ++++++++ internal/models/post.go | 12 ++ internal/models/posts/add.go | 58 +++++++ 9 files changed, 630 insertions(+), 6 deletions(-) create mode 100644 internal/bot/channel.go create mode 100644 internal/models/log.go create mode 100644 internal/models/logs/add.go create mode 100644 internal/models/logs/edit.go create mode 100644 internal/models/logs/find-open.go create mode 100644 internal/models/post.go create mode 100644 internal/models/posts/add.go diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 1a79643..2be8cbf 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -19,6 +19,7 @@ type Bot struct { ctxCancel context.CancelFunc loopCtx context.Context loopCancel context.CancelFunc + channels map[string]*Channel } // New creates a new Bot. @@ -33,7 +34,8 @@ func New(ctx context.Context, nick string, alternatives []string, user string, r }) bot := &Bot{ - client: client, + client: client, + channels: make(map[string]*Channel), } client.SetValue(botKey, bot) @@ -71,6 +73,26 @@ func (bot *Bot) Connect(server string, ssl bool, maxRetries int) (err error) { return err } +func (bot *Bot) addChannel(channelName string) *Channel { + if bot.channels[channelName] != nil { + return bot.channels[channelName] + } + + channel := newChannel(bot.ctx, channelName, bot.client) + go channel.loop() + + bot.channels[channelName] = channel + return channel +} + +func (bot *Bot) handlePost(channelName string, post ChannelPost) { + channel := bot.channels[channelName] + if channel == nil { + channel = bot.addChannel(channelName) + } + channel.ch <- post +} + func (bot *Bot) loop() { bot.loopCtx, bot.loopCancel = context.WithCancel(bot.ctx) diff --git a/internal/bot/channel.go b/internal/bot/channel.go new file mode 100644 index 0000000..9297aff --- /dev/null +++ b/internal/bot/channel.go @@ -0,0 +1,267 @@ +package bot + +import ( + "context" + "log" + "strings" + "time" + + "git.aiterp.net/gisle/irc" + "git.aiterp.net/rpdata/logbot3/internal/models/channels" + "git.aiterp.net/rpdata/logbot3/internal/models/logs" + "git.aiterp.net/rpdata/logbot3/internal/models/posts" +) + +// A Channel represents a handler for a single channel that takes care of the logging and such. +type Channel struct { + ctx context.Context + cancel context.CancelFunc + ch chan ChannelPost + + lastPostSessionID string + lastPostTime time.Time + + name string + parentCtx context.Context + client *irc.Client +} + +func newChannel(parentCtx context.Context, name string, client *irc.Client) *Channel { + ctx, cancel := context.WithCancel(context.Background()) + + return &Channel{ + name: name, + parentCtx: parentCtx, + client: client, + ch: make(chan ChannelPost, 8), + + ctx: ctx, + cancel: cancel, + } +} + +func (channel *Channel) loop() { + queue := make([]ChannelPost, 0, 8) + minutely := time.NewTicker(time.Minute) + + defer channel.cancel() + defer minutely.Stop() + + session, err := logs.FindOpen(channel.parentCtx, channel.name) + if err == nil { + minsUntilDeadline := 1 + int(((time.Hour * 2) - time.Since(session.LatestTime())).Minutes()) + channel.client.Sayf(channel.name, "Session https://aiterp.net/logs/%s will be closed in %d minutes if there are no new posts.", session.ID, minsUntilDeadline) + + channel.lastPostSessionID = session.ID + channel.lastPostTime = session.LatestTime() + } + + for { + select { + case post := <-channel.ch: + { + log.Printf("Received %s post from %s", post.Kind, post.Nick) + + // Handle bot commands, or add to queue otherwise. + if cmd := post.botCommand(); cmd != nil { + switch cmd.Verb { + case "end", "ends", "endsession": + { + session, err := logs.FindOpen(channel.parentCtx, channel.name) + if err == logs.ErrNoneOpen { + channel.client.Say(channel.name, "No open session found. Mission accomplished, I guess?") + break + } else if err != nil { + log.Println("Could not find session:", err) + break + } + + _, err = logs.SetOpen(channel.parentCtx, session, false) + if err != nil { + channel.client.Say(channel.name, "Something went wrong when closing the log, please use the website instead.") + log.Println("Could not set open:", err) + } + } + case "tag", "event": + { + if cmd.Text == "" { + channel.client.Say(channel.name, "Usage: !tag ") + } + + session, err := logs.FindOpen(channel.parentCtx, channel.name) + if err == logs.ErrNoneOpen { + channel.client.Say(channel.name, "No open session found. You can edit closed logs on the website.") + break + } else if err != nil { + log.Println("Could not find session:", err) + break + } + + _, err = logs.SetEventName(channel.parentCtx, session, cmd.Text) + if err != nil { + channel.client.Say(channel.name, "Something went wrong when setting the event name, please use the website instead.") + log.Println("Could not set event name:", err) + } + } + } + } else { + queue = append(queue, post) + + if post.Time.After(channel.lastPostTime) { + channel.lastPostTime = post.Time + } + } + + // Stop here if there's nothing to post. + if len(queue) == 0 { + break + } + + // Buffer up posts close to one another. + deadline := time.After(time.Second * 3) + buffering := true + for buffering { + select { + case post := <-channel.ch: + { + queue = append(queue, post) + } + case <-deadline: + { + buffering = false + } + } + } + + // Select session. + session, err := logs.FindOpen(channel.parentCtx, channel.name) + if err == logs.ErrNoneOpen { + eventName := "" + channelData, err := channels.Find(channel.parentCtx, channel.name) + if err == nil { + eventName = channelData.EventName + } + + session, err = logs.Add(channel.parentCtx, channel.name, queue[0].Time, true, eventName) + if err != nil { + channel.client.Say(channel.name, "This unit failed to open session: "+err.Error()) + log.Println("Failed to open session:", err) + } + } else if err != nil { + channel.client.Say(channel.name, "This unit is unable to check active sessions: "+err.Error()) + break + } + log.Println("Selected session:", session.ID) + + // Remember which session was last posted to. + channel.lastPostSessionID = session.ID + + // Post posts + lastSuccess := -1 + for i, channelPost := range queue { + post, err := posts.Add(channel.parentCtx, session, channelPost.Time, channelPost.Kind, channelPost.Nick, channelPost.Text) + if err != nil { + log.Println("Failed to post:", err) + break + } + + summary := "" + for _, ru := range post.Text { + summary += string(ru) + if len(summary) > 30 { + summary += "..." + break + } + } + + log.Printf("Posted (id=%s, kind=%s, nick=%s, delay=%s): %s", post.ID, post.Kind, post.Nick, time.Since(post.Time), summary) + lastSuccess = i + } + if lastSuccess >= 0 { + copy(queue, queue[lastSuccess:]) + queue = queue[:len(queue)-(lastSuccess+1)] + } + } + + case now := <-minutely.C: + if !channel.lastPostTime.IsZero() && now.Sub(channel.lastPostTime) > (time.Hour*2) { + session, err := logs.FindOpen(channel.parentCtx, channel.name) + if err == logs.ErrNoneOpen { + log.Println(channel.name, "Log already closed.") + channel.lastPostTime = time.Time{} + channel.lastPostSessionID = "" + break + } else if err != nil { + log.Println(channel.name, "Could not find session:", err) + break + } + + if session.ID != channel.lastPostSessionID { + log.Println("Aborted auto-close in", channel.name, "due to session change.") + channel.lastPostTime = time.Time{} + channel.lastPostSessionID = "" + break + } + + if now.Sub(session.LatestTime()) < (time.Hour * 2) { + log.Println("Aborted auto-close in", channel.name, "due to session being more recent.") + channel.lastPostTime = session.LatestTime() + break + } + + _, err = logs.SetOpen(channel.parentCtx, session, false) + if err != nil { + log.Println("Could not set open:", err) + break + } + + channel.client.Sayf(channel.name, "Log session closed due to 2 hours of inactivity. See log at https://aiterp.net/logs/%s", channel.lastPostSessionID) + + channel.lastPostTime = time.Time{} + channel.lastPostSessionID = "" + } + + case <-channel.parentCtx.Done(): + { + // Time to pack up shop. + return + } + } + } +} + +func (channel *Channel) wait(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-channel.ctx.Done(): + return nil + } +} + +// A ChannelPost is a post made to a channel. +type ChannelPost struct { + Kind string + Time time.Time + Nick string + Text string + Account string +} + +func (post *ChannelPost) botCommand() *botCommand { + if !strings.HasPrefix(post.Text, "!") { + return nil + } + + split := strings.SplitN(post.Text, " ", 2) + + if len(split) == 1 { + return &botCommand{Verb: strings.ToLower(split[0][1:]), Text: ""} + } + return &botCommand{Verb: strings.ToLower(split[0][1:]), Text: split[1]} +} + +type botCommand struct { + Verb string + Text string +} diff --git a/internal/bot/handler.go b/internal/bot/handler.go index 6cc1fd2..8c4aeec 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -34,17 +34,13 @@ func handler(event *irc.Event, client *irc.Client) { case "packet.join": { if client.Nick() == event.Nick { - // TODO: Open channel handler + bot.addChannel(event.Arg(0)) } log.Println("(JOIN)", event.Nick, "joined", event.Arg(0)) } case "packet.part": { - if client.Nick() == event.Nick { - // TODO: Close channel handler - } - log.Println("(PART)", event.Nick, "left", event.Arg(0)) } case "packet.quit": @@ -52,6 +48,44 @@ func handler(event *irc.Event, client *irc.Client) { log.Println("(QUIT)", event.Nick, "quit") } + case "ctcp.action", "packet.privmsg": + { + if event.Nick == bot.client.Nick() { + break + } + + channel := event.ChannelTarget() + if channel == nil { + break + } + + account := "" + if user, ok := bot.client.FindUser(event.Nick); ok { + account = user.Account + } + + kind := "" + if event.Verb() == "action" { + kind = "action" + } else { + if strings.HasPrefix(event.Nick, "=") { + kind = "scene" + } else { + kind = "text" + } + } + + post := ChannelPost{ + Account: account, + Kind: kind, + Nick: event.Nick, + Time: event.Time, + Text: event.Text, + } + + bot.handlePost(channel.Name(), post) + } + // Log initial numerics for debugging's sake case "packet.001", "packet.002", "packet.003", "packet.251", "packet.255", "packet.265", "packet.266", "packet.250", "packet.375", "packet.372", "packet.376": { diff --git a/internal/models/log.go b/internal/models/log.go new file mode 100644 index 0000000..d3cbb74 --- /dev/null +++ b/internal/models/log.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// A Log represents a log session. +type Log struct { + ID string `json:"id"` + Date time.Time `json:"date"` + ChannelName string `json:"channelName"` + Title string `json:"title"` + EventName string `json:"eventName"` + Description string `json:"description"` + Open bool `json:"open"` + Posts []Post `json:"posts"` +} + +// LatestTime gets the latest timestamp in the log. +func (log *Log) LatestTime() time.Time { + if len(log.Posts) == 0 { + return log.Date + } + + latest := log.Date + for _, post := range log.Posts { + if post.Time.After(latest) { + latest = post.Time + } + } + + return latest +} diff --git a/internal/models/logs/add.go b/internal/models/logs/add.go new file mode 100644 index 0000000..c079aad --- /dev/null +++ b/internal/models/logs/add.go @@ -0,0 +1,67 @@ +package logs + +import ( + "context" + "encoding/json" + "time" + + "git.aiterp.net/rpdata/logbot3/internal/api" + "git.aiterp.net/rpdata/logbot3/internal/models" +) + +// Add adds a log file. +func Add(ctx context.Context, channelName string, date time.Time, open bool, event string) (models.Log, error) { + input := addInput{ + Date: date, + Channel: channelName, + Open: &open, + Event: &event, + } + + data, err := api.Global().Query(ctx, addGQL, map[string]interface{}{"input": input}, []string{"log.add"}) + if err != nil { + return models.Log{}, err + } + + res := addResult{} + err = json.Unmarshal(data, &res) + if err != nil { + return models.Log{}, err + } + + return res.Log, nil +} + +type addResult struct { + Log models.Log `json:"addLog"` +} + +type addInput struct { + Date time.Time `json:"date"` + Channel string `json:"channel"` + Title *string `json:"title"` + Open *bool `json:"open"` + Event *string `json:"event"` + Description *string `json:"description"` +} + +const addGQL = ` + mutation AddLog($input:LogAddInput!) { + addLog(input:$input) { + id + date + channelName + title + eventName + description + open + posts { + id + time + kind + nick + text + } + } + } +` diff --git a/internal/models/logs/edit.go b/internal/models/logs/edit.go new file mode 100644 index 0000000..6741f51 --- /dev/null +++ b/internal/models/logs/edit.go @@ -0,0 +1,67 @@ +package logs + +import ( + "context" + "encoding/json" + + "git.aiterp.net/rpdata/logbot3/internal/api" + "git.aiterp.net/rpdata/logbot3/internal/models" +) + +// SetOpen changes the open state of a log. +func SetOpen(ctx context.Context, log models.Log, open bool) (models.Log, error) { + return edit(ctx, editInput{ID: log.ID, Open: &open}) +} + +// SetEventName changes the event name of a log. +func SetEventName(ctx context.Context, log models.Log, event string) (models.Log, error) { + return edit(ctx, editInput{ID: log.ID, Event: &event}) +} + +func edit(ctx context.Context, input editInput) (models.Log, error) { + data, err := api.Global().Query(ctx, editGQL, map[string]interface{}{"input": input}, []string{"log.edit"}) + if err != nil { + return models.Log{}, err + } + + res := editResult{} + err = json.Unmarshal(data, &res) + if err != nil { + return models.Log{}, err + } + + return res.Log, nil +} + +type editResult struct { + Log models.Log `json:"editLog"` +} + +type editInput struct { + ID string `json:"id"` + Title *string `json:"title"` + Event *string `json:"event"` + Description *string `json:"description"` + Open *bool `json:"open"` +} + +const editGQL = ` + mutation EditLog($input:LogEditInput!) { + editLog(input:$input) { + id + date + channelName + title + eventName + description + open + posts { + id + time + kind + nick + text + } + } + } +` diff --git a/internal/models/logs/find-open.go b/internal/models/logs/find-open.go new file mode 100644 index 0000000..38bc647 --- /dev/null +++ b/internal/models/logs/find-open.go @@ -0,0 +1,66 @@ +package logs + +import ( + "context" + "encoding/json" + "errors" + + "git.aiterp.net/rpdata/logbot3/internal/api" + "git.aiterp.net/rpdata/logbot3/internal/models" +) + +// ErrNoneOpen is returned by FindOpen if no logs are open, but the query did succeed. +var ErrNoneOpen = errors.New("No open logs") + +// FindOpen lists all changes according to the filter. +func FindOpen(ctx context.Context, channelName string) (models.Log, error) { + data, err := api.Global().Query(ctx, findOpenGQL, map[string]interface{}{"channel": channelName}, nil) + if err != nil { + return models.Log{}, err + } + + res := findOpenResult{} + err = json.Unmarshal(data, &res) + if err != nil { + return models.Log{}, err + } + if len(res.Logs) == 0 { + return models.Log{}, ErrNoneOpen + } + + // This shouldn't happen, but if there are more than one + // open logs for a channel, select the most recent one. + selected := res.Logs[0] + for _, log := range res.Logs[1:] { + if log.Date.After(selected.Date) { + selected = log + } + } + + return selected, nil +} + +type findOpenResult struct { + Logs []models.Log `json:"logs"` +} + +var findOpenGQL = ` + query FindOpen($channel:String!) { + logs(filter:{open:true, channels:[$channel]}) { + id + date + channelName + title + eventName + description + open + posts { + id + time + kind + nick + text + } + } + } +` diff --git a/internal/models/post.go b/internal/models/post.go new file mode 100644 index 0000000..1e3ba3d --- /dev/null +++ b/internal/models/post.go @@ -0,0 +1,12 @@ +package models + +import "time" + +// A Post is a post. +type Post struct { + ID string `json:"id"` + Time time.Time `json:"time"` + Kind string `json:"kind"` + Nick string `json:"nick"` + Text string `json:"text"` +} diff --git a/internal/models/posts/add.go b/internal/models/posts/add.go new file mode 100644 index 0000000..427e006 --- /dev/null +++ b/internal/models/posts/add.go @@ -0,0 +1,58 @@ +package posts + +import ( + "context" + "encoding/json" + "time" + + "git.aiterp.net/rpdata/logbot3/internal/api" + "git.aiterp.net/rpdata/logbot3/internal/models" +) + +// Add adds a log file. +func Add(ctx context.Context, log models.Log, time time.Time, kind, nick, text string) (models.Post, error) { + input := addInput{ + LogID: log.ID, + Time: time, + Kind: kind, + Nick: nick, + Text: text, + } + + data, err := api.Global().Query(ctx, addGQL, map[string]interface{}{"input": input}, []string{"post.add"}) + if err != nil { + return models.Post{}, err + } + + res := addResult{} + err = json.Unmarshal(data, &res) + if err != nil { + return models.Post{}, err + } + + return res.Post, nil +} + +type addResult struct { + Post models.Post `json:"addPost"` +} + +type addInput struct { + LogID string `json:"logId"` + Time time.Time `json:"time"` + Kind string `json:"kind"` + Nick string `json:"nick"` + Text string `json:"text"` +} + +const addGQL = ` + mutation AddPost($input:PostAddInput!) { + addPost(input:$input) { + id + time + kind + nick + text + } + } +`