Gisle Aune
6 years ago
9 changed files with 630 additions and 6 deletions
-
24internal/bot/bot.go
-
267internal/bot/channel.go
-
44internal/bot/handler.go
-
31internal/models/log.go
-
67internal/models/logs/add.go
-
67internal/models/logs/edit.go
-
66internal/models/logs/find-open.go
-
12internal/models/post.go
-
58internal/models/posts/add.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 <Event Name>") |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
` |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
` |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
` |
@ -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"` |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
` |
Write
Preview
Loading…
Cancel
Save
Reference in new issue