Add tests and edit comments if the item they're replying to has been edited

This commit is contained in:
Elara 2023-09-02 15:52:26 -07:00
parent dfd40c55f5
commit b049a4359d
10 changed files with 522 additions and 78 deletions

View File

@ -5,6 +5,7 @@ import (
"os"
"strconv"
"text/template"
"time"
"github.com/Masterminds/sprig"
"github.com/pelletier/go-toml/v2"
@ -29,9 +30,10 @@ type Reply struct {
}
type Config struct {
ConfigFile *ConfigFile
Regexes map[string]*pcre.Regexp
Tmpls map[string]*template.Template
ConfigFile *ConfigFile
Regexes map[string]*pcre.Regexp
Tmpls map[string]*template.Template
PollInterval time.Duration
}
func loadConfig(path string) (Config, error) {
@ -60,7 +62,7 @@ func loadConfig(path string) (Config, error) {
return Config{}, err
}
cfg := Config{cfgFile, compiledRegexes, compiledTmpls}
cfg := Config{cfgFile, compiledRegexes, compiledTmpls, 15 * time.Second}
validateConfig(cfg)
return cfg, nil
}

7
go.mod
View File

@ -1,14 +1,15 @@
module go.elara.ws/lemmy-reply-bot
go 1.19
go 1.21.0
//replace go.elara.ws/go-lemmy => /home/arsen/Code/go-lemmy
//replace go.elara.ws/go-lemmy => /home/elara/Code/go-lemmy
require (
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/jarcoal/httpmock v1.3.1
github.com/pelletier/go-toml/v2 v2.0.6
github.com/spf13/pflag v1.0.5
go.elara.ws/go-lemmy v0.18.0
go.elara.ws/go-lemmy v0.18.1-0.20230902225105-9f4bae88f2fe
go.elara.ws/logger v0.0.0-20230421022458-e80700db2090
go.elara.ws/pcre v0.0.0-20230805032557-4ce849193f64
modernc.org/sqlite v1.25.0

15
go.sum
View File

@ -11,9 +11,11 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.1 h1:Vjg2VEcdHpwq+oY63s/ksHrgJYCTo0bwWvmmYWdE9fQ=
@ -22,11 +24,16 @@ github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@ -50,8 +57,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.elara.ws/go-lemmy v0.18.0 h1:c83VhfGtePegDZRV8X4PWfkl4cqBqug4li20VChN6sE=
go.elara.ws/go-lemmy v0.18.0/go.mod h1:rurQND/HT3yWfX/T4w+hb6vEwRAeAlV+9bSGFkkx5rA=
go.elara.ws/go-lemmy v0.18.1-0.20230902225105-9f4bae88f2fe h1:X7mPzVAc7zZipyzxzi4kdFuRptEsM6GJYLLJQtKiJrQ=
go.elara.ws/go-lemmy v0.18.1-0.20230902225105-9f4bae88f2fe/go.mod h1:rurQND/HT3yWfX/T4w+hb6vEwRAeAlV+9bSGFkkx5rA=
go.elara.ws/logger v0.0.0-20230421022458-e80700db2090 h1:RVC8XvWo6Yw4HUshqx4TSzuBDScDghafU6QFRJ4xPZg=
go.elara.ws/logger v0.0.0-20230421022458-e80700db2090/go.mod h1:qng49owViqsW5Aey93lwBXONw20oGbJIoLVscB16mPM=
go.elara.ws/pcre v0.0.0-20230805032557-4ce849193f64 h1:QixGnJE1jP08Hs1G3rS7tZGd8DeBRtz9RBpk08WlGh4=
@ -97,7 +104,9 @@ modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
@ -111,6 +120,8 @@ modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=

View File

@ -8,6 +8,7 @@ import ()
type RepliedItem struct {
ID int64
ReplyID int64
ItemType string
UpdatedTime int64
}

View File

@ -10,7 +10,7 @@ import (
)
const addItem = `-- name: AddItem :exec
INSERT OR REPLACE INTO replied_items (id, item_type, updated_time) VALUES (?, ?, ?)
INSERT OR REPLACE INTO replied_items (id, reply_id, item_type, updated_time) VALUES (?, -1, ?, ?)
`
type AddItemParams struct {
@ -24,19 +24,37 @@ func (q *Queries) AddItem(ctx context.Context, arg AddItemParams) error {
return err
}
const itemExists = `-- name: ItemExists :one
SELECT COUNT(1) FROM replied_items WHERE item_type = ? AND id = ? AND updated_time = ?
const getItem = `-- name: GetItem :one
SELECT id, reply_id, item_type, updated_time FROM replied_items WHERE item_type = ? AND id = ? LIMIT 1
`
type ItemExistsParams struct {
ItemType string
ID int64
UpdatedTime int64
type GetItemParams struct {
ItemType string
ID int64
}
func (q *Queries) ItemExists(ctx context.Context, arg ItemExistsParams) (int64, error) {
row := q.db.QueryRowContext(ctx, itemExists, arg.ItemType, arg.ID, arg.UpdatedTime)
var count int64
err := row.Scan(&count)
return count, err
func (q *Queries) GetItem(ctx context.Context, arg GetItemParams) (RepliedItem, error) {
row := q.db.QueryRowContext(ctx, getItem, arg.ItemType, arg.ID)
var i RepliedItem
err := row.Scan(
&i.ID,
&i.ReplyID,
&i.ItemType,
&i.UpdatedTime,
)
return i, err
}
const setReplyID = `-- name: SetReplyID :exec
UPDATE replied_items SET reply_id = ? WHERE id = ?
`
type SetReplyIDParams struct {
ReplyID int64
ID int64
}
func (q *Queries) SetReplyID(ctx context.Context, arg SetReplyIDParams) error {
_, err := q.db.ExecContext(ctx, setReplyID, arg.ReplyID, arg.ID)
return err
}

View File

@ -1,7 +1,6 @@
package main
import (
"fmt"
"os"
"go.elara.ws/logger"

174
main.go
View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
_ "embed"
"errors"
"os/signal"
"strings"
"syscall"
@ -19,22 +20,19 @@ import (
_ "modernc.org/sqlite"
)
//go:generate sqlc generate
//go:embed sql/schema.sql
var schema string
func init() {
db, err := sql.Open("sqlite", "replied.db")
func openDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
log.Fatal("Error opening database during init").Err(err).Send()
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(schema)
if err != nil {
log.Fatal("Error initializing database").Err(err).Send()
}
err = db.Close()
if err != nil {
log.Fatal("Error closing database after init").Err(err).Send()
}
return db, err
}
func main() {
@ -51,6 +49,13 @@ func main() {
log.Fatal("Error loading config file").Err(err).Send()
}
db, err := openDB("replied.db")
if err != nil {
log.Fatal("Error opening reply database").Err(err).Send()
}
defer db.Close()
rs := store.New(db)
c, err := lemmy.New(cfg.ConfigFile.Lemmy.InstanceURL)
if err != nil {
log.Fatal("Error creating new Lemmy API client").Err(err).Send()
@ -69,22 +74,19 @@ func main() {
replyCh := make(chan replyJob, 200)
if !*dryRun {
go commentReplyWorker(ctx, c, replyCh)
// Start the reply worker in the background
go commentReplyWorker(ctx, c, rs, replyCh)
}
commentWorker(ctx, c, cfg, replyCh)
// Start the comment worker
commentWorker(ctx, c, cfg, rs, replyCh)
}
func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh chan<- replyJob) {
db, err := sql.Open("sqlite", "replied.db")
if err != nil {
log.Fatal("Error opening reply database").Err(err).Send()
}
rs := store.New(db)
func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, rs *store.Queries, replyCh chan<- replyJob) {
for {
select {
case <-time.After(15 * time.Second):
case <-time.After(cfg.PollInterval):
// Get 50 of the newest comments from Lemmy
comments, err := c.Comments(ctx, types.GetComments{
Type: types.NewOptional(types.ListingTypeLocal),
Sort: types.NewOptional(types.CommentSortTypeNew),
@ -101,16 +103,28 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
continue
}
// If the item we're checking for already exists, we've already replied, so skip it
if c, err := rs.ItemExists(ctx, store.ItemExistsParams{
ID: int64(c.Comment.ID),
ItemType: store.Comment,
UpdatedTime: c.Comment.Updated.Unix(),
}); c > 0 && err == nil {
continue
edit := false
// Try to get comment item from the database
item, err := rs.GetItem(ctx, store.GetItemParams{
ID: int64(c.Comment.ID),
ItemType: store.Comment,
})
if errors.Is(err, sql.ErrNoRows) {
// If the item doesn't exist, we need to reply to it,
// so don't continue or set edit
} else if err != nil {
log.Warn("Error checking if item exists").Err(err).Send()
continue
} else if item.UpdatedTime == c.Comment.Updated.Unix() {
// If the item we're checking for exists and hasn't been edited,
// we've already replied, so skip it
continue
} else if item.UpdatedTime != c.Comment.Updated.Unix() {
// If the item exists but has been edited since we replied,
// set edit to true so we know to edit it instead of making
// a new comment
edit = true
}
for i, reply := range cfg.ConfigFile.Replies {
@ -129,6 +143,13 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
PostID: c.Comment.PostID,
}
// If edit is set to true, we need to edit the comment,
// so set the job's EditID so the reply worker knows which
// comment to edit
if edit {
job.EditID = int(item.ReplyID)
}
matches := re.FindAllStringSubmatch(c.Comment.Content, -1)
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
Matches: toSubmatches(matches),
@ -141,6 +162,8 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
replyCh <- job
// Add the reply to the database so we don't reply to it
// again if we encounter it again
err = rs.AddItem(ctx, store.AddItemParams{
ID: int64(c.Comment.ID),
ItemType: store.Comment,
@ -153,6 +176,7 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
}
}
// Get 20 of the newest posts from Lemmy
posts, err := c.Posts(ctx, types.GetPosts{
Type: types.NewOptional(types.ListingTypeLocal),
Sort: types.NewOptional(types.SortTypeNew),
@ -169,16 +193,28 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
continue
}
// If the item we're checking for already exists, we've already replied, so skip it
if c, err := rs.ItemExists(ctx, store.ItemExistsParams{
ID: int64(p.Post.ID),
ItemType: store.Post,
UpdatedTime: p.Post.Updated.Unix(),
}); c > 0 && err == nil {
continue
edit := false
// Try to get post item from the database
item, err := rs.GetItem(ctx, store.GetItemParams{
ID: int64(p.Post.ID),
ItemType: store.Post,
})
if errors.Is(err, sql.ErrNoRows) {
// If the item doesn't exist, we need to reply to it,
// so don't continue or set edit
} else if err != nil {
log.Warn("Error checking if item exists").Err(err).Send()
continue
} else if item.UpdatedTime == p.Post.Updated.Unix() {
// If the item we're checking for exists and hasn't been edited,
// we've already replied, so skip it
continue
} else if item.UpdatedTime != p.Post.Updated.Unix() {
// If the item exists but has been edited since we replied,
// set edit to true so we know to edit it instead of making
// a new comment
edit = true
}
body := p.Post.URL.ValueOr("") + "\n\n" + p.Post.Body.ValueOr("")
@ -195,6 +231,13 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
job := replyJob{PostID: p.Post.ID}
// If edit is set to true, we need to edit the comment,
// so set the job's EditID so the reply worker knows which
// comment to edit
if edit {
job.EditID = int(item.ReplyID)
}
matches := re.FindAllStringSubmatch(body, -1)
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
Matches: toSubmatches(matches),
@ -207,6 +250,8 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
replyCh <- job
// Add the reply to the database so we don't reply to it
// again if we encounter it again
err = rs.AddItem(ctx, store.AddItemParams{
ID: int64(p.Post.ID),
ItemType: store.Post,
@ -219,11 +264,6 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
}
}
case <-ctx.Done():
err = db.Close()
if err != nil {
log.Warn("Error closing database").Err(err).Send()
continue
}
return
}
}
@ -232,27 +272,55 @@ func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, replyCh cha
type replyJob struct {
Content string
CommentID types.Optional[int]
EditID int
PostID int
}
func commentReplyWorker(ctx context.Context, c *lemmy.Client, ch <-chan replyJob) {
func commentReplyWorker(ctx context.Context, c *lemmy.Client, rs *store.Queries, ch <-chan replyJob) {
for {
select {
case reply := <-ch:
cr, err := c.CreateComment(ctx, types.CreateComment{
PostID: reply.PostID,
ParentID: reply.CommentID,
Content: reply.Content,
})
if err != nil {
log.Warn("Error while trying to create new comment").Err(err).Send()
}
// If the edit ID is set
if reply.EditID != 0 {
// Edit the comment with the specified ID with the new content
cr, err := c.EditComment(ctx, types.EditComment{
CommentID: reply.EditID,
Content: types.NewOptional(reply.Content),
})
if err != nil {
log.Warn("Error while trying to create new comment").Err(err).Send()
}
log.Info("Created new comment").
Int("post-id", reply.PostID).
Int("parent-id", reply.CommentID.ValueOr(-1)).
Int("comment-id", cr.CommentView.Comment.ID).
Send()
log.Info("Edited comment").
Int("comment-id", cr.CommentView.Comment.ID).
Send()
} else {
// Create a new comment replying to a post/comment
cr, err := c.CreateComment(ctx, types.CreateComment{
PostID: reply.PostID,
ParentID: reply.CommentID,
Content: reply.Content,
})
if err != nil {
log.Warn("Error while trying to create new comment").Err(err).Send()
}
// Set the reply ID for the post/comment in the database
// so that we know which comment ID to edit if we need to.
err = rs.SetReplyID(ctx, store.SetReplyIDParams{
ID: int64(reply.CommentID.ValueOr(reply.PostID)),
ReplyID: int64(cr.CommentView.Comment.ID),
})
if err != nil {
log.Warn("Error setting the reply ID of the new comment").Err(err).Send()
}
log.Info("Created new comment").
Int("post-id", reply.PostID).
Int("parent-id", reply.CommentID.ValueOr(-1)).
Int("comment-id", cr.CommentView.Comment.ID).
Send()
}
case <-ctx.Done():
return
}

340
main_test.go Normal file
View File

@ -0,0 +1,340 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"reflect"
"sync"
"testing"
"time"
"github.com/jarcoal/httpmock"
"go.elara.ws/go-lemmy"
"go.elara.ws/go-lemmy/types"
"go.elara.ws/lemmy-reply-bot/internal/store"
)
func TestCreate(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// register list API endpoints
registerListComments(t)
registerListPosts(t)
var commentReplies []string
var postReplies []string
// Whenver the create comment endpoint is called, if the comment is replying to a post,
// append it to the postReplies slice. If it's replying to another comment, append it to
// the commentReplies slice.
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
var cc types.CreateComment
if err := json.NewDecoder(r.Body).Decode(&cc); err != nil {
t.Fatal("Error decoding CreateComment request:", err)
}
// Check whether the comment is replying to a post or another comment
if cc.PostID != 0 {
// If the comment is a reply to a post, append it to postReplies
postReplies = append(postReplies, cc.Content)
} else {
// If the comment is a reply to another comment, append it to commentReplies
commentReplies = append(commentReplies, cc.Content)
}
// Return a successful response
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
// Open a new in-memory reply database
db, err := openDB(":memory:")
if err != nil {
t.Fatal("Error opening in-memory database:", err)
}
defer db.Close()
// Create a context that will get canceled in 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Run the workers concurrently
wg := initWorkers(t, ctx, db)
// Wait for the workers to stop due to context cancellation
wg.Wait()
expectedCommentReplies := []string{"pong", "Lemmy Comment!"}
expectedPostReplies := []string{"pong", "Lemmy Post!"}
if !reflect.DeepEqual(commentReplies, expectedCommentReplies) {
t.Errorf("[Comment] Expected %v, got %v", expectedCommentReplies, commentReplies)
}
if !reflect.DeepEqual(postReplies, expectedPostReplies) {
t.Errorf("[Post] Expected %v, got %v", expectedPostReplies, postReplies)
}
}
func TestEdit(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// register list API endpoints
registerListComments(t)
registerListPosts(t)
// We don't care about new comments in this test case, so we don't do anything in the comment handler
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
edited := map[int]string{}
// Whenever the edit comment endpoint is called, add the edited comment to
// the edited map, so that it can be checked later
httpmock.RegisterResponder("PUT", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
var ec types.EditComment
if err := json.NewDecoder(r.Body).Decode(&ec); err != nil {
t.Fatal("Error decoding CreateComment request:", err)
}
edited[ec.CommentID] = ec.Content.ValueOr("")
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
// Open a new in-memory reply database
db, err := openDB(":memory:")
if err != nil {
t.Fatal("Error opening in-memory database:", err)
}
defer db.Close()
rs := store.New(db)
// Add a new comment with id 12 that was updated before the fake one in
// registerListComments. This will cause the bot to edit that comment.
rs.AddItem(context.Background(), store.AddItemParams{
ID: 12,
ItemType: store.Comment,
UpdatedTime: 0,
})
// Set the reply ID of comment id 12 to 100 so we know it edited the
// right comment when it calls the edit API endpoint.
rs.SetReplyID(context.Background(), store.SetReplyIDParams{
ID: 12,
ReplyID: 100,
})
// Add a new post with id 3 that was updated before the fake one in
// registerListPosts. This will cause the bot to edit that comment.
rs.AddItem(context.Background(), store.AddItemParams{
ID: 3,
ItemType: store.Post,
UpdatedTime: 0,
})
// Set the reply ID of post id 3 to 100 so we know it edited the
// right comment when it calls the edit API endpoint.
rs.SetReplyID(context.Background(), store.SetReplyIDParams{
ID: 3,
ReplyID: 101,
})
// Create a context that will get canceled in 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Run the workers concurrently
wg := initWorkers(t, ctx, db)
// Wait for the workers to stop due to context cancellation
wg.Wait()
expected := map[int]string{
100: "Lemmy Comment!",
101: "Lemmy Post!",
}
if !reflect.DeepEqual(edited, expected) {
t.Errorf("Expected %v, got %v", expected, edited)
}
}
// testConfig returns a new Config for testing purposes.
func testConfig(t *testing.T) Config {
t.Helper()
cfgFile := &ConfigFile{
Replies: []Reply{
{
Regex: "ping",
Msg: "pong",
},
{
Regex: "Hello, (.+)",
Msg: "{{.Match 0 1}}!",
},
},
}
compiledRegexes, compiledTmpls, err := compileReplies(cfgFile.Replies)
if err != nil {
t.Fatal("Error compiling replies:", err)
}
return Config{
ConfigFile: cfgFile,
Regexes: compiledRegexes,
Tmpls: compiledTmpls,
PollInterval: time.Second,
}
}
// initWorkers does some setup and then starts the bot workers in separate goroutines.
// It returns a WaitGroup that's released when both of the workers return
func initWorkers(t *testing.T, ctx context.Context, db *sql.DB) *sync.WaitGroup {
t.Helper()
// Register a login endpoint that always returns test_token
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/user/login", func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.LoginResponse{JWT: types.NewOptional("test_token")})
})
// Create a new lemmy client using the mocked instance
c, err := lemmy.New("https://lemmy.example.com")
if err != nil {
t.Fatal("Error creating lemmy client:", err)
}
// Log in to the fake instance
err = c.ClientLogin(ctx, types.Login{
UsernameOrEmail: "test_username",
Password: "test_password",
})
if err != nil {
t.Fatal("Error logging in to mocked client:", err)
}
// Create a config for testing
cfg := testConfig(t)
rs := store.New(db)
replyCh := make(chan replyJob, 200)
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
commentWorker(ctx, c, cfg, rs, replyCh)
}()
go func() {
defer wg.Done()
commentReplyWorker(ctx, c, rs, replyCh)
}()
return wg
}
// registerListComments registers an HTTP mock for the /comment/list API endpoint
func registerListComments(t *testing.T) {
t.Helper()
httpmock.RegisterResponder("GET", `=~^https://lemmy\.example\.com/api/v3/comment/list\?.*`, func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.GetCommentsResponse{
Comments: []types.CommentView{
{
Comment: types.Comment{ // Should match reply index 0
ID: 10,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "ping",
},
Community: types.Community{
Local: true,
},
},
{
Comment: types.Comment{ // Should be skipped due to non-local community
ID: 11,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "ping",
},
Community: types.Community{
Local: false,
},
},
{
Comment: types.Comment{ // Should match reply index 1
ID: 12,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Updated: types.LemmyTime{Time: time.Unix(1581700620, 0)},
Content: "Hello, Lemmy Comment",
},
Community: types.Community{
Local: true,
},
},
{
Comment: types.Comment{ // Shouldn't match
ID: 13,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "This comment doesn't match any replies",
},
Community: types.Community{
Local: true,
},
},
},
})
})
}
// registerListPosts registers an HTTP mock for the /post/list API endpoint
func registerListPosts(t *testing.T) {
t.Helper()
httpmock.RegisterResponder("GET", `=~^https://lemmy\.example\.com/api/v3/post/list\?.*`, func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.GetPostsResponse{
Posts: []types.PostView{
{
Post: types.Post{ // Should match reply index 0
ID: 1,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("ping"),
},
Community: types.Community{
Local: true,
},
},
{
Post: types.Post{ // Should be skipped due to non-local community
ID: 2,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("ping"),
},
Community: types.Community{
Local: false,
},
},
{
Post: types.Post{ // Should match reply index 1
ID: 3,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Updated: types.LemmyTime{Time: time.Unix(1581700620, 0)},
Body: types.NewOptional("Hello, Lemmy Post"),
},
Community: types.Community{
Local: true,
},
},
{
Post: types.Post{ // Shouldn't match
ID: 4,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("This comment doesn't match any replies"),
},
Community: types.Community{
Local: true,
},
},
},
})
})
}

View File

@ -1,5 +1,8 @@
/* name: ItemExists :one */
SELECT COUNT(1) FROM replied_items WHERE item_type = ? AND id = ? AND updated_time = ?;
/* name: GetItem :one */
SELECT * FROM replied_items WHERE item_type = ? AND id = ? LIMIT 1;
/* name: AddItem :exec */
INSERT OR REPLACE INTO replied_items (id, item_type, updated_time) VALUES (?, ?, ?);
INSERT OR REPLACE INTO replied_items (id, reply_id, item_type, updated_time) VALUES (?, -1, ?, ?);
/* name: SetReplyID :exec */
UPDATE replied_items SET reply_id = ? WHERE id = ?;

View File

@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS replied_items (
id INT NOT NULL PRIMARY KEY,
reply_id INT NOT NULL,
item_type TEXT NOT NULL CHECK( item_type IN ('p', 'c') ),
updated_time INT NOT NULL,
UNIQUE(id, item_type)