diff --git a/config.go b/config.go index e834dff..c6bcd2b 100644 --- a/config.go +++ b/config.go @@ -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 } diff --git a/go.mod b/go.mod index 69cba5d..29e195d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ec0dd73..84d6ff0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/store/models.go b/internal/store/models.go index 1e20037..29b64fa 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -8,6 +8,7 @@ import () type RepliedItem struct { ID int64 + ReplyID int64 ItemType string UpdatedTime int64 } diff --git a/internal/store/queries.sql.go b/internal/store/queries.sql.go index 9603254..85a5739 100644 --- a/internal/store/queries.sql.go +++ b/internal/store/queries.sql.go @@ -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 } diff --git a/logger.go b/logger.go index a813b5f..464c8b3 100644 --- a/logger.go +++ b/logger.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "os" "go.elara.ws/logger" diff --git a/main.go b/main.go index d861748..8e9b3d1 100644 --- a/main.go +++ b/main.go @@ -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 } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..82b18b5 --- /dev/null +++ b/main_test.go @@ -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, + }, + }, + }, + }) + }) +} diff --git a/sql/queries.sql b/sql/queries.sql index 8ee34af..924aa57 100644 --- a/sql/queries.sql +++ b/sql/queries.sql @@ -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 (?, ?, ?); \ No newline at end of file +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 = ?; \ No newline at end of file diff --git a/sql/schema.sql b/sql/schema.sql index 6549e97..9c673f0 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -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)