Add tests and edit comments if the item they're replying to has been edited
This commit is contained in:
parent
dfd40c55f5
commit
b049a4359d
10
config.go
10
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
|
||||
}
|
||||
|
7
go.mod
7
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
|
||||
|
15
go.sum
15
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=
|
||||
|
@ -8,6 +8,7 @@ import ()
|
||||
|
||||
type RepliedItem struct {
|
||||
ID int64
|
||||
ReplyID int64
|
||||
ItemType string
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
174
main.go
174
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
|
||||
}
|
||||
|
340
main_test.go
Normal file
340
main_test.go
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
@ -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 = ?;
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user