package bot import ( "context" "log" "strings" "time" "github.com/gissleh/irc" "git.aiterp.net/rpdata/logbot3/internal/config" "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 parent *Bot client *irc.Client } func newChannel(parent *Bot, name string, client *irc.Client) *Channel { ctx, cancel := context.WithCancel(parent.ctx) return &Channel{ name: name, parent: parent, parentCtx: parent.ctx, 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() // In case of a crash, it should take ownership of sessions when rejoining. session, err := logs.FindOpen(channel.parentCtx, channel.name) if err == nil { minsUntilDeadline := 1 + int(((time.Hour * 2) - time.Since(session.LatestTime())).Minutes()) if minsUntilDeadline < 1 { minsUntilDeadline = 1 } channel.client.Sayf(channel.name, "Session https://aiterp.net/logs/%s will be closed in %d min if there are no new posts.", session.ID, minsUntilDeadline) channel.lastPostSessionID = session.ID channel.lastPostTime = session.LatestTime() } // If the bot is op, run the commands from the configuration. go func() { time.Sleep(time.Second) commands := config.Get().Commands.OnJoinOp target := channel.client.Channel(channel.name) if len(commands) > 0 && target != nil { me, ok := target.UserList().User(channel.client.Nick()) if ok && strings.ContainsRune(me.Modes, 'o') { channel.parent.runCommands(commands, target, map[string]string{ "chan": channel.name, }) } } }() for { select { case post := <-channel.ch: { // 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) } } case "register": { target := channel.client.Channel(channel.name) if target != nil { me, ok := target.UserList().User(channel.client.Nick()) if !ok || !strings.ContainsRune(me.Modes, 'o') { channel.client.Say(channel.name, "This unit has insufficient privileges.") break } chanServNick := config.Get().Names.ChanServ if chanServNick == "" { channel.client.Say(channel.name, "This function is not enabled.") break } channel.client.Sayf(chanServNick, "REGISTER %s", target.Name()) } } } } else { queue = append(queue, post) if post.Time.After(channel.lastPostTime) { channel.lastPostTime = post.Time } } // Posts after here aren't going to be bot commands, so log its reception. log.Printf("Received %s post from %s", post.Kind, post.Nick) // 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 after 2+ hours of inactivity. See log at https://aiterp.net/logs/%s", channel.lastPostSessionID) channel.lastPostTime = time.Time{} channel.lastPostSessionID = "" } case <-channel.ctx.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 }