2022-12-11 04:08:29 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2023-08-19 02:10:43 +00:00
|
|
|
"database/sql"
|
|
|
|
_ "embed"
|
2023-09-02 22:52:26 +00:00
|
|
|
"errors"
|
2022-12-11 04:08:29 +00:00
|
|
|
"os/signal"
|
|
|
|
"strings"
|
|
|
|
"syscall"
|
2023-01-10 00:10:05 +00:00
|
|
|
"text/template"
|
2023-08-19 02:10:43 +00:00
|
|
|
"time"
|
2023-01-24 09:24:03 +00:00
|
|
|
"unsafe"
|
2022-12-11 04:08:29 +00:00
|
|
|
|
|
|
|
"github.com/spf13/pflag"
|
2023-04-21 03:04:46 +00:00
|
|
|
"go.elara.ws/go-lemmy"
|
|
|
|
"go.elara.ws/go-lemmy/types"
|
2023-08-19 02:10:43 +00:00
|
|
|
"go.elara.ws/lemmy-reply-bot/internal/store"
|
2023-04-21 03:04:46 +00:00
|
|
|
"go.elara.ws/logger/log"
|
2023-08-19 02:10:43 +00:00
|
|
|
_ "modernc.org/sqlite"
|
2022-12-11 04:08:29 +00:00
|
|
|
)
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
//go:generate sqlc generate
|
|
|
|
|
2023-08-19 02:10:43 +00:00
|
|
|
//go:embed sql/schema.sql
|
|
|
|
var schema string
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
func openDB(path string) (*sql.DB, error) {
|
|
|
|
db, err := sql.Open("sqlite", path)
|
2023-08-19 02:10:43 +00:00
|
|
|
if err != nil {
|
2023-09-02 22:52:26 +00:00
|
|
|
return nil, err
|
2023-08-19 02:10:43 +00:00
|
|
|
}
|
2023-09-02 22:52:26 +00:00
|
|
|
db.SetMaxOpenConns(1)
|
2023-08-19 02:10:43 +00:00
|
|
|
_, err = db.Exec(schema)
|
2023-09-02 22:52:26 +00:00
|
|
|
return db, err
|
2023-08-19 02:10:43 +00:00
|
|
|
}
|
|
|
|
|
2022-12-11 04:08:29 +00:00
|
|
|
func main() {
|
|
|
|
configPath := pflag.StringP("config", "c", "./lemmy-reply-bot.toml", "Path to the config file")
|
|
|
|
dryRun := pflag.BoolP("dry-run", "D", false, "Don't actually send comments, just check for matches")
|
|
|
|
pflag.Parse()
|
|
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
defer cancel()
|
|
|
|
|
2023-08-19 13:56:42 +00:00
|
|
|
cfg, err := loadConfig(*configPath)
|
2022-12-11 04:08:29 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Fatal("Error loading config file").Err(err).Send()
|
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
db, err := openDB("replied.db")
|
|
|
|
if err != nil {
|
|
|
|
log.Fatal("Error opening reply database").Err(err).Send()
|
|
|
|
}
|
|
|
|
defer db.Close()
|
|
|
|
rs := store.New(db)
|
|
|
|
|
2023-08-19 13:56:42 +00:00
|
|
|
c, err := lemmy.New(cfg.ConfigFile.Lemmy.InstanceURL)
|
2022-12-11 04:08:29 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Fatal("Error creating new Lemmy API client").Err(err).Send()
|
|
|
|
}
|
|
|
|
|
2023-01-05 22:01:33 +00:00
|
|
|
err = c.ClientLogin(ctx, types.Login{
|
2023-08-19 13:56:42 +00:00
|
|
|
UsernameOrEmail: cfg.ConfigFile.Lemmy.Account.UserOrEmail,
|
|
|
|
Password: cfg.ConfigFile.Lemmy.Account.Password,
|
2022-12-11 04:08:29 +00:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Fatal("Error logging in to Lemmy instance").Err(err).Send()
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Info("Successfully logged in to Lemmy instance").Send()
|
|
|
|
|
|
|
|
replyCh := make(chan replyJob, 200)
|
|
|
|
|
|
|
|
if !*dryRun {
|
2023-09-02 22:52:26 +00:00
|
|
|
// Start the reply worker in the background
|
|
|
|
go commentReplyWorker(ctx, c, rs, replyCh)
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// Start the comment worker
|
|
|
|
commentWorker(ctx, c, cfg, rs, replyCh)
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, rs *store.Queries, replyCh chan<- replyJob) {
|
2022-12-11 04:08:29 +00:00
|
|
|
for {
|
|
|
|
select {
|
2023-09-02 22:52:26 +00:00
|
|
|
case <-time.After(cfg.PollInterval):
|
|
|
|
// Get 50 of the newest comments from Lemmy
|
2023-08-19 02:10:43 +00:00
|
|
|
comments, err := c.Comments(ctx, types.GetComments{
|
|
|
|
Type: types.NewOptional(types.ListingTypeLocal),
|
|
|
|
Sort: types.NewOptional(types.CommentSortTypeNew),
|
|
|
|
Limit: types.NewOptional[int64](50),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error getting comments").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
2022-12-11 04:08:29 +00:00
|
|
|
|
2023-08-19 02:10:43 +00:00
|
|
|
for _, c := range comments.Comments {
|
|
|
|
// Skip all non-local comments
|
|
|
|
if !c.Community.Local {
|
2023-05-04 19:48:39 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
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
|
2023-08-19 02:10:43 +00:00
|
|
|
} else if err != nil {
|
|
|
|
log.Warn("Error checking if item exists").Err(err).Send()
|
2022-12-11 04:08:29 +00:00
|
|
|
continue
|
2023-09-02 22:52:26 +00:00
|
|
|
} 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
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
|
|
|
|
2023-08-19 13:56:42 +00:00
|
|
|
for i, reply := range cfg.ConfigFile.Replies {
|
|
|
|
re := cfg.Regexes[reply.Regex]
|
2023-08-19 02:10:43 +00:00
|
|
|
if !re.MatchString(c.Comment.Content) {
|
2022-12-11 04:08:29 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Info("Matched comment body").
|
|
|
|
Int("reply-index", i).
|
2023-08-19 02:10:43 +00:00
|
|
|
Int("comment-id", c.Comment.ID).
|
2022-12-11 04:08:29 +00:00
|
|
|
Send()
|
|
|
|
|
|
|
|
job := replyJob{
|
2023-08-19 02:10:43 +00:00
|
|
|
CommentID: types.NewOptional(c.Comment.ID),
|
|
|
|
PostID: c.Comment.PostID,
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2023-08-19 02:10:43 +00:00
|
|
|
matches := re.FindAllStringSubmatch(c.Comment.Content, -1)
|
2023-08-19 13:56:42 +00:00
|
|
|
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
|
2023-01-24 09:24:03 +00:00
|
|
|
Matches: toSubmatches(matches),
|
2023-08-19 02:10:43 +00:00
|
|
|
Type: "comment",
|
2023-01-24 09:24:03 +00:00
|
|
|
})
|
2023-01-10 00:10:05 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error while executing template").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
2022-12-11 04:08:29 +00:00
|
|
|
|
|
|
|
replyCh <- job
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// Add the reply to the database so we don't reply to it
|
|
|
|
// again if we encounter it again
|
2023-08-19 02:10:43 +00:00
|
|
|
err = rs.AddItem(ctx, store.AddItemParams{
|
|
|
|
ID: int64(c.Comment.ID),
|
|
|
|
ItemType: store.Comment,
|
|
|
|
UpdatedTime: c.Comment.Updated.Unix(),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error adding comment to the reply store").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
2023-01-23 21:34:36 +00:00
|
|
|
}
|
2023-08-19 02:10:43 +00:00
|
|
|
}
|
2023-01-23 21:34:36 +00:00
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// Get 20 of the newest posts from Lemmy
|
2023-08-19 02:10:43 +00:00
|
|
|
posts, err := c.Posts(ctx, types.GetPosts{
|
|
|
|
Type: types.NewOptional(types.ListingTypeLocal),
|
|
|
|
Sort: types.NewOptional(types.SortTypeNew),
|
|
|
|
Limit: types.NewOptional[int64](20),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error getting comments").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, p := range posts.Posts {
|
|
|
|
// Skip all non-local posts
|
|
|
|
if !p.Community.Local {
|
2023-05-04 19:48:39 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
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
|
2023-08-19 02:10:43 +00:00
|
|
|
} else if err != nil {
|
|
|
|
log.Warn("Error checking if item exists").Err(err).Send()
|
2023-01-23 21:34:36 +00:00
|
|
|
continue
|
2023-09-02 22:52:26 +00:00
|
|
|
} 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
|
2023-01-23 21:34:36 +00:00
|
|
|
}
|
|
|
|
|
2023-08-19 02:10:43 +00:00
|
|
|
body := p.Post.URL.ValueOr("") + "\n\n" + p.Post.Body.ValueOr("")
|
2023-08-19 13:56:42 +00:00
|
|
|
for i, reply := range cfg.ConfigFile.Replies {
|
|
|
|
re := cfg.Regexes[reply.Regex]
|
2023-01-23 22:13:39 +00:00
|
|
|
if !re.MatchString(body) {
|
2023-01-23 21:34:36 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2023-01-23 22:24:50 +00:00
|
|
|
log.Info("Matched post body").
|
2023-01-23 21:34:36 +00:00
|
|
|
Int("reply-index", i).
|
2023-08-19 02:10:43 +00:00
|
|
|
Int("post-id", p.Post.ID).
|
2023-01-23 21:34:36 +00:00
|
|
|
Send()
|
|
|
|
|
2023-08-19 02:10:43 +00:00
|
|
|
job := replyJob{PostID: p.Post.ID}
|
2023-01-23 21:34:36 +00:00
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2023-01-23 22:13:39 +00:00
|
|
|
matches := re.FindAllStringSubmatch(body, -1)
|
2023-08-19 13:56:42 +00:00
|
|
|
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
|
2023-01-24 09:24:03 +00:00
|
|
|
Matches: toSubmatches(matches),
|
2023-08-19 02:10:43 +00:00
|
|
|
Type: "post",
|
2023-01-24 09:24:03 +00:00
|
|
|
})
|
2023-01-23 21:34:36 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error while executing template").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
replyCh <- job
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
// Add the reply to the database so we don't reply to it
|
|
|
|
// again if we encounter it again
|
2023-08-19 02:10:43 +00:00
|
|
|
err = rs.AddItem(ctx, store.AddItemParams{
|
|
|
|
ID: int64(p.Post.ID),
|
|
|
|
ItemType: store.Post,
|
|
|
|
UpdatedTime: p.Post.Updated.Unix(),
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
log.Warn("Error adding post to the reply store").Err(err).Send()
|
|
|
|
continue
|
|
|
|
}
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type replyJob struct {
|
|
|
|
Content string
|
2023-01-23 21:34:36 +00:00
|
|
|
CommentID types.Optional[int]
|
2023-09-02 22:52:26 +00:00
|
|
|
EditID int
|
2022-12-11 04:08:29 +00:00
|
|
|
PostID int
|
|
|
|
}
|
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
func commentReplyWorker(ctx context.Context, c *lemmy.Client, rs *store.Queries, ch <-chan replyJob) {
|
2022-12-11 04:08:29 +00:00
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case reply := <-ch:
|
2023-09-02 22:52:26 +00:00
|
|
|
// 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()
|
|
|
|
}
|
2022-12-11 04:08:29 +00:00
|
|
|
|
2023-09-02 22:52:26 +00:00
|
|
|
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()
|
|
|
|
}
|
2022-12-11 04:08:29 +00:00
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-24 09:24:03 +00:00
|
|
|
func executeTmpl(tmpl *template.Template, tc TmplContext) (string, error) {
|
2023-01-10 00:10:05 +00:00
|
|
|
sb := &strings.Builder{}
|
2023-01-24 09:24:03 +00:00
|
|
|
err := tmpl.Execute(sb, tc)
|
2023-01-10 00:10:05 +00:00
|
|
|
return sb.String(), err
|
2022-12-11 04:08:29 +00:00
|
|
|
}
|
2023-01-09 20:55:51 +00:00
|
|
|
|
2023-01-24 09:31:13 +00:00
|
|
|
// toSubmatches converts matches coming from PCRE2 to a
|
|
|
|
// submatch array used for the template
|
2023-01-24 09:24:03 +00:00
|
|
|
func toSubmatches(s [][]string) []Submatches {
|
2023-01-24 09:31:13 +00:00
|
|
|
// Unfortunately, Go doesn't allow for this conversion
|
|
|
|
// even though the memory layout is identical and it's
|
|
|
|
// safe, so it is done using unsafe pointer magic
|
2023-01-24 09:24:03 +00:00
|
|
|
return *(*[]Submatches)(unsafe.Pointer(&s))
|
|
|
|
}
|