Compare commits

...

No commits in common. "master" and "legacy" have entirely different histories.

18 changed files with 972 additions and 509 deletions

5
.gitignore vendored
View File

@ -1 +1,4 @@
/lemmy-reply-bot
/lemmy-reply-bot.toml
/lemmy-reply-bot
/replied.bin
/replied.db

View File

@ -1,6 +1,6 @@
# Lemmy Reply Bot
This project is a simple bot that replies to comments and posts on Lemmy. It gets the newest posts and comments every configurable interval, and sees if they match any regex configured in the config file. If it finds one that does, it replies with the message corresponding to that regex.
This project is a simple bot that replies to comments and posts on Lemmy. It gets the newest posts and comments every 15 seconds, and sees if they match any regex configured in the config file. If it finds one that does, it replies with the message corresponding to that regex.
### Features
@ -8,13 +8,12 @@ This project is a simple bot that replies to comments and posts on Lemmy. It get
- Powerful PCRE2 regular expressions for detecting triggers
- Ability to use regex capture groups in replies
- Persistent duplicate reply prevention via a filesystem store
- Powerful templates via [Salix](https://go.elara.ws/salix)
### Configuration
This repo contains a file called `lemmy-reply-bot.example.toml`. This is an example config file. You can edit it to fit your needs. The config contains your password, so its permissions must be set to 600 or the bot will refuse to start.
This repo contains a file called `lemmy-reply-bot.example.toml`. This is an example config file. Copy it to `lemmy-reply-bot.toml` and edit it to fit your needs. The config contains your password, so its permissions must be set to 600 or the bot will refuse to start.
This bot uses my [Pure-Go PCRE2 port](https://go.elara.ws/pcre) for regular expressions, so you can use any of PCRE2's features, and [Regex101](https://regex101.com/) in PCRE2 mode for testing.
This bot uses my [Pure-Go PCRE2 port](https://go.arsenm.dev/pcre) for regular expressions, so you can use any of PCRE2's features, and [Regex101](https://regex101.com/) in PCRE2 mode for testing.
If any regular expressions configured in the file also match the reply messages, the bot will refuse to start because this may cause an infinite loop.

106
config.go
View File

@ -1,50 +1,43 @@
package main
import (
"net/url"
"os"
"strconv"
"text/template"
"time"
"github.com/Masterminds/sprig"
"github.com/pelletier/go-toml/v2"
"go.elara.ws/logger/log"
"go.elara.ws/pcre"
"go.elara.ws/salix"
)
type Config struct {
File *ConfigFile
PollInterval time.Duration
Regexes map[string]*pcre.Regexp
Tmpls *salix.Namespace
}
type ConfigFile struct {
Lemmy Lemmy `toml:"lemmy"`
Lemmy struct {
InstanceURL string `toml:"instanceURL"`
Account struct {
UserOrEmail string `toml:"userOrEmail"`
Password string `toml:"password"`
} `toml:"account"`
} `toml:"lemmy"`
Replies []Reply `toml:"reply"`
}
type Lemmy struct {
InstanceURL string `toml:"instance_url"`
PollInterval string `toml:"poll_interval"`
Account LemmyAccount `toml:"account"`
}
type LemmyAccount struct {
UserOrEmail string `toml:"user_or_email"`
Password string `toml:"password"`
}
type Reply struct {
Regex string `toml:"regex"`
Template string `toml:"template"`
Regex string `toml:"regex"`
Msg string `toml:"msg"`
}
type Config struct {
ConfigFile *ConfigFile
Regexes map[string]*pcre.Regexp
Tmpls map[string]*template.Template
PollInterval time.Duration
}
func loadConfig(path string) (Config, error) {
fl, err := os.Open(path)
if err != nil {
return Config{}, err
}
fi, err := fl.Stat()
fi, err := os.Stat(path)
if err != nil {
return Config{}, err
}
@ -53,35 +46,33 @@ func loadConfig(path string) (Config, error) {
log.Fatal("Your config file's permissions are insecure. Please use chmod to set them to 600. Refusing to start.").Send()
}
cfgFile := &ConfigFile{Lemmy: Lemmy{PollInterval: "10s"}}
fl, err := os.Open(path)
if err != nil {
return Config{}, err
}
cfgFile := &ConfigFile{}
err = toml.NewDecoder(fl).Decode(cfgFile)
if err != nil {
return Config{}, err
}
out := Config{File: cfgFile}
out.Regexes, out.Tmpls, err = compileReplies(cfgFile.Replies)
compiledRegexes, compiledTmpls, err := compileReplies(cfgFile.Replies)
if err != nil {
return Config{}, err
}
out.PollInterval, err = time.ParseDuration(cfgFile.Lemmy.PollInterval)
if err != nil {
return Config{}, err
}
return out, nil
cfg := Config{cfgFile, compiledRegexes, compiledTmpls, 15 * time.Second}
validateConfig(cfg)
return cfg, nil
}
func compileReplies(replies []Reply) (map[string]*pcre.Regexp, *salix.Namespace, error) {
regexes := map[string]*pcre.Regexp{}
ns := salix.New().WithVarMap(map[string]any{
"regexReplace": regexReplace,
})
func compileReplies(replies []Reply) (map[string]*pcre.Regexp, map[string]*template.Template, error) {
compiledRegexes := map[string]*pcre.Regexp{}
compiledTmpls := map[string]*template.Template{}
for _, reply := range replies {
if _, ok := regexes[reply.Regex]; ok {
for i, reply := range replies {
if _, ok := compiledRegexes[reply.Regex]; ok {
continue
}
@ -89,21 +80,32 @@ func compileReplies(replies []Reply) (map[string]*pcre.Regexp, *salix.Namespace,
if err != nil {
return nil, nil, err
}
regexes[reply.Regex] = re
compiledRegexes[reply.Regex] = re
_, err = ns.ParseString(reply.Regex, reply.Template)
tmpl, err := template.
New(strconv.Itoa(i)).
Funcs(sprig.TxtFuncMap()).
Parse(reply.Msg)
if err != nil {
return nil, nil, err
}
compiledTmpls[reply.Regex] = tmpl
}
return regexes, ns, nil
return compiledRegexes, compiledTmpls, nil
}
func regexReplace(str, pattern, new string) (string, error) {
re, err := pcre.Compile(pattern)
func validateConfig(cfg Config) {
_, err := url.Parse(cfg.ConfigFile.Lemmy.InstanceURL)
if err != nil {
return "", err
log.Fatal("Lemmy instance URL is not valid").Err(err).Send()
}
for i, reply := range cfg.ConfigFile.Replies {
re := cfg.Regexes[reply.Regex]
if re.MatchString(reply.Msg) {
log.Fatal("Regular expression matches message. This may create an infinite loop. Refusing to start.").Int("reply-index", i).Send()
}
}
return re.ReplaceAllString(str, new), nil
}

70
go.mod
View File

@ -1,51 +1,47 @@
module go.elara.ws/lemmy-reply-bot
go 1.21.5
go 1.21.0
//replace go.elara.ws/go-lemmy => /home/elara/Code/go-lemmy
require (
github.com/chaisql/chai v0.16.0
github.com/pelletier/go-toml/v2 v2.0.5
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.19.0
go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae
go.elara.ws/go-lemmy v0.18.1-0.20230925023946-7e4c13d03170
go.elara.ws/logger v0.0.0-20230421022458-e80700db2090
go.elara.ws/pcre v0.0.0-20230805032557-4ce849193f64
go.elara.ws/salix v0.0.0-20240103024736-25037db86a10
modernc.org/sqlite v1.25.0
)
require (
github.com/DataDog/zstd v1.5.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cockroachdb/errors v1.11.1 // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/pebble v1.0.0 // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/getsentry/sentry-go v0.25.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-module/carbon/v2 v2.2.14 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gookit/color v1.5.1 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
modernc.org/libc v1.16.8 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.1.1 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

189
go.sum
View File

@ -1,86 +1,49 @@
github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ=
github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chaisql/chai v0.16.0 h1:UVvVOcf9H/OfSNRAzH9j1TuJnetUGGqV6gaAXZ8mrjQ=
github.com/chaisql/chai v0.16.0/go.mod h1:DYGursaN0/64vw3puP+ICq/sYr+TfdbKo9jmRax6J3Q=
github.com/cockroachdb/datadriven v1.0.3-0.20230801171734-e384cf455877 h1:1MLK4YpFtIEo3ZtMA5C795Wtv5VuUnrXX7mQG+aHg6o=
github.com/cockroachdb/datadriven v1.0.3-0.20230801171734-e384cf455877/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
github.com/cockroachdb/errors v1.11.1 h1:xSEW75zKaKCWzR3OfxXUxgrk/NtT4G1MiOv5lWZazG8=
github.com/cockroachdb/errors v1.11.1/go.mod h1:8MUxA3Gi6b25tYlFEBGLf+D8aISL+M4MIpiWMSNRfxw=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.0.0 h1:WZWlV/s78glZbY2ylUITDOWSVBD3cLjcWPLRPFbHNYg=
github.com/cockroachdb/pebble v1.0.0/go.mod h1:bynZ3gvVyhlvjLI7PT6dmZ7g76xzJ7HpxfjgkzCGz6s=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-module/carbon/v2 v2.2.14 h1:mT2hpNoCQVnkboZ6iyRf7WCbXtZTRXFBvXXWMp0PaMc=
github.com/golang-module/carbon/v2 v2.2.14/go.mod h1:XDALX7KgqmHk95xyLeaqX9/LJGbfLATyruTziq68SZ8=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
github.com/gookit/color v1.5.1/go.mod h1:wZFzea4X8qN6vHOSP2apMb4/+w/orMznEzYsIHPaqKM=
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -89,82 +52,76 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.elara.ws/go-lemmy v0.19.0 h1:FdPfiA+8yOa2IhrLdBp8jdYbnY6H55bfwnBbiGr0OHg=
go.elara.ws/go-lemmy v0.19.0/go.mod h1:aZbF/4c1VA7qPXsP4Pth0ERu3HGZFPPl8bTY1ltBrcQ=
go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae h1:d+gJUhEWSrOjrrfgeydYWEr8TTnx0DLvcVhghaOsFeE=
go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae/go.mod h1:qng49owViqsW5Aey93lwBXONw20oGbJIoLVscB16mPM=
go.elara.ws/go-lemmy v0.18.1-0.20230925023946-7e4c13d03170 h1:TKscLCs4Rr0uUxNyAXWMdUUVQsh+0KYggkPcLiHN9tY=
go.elara.ws/go-lemmy v0.18.1-0.20230925023946-7e4c13d03170/go.mod h1:aZbF/4c1VA7qPXsP4Pth0ERu3HGZFPPl8bTY1ltBrcQ=
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=
go.elara.ws/pcre v0.0.0-20230805032557-4ce849193f64/go.mod h1:EF48C6VnP4wBayzFGk6lXqbiLucH7EfiaYOgiiCe5k4=
go.elara.ws/salix v0.0.0-20240103024736-25037db86a10 h1:JOFqDTLiWO+WL/+BofQAd63pv1SfEawsGVbqnSVFf6E=
go.elara.ws/salix v0.0.0-20240103024736-25037db86a10/go.mod h1:niWia13iw7qDrS1C1mlqv5hxO1sunt8CcOQAB5yVlNU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No=
golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
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 v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.8 h1:Ux98PaOMvolgoFX/YwusFOHBnanXdGRmWgI8ciI2z4o=
modernc.org/libc v1.16.8/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.0 h1:2pXdbgdP5hIyDp2JqIwkHNZ1sAjEbh8GnRpcqFWBf7E=
modernc.org/memory v1.7.0/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
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

@ -1,65 +0,0 @@
package db
import (
"time"
"github.com/chaisql/chai"
)
var db *chai.DB
// Init opens the database and applies migrations
func Init(path string) error {
g, err := chai.Open(path)
if err != nil {
return err
}
db = g
return db.Exec(`
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 ['post', 'comment'] ),
updated TIMESTAMP NOT NULL,
UNIQUE(id, item_type)
)
`)
}
func Close() error {
return db.Close()
}
type ItemType string
const (
Post ItemType = "post"
Comment ItemType = "comment"
)
type Item struct {
ID int64 `chai:"id"`
ReplyID int64 `chai:"reply_id"`
ItemType ItemType `chai:"item_type"`
Updated time.Time `chai:"updated"`
}
func AddItem(i Item) error {
return db.Exec(`INSERT INTO replied_items VALUES ?`, &i)
}
func SetUpdatedTime(id int64, itemType ItemType, updated time.Time) error {
return db.Exec(`UPDATE replied_items SET updated = ? WHERE id = ? AND item_type = ?`, updated, id, itemType)
}
func GetItem(id int64, itemType ItemType) (*Item, error) {
row, err := db.QueryRow(`SELECT * FROM replied_items WHERE id = ? AND item_type = ?`, id, itemType)
if chai.IsNotFoundError(err) {
return nil, nil
} else if err != nil {
return nil, err
} else {
out := &Item{}
return out, row.StructScan(out)
}
}

31
internal/store/db.go Normal file
View File

@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.19.1
package store
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

14
internal/store/models.go Normal file
View File

@ -0,0 +1,14 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.19.1
package store
import ()
type RepliedItem struct {
ID int64
ReplyID int64
ItemType string
UpdatedTime int64
}

View File

@ -0,0 +1,60 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.19.1
// source: queries.sql
package store
import (
"context"
)
const addItem = `-- name: AddItem :exec
INSERT OR REPLACE INTO replied_items (id, reply_id, item_type, updated_time) VALUES (?, -1, ?, ?)
`
type AddItemParams struct {
ID int64
ItemType string
UpdatedTime int64
}
func (q *Queries) AddItem(ctx context.Context, arg AddItemParams) error {
_, err := q.db.ExecContext(ctx, addItem, arg.ID, arg.ItemType, arg.UpdatedTime)
return err
}
const getItem = `-- name: GetItem :one
SELECT id, reply_id, item_type, updated_time FROM replied_items WHERE item_type = ? AND id = ? LIMIT 1
`
type GetItemParams struct {
ItemType string
ID int64
}
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
}

6
internal/store/types.go Normal file
View File

@ -0,0 +1,6 @@
package store
const (
Comment = "c"
Post = "p"
)

View File

@ -1,54 +1,46 @@
[lemmy]
instance_url = "https://lemmy.ml"
poll_interval = "10s"
instanceURL = "https://lemmy.ml"
[lemmy.account]
user_or_email = "user@example.com"
userOrEmail = "user@example.com"
password = "ExamplePassword123"
# Replies to any message starting with "!!BOT_TEST", with some information
# about what it's replying to
# Replies to any message starting with "!!BOT_TEST" with everything
# after "!!BOT_TEST"
#
# Example: !!BOT_TEST Hello :3
# Example: !!BOT_TEST Hello, World
[[reply]]
regex = "!!BOT_TEST (.+)"
template = '''
ID: #(id) \
Type: #(type) \
Content: #(matches[0][1])
'''
regex = "!!BOT_TEST (.*)"
msg = "{{.Match 0 1}}"
# Returns archive links for URLs preceded with "!archive"
#
# Example: !archive https://gitea.elara.ws/Elara6331/lemmy-reply-bot
# Example: !archive https://https://gitea.elara.ws/Elara6331/lemmy-reply-bot
[[reply]]
regex = '!archive (https?)://([.\w\d]+\.[\w\d]{2,4}[\w\d?&=%/.-]*)'
msg = '''
Here are the archive links you requested:
#for(i, match in matches):
#if(len(matches) > 1):Link #(i+1):#!if
- [archive.vn](https://archive.vn/#(match[1])://#(match[2]))
- [archive.org](https://web.archive.org/web/#(match[1])://#(match[2]))
- [ghostarchive.org](https://ghostarchive.org/search?term=#(match[1])://#(match[2]))
#!for
{{range $i, $match := .Matches}}
{{if len $.Matches | ne 1}}Link {{add $i 1}}:{{end}}
- [archive.vn](https://archive.vn/{{$match.Item 1}}://{{$match.Item 2}})
- [archive.org](https://web.archive.org/web/{{$match.Item 1}}://{{$match.Item 2}})
- [ghostarchive.org](https://ghostarchive.org/search?term={{$match.Item 1}}://{{$match.Item 2}})
{{end}}
'''
# Returns invidious links for YouTube URLs
#
# Example: https://www.youtube.com/watch?v=2vPhySbRETM
[[reply]]
regex = 'https?://(?:(?:www|m)\.)?youtu(?:\.be/|be\.com/(?:watch\?v=|shorts/))([\w\d-]{11})[&?]?([\w\d?&=%/-]*)'
msg = '''
#(len(matches) == 1 ? "A YouTube link was" : "YouTube links were") detected in your #(type). Here are links to the same #(len(matches) == 1 ? "video" : "videos") on Invidious, which is a YouTube frontend that protects your privacy:
{{if len .Matches | eq 1}}A YouTube link was{{else}}YouTube links were{{end}} detected in your {{.Type}}. Here are links to the same {{if len .Matches | eq 1}}video{{else}}videos{{end}} on Invidious, which is a YouTube frontend that protects your privacy:
#for(i, match in matches):
#if(len(matches) > 1):Link #(i+1):#!if
- [yewtu.be](https://yewtu.be/watch?v=$(match[1])&#(match[2]))
- [invidious.weblibre.org](https://invidious.weblibre.org/watch?v=#(match[1])&#(match[2]))
- [inv.vern.cc](https://inv.vern.cc/watch?v=#(match[1])&#(match[2]))
#!for
{{range $i, $match := .Matches}}
{{if len $.Matches | ne 1}}Link {{add $i 1}}:{{end}}
- [yewtu.be](https://yewtu.be/watch?v={{$match.Item 1}}&{{$match.Item 2}})
- [invidious.weblibre.org](https://invidious.weblibre.org/watch?v={{$match.Item 1}}&{{$match.Item 2}})
- [inv.vern.cc](https://inv.vern.cc/watch?v={{$match.Item 1}}&{{$match.Item 2}})
{{end}}
'''

18
logger.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"os"
"go.elara.ws/logger"
"go.elara.ws/logger/log"
)
func init() {
l := logger.NewPretty(os.Stderr)
if os.Getenv("LEMMY_REPLY_BOT_DEBUG") == "1" {
l.Level = logger.LogLevelDebug
}
log.Logger = l
}

480
main.go
View File

@ -2,287 +2,357 @@ package main
import (
"context"
"os"
"database/sql"
_ "embed"
"errors"
"os/signal"
"strings"
"syscall"
"text/template"
"time"
"unsafe"
"github.com/spf13/pflag"
"go.elara.ws/go-lemmy"
"go.elara.ws/lemmy-reply-bot/internal/db"
"go.elara.ws/logger"
"go.elara.ws/go-lemmy/types"
"go.elara.ws/lemmy-reply-bot/internal/store"
"go.elara.ws/logger/log"
"go.elara.ws/salix"
_ "modernc.org/sqlite"
)
func init() {
log.Logger = logger.NewPretty(os.Stderr)
//go:generate sqlc generate
//go:embed sql/schema.sql
var schema string
func openDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(schema)
return db, err
}
func main() {
cfgPath := pflag.StringP("config-path", "c", "/etc/lemmy-reply-bot/config.toml", "Path to the config file")
dbPath := pflag.StringP("db-path", "d", "/etc/lemmy-reply-bot/replies", "Path to the ChaiSQL database")
configPath := pflag.StringP("config", "c", "./lemmy-reply-bot.toml", "Path to the config file")
dbPath := pflag.StringP("db-path", "d", "./replied.db", "Path to the database")
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, os.Interrupt, syscall.SIGTERM)
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
err := db.Init(*dbPath)
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatal("Error initializing database").Err(err).Send()
log.Fatal("Error loading config file").Err(err).Send()
}
cfg, err := loadConfig(*cfgPath)
db, err := openDB(*dbPath)
if err != nil {
log.Fatal("Error loading config").Err(err).Send()
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()
}
c, err := lemmy.New(cfg.File.Lemmy.InstanceURL)
if err != nil {
log.Fatal("Error creating new lemmy client").Err(err).Send()
}
err = c.ClientLogin(ctx, lemmy.Login{
UsernameOrEmail: cfg.File.Lemmy.Account.UserOrEmail,
Password: cfg.File.Lemmy.Account.Password,
err = c.ClientLogin(ctx, types.Login{
UsernameOrEmail: cfg.ConfigFile.Lemmy.Account.UserOrEmail,
Password: cfg.ConfigFile.Lemmy.Account.Password,
})
if err != nil {
log.Fatal("Error logging into lemmy").Err(err).Send()
log.Fatal("Error logging in to Lemmy instance").Err(err).Send()
}
log.Info("Successfully logged in!").Send()
log.Info("Successfully logged in to Lemmy instance").Send()
go poll(ctx, cfg, c)
replyCh := make(chan replyJob, 200)
<-ctx.Done()
_ = db.Close()
if !*dryRun {
// Start the reply worker in the background
go commentReplyWorker(ctx, c, rs, replyCh)
}
// Start the comment worker
commentWorker(ctx, c, cfg, rs, replyCh)
}
func poll(ctx context.Context, cfg Config, c *lemmy.Client) {
func commentWorker(ctx context.Context, c *lemmy.Client, cfg Config, rs *store.Queries, replyCh chan<- replyJob) {
for {
select {
case <-time.After(cfg.PollInterval):
// Get 20 of the newest comments from Lemmy
comments, err := c.Comments(ctx, lemmy.GetComments{
Type: lemmy.NewOptional(lemmy.ListingTypeLocal),
Sort: lemmy.NewOptional(lemmy.CommentSortTypeNew),
Limit: lemmy.NewOptional[int64](20),
// 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),
Limit: types.NewOptional[float64](50),
})
if err != nil {
log.Warn("Error getting comments").Err(err).Send()
continue
}
handleComments(ctx, comments.Comments, cfg, c)
for _, c := range comments.Comments {
// Skip all non-local comments
if !c.Community.Local {
continue
}
// Get 20 of the newest comments from Lemmy
posts, err := c.Posts(ctx, lemmy.GetPosts{
Type: lemmy.NewOptional(lemmy.ListingTypeLocal),
Sort: lemmy.NewOptional(lemmy.SortTypeNew),
Limit: lemmy.NewOptional[int64](20),
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.ValueOrEmpty().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.ValueOrEmpty().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 {
re := cfg.Regexes[reply.Regex]
if !re.MatchString(c.Comment.Content) {
continue
}
log.Info("Matched comment body").
Int("reply-index", i).
Float64("comment-id", c.Comment.ID).
Send()
job := replyJob{
CommentID: types.NewOptional(c.Comment.ID),
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 = float64(item.ReplyID)
}
matches := re.FindAllStringSubmatch(c.Comment.Content, -1)
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
Matches: toSubmatches(matches),
Type: "comment",
})
if err != nil {
log.Warn("Error while executing template").Err(err).Send()
continue
}
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,
UpdatedTime: c.Comment.Updated.ValueOrEmpty().Unix(),
})
if err != nil {
log.Warn("Error adding comment to the reply store").Err(err).Send()
continue
}
}
}
// 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),
Limit: types.NewOptional[float64](20),
})
if err != nil {
log.Warn("Error getting posts").Err(err).Send()
log.Warn("Error getting comments").Err(err).Send()
continue
}
handlePosts(ctx, posts.Posts, cfg, c)
for _, p := range posts.Posts {
// Skip all non-local posts
if !p.Community.Local {
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.ValueOrEmpty().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.ValueOrEmpty().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("")
for i, reply := range cfg.ConfigFile.Replies {
re := cfg.Regexes[reply.Regex]
if !re.MatchString(body) {
continue
}
log.Info("Matched post body").
Int("reply-index", i).
Float64("post-id", p.Post.ID).
Send()
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 = float64(item.ReplyID)
}
matches := re.FindAllStringSubmatch(body, -1)
job.Content, err = executeTmpl(cfg.Tmpls[reply.Regex], TmplContext{
Matches: toSubmatches(matches),
Type: "post",
})
if err != nil {
log.Warn("Error while executing template").Err(err).Send()
continue
}
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,
UpdatedTime: p.Post.Updated.ValueOrEmpty().Unix(),
})
if err != nil {
log.Warn("Error adding post to the reply store").Err(err).Send()
continue
}
}
}
case <-ctx.Done():
return
}
}
}
func handleComments(ctx context.Context, comments []lemmy.CommentView, cfg Config, c *lemmy.Client) {
for _, comment := range comments {
if !comment.Community.Local {
continue
}
type replyJob struct {
Content string
CommentID types.Optional[float64]
EditID float64
PostID float64
}
item, err := db.GetItem(comment.Comment.ID, db.Comment)
if err != nil {
log.Warn("Error getting comment from db").Err(err).Send()
continue
}
edit := false
if item == nil {
// If the item is nil, it doesn't exist, which means we need to
// create a new reply, so we don't set edit to true in this case.
} else if item.Updated.Equal(comment.Comment.Updated) {
// If the item exists but hasn't been edited since we've last seen it,
// we can skip it since we've already replied to it.
continue
} else if item.Updated.Before(comment.Comment.Updated) {
// If the item exists and has been edited since we've last seen it,
// we need to edit it, so we set edit to true.
edit = true
}
for i, reply := range cfg.File.Replies {
re := cfg.Regexes[reply.Regex]
if !re.MatchString(comment.Comment.Content) {
continue
}
log.Info("Matched comment body").
Int("reply-index", i).
Int64("comment-id", comment.Comment.ID).
Send()
matches := re.FindAllStringSubmatch(comment.Comment.Content, -1)
content, err := executeTmpl(cfg.Tmpls, reply.Regex, map[string]any{
"id": comment.Comment.ID,
"type": db.Comment,
"matches": matches,
})
if err != nil {
log.Warn("Error executing template").Int("index", i).Err(err).Send()
continue
}
if edit {
_, err = c.EditComment(ctx, lemmy.EditComment{
CommentID: item.ReplyID,
Content: lemmy.NewOptional(content),
func commentReplyWorker(ctx context.Context, c *lemmy.Client, rs *store.Queries, ch <-chan replyJob) {
for {
select {
case reply := <-ch:
// 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 editing comment").Int64("id", item.ReplyID).Err(err).Send()
log.Warn("Error while trying to edit comment").Err(err).Send()
continue
}
log.Info("Edited comment").Int64("parent-id", item.ID).Int64("reply-id", item.ReplyID).Send()
err = db.SetUpdatedTime(comment.Comment.ID, db.Comment, comment.Comment.Updated)
// 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 new updated time").Int64("id", item.ReplyID).Err(err).Send()
log.Warn("Error setting the reply ID of the new comment").Err(err).Send()
continue
}
log.Info("Edited comment").
Float64("comment-id", cr.CommentView.Comment.ID).
Send()
} else {
cr, err := c.CreateComment(ctx, lemmy.CreateComment{
PostID: comment.Comment.PostID,
Content: content,
ParentID: lemmy.NewOptional(comment.Comment.ID),
// 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 creating reply").Int64("comment-id", comment.Comment.ID).Err(err).Send()
log.Warn("Error while trying to create new comment").Err(err).Send()
continue
}
log.Info("Created comment").Int64("parent-id", comment.Comment.ID).Int64("reply-id", cr.CommentView.Comment.ID).Send()
err = db.AddItem(db.Item{
ID: comment.Comment.ID,
ReplyID: cr.CommentView.Comment.ID,
ItemType: db.Comment,
Updated: comment.Comment.Updated,
// 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 adding reply to database").Int64("id", item.ReplyID).Err(err).Send()
log.Warn("Error setting the reply ID of the new comment").Err(err).Send()
continue
}
log.Info("Created new comment").
Float64("post-id", reply.PostID).
Float64("parent-id", reply.CommentID.ValueOr(-1)).
Float64("comment-id", cr.CommentView.Comment.ID).
Send()
}
case <-ctx.Done():
return
}
}
}
func handlePosts(ctx context.Context, posts []lemmy.PostView, cfg Config, c *lemmy.Client) {
for _, post := range posts {
if !post.Community.Local {
continue
}
item, err := db.GetItem(post.Post.ID, db.Post)
if err != nil {
log.Warn("Error getting comment from db").Err(err).Send()
continue
}
edit := false
if item == nil {
// If the item is nil, it doesn't exist, which means we need to
// reply to it, so we don't set edit to true in this case.
} else if item.Updated.Equal(post.Post.Updated) {
// If the item exists but hasn't been edited since we've last seen it,
// we can skip it since we've already replied to it.
continue
} else if item.Updated.Before(post.Post.Updated) {
// If the item exists and has been edited since we've last seen it,
// we need to edit it, so we set edit to true.
edit = true
}
for i, reply := range cfg.File.Replies {
re := cfg.Regexes[reply.Regex]
content := post.Post.URL.ValueOrZero() + "\n\n" + post.Post.Body.ValueOrZero()
if !re.MatchString(content) {
continue
}
log.Info("Matched post body").
Int("reply-index", i).
Int64("post-id", post.Post.ID).
Send()
matches := re.FindAllStringSubmatch(content, -1)
content, err := executeTmpl(cfg.Tmpls, reply.Regex, map[string]any{
"id": post.Post.ID,
"type": db.Post,
"matches": matches,
})
if err != nil {
log.Warn("Error executing template").Int("index", i).Err(err).Send()
continue
}
if edit {
_, err = c.EditComment(ctx, lemmy.EditComment{
CommentID: item.ReplyID,
Content: lemmy.NewOptional(content),
})
if err != nil {
log.Warn("Error editing post").Int64("id", item.ReplyID).Err(err).Send()
continue
}
log.Info("Edited comment").Int64("post-id", item.ID).Int64("reply-id", item.ReplyID).Send()
err = db.SetUpdatedTime(post.Post.ID, db.Post, post.Post.Updated)
if err != nil {
log.Warn("Error setting new updated time").Int64("id", item.ReplyID).Err(err).Send()
continue
}
} else {
cr, err := c.CreateComment(ctx, lemmy.CreateComment{
PostID: post.Post.ID,
Content: content,
})
if err != nil {
log.Warn("Error creating reply").Int64("post-id", post.Post.ID).Err(err).Send()
continue
}
log.Info("Created comment").Int64("post-id", post.Post.ID).Int64("reply-id", cr.CommentView.Comment.ID).Send()
err = db.AddItem(db.Item{
ID: post.Post.ID,
ReplyID: cr.CommentView.Comment.ID,
ItemType: db.Post,
Updated: post.Post.Updated,
})
if err != nil {
log.Warn("Error adding reply to database").Int64("id", item.ReplyID).Err(err).Send()
continue
}
}
}
}
}
func executeTmpl(ns *salix.Namespace, name string, vars map[string]any) (string, error) {
func executeTmpl(tmpl *template.Template, tc TmplContext) (string, error) {
sb := &strings.Builder{}
err := ns.ExecuteTemplate(sb, name, vars)
err := tmpl.Execute(sb, tc)
return sb.String(), err
}
// toSubmatches converts matches coming from PCRE2 to a
// submatch array used for the template
func toSubmatches(s [][]string) []Submatches {
// 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
return *(*[]Submatches)(unsafe.Pointer(&s))
}

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[float64]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[float64]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.NewOptional(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.NewOptional(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,
},
},
},
})
})
}

8
sql/queries.sql Normal file
View File

@ -0,0 +1,8 @@
/* name: GetItem :one */
SELECT * FROM replied_items WHERE item_type = ? AND id = ? LIMIT 1;
/* name: AddItem :exec */
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 = ?;

7
sql/schema.sql Normal file
View File

@ -0,0 +1,7 @@
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)
);

9
sqlc.yaml Normal file
View File

@ -0,0 +1,9 @@
version: '2'
sql:
- schema: sql/schema.sql
queries: sql/queries.sql
engine: sqlite
gen:
go:
package: store
out: internal/store

16
types.go Normal file
View File

@ -0,0 +1,16 @@
package main
type Submatches []string
func (sm Submatches) Item(i int) string {
return sm[i]
}
type TmplContext struct {
Matches []Submatches
Type string
}
func (tc TmplContext) Match(i, j int) string {
return tc.Matches[i][j]
}