Compare commits
No commits in common. "master" and "legacy" have entirely different histories.
|
@ -1 +1,4 @@
|
|||
/lemmy-reply-bot
|
||||
/lemmy-reply-bot.toml
|
||||
/lemmy-reply-bot
|
||||
/replied.bin
|
||||
/replied.db
|
|
@ -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
106
config.go
|
@ -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
70
go.mod
|
@ -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
189
go.sum
|
@ -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=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package store
|
||||
|
||||
const (
|
||||
Comment = "c"
|
||||
Post = "p"
|
||||
)
|
|
@ -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}}
|
||||
'''
|
||||
|
|
|
@ -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
480
main.go
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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 = ?;
|
|
@ -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)
|
||||
);
|
|
@ -0,0 +1,9 @@
|
|||
version: '2'
|
||||
sql:
|
||||
- schema: sql/schema.sql
|
||||
queries: sql/queries.sql
|
||||
engine: sqlite
|
||||
gen:
|
||||
go:
|
||||
package: store
|
||||
out: internal/store
|
Loading…
Reference in New Issue