15 Commits

Author SHA1 Message Date
c0c8366b7b Fix typo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-29 17:20:50 -07:00
ddff13518f Run formatter
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-29 15:52:37 -07:00
310f417521 Update rqlite/sql to latest commit 2024-10-29 15:52:24 -07:00
d2f901e1b1 Remove lockableRuntime as it's no longer needed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-10-27 17:38:05 -07:00
5662e3dbb8 Integrate event loop for setTimeout, setInterval, etc.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-27 17:34:30 -07:00
b059438179 Pass session to plugin init function
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-10-27 12:26:04 -07:00
4f533eac6b Rename /plugin to /pluginadm and make /prun and /phelp subcommands of /plugin instead
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-04-24 06:30:31 -07:00
5d327f3fd2 Add plugin system
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-04-23 18:25:37 -07:00
c28bc88939 Add "Symbols and Pictographs Extended-A" unicode block
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-04-21 07:24:03 -07:00
4f57cbf780 Fix RemoveVettingReq on MemberLeave event
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-22 23:44:11 +00:00
48668d3af7 Fix approve command
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-20 21:55:22 -08:00
3cc0003d65 Fix an issue where regex reactions would happen even if the regex doesn't match
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-13 19:08:39 -08:00
53ecef743c Use references for emoji structs in component buttons
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-10 00:14:27 -08:00
29b787d31e Update discordgo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-10 00:02:17 -08:00
a1257cd9e7 Restructure the code to standardize file structure
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-31 14:41:08 -08:00
55 changed files with 2928 additions and 1109 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/dist/
/plugins/
/owobot
/owobot.db

View File

@@ -8,10 +8,16 @@ Thanks for your interest in contributing to owobot! This page contains informati
owobot consists of several independent systems, such as the `starboard` system, `members` system, `commands` system, etc. These systems are what actually interact with users and they're all in the `internal/systems` directory.
All the systems that require initialization have an `Init(*discordgo.Session) error` function, which does things like registers all the commands and handlers, and performs any other initialization steps that need to be done for that system. These `Init` functions are called by `main.go` when the bot starts up.
All the systems that require initialization have an `init.go` file with an `Init(*discordgo.Session) error` function, which does things like registers all the commands and handlers, and performs any other initialization steps that need to be done for that system. These `Init` functions are called by `main.go` when the bot starts up.
The `commands` system always starts last because the other systems register commands that it needs to know about before it does its initialization.
System file structure:
- `init.go`: This file contains the Init function that does all the required initialization, as well as any functions meant to be imported by other systems, such as the `commands.Register()` and `eventlog.Log()` functions.
- `handlers.go`: This file contains all the event handler functions.
- `commands.go`: This file contains all the command handler functions.
### Database
All the database code is in `internal/db`. owobot doesn't use any ORM or framework for the database, it directly executes SQL queries. Database migrations are stored in `internal/db/migrations`. They are sql files whose names contain the date when they were made and an extra number to avoid collisions in case multiple migrations are ever made in the same day.

View File

@@ -27,9 +27,10 @@ import (
)
type Config struct {
Token string `env:"TOKEN" toml:"token"`
DBPath string `env:"DB_PATH" toml:"db_path"`
Activity Activity `envPrefix:"ACTIVITY_" toml:"activity"`
Token string `env:"TOKEN" toml:"token"`
DBPath string `env:"DB_PATH" toml:"db_path"`
PluginDir string `env:"PLUGIN_DIR" toml:"plugin_dir"`
Activity Activity `envPrefix:"ACTIVITY_" toml:"activity"`
}
type Activity struct {
@@ -40,8 +41,9 @@ type Activity struct {
func loadConfig() (*Config, error) {
// Create a new config struct with default values
cfg := &Config{
Token: "",
DBPath: "owobot.db",
Token: "",
DBPath: "owobot.db",
PluginDir: "plugins",
Activity: Activity{
Type: -1,
Name: "",

27
go.mod
View File

@@ -3,34 +3,43 @@ module go.elara.ws/owobot
go 1.21.0
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/bwmarrin/discordgo v0.28.1
github.com/caarlos0/env/v10 v10.0.0
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d
github.com/jmoiron/sqlx v1.3.5
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/lestrrat-go/strftime v1.0.6
github.com/pelletier/go-toml/v2 v2.1.0
github.com/rivo/uniseg v0.4.4
github.com/rqlite/sql v0.0.0-20241029220113-152a320b02f7
github.com/valyala/fasttemplate v1.2.2
go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae
go.elara.ws/vercmp v0.0.0-20231003203944-671892886053
golang.org/x/net v0.24.0
modernc.org/sqlite v1.27.0
mvdan.cc/xurls v1.1.0
mvdan.cc/xurls/v2 v2.5.0
)
require (
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gookit/color v1.5.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mvdan/xurls v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/tools v0.1.12 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.6.0 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect

92
go.sum
View File

@@ -1,28 +1,53 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA=
github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d h1:W1n4DvpzZGOISgp7wWNtraLcHtnmnTwBlJidqtMIuwQ=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
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/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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/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/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
github.com/lestrrat-go/strftime v1.0.6 h1:CFGsDEt1pOpFNU+TJB0nhz9jl+K0hZSLE205AhTIGQQ=
@@ -34,8 +59,6 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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/mvdan/xurls v1.1.0 h1:OpuDelGQ1R1ueQ6sSryzi6P+1RtBpfQHM8fJwlE45ww=
github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -46,6 +69,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rqlite/sql v0.0.0-20241029220113-152a320b02f7 h1:Mnz6yd4FWtiD6bbH9WHFFHfrOM2OYTUTmwrsckRc4W8=
github.com/rqlite/sql v0.0.0-20241029220113-152a320b02f7/go.mod h1:ib9zVtNgRKiGuoMyUqqL5aNpk+r+++YlyiVIkclVqPg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -61,25 +87,61 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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/vercmp v0.0.0-20231003203944-671892886053 h1:tQ6Kyq9I0Sw9bmXQ1MZdH5EVpEc5brXe8utBCTI5pr0=
go.elara.ws/vercmp v0.0.0-20231003203944-671892886053/go.mod h1:/7PNW7nFnDR5W7UXZVc04gdVLR/wBNgkm33KgIz0OBk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
@@ -111,5 +173,5 @@ 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=
mvdan.cc/xurls v1.1.0 h1:kj0j2lonKseISJCiq1Tfk+iTv65dDGCl0rTbanXJGGc=
mvdan.cc/xurls v1.1.0/go.mod h1:TNWuhvo+IqbUCmtUIb/3LJSQdrzel8loVpgFm0HikbI=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View File

@@ -34,6 +34,11 @@ var migrations embed.FS
var db *sqlx.DB
// DB returns the global database instance
func DB() *sqlx.DB {
return db
}
// Init opens the database and applies migrations
func Init(ctx context.Context, dsn string) error {
g, err := sqlx.Open("sqlite", dsn)

View File

@@ -21,20 +21,23 @@ package db
import (
"database/sql"
"errors"
"fmt"
"slices"
)
type Guild struct {
ID string `db:"id"`
StarboardChanID string `db:"starboard_chan_id"`
StarboardStars int `db:"starboard_stars"`
LogChanID string `db:"log_chan_id"`
TicketLogChanID string `db:"ticket_log_chan_id"`
TicketCategoryID string `db:"ticket_category_id"`
VettingReqChanID string `db:"vetting_req_chan_id"`
VettingRoleID string `db:"vetting_role_id"`
TimeFormat string `db:"time_format"`
WelcomeChanID string `db:"welcome_chan_id"`
WelcomeMsg string `db:"welcome_msg"`
ID string `db:"id"`
StarboardChanID string `db:"starboard_chan_id"`
StarboardStars int `db:"starboard_stars"`
LogChanID string `db:"log_chan_id"`
TicketLogChanID string `db:"ticket_log_chan_id"`
TicketCategoryID string `db:"ticket_category_id"`
VettingReqChanID string `db:"vetting_req_chan_id"`
VettingRoleID string `db:"vetting_role_id"`
TimeFormat string `db:"time_format"`
WelcomeChanID string `db:"welcome_chan_id"`
WelcomeMsg string `db:"welcome_msg"`
EnabledPlugins StringSlice `db:"enabled_plugins"`
}
func AllGuilds() ([]Guild, error) {
@@ -104,6 +107,39 @@ func SetWelcomeMsg(guildID, msg string) error {
return err
}
func EnablePlugin(guildID, pluginName string) error {
var enabledPlugins StringSlice
err := db.QueryRow("SELECT enabled_plugins FROM guilds WHERE id = ?", guildID).Scan(&enabledPlugins)
if err != nil {
return err
}
if slices.Contains(enabledPlugins, pluginName) {
return fmt.Errorf("y: ploogin %q is already enabled", pluginName)
}
enabledPlugins = append(enabledPlugins, pluginName)
_, err = db.Exec("UPDATE guilds SET enabled_plugins = ? WHERE id = ?", enabledPlugins, guildID)
return err
}
func DisablePlugin(guildID, pluginName string) error {
var enabledPlugins StringSlice
err := db.QueryRow("SELECT enabled_plugins FROM guilds WHERE id = ?", guildID).Scan(&enabledPlugins)
if err != nil {
return err
}
if i := slices.Index(enabledPlugins, pluginName); i == -1 {
return fmt.Errorf("ploogin %q is already disabled", pluginName)
} else {
enabledPlugins = append(enabledPlugins[:i], enabledPlugins[i+1:]...)
}
_, err = db.Exec("UPDATE guilds SET enabled_plugins = ? WHERE id = ?", enabledPlugins, guildID)
return err
}
func IsVettingMsg(msgID string) (bool, error) {
var out bool
err := db.QueryRow("SELECT 1 FROM guild WHERE vetting_msg_id = ?", msgID).Scan(&out)

View File

@@ -0,0 +1,11 @@
/* plugins stores information about all the plugins defined for this bot. */
/* This will be used to let plugins perform actions when they're updated */
CREATE TABLE plugins (
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT NOT NULL,
UNIQUE(name) ON CONFLICT REPLACE
);
/* Add a column to allow guilds to enable whichever plugins they want */
ALTER TABLE guilds ADD COLUMN enabled_plugins TEXT NOT NULL DEFAULT '';

42
internal/db/plugins.go Normal file
View File

@@ -0,0 +1,42 @@
/*
* owobot - Your server's guardian and entertainer
* Copyright (C) 2023 owobot Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package db
type PluginInfo struct {
Name string `db:"name"`
Version string `db:"version"`
Desc string `db:"description"`
}
func (pi PluginInfo) IsValid() bool {
if pi.Name == "" || pi.Version == "" || pi.Desc == "" {
return false
}
return true
}
func AddPlugin(pi PluginInfo) error {
_, err := db.NamedExec(`INSERT OR REPLACE INTO plugins VALUES (:name, :version, :description)`, pi)
return err
}
func GetPlugin(name string) (out PluginInfo, err error) {
err = db.QueryRowx("SELECT * FROM plugins WHERE name = ? LIMIT 1", name).StructScan(&out)
return
}

View File

@@ -0,0 +1,156 @@
package sqltabler
import (
"errors"
"io"
"strings"
sqlparser "github.com/rqlite/sql"
)
// Modify adds a prefix and suffix to every table name found in stmt.
func Modify(stmt, prefix, suffix string) (string, error) {
parser := sqlparser.NewParser(strings.NewReader(stmt))
sb := strings.Builder{}
for {
s, err := parser.ParseStatement()
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return "", err
}
modify(s, prefix, suffix)
sb.WriteString(s.String())
sb.WriteByte(';')
}
return sb.String(), nil
}
// modify changes all the table, view, trigger, and index names in a single statement
func modify(stmt any, prefix, suffix string) {
switch stmt := stmt.(type) {
case *sqlparser.SelectStatement:
modifySource(stmt.Source, prefix, suffix)
modify(stmt.WhereExpr, prefix, suffix)
case *sqlparser.InsertStatement:
stmt.Table.Name = prefix + stmt.Table.Name + suffix
if stmt.Select != nil {
modify(stmt.Select, prefix, suffix)
}
case *sqlparser.UpdateStatement:
stmt.Table.Name.Name = prefix + stmt.Table.Name.Name + suffix
for _, assignment := range stmt.Assignments {
modify(assignment, prefix, suffix)
}
case *sqlparser.CreateTableStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
if stmt.Select != nil {
modify(stmt.Select, prefix, suffix)
}
for _, col := range stmt.Columns {
modify(col, prefix, suffix)
}
for _, constraint := range stmt.Constraints {
modify(constraint, prefix, suffix)
}
case *sqlparser.CreateViewStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
if stmt.Select != nil {
modify(stmt.Select, prefix, suffix)
}
case *sqlparser.AlterTableStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
if stmt.NewName != nil {
stmt.NewName.Name = prefix + stmt.NewName.Name + suffix
}
if stmt.ColumnDef != nil {
modify(stmt.ColumnDef, prefix, suffix)
}
case *sqlparser.Call:
for _, arg := range stmt.Args {
modify(arg, prefix, suffix)
}
case *sqlparser.FilterClause:
modify(stmt.X, prefix, suffix)
case *sqlparser.DeleteStatement:
stmt.Table.Name.Name = prefix + stmt.Table.Name.Name + suffix
if stmt.WhereExpr != nil {
modify(stmt.WhereExpr, prefix, suffix)
}
case *sqlparser.AnalyzeStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
case *sqlparser.ExplainStatement:
modify(stmt.Stmt, prefix, suffix)
case *sqlparser.CreateIndexStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
stmt.Table.Name = prefix + stmt.Table.Name + suffix
case *sqlparser.CreateTriggerStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
stmt.Table.Name = prefix + stmt.Table.Name + suffix
if stmt.WhenExpr != nil {
modify(stmt.WhenExpr, prefix, suffix)
}
for _, istmt := range stmt.Body {
modify(istmt, prefix, suffix)
}
case *sqlparser.CTE:
stmt.TableName.Name = prefix + stmt.TableName.Name + suffix
if stmt.Select != nil {
modify(stmt.Select, prefix, suffix)
}
case *sqlparser.DropTableStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
case *sqlparser.DropViewStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
case *sqlparser.DropIndexStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
case *sqlparser.DropTriggerStatement:
stmt.Name.Name = prefix + stmt.Name.Name + suffix
case *sqlparser.ForeignKeyConstraint:
stmt.ForeignTable.Name = prefix + stmt.ForeignTable.Name + suffix
case *sqlparser.OnConstraint:
modify(stmt.X, prefix, suffix)
case *sqlparser.ExprList:
for _, expr := range stmt.Exprs {
modify(expr, prefix, suffix)
}
case *sqlparser.UnaryExpr:
modify(stmt.X, prefix, suffix)
case *sqlparser.BinaryExpr:
modify(stmt.X, prefix, suffix)
modify(stmt.Y, prefix, suffix)
case *sqlparser.ParenExpr:
modify(stmt.X, prefix, suffix)
case *sqlparser.CastExpr:
modify(stmt.X, prefix, suffix)
case *sqlparser.OrderingTerm:
modify(stmt.X, prefix, suffix)
case *sqlparser.Assignment:
modify(stmt.Expr, prefix, suffix)
case *sqlparser.ColumnDefinition:
for _, constraint := range stmt.Constraints {
modify(constraint, prefix, suffix)
}
case *sqlparser.QualifiedRef:
if stmt.Table != nil {
stmt.Table.Name = prefix + stmt.Table.Name + suffix
}
case *sqlparser.CaseExpr:
modify(stmt.ElseExpr, prefix, suffix)
for _, block := range stmt.Blocks {
modify(block.Condition, prefix, suffix)
modify(block.Body, prefix, suffix)
}
}
}
func modifySource(source sqlparser.Source, prefix, suffix string) {
switch source := source.(type) {
case *sqlparser.QualifiedTableName:
source.Name.Name = prefix + source.Name.Name + suffix
case *sqlparser.JoinClause:
modifySource(source.X, prefix, suffix)
modifySource(source.Y, prefix, suffix)
modify(source.Constraint, prefix, suffix)
}
}

View File

@@ -37,7 +37,7 @@ func VettingReqUserID(guildID, msgID string) (string, error) {
return out, err
}
func RemoveVettingReq(guildID, msgID string) error {
_, err := db.Exec("DELETE FROM vetting_requests WHERE msg_id = ? AND guild_id = ?", msgID, guildID)
func RemoveVettingReq(guildID, userID string) error {
_, err := db.Exec("DELETE FROM vetting_requests WHERE user_id = ? AND guild_id = ?", userID, guildID)
return err
}

View File

@@ -60,6 +60,11 @@ var (
Hi: 0x1F9FF,
Stride: 1,
},
{ // Symbols and Pictographs Extended-A
Lo: 0x1FA70,
Hi: 0x1FAFF,
Stride: 1,
},
},
R16: []unicode.Range16{
{ // Zero-width characters

View File

@@ -0,0 +1,41 @@
package commands
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/util"
)
// onCmd handles any command interaction and routes it to the correct command
// if it was registered using the [Register] function.
func onCmd(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
data := i.ApplicationCommandData()
mu.Lock()
cmdFn, ok := cmds[data.Name]
if !ok {
mu.Unlock()
return
}
mu.Unlock()
err := cmdFn(s, i)
if err != nil {
log.Warn("Error in command function").Str("cmd", data.Name).Err(err).Send()
sendError(s, i.Interaction, err)
return
}
}
// sendError responds to an interaction with an ephemeral message containing an error
func sendError(s *discordgo.Session, i *discordgo.Interaction, serr error) {
err := util.RespondEphemeral(s, i, "ERROR: "+serr.Error())
if err != nil {
log.Warn("Error while trying to send error").Err(err).Send()
return
}
}

View File

@@ -41,28 +41,6 @@ func Init(s *discordgo.Session) error {
return err
}
func onCmd(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
data := i.ApplicationCommandData()
mu.Lock()
defer mu.Unlock()
cmdFn, ok := cmds[data.Name]
if !ok {
return
}
err := cmdFn(s, i)
if err != nil {
log.Warn("Error in command function").Str("cmd", data.Name).Err(err).Send()
sendError(s, i.Interaction, err)
return
}
}
func Register(s *discordgo.Session, fn CmdFunc, ac *discordgo.ApplicationCommand) {
// If the DM permission hasn't been explicitly set, assume false
if ac.DMPermission == nil {
@@ -81,14 +59,6 @@ func Register(s *discordgo.Session, fn CmdFunc, ac *discordgo.ApplicationCommand
acs = append(acs, ac)
}
func sendError(s *discordgo.Session, i *discordgo.Interaction, serr error) {
err := util.RespondEphemeral(s, i, "ERROR: "+serr.Error())
if err != nil {
log.Warn("Error while trying to send error").Err(err).Send()
return
}
}
// commandSync checks if any registered commands have been removed and, if so,
// deletes them.
func commandSync(s *discordgo.Session) error {

View File

@@ -0,0 +1,66 @@
package eventlog
import (
"fmt"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/util"
)
// eventlogCmd handles the `/eventlog` command and routes it to the correct subcommand.
func eventlogCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "channel":
return channelCmd(s, i)
case "ticket_channel":
return ticketChannelCmd(s, i)
case "time_format":
return timeFormatCmd(s, i)
default:
return fmt.Errorf("unknown eventlog subcommand: %s", name)
}
}
// channelCmd handles the `/eventlog channel` command.
func channelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetLogChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set event log channel to <#%s>!", c.ID))
}
// ticketChannelCmd handles the `/eventlog ticket_channel` command.
func ticketChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetTicketLogChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set ticket log channel to <#%s>!", c.ID))
}
// timeFormatCmd handles the `/eventlog time_format` command
func timeFormatCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
timeFmt := args[0].StringValue()
err := db.SetTimeFormat(i.GuildID, timeFmt)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully set the time format!")
}

View File

@@ -19,7 +19,6 @@
package eventlog
import (
"fmt"
"io"
"github.com/bwmarrin/discordgo"
@@ -81,59 +80,7 @@ func Init(s *discordgo.Session) error {
return nil
}
func eventlogCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "channel":
return channelCmd(s, i)
case "ticket_channel":
return ticketChannelCmd(s, i)
case "time_format":
return timeFormatCmd(s, i)
default:
return fmt.Errorf("unknown eventlog subcommand: %s", name)
}
}
func channelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetLogChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set event log channel to <#%s>!", c.ID))
}
func ticketChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetTicketLogChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set ticket log channel to <#%s>!", c.ID))
}
func timeFormatCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
timeFmt := args[0].StringValue()
err := db.SetTimeFormat(i.GuildID, timeFmt)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully set the time format!")
}
// Entry represents an entry in the event log
type Entry struct {
Title string
Description string
@@ -141,6 +88,7 @@ type Entry struct {
Author *discordgo.User
}
// Log writes an entry to the event log channel if it exists
func Log(s *discordgo.Session, guildID string, e Entry) error {
guild, err := db.GuildByID(guildID)
if err != nil {
@@ -173,6 +121,7 @@ func Log(s *discordgo.Session, guildID string, e Entry) error {
return err
}
// TicketMsgLog writes a message log to the ticket log channel if it exists
func TicketMsgLog(s *discordgo.Session, guildID string, msgLog io.Reader) error {
guild, err := db.GuildByID(guildID)
if err != nil {

View File

@@ -0,0 +1,17 @@
package guilds
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
)
// onGuildCreate listens for when the bot joins a new guild and adds it
// to the database if it doesn't already exist.
func onGuildCreate(s *discordgo.Session, gc *discordgo.GuildCreate) {
err := db.CreateGuild(gc.ID)
if err != nil {
log.Warn("Error creating guild").Err(err).Send()
return
}
}

View File

@@ -29,17 +29,8 @@ func Init(s *discordgo.Session) error {
return guildSync(s)
}
// onGuildCreate adds a guild to the database if it doesn't already exist
func onGuildCreate(s *discordgo.Session, gc *discordgo.GuildCreate) {
err := db.CreateGuild(gc.ID)
if err != nil {
log.Warn("Error creating guild").Err(err).Send()
return
}
}
// guildSync makes sure all the guilds the bot is in
// exist in the database. If not, it adds them.
// guildSync looks through all the guilds that the bot is in,
// and if any of them don't exist in the database, it adds them.
func guildSync(s *discordgo.Session) error {
for _, guild := range s.State.Guilds {
err := db.CreateGuild(guild.ID)

View File

@@ -0,0 +1,191 @@
package members
import (
"fmt"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/systems/eventlog"
)
// onMemberAdd attempts to detect which invite(s) were used to invite the user
// and logs the member join.
func onMemberAdd(s *discordgo.Session, gma *discordgo.GuildMemberAdd) {
invites, err := findLastUsedInvites(s, gma.GuildID)
if err != nil {
log.Warn("Error finding last used invite").Err(err).Send()
}
code := "Unknown"
if len(invites) > 0 {
code = strings.Join(invites, " or ")
}
err = eventlog.Log(s, gma.GuildID, eventlog.Entry{
Title: "New Member Joined!",
Description: fmt.Sprintf("**User:**\n%s\n**ID:**\n%s\n**Invite Code:**\n%s", gma.Member.User.Mention(), gma.Member.User.ID, code),
Author: gma.Member.User,
})
if err != nil {
log.Warn("Error sending member joined log").Str("member", gma.Member.User.Username).Err(err).Send()
}
}
// onMemberUpdate logs member updates, such as roles being assigned or removed
func onMemberUpdate(s *discordgo.Session, gmu *discordgo.GuildMemberUpdate) {
if gmu.BeforeUpdate == nil || gmu.Member == nil {
return
}
if !slices.Equal(gmu.BeforeUpdate.Roles, gmu.Member.Roles) {
var added, removed []string
for _, newRole := range gmu.Member.Roles {
if !slices.Contains(gmu.BeforeUpdate.Roles, newRole) {
added = append(added, fmt.Sprintf("<@&%s>", newRole))
}
}
for _, oldRole := range gmu.BeforeUpdate.Roles {
if !slices.Contains(gmu.Member.Roles, oldRole) {
removed = append(removed, fmt.Sprintf("<@&%s>", oldRole))
}
}
err := eventlog.Log(s, gmu.GuildID, eventlog.Entry{
Title: "Roles Updated",
Description: fmt.Sprintf(
"**User:** %s\n**Added:** %s\n**Removed:** %s",
gmu.Member.User.Mention(),
strings.Join(added, " "),
strings.Join(removed, " "),
),
Author: gmu.Member.User,
})
if err != nil {
log.Warn("Error roles updated log").Str("member", gmu.Member.User.Username).Err(err).Send()
}
}
}
// onMemberLeave logs member leave events and handles bans and kicks
func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
err := handleBanOrKick(s, gmr)
if err != nil {
log.Warn("Error logging ban or kick").Str("member", gmr.Member.User.Username).Err(err).Send()
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "Member Left",
Description: fmt.Sprintf("**User:**\n%s\n**ID:**\n%s", gmr.Member.User.Mention(), gmr.Member.User.ID),
Author: gmr.Member.User,
})
if err != nil {
log.Warn("Error sending member left log").Str("member", gmr.Member.User.Username).Err(err).Send()
}
}
// onChannelDelete attempts to detect the user responsible for a channel deletion
// and logs it. It also handles rate limiting for channel delete events.
func onChannelDelete(s *discordgo.Session, cd *discordgo.ChannelDelete) {
if cd.Type == discordgo.ChannelTypeDM || cd.Type == discordgo.ChannelTypeGroupDM {
return
}
auditLog, err := s.GuildAuditLog(cd.GuildID, "", "", int(discordgo.AuditLogActionChannelDelete), 5)
if err != nil {
log.Error("Error getting audit log").Err(err).Send()
return
}
for _, entry := range auditLog.AuditLogEntries {
// If the deleted channel isn't the one this event is for,
// skip it.
if entry.TargetID != cd.ID {
continue
}
// If the bot deleted the channel, we don't care about this event
if entry.UserID == s.State.User.ID {
return
}
err = handleRatelimit(s, "channel_delete", cd.GuildID, entry.UserID)
if err != nil {
log.Error("Error handling rate limit").Err(err).Send()
}
member, err := cache.Member(s, cd.GuildID, entry.UserID)
if err != nil {
log.Error("Error getting member").Err(err).Send()
return
}
err = eventlog.Log(s, cd.GuildID, eventlog.Entry{
Title: "Channel Deleted",
Description: fmt.Sprintf("**Name:** `%s`\n**Deleted By:** %s", cd.Name, member.User.Mention()),
Author: member.User,
})
if err != nil {
log.Warn("Error sending channel deleted log").Str("channel", cd.Name).Err(err).Send()
return
}
return
}
}
// handleBanOrKick attempts to detect the user responsible for a ban or kick, and
// logs it. It also handles rate limiting for bans and kicks.
func handleBanOrKick(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) error {
auditLog, err := s.GuildAuditLog(gmr.GuildID, "", "", 0, 5)
if err != nil {
return err
}
for _, entry := range auditLog.AuditLogEntries {
// If there's no action type or the user isn't the one this
// event is for, skip it.
if entry.ActionType == nil || entry.TargetID != gmr.User.ID {
continue
}
switch *entry.ActionType {
case discordgo.AuditLogActionMemberBanAdd:
executor, err := cache.Member(s, gmr.GuildID, entry.UserID)
if err != nil {
return err
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "User banned",
Description: fmt.Sprintf("**Target:** %s\n**Banned by:** %s\n**Reason:** %s", gmr.User.Mention(), executor.User.Mention(), entry.Reason),
Author: gmr.User,
})
if err != nil {
return err
}
return handleRatelimit(s, "ban", gmr.GuildID, executor.User.ID)
case discordgo.AuditLogActionMemberKick:
executor, err := cache.Member(s, gmr.GuildID, entry.UserID)
if err != nil {
return err
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "User kicked",
Description: fmt.Sprintf("**Target:** %s\n**Kicked by:** %s\n**Reason:** %s", gmr.User.Mention(), executor.User.Mention(), entry.Reason),
Author: gmr.User,
})
if err != nil {
return err
}
return handleRatelimit(s, "kick", gmr.GuildID, executor.User.ID)
}
}
return nil
}

View File

@@ -42,7 +42,7 @@ func addOneToMap(invite *discordgo.Invite) {
inviteMap[invite.Code] = invite
}
// findLastUsedInvites attempts to detect the invites that potentially might've been used last
// findLastUsedInvites tries to detect the invites that potentially might've been used last
// in order to figure out what invite a user used to join.
func findLastUsedInvites(s *discordgo.Session, guildID string) ([]string, error) {
invites, err := s.GuildInvites(guildID)

View File

@@ -1,15 +1,6 @@
package members
import (
"fmt"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/systems/eventlog"
)
import "github.com/bwmarrin/discordgo"
func Init(s *discordgo.Session) error {
go populateInviteMap(s)
@@ -19,182 +10,3 @@ func Init(s *discordgo.Session) error {
s.AddHandler(onChannelDelete)
return nil
}
// onMemberAdd attempts to detect which invite(s) were used to invite the user
// and logs the member join.
func onMemberAdd(s *discordgo.Session, gma *discordgo.GuildMemberAdd) {
invites, err := findLastUsedInvites(s, gma.GuildID)
if err != nil {
log.Warn("Error finding last used invite").Err(err).Send()
}
code := "Unknown"
if len(invites) > 0 {
code = strings.Join(invites, " or ")
}
err = eventlog.Log(s, gma.GuildID, eventlog.Entry{
Title: "New Member Joined!",
Description: fmt.Sprintf("**User:**\n%s\n**ID:**\n%s\n**Invite Code:**\n%s", gma.Member.User.Mention(), gma.Member.User.ID, code),
Author: gma.Member.User,
})
if err != nil {
log.Warn("Error sending member joined log").Str("member", gma.Member.User.Username).Err(err).Send()
}
}
// onMemberUpdate logs member updates, such as roles being assigned or removed
func onMemberUpdate(s *discordgo.Session, gmu *discordgo.GuildMemberUpdate) {
if gmu.BeforeUpdate == nil || gmu.Member == nil {
return
}
if !slices.Equal(gmu.BeforeUpdate.Roles, gmu.Member.Roles) {
var added, removed []string
for _, newRole := range gmu.Member.Roles {
if !slices.Contains(gmu.BeforeUpdate.Roles, newRole) {
added = append(added, fmt.Sprintf("<@&%s>", newRole))
}
}
for _, oldRole := range gmu.BeforeUpdate.Roles {
if !slices.Contains(gmu.Member.Roles, oldRole) {
removed = append(removed, fmt.Sprintf("<@&%s>", oldRole))
}
}
err := eventlog.Log(s, gmu.GuildID, eventlog.Entry{
Title: "Roles Updated",
Description: fmt.Sprintf(
"**User:** %s\n**Added:** %s\n**Removed:** %s",
gmu.Member.User.Mention(),
strings.Join(added, " "),
strings.Join(removed, " "),
),
Author: gmu.Member.User,
})
if err != nil {
log.Warn("Error roles updated log").Str("member", gmu.Member.User.Username).Err(err).Send()
}
}
}
// onMemberLeave logs member leave events and handles bans and kicks
func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
err := handleBanOrKick(s, gmr)
if err != nil {
log.Warn("Error logging ban or kick").Str("member", gmr.Member.User.Username).Err(err).Send()
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "Member Left",
Description: fmt.Sprintf("**User:**\n%s\n**ID:**\n%s", gmr.Member.User.Mention(), gmr.Member.User.ID),
Author: gmr.Member.User,
})
if err != nil {
log.Warn("Error sending member left log").Str("member", gmr.Member.User.Username).Err(err).Send()
}
}
// onChannelDelete attempts to detect the user responsible for a channel deletion
// and logs it. It also handles rate limiting for channel delete events.
func onChannelDelete(s *discordgo.Session, cd *discordgo.ChannelDelete) {
if cd.Type == discordgo.ChannelTypeDM || cd.Type == discordgo.ChannelTypeGroupDM {
return
}
auditLog, err := s.GuildAuditLog(cd.GuildID, "", "", int(discordgo.AuditLogActionChannelDelete), 5)
if err != nil {
log.Error("Error getting audit log").Err(err).Send()
return
}
for _, entry := range auditLog.AuditLogEntries {
// If the deleted channel isn't the one this event is for,
// skip it.
if entry.TargetID != cd.ID {
continue
}
// If the bot deleted the channel, we don't care about this event
if entry.UserID == s.State.User.ID {
return
}
err = handleRatelimit(s, "channel_delete", cd.GuildID, entry.UserID)
if err != nil {
log.Error("Error handling rate limit").Err(err).Send()
}
member, err := cache.Member(s, cd.GuildID, entry.UserID)
if err != nil {
log.Error("Error getting member").Err(err).Send()
return
}
err = eventlog.Log(s, cd.GuildID, eventlog.Entry{
Title: "Channel Deleted",
Description: fmt.Sprintf("**Name:** `%s`\n**Deleted By:** %s", cd.Name, member.User.Mention()),
Author: member.User,
})
if err != nil {
log.Warn("Error sending channel deleted log").Str("channel", cd.Name).Err(err).Send()
return
}
return
}
}
// handleBanOrKick attempts to detect the user responsible for a ban or kick, and
// logs it. It also handles rate limiting for bans and kicks.
func handleBanOrKick(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) error {
auditLog, err := s.GuildAuditLog(gmr.GuildID, "", "", 0, 5)
if err != nil {
return err
}
for _, entry := range auditLog.AuditLogEntries {
// If there's no action type or the user isn't the one this
// event is for, skip it.
if entry.ActionType == nil || entry.TargetID != gmr.User.ID {
continue
}
switch *entry.ActionType {
case discordgo.AuditLogActionMemberBanAdd:
executor, err := cache.Member(s, gmr.GuildID, entry.UserID)
if err != nil {
return err
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "User banned",
Description: fmt.Sprintf("**Target:** %s\n**Banned by:** %s\n**Reason:** %s", gmr.User.Mention(), executor.User.Mention(), entry.Reason),
Author: gmr.User,
})
if err != nil {
return err
}
return handleRatelimit(s, "ban", gmr.GuildID, executor.User.ID)
case discordgo.AuditLogActionMemberKick:
executor, err := cache.Member(s, gmr.GuildID, entry.UserID)
if err != nil {
return err
}
err = eventlog.Log(s, gmr.GuildID, eventlog.Entry{
Title: "User kicked",
Description: fmt.Sprintf("**Target:** %s\n**Kicked by:** %s\n**Reason:** %s", gmr.User.Mention(), executor.User.Mention(), entry.Reason),
Author: gmr.User,
})
if err != nil {
return err
}
return handleRatelimit(s, "kick", gmr.GuildID, executor.User.ID)
}
}
return nil
}

View File

@@ -0,0 +1,100 @@
package plugins
import (
"github.com/bwmarrin/discordgo"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/util"
)
// Plugins is a list of plugins
var Plugins []Plugin
// Plugin represents an owobot plugin
type Plugin struct {
Info db.PluginInfo
Commands []Command
Loop *eventloop.EventLoop
api *owobotAPI
}
// Command represents a plugin command
type Command struct {
Name string
Desc string
Usage goja.Value
OnExec goja.Value
Permissions []int64
Subcommands []Command
}
func (c Command) usage() string {
if c.Usage == nil {
return ""
} else {
return c.Usage.String()
}
}
type owobotAPI struct {
PluginInfo db.PluginInfo
Init goja.Value
OnEnable goja.Value
OnDisable goja.Value
Commands []Command
path string
loop *eventloop.EventLoop
}
func (oa *owobotAPI) Enabled(guildID string) bool {
return pluginEnabled(guildID, oa.PluginInfo.Name)
}
func (oa *owobotAPI) Respond(s *discordgo.Session, i *discordgo.Interaction, content string) error {
return util.Respond(s, i, content)
}
func (oa *owobotAPI) RespondEphemeral(s *discordgo.Session, i *discordgo.Interaction, content string) error {
return util.RespondEphemeral(s, i, content)
}
// On adds an event handler function for the given event type
func (oa *owobotAPI) On(eventType string, fn goja.Value) {
if !oa.PluginInfo.IsValid() {
log.Warn("No plugin information provided, ignoring handler registration.").Str("path", oa.path).Send()
return
}
callable, ok := goja.AssertFunction(fn)
if !ok {
log.Warn("Value passed to handler registrar is not a function, ignoring.").
Str("plugin", oa.PluginInfo.Name).
Str("event-type", eventType).
Send()
return
}
handlersMtx.Lock()
defer handlersMtx.Unlock()
oa.loop.RunOnLoop(func(vm *goja.Runtime) {
this := vm.ToValue(oa)
handlerMap[eventType] = append(handlerMap[eventType], Handler{
PluginName: oa.PluginInfo.Name,
Func: func(s *discordgo.Session, data any) {
_, err := callable(this, vm.ToValue(s), vm.ToValue(data))
if err != nil {
log.Error("Exception thrown in plugin function").
Str("plugin", oa.PluginInfo.Name).
Str("event-type", eventType).
Err(err).
Send()
}
},
})
})
}

View File

@@ -0,0 +1,346 @@
package builtins
import (
"github.com/bwmarrin/discordgo"
)
var Constants = map[string]any{
"Permissions": map[string]int64{
"ReadMessages": discordgo.PermissionViewChannel,
"SendMessages": discordgo.PermissionSendMessages,
"SendTTSMessages": discordgo.PermissionSendTTSMessages,
"ManageMessages": discordgo.PermissionManageMessages,
"EmbedLinks": discordgo.PermissionEmbedLinks,
"AttachFiles": discordgo.PermissionAttachFiles,
"ReadMessageHistory": discordgo.PermissionReadMessageHistory,
"MentionEveryone": discordgo.PermissionMentionEveryone,
"UseExternalEmojis": discordgo.PermissionUseExternalEmojis,
"UseSlashCommands": discordgo.PermissionUseSlashCommands,
"ManageThreads": discordgo.PermissionManageThreads,
"CreatePublicThreads": discordgo.PermissionCreatePublicThreads,
"CreatePrivateThreads": discordgo.PermissionCreatePrivateThreads,
"UseExternalStickers": discordgo.PermissionUseExternalStickers,
"SendMessagesInThreads": discordgo.PermissionSendMessagesInThreads,
"VoicePrioritySpeaker": discordgo.PermissionVoicePrioritySpeaker,
"VoiceStreamVideo": discordgo.PermissionVoiceStreamVideo,
"VoiceConnect": discordgo.PermissionVoiceConnect,
"VoiceSpeak": discordgo.PermissionVoiceSpeak,
"VoiceMuteMembers": discordgo.PermissionVoiceMuteMembers,
"VoiceDeafenMembers": discordgo.PermissionVoiceDeafenMembers,
"VoiceMoveMembers": discordgo.PermissionVoiceMoveMembers,
"VoiceUseVAD": discordgo.PermissionVoiceUseVAD,
"VoiceRequestToSpeak": discordgo.PermissionVoiceRequestToSpeak,
"UseActivities": discordgo.PermissionUseActivities,
"ChangeNickname": discordgo.PermissionChangeNickname,
"ManageNicknames": discordgo.PermissionManageNicknames,
"ManageRoles": discordgo.PermissionManageRoles,
"ManageWebhooks": discordgo.PermissionManageWebhooks,
"ManageEmojis": discordgo.PermissionManageEmojis,
"ManageEvents": discordgo.PermissionManageEvents,
"CreateInstantInvite": discordgo.PermissionCreateInstantInvite,
"KickMembers": discordgo.PermissionKickMembers,
"BanMembers": discordgo.PermissionBanMembers,
"Administrator": discordgo.PermissionAdministrator,
"ManageChannels": discordgo.PermissionManageChannels,
"ManageServer": discordgo.PermissionManageServer,
"AddReactions": discordgo.PermissionAddReactions,
"ViewAuditLogs": discordgo.PermissionViewAuditLogs,
"ViewChannel": discordgo.PermissionViewChannel,
"ViewGuildInsights": discordgo.PermissionViewGuildInsights,
"ModerateMembers": discordgo.PermissionModerateMembers,
"AllText": discordgo.PermissionAllText,
"AllVoice": discordgo.PermissionAllVoice,
"AllChannel": discordgo.PermissionAllChannel,
"All": discordgo.PermissionAll,
},
"MessageFlag": map[string]discordgo.MessageFlags{
"CrossPosted": discordgo.MessageFlagsCrossPosted,
"IsCrossPosted": discordgo.MessageFlagsIsCrossPosted,
"SuppressEmbeds": discordgo.MessageFlagsSuppressEmbeds,
"SupressEmbeds": discordgo.MessageFlagsSupressEmbeds,
"SourceMessageDeleted": discordgo.MessageFlagsSourceMessageDeleted,
"Urgent": discordgo.MessageFlagsUrgent,
"HasThread": discordgo.MessageFlagsHasThread,
"Ephemeral": discordgo.MessageFlagsEphemeral,
"Loading": discordgo.MessageFlagsLoading,
"FailedToMentionSomeRolesInThread": discordgo.MessageFlagsFailedToMentionSomeRolesInThread,
"SuppressNotifications": discordgo.MessageFlagsSuppressNotifications,
"IsVoiceMessage": discordgo.MessageFlagsIsVoiceMessage,
},
"MessageType": map[string]discordgo.MessageType{
"Default": discordgo.MessageTypeDefault,
"RecipientAdd": discordgo.MessageTypeRecipientAdd,
"RecipientRemove": discordgo.MessageTypeRecipientRemove,
"Call": discordgo.MessageTypeCall,
"ChannelNameChange": discordgo.MessageTypeChannelNameChange,
"ChannelIconChange": discordgo.MessageTypeChannelIconChange,
"ChannelPinnedMessage": discordgo.MessageTypeChannelPinnedMessage,
"GuildMemberJoin": discordgo.MessageTypeGuildMemberJoin,
"UserPremiumGuildSubscription": discordgo.MessageTypeUserPremiumGuildSubscription,
"UserPremiumGuildSubscriptionTierOne": discordgo.MessageTypeUserPremiumGuildSubscriptionTierOne,
"UserPremiumGuildSubscriptionTierTwo": discordgo.MessageTypeUserPremiumGuildSubscriptionTierTwo,
"UserPremiumGuildSubscriptionTierThree": discordgo.MessageTypeUserPremiumGuildSubscriptionTierThree,
"ChannelFollowAdd": discordgo.MessageTypeChannelFollowAdd,
"GuildDiscoveryDisqualified": discordgo.MessageTypeGuildDiscoveryDisqualified,
"GuildDiscoveryRequalified": discordgo.MessageTypeGuildDiscoveryRequalified,
"ThreadCreated": discordgo.MessageTypeThreadCreated,
"Reply": discordgo.MessageTypeReply,
"ChatInputCommand": discordgo.MessageTypeChatInputCommand,
"ThreadStarterMessage": discordgo.MessageTypeThreadStarterMessage,
"ContextMenuCommand": discordgo.MessageTypeContextMenuCommand,
},
"Status": map[string]discordgo.Status{
"Online": discordgo.StatusOnline,
"Idle": discordgo.StatusIdle,
"DoNotDisturb": discordgo.StatusDoNotDisturb,
"Invisible": discordgo.StatusInvisible,
"Offline": discordgo.StatusOffline,
},
"UserFlags": map[string]discordgo.UserFlags{
"DiscordEmployee": discordgo.UserFlagDiscordEmployee,
"DiscordPartner": discordgo.UserFlagDiscordPartner,
"HypeSquadEvents": discordgo.UserFlagHypeSquadEvents,
"BugHunterLevel1": discordgo.UserFlagBugHunterLevel1,
"HouseBravery": discordgo.UserFlagHouseBravery,
"HouseBrilliance": discordgo.UserFlagHouseBrilliance,
"HouseBalance": discordgo.UserFlagHouseBalance,
"EarlySupporter": discordgo.UserFlagEarlySupporter,
"TeamUser": discordgo.UserFlagTeamUser,
"System": discordgo.UserFlagSystem,
"BugHunterLevel2": discordgo.UserFlagBugHunterLevel2,
"VerifiedBot": discordgo.UserFlagVerifiedBot,
"VerifiedBotDeveloper": discordgo.UserFlagVerifiedBotDeveloper,
"DiscordCertifiedModerator": discordgo.UserFlagDiscordCertifiedModerator,
"BotHTTPInteractions": discordgo.UserFlagBotHTTPInteractions,
"ActiveBotDeveloper": discordgo.UserFlagActiveBotDeveloper,
},
"RoleFlags": map[string]discordgo.RoleFlags{
"InPrompt": discordgo.RoleFlagInPrompt,
},
"SelectMenuType": map[string]discordgo.SelectMenuType{
"String": discordgo.StringSelectMenu,
"User": discordgo.UserSelectMenu,
"Role": discordgo.RoleSelectMenu,
"Mentionable": discordgo.MentionableSelectMenu,
"Channel": discordgo.ChannelSelectMenu,
},
"ComponentType": map[string]discordgo.ComponentType{
"ActionsRow": discordgo.ActionsRowComponent,
"Button": discordgo.ButtonComponent,
"SelectMenu": discordgo.SelectMenuComponent,
"TextInput": discordgo.TextInputComponent,
"UserSelectMenu": discordgo.UserSelectMenuComponent,
"RoleSelectMenu": discordgo.RoleSelectMenuComponent,
"MentionableSelectMenu": discordgo.MentionableSelectMenuComponent,
"ChannelSelectMenu": discordgo.ChannelSelectMenuComponent,
},
"EmbedType": map[string]discordgo.EmbedType{
"Rich": discordgo.EmbedTypeRich,
"Image": discordgo.EmbedTypeImage,
"Video": discordgo.EmbedTypeVideo,
"Gifv": discordgo.EmbedTypeGifv,
"Article": discordgo.EmbedTypeArticle,
"Link": discordgo.EmbedTypeLink,
},
"MfaLevel": map[string]discordgo.MfaLevel{
"None": discordgo.MfaLevelNone,
"Elevated": discordgo.MfaLevelElevated,
},
"PermissionOverwriteType": map[string]discordgo.PermissionOverwriteType{
"Role": discordgo.PermissionOverwriteTypeRole,
"Member": discordgo.PermissionOverwriteTypeMember,
},
"PremiumTier": map[string]discordgo.PremiumTier{
"None": discordgo.PremiumTierNone,
"Tier1": discordgo.PremiumTier1,
"Tier2": discordgo.PremiumTier2,
"Tier3": discordgo.PremiumTier3,
},
"SelectMenuDefaultValueType": map[string]discordgo.SelectMenuDefaultValueType{
"User": discordgo.SelectMenuDefaultValueUser,
"Role": discordgo.SelectMenuDefaultValueRole,
"Channel": discordgo.SelectMenuDefaultValueChannel,
},
"StageInstancePrivacyLevel": map[string]discordgo.StageInstancePrivacyLevel{
"Public": discordgo.StageInstancePrivacyLevelPublic,
"GuildOnly": discordgo.StageInstancePrivacyLevelGuildOnly,
},
"StickerFormat": map[string]discordgo.StickerFormat{
"PNG": discordgo.StickerFormatTypePNG,
"APNG": discordgo.StickerFormatTypeAPNG,
"Lottie": discordgo.StickerFormatTypeLottie,
"GIF": discordgo.StickerFormatTypeGIF,
},
"StickerType": map[string]discordgo.StickerType{
"Standard": discordgo.StickerTypeStandard,
"Guild": discordgo.StickerTypeGuild,
},
"ExpireBehavior": map[string]discordgo.ExpireBehavior{
"RemoveRole": discordgo.ExpireBehaviorRemoveRole,
"Kick": discordgo.ExpireBehaviorKick,
},
"ExplicitContentFilterLevel": map[string]discordgo.ExplicitContentFilterLevel{
"Disabled": discordgo.ExplicitContentFilterDisabled,
"MembersWithoutRoles": discordgo.ExplicitContentFilterMembersWithoutRoles,
"AllMembers": discordgo.ExplicitContentFilterAllMembers,
},
"ForumLayout": map[string]discordgo.ForumLayout{
"NotSet": discordgo.ForumLayoutNotSet,
"ListView": discordgo.ForumLayoutListView,
"GalleryView": discordgo.ForumLayoutGalleryView,
},
"ForumSortOrderType": map[string]discordgo.ForumSortOrderType{
"LatestActivity": discordgo.ForumSortOrderLatestActivity,
"CreationDate": discordgo.ForumSortOrderCreationDate,
},
"GuildFeature": map[string]discordgo.GuildFeature{
"AnimatedBanner": discordgo.GuildFeatureAnimatedBanner,
"AnimatedIcon": discordgo.GuildFeatureAnimatedIcon,
"AutoModeration": discordgo.GuildFeatureAutoModeration,
"Banner": discordgo.GuildFeatureBanner,
"Community": discordgo.GuildFeatureCommunity,
"Discoverable": discordgo.GuildFeatureDiscoverable,
"Featurable": discordgo.GuildFeatureFeaturable,
"InviteSplash": discordgo.GuildFeatureInviteSplash,
"MemberVerificationGateEnabled": discordgo.GuildFeatureMemberVerificationGateEnabled,
"MonetizationEnabled": discordgo.GuildFeatureMonetizationEnabled,
"MoreStickers": discordgo.GuildFeatureMoreStickers,
"News": discordgo.GuildFeatureNews,
"Partnered": discordgo.GuildFeaturePartnered,
"PreviewEnabled": discordgo.GuildFeaturePreviewEnabled,
"PrivateThreads": discordgo.GuildFeaturePrivateThreads,
"RoleIcons": discordgo.GuildFeatureRoleIcons,
"TicketedEventsEnabled": discordgo.GuildFeatureTicketedEventsEnabled,
"VanityURL": discordgo.GuildFeatureVanityURL,
"Verified": discordgo.GuildFeatureVerified,
"VipRegions": discordgo.GuildFeatureVipRegions,
"WelcomeScreenEnabled": discordgo.GuildFeatureWelcomeScreenEnabled,
},
"GuildNSFWLevel": map[string]discordgo.GuildNSFWLevel{
"Default": discordgo.GuildNSFWLevelDefault,
"Explicit": discordgo.GuildNSFWLevelExplicit,
"Safe": discordgo.GuildNSFWLevelSafe,
"AgeRestricted": discordgo.GuildNSFWLevelAgeRestricted,
},
"GuildOnboardingMode": map[string]discordgo.GuildOnboardingMode{
"Default": discordgo.GuildOnboardingModeDefault,
"Advanced": discordgo.GuildOnboardingModeAdvanced,
},
"GuildOnboardingPromptType": map[string]discordgo.GuildOnboardingPromptType{
"MultipleChoice": discordgo.GuildOnboardingPromptTypeMultipleChoice,
"Dropdown": discordgo.GuildOnboardingPromptTypeDropdown,
},
"GuildScheduledEventEntityType": map[string]discordgo.GuildScheduledEventEntityType{
"StageInstance": discordgo.GuildScheduledEventEntityTypeStageInstance,
"Voice": discordgo.GuildScheduledEventEntityTypeVoice,
"External": discordgo.GuildScheduledEventEntityTypeExternal,
},
"GuildScheduledEventPrivacyLevel": map[string]discordgo.GuildScheduledEventPrivacyLevel{
"GuildOnly": discordgo.GuildScheduledEventPrivacyLevelGuildOnly,
},
"GuildScheduledEventStatus": map[string]discordgo.GuildScheduledEventStatus{
"Scheduled": discordgo.GuildScheduledEventStatusScheduled,
"Active": discordgo.GuildScheduledEventStatusActive,
"Completed": discordgo.GuildScheduledEventStatusCompleted,
"Canceled": discordgo.GuildScheduledEventStatusCanceled,
},
"Intent": map[string]discordgo.Intent{
"Guilds": discordgo.IntentGuilds,
"GuildMembers": discordgo.IntentGuildMembers,
"GuildModeration": discordgo.IntentGuildModeration,
"GuildEmojis": discordgo.IntentGuildEmojis,
"GuildIntegrations": discordgo.IntentGuildIntegrations,
"GuildWebhooks": discordgo.IntentGuildWebhooks,
"GuildInvites": discordgo.IntentGuildInvites,
"GuildVoiceStates": discordgo.IntentGuildVoiceStates,
"GuildPresences": discordgo.IntentGuildPresences,
"GuildMessages": discordgo.IntentGuildMessages,
"GuildMessageReactions": discordgo.IntentGuildMessageReactions,
"GuildMessageTyping": discordgo.IntentGuildMessageTyping,
"GuildBans": discordgo.IntentGuildBans,
"DirectMessages": discordgo.IntentDirectMessages,
"DirectMessageReactions": discordgo.IntentDirectMessageReactions,
"DirectMessageTyping": discordgo.IntentDirectMessageTyping,
"MessageContent": discordgo.IntentMessageContent,
"GuildScheduledEvents": discordgo.IntentGuildScheduledEvents,
"AutoModerationConfiguration": discordgo.IntentAutoModerationConfiguration,
"AutoModerationExecution": discordgo.IntentAutoModerationExecution,
"AllWithoutPrivileged": discordgo.IntentsAllWithoutPrivileged,
"IntentsAll": discordgo.IntentsAll,
"IntentsNone": discordgo.IntentsNone,
},
"InteractionResponseType": map[string]discordgo.InteractionResponseType{
"Pong": discordgo.InteractionResponsePong,
"ChannelMessageWithSource": discordgo.InteractionResponseChannelMessageWithSource,
"DeferredChannelMessageWithSource": discordgo.InteractionResponseDeferredChannelMessageWithSource,
"DeferredMessageUpdate": discordgo.InteractionResponseDeferredMessageUpdate,
"UpdateMessage": discordgo.InteractionResponseUpdateMessage,
"ApplicationCommandAutocompleteResult": discordgo.InteractionApplicationCommandAutocompleteResult,
"Modal": discordgo.InteractionResponseModal,
},
"InteractionType": map[string]discordgo.InteractionType{
"Ping": discordgo.InteractionPing,
"ApplicationCommand": discordgo.InteractionApplicationCommand,
"MessageComponent": discordgo.InteractionMessageComponent,
"ApplicationCommandAutocomplete": discordgo.InteractionApplicationCommandAutocomplete,
"ModalSubmit": discordgo.InteractionModalSubmit,
},
"InviteTargetType": map[string]discordgo.InviteTargetType{
"Stream": discordgo.InviteTargetStream,
"EmbeddedApplication": discordgo.InviteTargetEmbeddedApplication,
},
"Locale": map[string]discordgo.Locale{
"EnglishUS": discordgo.EnglishUS,
"EnglishGB": discordgo.EnglishGB,
"Bulgarian": discordgo.Bulgarian,
"ChineseCN": discordgo.ChineseCN,
"ChineseTW": discordgo.ChineseTW,
"Croatian": discordgo.Croatian,
"Czech": discordgo.Czech,
"Danish": discordgo.Danish,
"Dutch": discordgo.Dutch,
"Finnish": discordgo.Finnish,
"French": discordgo.French,
"German": discordgo.German,
"Greek": discordgo.Greek,
"Hindi": discordgo.Hindi,
"Hungarian": discordgo.Hungarian,
"Italian": discordgo.Italian,
"Japanese": discordgo.Japanese,
"Korean": discordgo.Korean,
"Lithuanian": discordgo.Lithuanian,
"Norwegian": discordgo.Norwegian,
"Polish": discordgo.Polish,
"PortugueseBR": discordgo.PortugueseBR,
"Romanian": discordgo.Romanian,
"Russian": discordgo.Russian,
"SpanishES": discordgo.SpanishES,
"SpanishLATAM": discordgo.SpanishLATAM,
"Swedish": discordgo.Swedish,
"Thai": discordgo.Thai,
"Turkish": discordgo.Turkish,
"Ukrainian": discordgo.Ukrainian,
"Vietnamese": discordgo.Vietnamese,
"Unknown": discordgo.Unknown,
},
"MemberFlags": map[string]discordgo.MemberFlags{
"DidRejoin": discordgo.MemberFlagDidRejoin,
"CompletedOnboarding": discordgo.MemberFlagCompletedOnboarding,
"BypassesVerification": discordgo.MemberFlagBypassesVerification,
"StartedOnboarding": discordgo.MemberFlagStartedOnboarding,
},
"MembershipState": map[string]discordgo.MembershipState{
"Invited": discordgo.MembershipStateInvited,
"Accepted": discordgo.MembershipStateAccepted,
},
"MessageActivityType": map[string]discordgo.MessageActivityType{
"Join": discordgo.MessageActivityTypeJoin,
"Spectate": discordgo.MessageActivityTypeSpectate,
"Listen": discordgo.MessageActivityTypeListen,
"JoinRequest": discordgo.MessageActivityTypeJoinRequest,
},
"MessageNotifications": map[string]discordgo.MessageNotifications{
"AllMessages": discordgo.MessageNotificationsAllMessages,
"OnlyMentions": discordgo.MessageNotificationsOnlyMentions,
},
}

View File

@@ -0,0 +1,118 @@
package builtins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"runtime/debug"
"strings"
"golang.org/x/net/publicsuffix"
)
// Options contains options for the JavaScript fetch function
type Options struct {
Method string
Body string
Headers map[string]any
HandleCookies *bool
}
// Response contains the response object for the JavaScript fetch function
type Response struct {
Status string
StatusCode int
Headers http.Header
body []byte
}
func (r Response) JSON() (v any, err error) {
err = json.Unmarshal(r.body, &v)
return v, err
}
func (r Response) String() string {
return string(r.body)
}
// FetchFunc is the fetch function signature
type FetchFunc = func(string, *Options) (*Response, error)
func fetch(pluginName, pluginVersion string) FetchFunc {
// cookiejar.New always returns a nil error
jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
return func(url string, opts *Options) (*Response, error) {
if opts == nil {
t := true
opts = &Options{HandleCookies: &t}
}
if opts.HandleCookies == nil {
t := true
opts.HandleCookies = &t
}
if opts.Method == "" {
opts.Method = http.MethodGet
}
req, err := http.NewRequest(opts.Method, url, strings.NewReader(opts.Body))
if err != nil {
return nil, err
}
for key, value := range opts.Headers {
req.Header.Add(key, value.(string))
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", getUserAgent(pluginName, pluginVersion))
}
client := &http.Client{}
if *opts.HandleCookies {
client.Jar = jar
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Headers: resp.Header,
body: responseBody,
}, nil
}
}
// getUserAgent uses the built in vcs information to generate a user agent string
func getUserAgent(pluginName, pluginVersion string) string {
commit := "unknown"
modified := "unmodified"
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
commit = setting.Value[:8]
case "vcs.modified":
if setting.Value == "true" {
modified = "modified"
}
}
}
}
return fmt.Sprintf("owobot/%s (%s; %s/%s)", commit, modified, pluginName, pluginVersion)
}

View File

@@ -0,0 +1,42 @@
package builtins
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/systems/eventlog"
"go.elara.ws/owobot/internal/systems/tickets"
)
type eventLogAPI struct{}
func (eventLogAPI) Log(s *discordgo.Session, guildID string, e eventlog.Entry) error {
return eventlog.Log(s, guildID, e)
}
type ticketsAPI struct{}
func (ticketsAPI) Open(s *discordgo.Session, guildID string, user, executor *discordgo.User) (string, error) {
return tickets.Open(s, guildID, user, executor)
}
func (ticketsAPI) Close(s *discordgo.Session, guildID string, user, executor *discordgo.User) {
tickets.Close(s, guildID, user, executor)
}
type cacheAPI struct{}
func (cacheAPI) Channel(s *discordgo.Session, guildID, channelID string) (*discordgo.Channel, error) {
return cache.Channel(s, guildID, channelID)
}
func (cacheAPI) Member(s *discordgo.Session, guildID, userID string) (*discordgo.Member, error) {
return cache.Member(s, guildID, userID)
}
func (cacheAPI) Role(s *discordgo.Session, guildID, roleID string) (*discordgo.Role, error) {
return cache.Role(s, guildID, roleID)
}
func (cacheAPI) Roles(s *discordgo.Session, guildID string) ([]*discordgo.Role, error) {
return cache.Roles(s, guildID)
}

View File

@@ -0,0 +1,19 @@
package builtins
import (
"errors"
"github.com/dop251/goja"
)
// Register registers all the owobot APIs in JavaScript.
func Register(vm *goja.Runtime, pluginName, pluginVersion string) error {
return errors.Join(
vm.GlobalObject().Set("sql", sqlAPI{pluginName: pluginName}),
vm.GlobalObject().Set("vercmp", vercmpAPI{}),
vm.GlobalObject().Set("cache", cacheAPI{}),
vm.GlobalObject().Set("tickets", ticketsAPI{}),
vm.GlobalObject().Set("eventlog", eventLogAPI{}),
vm.GlobalObject().Set("fetch", fetch(pluginName, pluginVersion)),
)
}

View File

@@ -0,0 +1,59 @@
package builtins
import (
"github.com/jmoiron/sqlx"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/db/sqltabler"
)
type sqlAPI struct {
pluginName string
}
func (s sqlAPI) Exec(query string, args ...any) error {
newQuery, err := sqltabler.Modify(query, "_owobot_plugin_", "_"+s.pluginName)
if err != nil {
return err
}
_, err = db.DB().Exec(newQuery, args...)
return err
}
func (s sqlAPI) Query(query string, args ...any) ([]map[string]any, error) {
newQuery, err := sqltabler.Modify(query, "_owobot_plugin_", "_"+s.pluginName)
if err != nil {
return nil, err
}
rows, err := db.DB().Queryx(newQuery, args...)
if err != nil {
return nil, err
}
return rowsToMap(rows)
}
func (s sqlAPI) QueryOne(query string, args ...any) (map[string]any, error) {
newQuery, err := sqltabler.Modify(query, "_owobot_plugin_", "_"+s.pluginName)
if err != nil {
return nil, err
}
row := db.DB().QueryRowx(newQuery, args...)
if err := row.Err(); err != nil {
return nil, err
}
out := map[string]any{}
return out, row.MapScan(out)
}
func rowsToMap(rows *sqlx.Rows) ([]map[string]any, error) {
var out []map[string]any
for rows.Next() {
resultMap := map[string]any{}
err := rows.MapScan(resultMap)
if err != nil {
return nil, err
}
out = append(out, resultMap)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,21 @@
package builtins
import "go.elara.ws/vercmp"
type vercmpAPI struct{}
func (vercmpAPI) Newer(v1, v2 string) bool {
return vercmp.Compare(v1, v2) == 1
}
func (vercmpAPI) Older(v1, v2 string) bool {
return vercmp.Compare(v1, v2) == -1
}
func (vercmpAPI) Equal(v1, v2 string) bool {
return vercmp.Compare(v1, v2) == 0
}
func (vercmpAPI) Compare(v1, v2 string) int {
return vercmp.Compare(v1, v2)
}

View File

@@ -0,0 +1,272 @@
package plugins
import (
"errors"
"fmt"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/dop251/goja"
"github.com/kballard/go-shellquote"
"go.elara.ws/owobot/internal/util"
)
// pluginadmCmd handles the `/plugin` command and routes it to the correct subcommand.
func pluginadmCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "list":
return listCmd(s, i)
case "enable":
return enableCmd(s, i)
case "disable":
return disableCmd(s, i)
default:
return fmt.Errorf("unknown pluginadm subcommand: %s", name)
}
}
// listCmd handles the `/plugin list` command.
func listCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
sb := strings.Builder{}
for _, plugin := range Plugins {
sb.WriteString(plugin.Info.Name)
sb.WriteString(" (")
sb.WriteString(plugin.Info.Version)
sb.WriteString(`): "`)
sb.WriteString(plugin.Info.Desc)
sb.WriteByte('"')
if pluginEnabled(i.GuildID, plugin.Info.Name) {
sb.WriteString(" *")
}
sb.WriteByte('\n')
}
return util.RespondEphemeral(s, i.Interaction, sb.String())
}
// enableCmd handles the `/plugin enable` command.
func enableCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
pluginName := data.Options[0].Options[0].StringValue()
plugin, ok := findPlugin(pluginName)
if !ok {
return fmt.Errorf("no such plugin: %q", pluginName)
}
err := enablePlugin(i.GuildID, pluginName)
if err != nil {
return err
}
if plugin.api.OnEnable != nil {
callable, ok := goja.AssertFunction(plugin.api.OnEnable)
if !ok {
return fmt.Errorf("onEnable value is not callable")
}
errCh := make(chan error)
plugin.Loop.RunOnLoop(func(vm *goja.Runtime) {
_, err := callable(vm.ToValue(plugin.api), vm.ToValue(i.GuildID))
errCh <- err
})
if err := <-errCh; err != nil {
return fmt.Errorf("%s onEnable: %w", plugin.Info.Name, err)
}
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully enabled the %q plugin!", pluginName))
}
// disableCmd handles the `/plugin disable` command.
func disableCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
pluginName := data.Options[0].Options[0].StringValue()
plugin, ok := findPlugin(pluginName)
if !ok {
return fmt.Errorf("no such plugin: %q", pluginName)
}
err := disablePlugin(i.GuildID, pluginName)
if err != nil {
return err
}
if plugin.api.OnDisable != nil {
callable, ok := goja.AssertFunction(plugin.api.OnDisable)
if !ok {
return fmt.Errorf("onDisable value is not callable")
}
errCh := make(chan error)
plugin.Loop.RunOnLoop(func(vm *goja.Runtime) {
_, err := callable(vm.ToValue(plugin.api), vm.ToValue(i.GuildID))
errCh <- err
})
if err := <-errCh; err != nil {
return fmt.Errorf("%s onDisable: %w", plugin.Info.Name, err)
}
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully disabled the %q plugin", pluginName))
}
func pluginCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "run":
return pluginRunCmd(s, i)
case "help":
return pluginHelpCmd(s, i)
default:
return fmt.Errorf("unknown plugin subcommand: %s", name)
}
}
// pluginHelpCmd handles the `/phelp` command.
func pluginHelpCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
cmdStr := data.Options[0].Options[0].StringValue()
args, err := shellquote.Split(cmdStr)
if err != nil {
return err
}
for _, plugin := range Plugins {
if !pluginEnabled(i.GuildID, plugin.Info.Name) {
continue
}
cmd, _, ok := findCmd(plugin.Commands, args)
if !ok {
continue
}
for _, perm := range cmd.Permissions {
if i.Member.Permissions&perm == 0 {
return errors.New("you don't have permission to execute this command")
}
}
sb := strings.Builder{}
sb.WriteString("Usage: `")
sb.WriteString(cmdStr)
if usage := cmd.usage(); usage != "" {
sb.WriteString(" " + usage)
}
sb.WriteByte('`')
sb.WriteString("\n\n")
sb.WriteString("Description:\n```text\n")
sb.WriteString(cmd.Desc)
sb.WriteString("\n```\n")
if len(cmd.Subcommands) > 0 {
sb.WriteString("Subcommands:\n")
for _, subcmd := range cmd.Subcommands {
sb.WriteString("- `")
sb.WriteString(subcmd.Name)
if usage := subcmd.usage(); usage != "" {
sb.WriteString(" " + usage)
}
sb.WriteString("`: `")
sb.WriteString(subcmd.Desc)
sb.WriteString("`\n")
}
}
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Embeds: []*discordgo.MessageEmbed{{
Title: "Command `" + cmd.Name + "`",
Description: sb.String(),
}},
},
})
}
return fmt.Errorf("command not found: %q", args[0])
}
// pluginRunCmd handles the `/pluginRunCmd` command.
func pluginRunCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
cmdStr := data.Options[0].Options[0].StringValue()
args, err := shellquote.Split(cmdStr)
if err != nil {
return err
}
for _, plugin := range Plugins {
if !pluginEnabled(i.GuildID, plugin.Info.Name) {
continue
}
cmd, newArgs, ok := findCmd(plugin.Commands, args)
if !ok {
continue
}
for _, perm := range cmd.Permissions {
if i.Member.Permissions&perm == 0 {
return errors.New("you don't have permission to execute this command")
}
}
callable, ok := goja.AssertFunction(cmd.OnExec)
if !ok {
return fmt.Errorf("value in onExec is not callable")
}
errCh := make(chan error)
plugin.Loop.RunOnLoop(func(vm *goja.Runtime) {
_, err = callable(
vm.ToValue(cmd),
vm.ToValue(s),
vm.ToValue(i),
vm.ToValue(newArgs),
)
errCh <- err
})
return <-errCh
}
return fmt.Errorf("command not found: %q", args[0])
}
func findPlugin(name string) (Plugin, bool) {
for _, plugin := range Plugins {
if plugin.Info.Name == name {
return plugin, true
}
}
return Plugin{}, false
}
func findCmd(cmds []Command, args []string) (Command, []string, bool) {
if len(args) == 0 {
return Command{}, nil, false
}
for _, cmd := range cmds {
if args[0] != cmd.Name {
continue
}
if len(cmd.Subcommands) != 0 && len(args) > 1 {
subcmd, newArgs, ok := findCmd(cmd.Subcommands, args[1:])
if ok {
return subcmd, newArgs, true
}
}
return cmd, args[1:], true
}
return Command{}, nil, false
}

View File

@@ -0,0 +1,45 @@
package plugins
import (
"fmt"
"slices"
"go.elara.ws/owobot/internal/db"
)
var enabled = map[string][]string{}
func loadEnabled() error {
guilds, err := db.AllGuilds()
if err != nil {
return err
}
for _, guild := range guilds {
enabled[guild.ID] = []string(guild.EnabledPlugins)
}
return nil
}
func enablePlugin(guildID, pluginName string) error {
if slices.Contains(enabled[guildID], pluginName) {
return fmt.Errorf("plugin %q is already enabled", pluginName)
}
enabled[guildID] = append(enabled[guildID], pluginName)
return db.EnablePlugin(guildID, pluginName)
}
func disablePlugin(guildID, pluginName string) error {
if i := slices.Index(enabled[guildID], pluginName); i > -1 {
enabled[guildID] = append(enabled[guildID][:i], enabled[guildID][i+1:]...)
} else {
return fmt.Errorf("plugin %q is already disabled", pluginName)
}
return db.DisablePlugin(guildID, pluginName)
}
func pluginEnabled(guildID, pluginName string) bool {
if guildID == "" {
return false
}
return slices.Contains(enabled[guildID], pluginName)
}

View File

@@ -0,0 +1,34 @@
package plugins
import (
"reflect"
"strings"
"unicode"
)
type lowerCamelNameMapper struct{}
func (lowerCamelNameMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
return toLowerCamel(f.Name)
}
func (lowerCamelNameMapper) MethodName(_ reflect.Type, m reflect.Method) string {
return toLowerCamel(m.Name)
}
func toLowerCamel(name string) string {
if isUpper(name) {
return strings.ToLower(name)
} else {
return strings.ToLower(name[:1]) + name[1:]
}
}
func isUpper(s string) bool {
for _, char := range s {
if unicode.IsLower(char) {
return false
}
}
return true
}

View File

@@ -0,0 +1,134 @@
package plugins
import (
"reflect"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
)
// HandlerFunc is an event handler function.
type HandlerFunc func(session *discordgo.Session, data any)
// Handler represents a plugin event handler.
type Handler struct {
PluginName string
Func HandlerFunc
}
var (
handlersMtx = sync.Mutex{}
handlerMap = map[string][]Handler{}
)
// handlePluginEvent handles any discord event we receive and
// routes it to the appropriate plugin handler(s).
func handlePluginEvent(s *discordgo.Session, data any) {
name := reflect.TypeOf(data).Elem().Name()
handlers, ok := handlerMap[name]
if !ok {
return
}
for _, h := range handlers {
if !pluginEnabled(eventGuildID(data), h.PluginName) {
continue
}
h.Func(s, data)
}
}
// eventGuildID uses reflection to get the guild ID from an event
func eventGuildID(event any) string {
evt := reflect.ValueOf(event)
for evt.Kind() == reflect.Pointer {
evt = evt.Elem()
}
if evt.Kind() != reflect.Struct {
return ""
}
if id := evt.FieldByName("GuildID"); id.IsValid() {
return id.String()
} else if guild := evt.FieldByName("Guild"); guild.IsValid() {
if id := guild.FieldByName("ID"); id.IsValid() {
return id.String()
}
}
return ""
}
// handleAutocomplete handles autocomplete events for the /plugin run command.
func handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommandAutocomplete {
return
}
data := i.ApplicationCommandData()
if data.Name != "plugin" {
return
}
cmdStr := data.Options[0].Options[0].StringValue()
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{
Choices: getAllChoices(i.GuildID, cmdStr, i.Member),
},
})
}
// getAllChoices gets possible command strings for each plugin and converts them
// to Discord command options.
func getAllChoices(guildID, partial string, member *discordgo.Member) (out []*discordgo.ApplicationCommandOptionChoice) {
for _, plugin := range Plugins {
if !pluginEnabled(guildID, plugin.Info.Name) {
continue
}
out = append(out, getChoiceStrs(partial, "", plugin.Commands, member)...)
}
return out
}
// getChoiceStrs recursively looks through every command in cmds,
// and generates a list of strings to use as autocomplete options.
func getChoiceStrs(partial, prefix string, cmds []Command, member *discordgo.Member) []*discordgo.ApplicationCommandOptionChoice {
if len(cmds) == 0 {
return nil
}
partial = strings.TrimSpace(partial)
var out []*discordgo.ApplicationCommandOptionChoice
for _, cmd := range cmds {
for _, perm := range cmd.Permissions {
if member.Permissions&perm == 0 {
continue
}
}
sub := getChoiceStrs(strings.TrimPrefix(partial, cmd.Name), cmd.Name+" ", cmd.Subcommands, member)
out = append(out, sub...)
if cmd.OnExec == nil {
continue
}
qualifiedCmd := prefix + cmd.Name
if strings.Contains(qualifiedCmd, partial) {
out = append(out, &discordgo.ApplicationCommandOptionChoice{
Name: qualifiedCmd,
Value: qualifiedCmd,
})
}
}
return out
}

View File

@@ -0,0 +1,195 @@
package plugins
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/bwmarrin/discordgo"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/systems/plugins/builtins"
"go.elara.ws/owobot/internal/util"
)
func Init(s *discordgo.Session) error {
if err := loadEnabled(); err != nil {
return err
}
commands.Register(s, pluginCmd, &discordgo.ApplicationCommand{
Name: "plugin",
Description: "Interact with the plugins on this server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "run",
Description: "Run a plugin command",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "cmd",
Description: "The plugin command to run",
Required: true,
Autocomplete: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "help",
Description: "See how to use a plugin command",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "cmd",
Description: "The plugin command to help with",
Required: true,
Autocomplete: true,
},
},
},
},
})
commands.Register(s, pluginadmCmd, &discordgo.ApplicationCommand{
Name: "pluginadm",
Description: "Manage dynamic plugins for your server",
DefaultMemberPermissions: util.Pointer[int64](discordgo.PermissionManageServer),
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "list",
Description: "List all available plugins",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "enable",
Description: "Enable a plugin in this guild",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "plugin",
Description: "The name of the plugin to enable",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "disable",
Description: "Disable a plugin in this guild",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "plugin",
Description: "The name of the plugin to disable",
Required: true,
},
},
},
},
})
s.AddHandler(handleAutocomplete)
s.AddHandler(handlePluginEvent)
return nil
}
// Load recursively loads plugins from the given directory.
func Load(dir string, sess *discordgo.Session) error {
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || filepath.Ext(path) != ".js" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
loop := eventloop.NewEventLoop()
loop.Run(func(vm *goja.Runtime) {
vm.SetFieldNameMapper(lowerCamelNameMapper{})
})
api := &owobotAPI{loop: loop, path: path}
loop.Run(func(vm *goja.Runtime) {
err = errors.Join(
vm.GlobalObject().Set("owobot", api),
vm.GlobalObject().Set("discord", builtins.Constants),
vm.GlobalObject().Set("print", fmt.Println),
)
})
if err != nil {
return err
}
loop.Start()
errCh := make(chan error)
loop.RunOnLoop(func(vm *goja.Runtime) {
_, err := vm.RunScript(path, string(data))
errCh <- err
})
if err := <-errCh; err != nil {
return err
}
if !api.PluginInfo.IsValid() {
log.Warn("Plugin info not provided, skipping.").Str("path", path).Send()
return nil
}
prev, _ := db.GetPlugin(api.PluginInfo.Name)
err = db.AddPlugin(api.PluginInfo)
if err != nil {
return err
}
loop.RunOnLoop(func(vm *goja.Runtime) {
err := builtins.Register(vm, api.PluginInfo.Name, api.PluginInfo.Version)
errCh <- err
})
if err := <-errCh; err != nil {
return err
}
Plugins = append(Plugins, Plugin{
Info: api.PluginInfo,
Commands: api.Commands,
Loop: loop,
api: api,
})
if api.Init != nil {
callableInit, ok := goja.AssertFunction(api.Init)
if !ok {
log.Warn("Init value is not callable, ignoring.").Str("plugin", api.PluginInfo.Name).Send()
return nil
}
loop.RunOnLoop(func(vm *goja.Runtime) {
_, err := callableInit(vm.ToValue(api), vm.ToValue(prev), vm.ToValue(sess))
errCh <- err
})
if err := <-errCh; err != nil {
return fmt.Errorf("%s init: %w", api.PluginInfo.Name, err)
}
}
return nil
})
}

View File

@@ -0,0 +1,41 @@
package polls
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/db"
)
func pollCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
title := data.Options[0].StringValue()
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "**" + title + "**",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Add Option",
Style: discordgo.PrimaryButton,
CustomID: "poll-add-opt",
},
discordgo.Button{
Label: "Finish",
Style: discordgo.SuccessButton,
CustomID: "poll-finish",
},
}},
},
},
})
if err != nil {
return err
}
msg, err := s.InteractionResponse(i.Interaction)
if err != nil {
return err
}
return db.CreatePoll(msg.ID, i.Member.User.ID, title)
}

View File

@@ -15,69 +15,10 @@ import (
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/emoji"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/util"
"go.elara.ws/owobot/internal/xsync"
)
func Init(s *discordgo.Session) error {
s.AddHandler(util.InteractionErrorHandler("poll-add-opt", onPollAddOpt))
s.AddHandler(util.InteractionErrorHandler("poll-opt-submit", onAddOptModalSubmit))
s.AddHandler(util.InteractionErrorHandler("poll-finish", onPollFinish))
s.AddHandler(onPollReaction)
s.AddHandler(onVote)
commands.Register(s, pollCmd, &discordgo.ApplicationCommand{
Name: "poll",
Description: "Create a new poll",
Options: []*discordgo.ApplicationCommandOption{
{
Name: "title",
Description: "The title of the poll",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
},
})
return nil
}
func pollCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
title := data.Options[0].StringValue()
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "**" + title + "**",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Add Option",
Style: discordgo.PrimaryButton,
CustomID: "poll-add-opt",
},
discordgo.Button{
Label: "Finish",
Style: discordgo.SuccessButton,
CustomID: "poll-finish",
},
}},
},
},
})
if err != nil {
return err
}
msg, err := s.InteractionResponse(i.Interaction)
if err != nil {
return err
}
return db.CreatePoll(msg.ID, i.Member.User.ID, title)
}
// onPollAddOpt handles the Add Option button on unfinished polls.
func onPollAddOpt(s *discordgo.Session, i *discordgo.InteractionCreate) error {
if i.Type != discordgo.InteractionMessageComponent {
@@ -217,7 +158,7 @@ func onPollFinish(s *discordgo.Session, i *discordgo.InteractionCreate) error {
currentRow.Components = append(currentRow.Components, discordgo.Button{
CustomID: "vote:" + strconv.Itoa(i) + ":" + privacyToken,
Style: discordgo.SecondaryButton,
Emoji: discordgo.ComponentEmoji{
Emoji: &discordgo.ComponentEmoji{
Name: e.Name,
ID: e.ID,
},
@@ -364,7 +305,7 @@ func updatePollUnfinished(s *discordgo.Session, msgID, channelID string) error {
ID: msgID,
Channel: channelID,
Content: &content,
Components: []discordgo.MessageComponent{
Components: &[]discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Add Option",

View File

@@ -0,0 +1,30 @@
package polls
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/util"
)
func Init(s *discordgo.Session) error {
s.AddHandler(util.InteractionErrorHandler("poll-add-opt", onPollAddOpt))
s.AddHandler(util.InteractionErrorHandler("poll-opt-submit", onAddOptModalSubmit))
s.AddHandler(util.InteractionErrorHandler("poll-finish", onPollFinish))
s.AddHandler(onPollReaction)
s.AddHandler(onVote)
commands.Register(s, pollCmd, &discordgo.ApplicationCommand{
Name: "poll",
Description: "Create a new poll",
Options: []*discordgo.ApplicationCommandOption{
{
Name: "title",
Description: "The title of the poll",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
},
})
return nil
}

View File

@@ -31,6 +31,7 @@ import (
"go.elara.ws/owobot/internal/util"
)
// reactionsCmd handles the `/reactions` command and routes it to the correct subcommand.
func reactionsCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
@@ -50,6 +51,7 @@ func reactionsCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
}
}
// reactionsAddCmd handles the `/reactions add` command.
func reactionsAddCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
@@ -96,17 +98,16 @@ func reactionsAddCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error
return util.RespondEphemeral(s, i.Interaction, "Successfully added reaction!")
}
// reactionsListCmd handles the `/reactions list` command.
func reactionsListCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
reactions, err := db.Reactions(i.GuildID)
if err != nil {
return err
}
fmt.Println(reactions)
var sb strings.Builder
sb.WriteString("**Reactions:**\n")
for _, reaction := range reactions {
fmt.Println(reaction.Reaction)
sb.WriteString("- _[")
if reaction.Chance < 100 {
sb.WriteString(strconv.Itoa(reaction.Chance))
@@ -125,6 +126,7 @@ func reactionsListCmd(s *discordgo.Session, i *discordgo.InteractionCreate) erro
return util.RespondEphemeral(s, i.Interaction, sb.String())
}
// reactionsDeleteCmd handles the `/reactions delete` command.
func reactionsDeleteCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Make sure the user has the manage expressions permission
// in case a role/member override allows someone else to use it
@@ -143,6 +145,7 @@ func reactionsDeleteCmd(s *discordgo.Session, i *discordgo.InteractionCreate) er
return util.RespondEphemeral(s, i.Interaction, "Successfully removed reaction")
}
// reactionsExcludeCmd handles the `/reactions exclude` command.
func reactionsExcludeCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Make sure the user has the manage expressions permission
// in case a role/member override allows someone else to use it
@@ -168,6 +171,7 @@ func reactionsExcludeCmd(s *discordgo.Session, i *discordgo.InteractionCreate) e
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully excluded %s from receiving reactions", channel.Mention()))
}
// reactionsUnexcludeCmd handles the `/reactions unexclude` command.
func reactionsUnexcludeCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Make sure the user has the manage expressions permission
// in case a role/member override allows someone else to use it
@@ -193,6 +197,8 @@ func reactionsUnexcludeCmd(s *discordgo.Session, i *discordgo.InteractionCreate)
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully unexcluded %s from receiving reactions", channel.Mention()))
}
// validateEmoji checks if the given slice of emoji is valid.
// If an invalid emoji is found, it returns an error.
func validateEmoji(s db.StringSlice) error {
for i := range s {
s[i] = strings.TrimSpace(s[i])

View File

@@ -0,0 +1,121 @@
package reactions
import (
"fmt"
"math/rand"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/valyala/fasttemplate"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/emoji"
)
// onMessage handles all new messages. It checks if the message matches any reaction
// registered for that guild, and if it does, it performs all the matching reactions.
func onMessage(s *discordgo.Session, mc *discordgo.MessageCreate) {
if mc.Author.ID == s.State.User.ID {
return
}
reactions, err := db.Reactions(mc.GuildID)
if err != nil {
log.Error("Error getting reactions from database").Err(err).Send()
return
}
for _, reaction := range reactions {
if slices.Contains(reaction.ExcludedChannels, mc.ChannelID) {
continue
}
switch reaction.MatchType {
case db.MatchTypeContains:
if strings.Contains(strings.ToLower(mc.Content), reaction.Match) {
err = performReaction(s, reaction, reaction.Reaction, mc)
if err != nil {
log.Error("Error performing reaction").Err(err).Send()
continue
}
}
case db.MatchTypeRegex:
re, err := cache.Regex(reaction.Match)
if err != nil {
log.Error("Error compiling regex").Err(err).Send()
continue
}
var content db.StringSlice
switch reaction.ReactionType {
case db.ReactionTypeText:
submatch := re.FindSubmatch([]byte(mc.Content))
if len(submatch) > 1 {
replacements := map[string]any{}
for i, match := range submatch {
replacements[strconv.Itoa(i)] = match
}
content = db.StringSlice{
fasttemplate.ExecuteStringStd(reaction.Reaction[0], "{", "}", replacements),
}
} else if len(submatch) == 1 {
content = reaction.Reaction
}
case db.ReactionTypeEmoji:
if re.MatchString(mc.Content) {
content = reaction.Reaction
}
}
if content != nil {
err = performReaction(s, reaction, content, mc)
if err != nil {
log.Error("Error performing reaction").Err(err).Send()
continue
}
}
}
}
}
var (
rngMtx = sync.Mutex{}
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
)
func performReaction(s *discordgo.Session, reaction db.Reaction, content db.StringSlice, mc *discordgo.MessageCreate) error {
if reaction.Chance < 100 {
rngMtx.Lock()
randNum := rng.Intn(100) + 1
rngMtx.Unlock()
if randNum > reaction.Chance {
return nil
}
}
switch reaction.ReactionType {
case db.ReactionTypeText:
_, err := s.ChannelMessageSendReply(mc.ChannelID, content[0], mc.Reference())
if err != nil {
return err
}
case db.ReactionTypeEmoji:
for _, emojiStr := range content {
e, ok := emoji.Parse(emojiStr)
if !ok {
return fmt.Errorf("invalid emoji: %s", emojiStr)
}
err := s.MessageReactionAdd(mc.ChannelID, mc.ID, e.APIFormat())
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -19,20 +19,7 @@
package reactions
import (
"fmt"
"math/rand"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/valyala/fasttemplate"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/emoji"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/util"
)
@@ -170,104 +157,3 @@ func Init(s *discordgo.Session) error {
return nil
}
func onMessage(s *discordgo.Session, mc *discordgo.MessageCreate) {
if mc.Author.ID == s.State.User.ID {
return
}
reactions, err := db.Reactions(mc.GuildID)
if err != nil {
log.Error("Error getting reactions from database").Err(err).Send()
return
}
for _, reaction := range reactions {
if slices.Contains(reaction.ExcludedChannels, mc.ChannelID) {
continue
}
switch reaction.MatchType {
case db.MatchTypeContains:
if strings.Contains(strings.ToLower(mc.Content), reaction.Match) {
err = performReaction(s, reaction, reaction.Reaction, mc)
if err != nil {
log.Error("Error performing reaction").Err(err).Send()
continue
}
}
case db.MatchTypeRegex:
re, err := cache.Regex(reaction.Match)
if err != nil {
log.Error("Error compiling regex").Err(err).Send()
continue
}
content := reaction.Reaction
switch reaction.ReactionType {
case db.ReactionTypeText:
submatch := re.FindSubmatch([]byte(mc.Content))
if len(submatch) > 1 {
replacements := map[string]any{}
for i, match := range submatch {
replacements[strconv.Itoa(i)] = match
}
content = db.StringSlice{
fasttemplate.ExecuteStringStd(reaction.Reaction[0], "{", "}", replacements),
}
} else if len(submatch) == 1 {
content = reaction.Reaction
}
case db.ReactionTypeEmoji:
if re.MatchString(mc.Content) {
content = reaction.Reaction
}
}
if content[0] != "" {
err = performReaction(s, reaction, content, mc)
if err != nil {
log.Error("Error performing reaction").Err(err).Send()
continue
}
}
}
}
}
var (
rngMtx = sync.Mutex{}
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
)
func performReaction(s *discordgo.Session, reaction db.Reaction, content db.StringSlice, mc *discordgo.MessageCreate) error {
if reaction.Chance < 100 {
rngMtx.Lock()
randNum := rng.Intn(100) + 1
rngMtx.Unlock()
if randNum > reaction.Chance {
return nil
}
}
switch reaction.ReactionType {
case db.ReactionTypeText:
_, err := s.ChannelMessageSendReply(mc.ChannelID, content[0], mc.Reference())
if err != nil {
return err
}
case db.ReactionTypeEmoji:
for _, emojiStr := range content {
e, ok := emoji.Parse(emojiStr)
if !ok {
return fmt.Errorf("invalid emoji: %s", emojiStr)
}
err := s.MessageReactionAdd(mc.ChannelID, mc.ID, e.APIFormat())
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -20,15 +20,18 @@ package roles
import (
"fmt"
"regexp"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/emoji"
"go.elara.ws/owobot/internal/util"
)
// reactionRolesCmd calls the correct subcommand handler for the reaction_roles command
// reactionRolesCmd handles the `/reaction_roles` command and routes it to the correct subcommand.
func reactionRolesCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
@@ -46,7 +49,7 @@ func reactionRolesCmd(s *discordgo.Session, i *discordgo.InteractionCreate) erro
}
}
// reactionRolesNewCategoryCmd creates a new reaction role category.
// reactionRolesNewCategoryCmd handles the `/reaction_roles new_category` command.
func reactionRolesNewCategoryCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
@@ -76,7 +79,7 @@ func reactionRolesNewCategoryCmd(s *discordgo.Session, i *discordgo.InteractionC
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully added a new reaction role category called `%s`!", rrc.Name))
}
// reactionRolesRemoveCategoryCmd removes an existing reaction role category.
// reactionRolesRemoveCategoryCmd handles the `/reaction_roles remove_category` command.
func reactionRolesRemoveCategoryCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
@@ -101,7 +104,7 @@ func reactionRolesRemoveCategoryCmd(s *discordgo.Session, i *discordgo.Interacti
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Removed reaction role category `%s`", args[0].StringValue()))
}
// reactionRolesAddCmd adds a reaction role to a category.
// reactionRolesAddCmd handles the `/reaction_roles add` command.
func reactionRolesAddCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
@@ -128,7 +131,7 @@ func reactionRolesAddCmd(s *discordgo.Session, i *discordgo.InteractionCreate) e
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Added reaction role %s to `%s`", role.Mention(), category))
}
// reactionRolesRemoveCmd removes a reaction role from a category.
// reactionRolesRemoveCmd handles the `/reaction_roles remove` command.
func reactionRolesRemoveCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
@@ -149,6 +152,64 @@ func reactionRolesRemoveCmd(s *discordgo.Session, i *discordgo.InteractionCreate
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Removed reaction role %s from `%s`", role.Mention(), category))
}
var neopronounValidationRegex = regexp.MustCompile(`^[a-z]+(/[a-z]+)+$`)
// neopronounCmd handles the `/neopronoun` command.
func neopronounCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
name := data.Options[0].StringValue()
name = strings.ToLower(name)
if !neopronounValidationRegex.MatchString(name) {
return fmt.Errorf("invalid neopronoun: `%s`", name)
}
roles, err := cache.Roles(s, i.GuildID)
if err != nil {
return err
}
var roleID string
for _, role := range roles {
// Skip this role if it provides any permissions, so that
// we don't accidentally grant the member any extra permissions
if role.Permissions != 0 {
continue
}
if role.Name == name {
roleID = role.ID
break
}
}
if roleID == "" {
role, err := s.GuildRoleCreate(i.GuildID, &discordgo.RoleParams{
Name: name,
Mentionable: util.Pointer(false),
Permissions: util.Pointer[int64](0),
})
if err != nil {
return err
}
roleID = role.ID
}
if slices.Contains(i.Member.Roles, roleID) {
err = s.GuildMemberRoleRemove(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Unassigned the `%s` role", name))
} else {
err = s.GuildMemberRoleAdd(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully assigned the `%s` role to you!", name))
}
}
// updateReactionRoleCategoryMsg updates a reaction role category message
func updateReactionRoleCategoryMsg(s *discordgo.Session, channelID, category string) error {
rrc, err := db.GetReactionRoleCategory(channelID, category)
@@ -189,7 +250,7 @@ func updateReactionRoleCategoryMsg(s *discordgo.Session, channelID, category str
currentRow.Components = append(currentRow.Components, discordgo.Button{
CustomID: "role:" + roleID,
Style: discordgo.SecondaryButton,
Emoji: discordgo.ComponentEmoji{
Emoji: &discordgo.ComponentEmoji{
Name: e.Name,
ID: e.ID,
},
@@ -203,13 +264,13 @@ func updateReactionRoleCategoryMsg(s *discordgo.Session, channelID, category str
_, err = s.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: channelID,
ID: rrc.MsgID,
Embeds: []*discordgo.MessageEmbed{
Embeds: &[]*discordgo.MessageEmbed{
{
Title: rrc.Name,
Description: sb.String(),
},
},
Components: components,
Components: &components,
})
return err
}

View File

@@ -0,0 +1,40 @@
package roles
import (
"fmt"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/util"
)
// onRoleButton handles users clicking a role reaction button. It checks if they have
// the role the button is codes for, and if they do, it removes it. Otherwise, it
// assigns it to them.
func onRoleButton(s *discordgo.Session, i *discordgo.InteractionCreate) error {
if i.Type != discordgo.InteractionMessageComponent {
return nil
}
data := i.MessageComponentData()
buttonID, roleID, ok := strings.Cut(data.CustomID, ":")
if !ok || buttonID != "role" {
return nil
}
if slices.Contains(i.Member.Roles, roleID) {
err := s.GuildMemberRoleRemove(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Unassigned role <@&%s>", roleID))
} else {
err := s.GuildMemberRoleAdd(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully assigned role <@&%s> to you", roleID))
}
}

View File

@@ -19,10 +19,6 @@
package roles
import (
"fmt"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/util"
@@ -129,33 +125,3 @@ func Init(s *discordgo.Session) error {
return nil
}
// onRoleButton handles users clicking a role reaction button. It checks if they have
// the role the button is codes for, and if they do, it removes it. Otherwise, it
// assigns it to them.
func onRoleButton(s *discordgo.Session, i *discordgo.InteractionCreate) error {
if i.Type != discordgo.InteractionMessageComponent {
return nil
}
data := i.MessageComponentData()
buttonID, roleID, ok := strings.Cut(data.CustomID, ":")
if !ok || buttonID != "role" {
return nil
}
if slices.Contains(i.Member.Roles, roleID) {
err := s.GuildMemberRoleRemove(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Unassigned role <@&%s>", roleID))
} else {
err := s.GuildMemberRoleAdd(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully assigned role <@&%s> to you", roleID))
}
}

View File

@@ -1,88 +0,0 @@
/*
* owobot - Your server's guardian and entertainer
* Copyright (C) 2023 owobot Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package roles
import (
"fmt"
"regexp"
"slices"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/util"
)
var neopronounValidationRegex = regexp.MustCompile(`^[a-z]+(/[a-z]+)+$`)
// neopronounCmd assigns a neopronoun role to the user that ran it.
func neopronounCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
name := data.Options[0].StringValue()
name = strings.ToLower(name)
if !neopronounValidationRegex.MatchString(name) {
return fmt.Errorf("invalid neopronoun: `%s`", name)
}
roles, err := cache.Roles(s, i.GuildID)
if err != nil {
return err
}
var roleID string
for _, role := range roles {
// Skip this role if it provides any permissions, so that
// we don't accidentally grant the member any extra permissions
if role.Permissions != 0 {
continue
}
if role.Name == name {
roleID = role.ID
break
}
}
if roleID == "" {
role, err := s.GuildRoleCreate(i.GuildID, &discordgo.RoleParams{
Name: name,
Mentionable: util.Pointer(false),
Permissions: util.Pointer[int64](0),
})
if err != nil {
return err
}
roleID = role.ID
}
if slices.Contains(i.Member.Roles, roleID) {
err = s.GuildMemberRoleRemove(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Unassigned the `%s` role", name))
} else {
err = s.GuildMemberRoleAdd(i.GuildID, i.Member.User.ID, roleID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully assigned the `%s` role to you!", name))
}
}

View File

@@ -0,0 +1,55 @@
package starboard
import (
"errors"
"fmt"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/util"
)
// starboardCmd handles the `/starboard` command and routes it to the correct subcommand.
func starboardCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "channel":
return channelCmd(s, i)
case "stars":
return starsCmd(s, i)
default:
return fmt.Errorf("unknown subcommand: %s", name)
}
}
// channelCmd handles the `/starboard channel` command.
func channelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetStarboardChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set starboard channel to <#%s>!", c.ID))
}
// starsCmd handles the `/starboard stars` command.
func starsCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
stars := args[0].IntValue()
if stars <= 0 {
return errors.New("star amount must be greater than 0")
}
err := db.SetStarboardStars(i.GuildID, stars)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set the amount of stars required to get on the starboard to %d!", stars))
}

View File

@@ -0,0 +1,144 @@
package starboard
import (
"fmt"
"mime"
"net/url"
"path"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/systems/eventlog"
"mvdan.cc/xurls/v2"
)
// onReaction detects star reactions, and if the message qualifies for starboard
// based on the guild's settings, it replies to it and adds it to the starboard.
func onReaction(s *discordgo.Session, mra *discordgo.MessageReactionAdd) {
if mra.Emoji.Name != starEmoji {
return
}
msgExists, err := db.ExistsInStarboard(mra.MessageID)
if err != nil {
log.Warn("Error checking if the message exists in the starboard").Err(err).Send()
return
}
// If the message has already been added to the starboard,
// we can skip it.
if msgExists {
return
}
guild, err := db.GuildByID(mra.GuildID)
if err != nil {
log.Warn("Error getting guild from the database").Str("id", mra.GuildID).Err(err).Send()
return
}
// If the guild has no starboard channel ID set, we can
// skip this message.
if guild.StarboardChanID == "" {
return
}
reactions, err := s.MessageReactions(mra.ChannelID, mra.MessageID, starEmoji, guild.StarboardStars, "", "")
if err != nil {
log.Warn("Error getting message reactions").Err(err).Send()
return
}
if len(reactions) >= guild.StarboardStars {
msg, err := s.ChannelMessage(mra.ChannelID, mra.MessageID)
if err != nil {
log.Warn("Error getting channel message").Err(err).Send()
return
}
ch, err := s.Channel(mra.ChannelID)
if err != nil {
log.Warn("Error getting channel").Err(err).Send()
return
}
_, err = s.ChannelMessageSendReply(
msg.ChannelID,
fmt.Sprintf("Congrats %s! You've made it to <#%s>!!", msg.Author.Mention(), guild.StarboardChanID),
msg.Reference(),
)
if err != nil {
log.Warn("Error sending message reply").Err(err).Send()
return
}
embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("%s - #%s - %s has made it!", starEmoji, ch.Name, msg.Author.Username),
Author: &discordgo.MessageEmbedAuthor{
Name: msg.Author.Username,
IconURL: msg.Author.AvatarURL(""),
},
Description: fmt.Sprintf(
"[**Jump to Message**](https://discord.com/channels/%s/%s/%s)",
mra.GuildID,
msg.ChannelID,
msg.ID,
),
Color: embedColor,
}
eventlog.AddTimeToEmbed(guild.TimeFormat, embed)
if imageURL := getImageURL(msg); imageURL != "" {
// If the message has an image, add it to the embed
embed.Image = &discordgo.MessageEmbedImage{URL: imageURL}
}
if msg.Content != "" {
// If the message has content, we add it above the
// jump to message link currently in the embed description.
embed.Description = fmt.Sprintf(
"**Message Content**\n%s\n\n%s",
msg.Content,
embed.Description,
)
}
_, err = s.ChannelMessageSendEmbed(guild.StarboardChanID, embed)
if err != nil {
log.Warn("Error sending starboard message").Err(err).Send()
return
}
err = db.AddToStarboard(mra.MessageID)
if err != nil {
log.Warn("Error adding message to starboard").Err(err).Send()
return
}
}
}
// getImageURL looks through the message content and attachments
// to try to find images. If it finds one, it returns the URL.
// Otherwise, it returns an empty string.
func getImageURL(msg *discordgo.Message) string {
if xurl := xurls.Strict().FindString(msg.Content); xurl != "" {
u, err := url.Parse(xurl)
if err == nil {
mt := mime.TypeByExtension(path.Ext(u.Path))
if strings.HasPrefix(mt, "image/") {
return xurl
}
}
}
for _, attachment := range msg.Attachments {
if strings.HasPrefix(attachment.ContentType, "image/") {
return attachment.URL
}
}
return ""
}

View File

@@ -0,0 +1,71 @@
/*
* owobot - Your server's guardian and entertainer
* Copyright (C) 2023 owobot Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package starboard
import (
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/util"
)
const (
starEmoji = "\u2b50"
embedColor = 0xFF5833
)
func Init(s *discordgo.Session) error {
s.AddHandler(onReaction)
commands.Register(s, starboardCmd, &discordgo.ApplicationCommand{
Name: "starboard",
Description: "Modify starboard settings",
DefaultMemberPermissions: util.Pointer[int64](discordgo.PermissionManageServer),
Options: []*discordgo.ApplicationCommandOption{
{
Name: "channel",
Description: "Set the starboard channel",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "channel",
Description: "The channel to use for the starboard",
Type: discordgo.ApplicationCommandOptionChannel,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText},
Required: true,
},
},
},
{
Name: "stars",
Description: "Set the amount of stars for the starboard",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "stars",
Description: "The amount of stars to require",
Type: discordgo.ApplicationCommandOptionInteger,
Required: true,
},
},
},
},
})
return nil
}

View File

@@ -1,257 +0,0 @@
/*
* owobot - Your server's guardian and entertainer
* Copyright (C) 2023 owobot Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package starboard
import (
"errors"
"fmt"
"mime"
"net/url"
"path"
"strings"
"mvdan.cc/xurls"
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/systems/commands"
"go.elara.ws/owobot/internal/systems/eventlog"
"go.elara.ws/owobot/internal/util"
)
const (
starEmoji = "\u2b50"
embedColor = 0xFF5833
)
func Init(s *discordgo.Session) error {
s.AddHandler(onReaction)
commands.Register(s, starboardCmd, &discordgo.ApplicationCommand{
Name: "starboard",
Description: "Modify starboard settings",
DefaultMemberPermissions: util.Pointer[int64](discordgo.PermissionManageServer),
Options: []*discordgo.ApplicationCommandOption{
{
Name: "channel",
Description: "Set the starboard channel",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "channel",
Description: "The channel to use for the starboard",
Type: discordgo.ApplicationCommandOptionChannel,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText},
Required: true,
},
},
},
{
Name: "stars",
Description: "Set the amount of stars for the starboard",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "stars",
Description: "The amount of stars to require",
Type: discordgo.ApplicationCommandOptionInteger,
Required: true,
},
},
},
},
})
return nil
}
// starboardCmd calls the correct subcommand handler for the starboard command
func starboardCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "channel":
return channelCmd(s, i)
case "stars":
return starsCmd(s, i)
default:
return fmt.Errorf("unknown subcommand: %s", name)
}
}
// channelCmd sets the starboard channel for the guild
func channelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
c := args[0].ChannelValue(s)
err := db.SetStarboardChannel(i.GuildID, c.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set starboard channel to <#%s>!", c.ID))
}
// starsCmd sets the amount of stars that trigger the starboard for the guild
func starsCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Get the subcommand options
args := i.ApplicationCommandData().Options[0].Options
stars := args[0].IntValue()
if stars <= 0 {
return errors.New("star amount must be greater than 0")
}
err := db.SetStarboardStars(i.GuildID, stars)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set the amount of stars required to get on the starboard to %d!", stars))
}
// onReaction detects star reactions, and if the message qualifies for starboard
// based on the guild's settings, it replies to it and adds it to the starboard.
func onReaction(s *discordgo.Session, mra *discordgo.MessageReactionAdd) {
if mra.Emoji.Name != starEmoji {
return
}
msgExists, err := db.ExistsInStarboard(mra.MessageID)
if err != nil {
log.Warn("Error checking if the message exists in the starboard").Err(err).Send()
return
}
// If the message has already been added to the starboard,
// we can skip it.
if msgExists {
return
}
guild, err := db.GuildByID(mra.GuildID)
if err != nil {
log.Warn("Error getting guild from the database").Str("id", mra.GuildID).Err(err).Send()
return
}
// If the guild has no starboard channel ID set, we can
// skip this message.
if guild.StarboardChanID == "" {
return
}
reactions, err := s.MessageReactions(mra.ChannelID, mra.MessageID, starEmoji, guild.StarboardStars, "", "")
if err != nil {
log.Warn("Error getting message reactions").Err(err).Send()
return
}
if len(reactions) >= guild.StarboardStars {
msg, err := s.ChannelMessage(mra.ChannelID, mra.MessageID)
if err != nil {
log.Warn("Error getting channel message").Err(err).Send()
return
}
ch, err := s.Channel(mra.ChannelID)
if err != nil {
log.Warn("Error getting channel").Err(err).Send()
return
}
_, err = s.ChannelMessageSendReply(
msg.ChannelID,
fmt.Sprintf("Congrats %s! You've made it to <#%s>!!", msg.Author.Mention(), guild.StarboardChanID),
msg.Reference(),
)
if err != nil {
log.Warn("Error sending message reply").Err(err).Send()
return
}
embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("%s - #%s - %s has made it!", starEmoji, ch.Name, msg.Author.Username),
Author: &discordgo.MessageEmbedAuthor{
Name: msg.Author.Username,
IconURL: msg.Author.AvatarURL(""),
},
Description: fmt.Sprintf(
"[**Jump to Message**](https://discord.com/channels/%s/%s/%s)",
mra.GuildID,
msg.ChannelID,
msg.ID,
),
Color: embedColor,
}
eventlog.AddTimeToEmbed(guild.TimeFormat, embed)
if imageURL := getImageURL(msg); imageURL != "" {
// If the message has an image, add it to the embed
embed.Image = &discordgo.MessageEmbedImage{URL: imageURL}
}
if msg.Content != "" {
// If the message has content, we add it above the
// jump to message link currently in the embed description.
embed.Description = fmt.Sprintf(
"**Message Content**\n%s\n\n%s",
msg.Content,
embed.Description,
)
}
_, err = s.ChannelMessageSendEmbed(guild.StarboardChanID, embed)
if err != nil {
log.Warn("Error sending starboard message").Err(err).Send()
return
}
err = db.AddToStarboard(mra.MessageID)
if err != nil {
log.Warn("Error adding message to starboard").Err(err).Send()
return
}
}
}
// getImageURL looks through the message content and attachments
// to try to find images. If it finds one, it returns the URL.
// Otherwise, it returns an empty string.
func getImageURL(msg *discordgo.Message) string {
if xurl := xurls.Strict.FindString(msg.Content); xurl != "" {
u, err := url.Parse(xurl)
if err == nil {
mt := mime.TypeByExtension(path.Ext(u.Path))
if strings.HasPrefix(mt, "image/") {
return xurl
}
}
}
for _, attachment := range msg.Attachments {
if strings.HasPrefix(attachment.ContentType, "image/") {
return attachment.URL
}
}
return ""
}

View File

@@ -0,0 +1,50 @@
package tickets
import (
"fmt"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/util"
)
// ticketCmd handles the `/ticket` command.
func ticketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
chID, err := Open(s, i.GuildID, i.Member.User, i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully opened a ticket at <#%s>!", chID))
}
// modTicketCmd handles the `/mod_ticket` command.
func modTicketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
chID, err := Open(s, i.GuildID, data.Options[0].UserValue(s), i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully opened a ticket at <#%s>!", chID))
}
// closeTicketCmd handles the `/close_ticket` command.
func closeTicketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
user := data.Options[0].UserValue(s)
err := Close(s, i.GuildID, user, i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully closed ticket for <@%s>", user.ID))
}
// ticketCategoryCmd handles the `/ticket_category` command.
func ticketCategoryCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
category := data.Options[0].ChannelValue(s)
err := db.SetTicketCategory(i.GuildID, category.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set the ticket category to `%s`!", category.Name))
}

View File

@@ -0,0 +1,22 @@
package tickets
import (
"database/sql"
"errors"
"github.com/bwmarrin/discordgo"
"go.elara.ws/logger/log"
)
// onMemberLeave closes any tickets a user had open when they leave
func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
// If the user had a ticket open when they left, make sure to close it.
err := Close(s, gmr.GuildID, gmr.User, s.State.User)
if errors.Is(err, sql.ErrNoRows) {
// If the error is ErrNoRows, the user didn't have a ticket, so just return
return
} else if err != nil {
log.Warn("Error removing ticket after user left").Err(err).Send()
return
}
}

View File

@@ -2,8 +2,6 @@ package tickets
import (
"bytes"
"database/sql"
"errors"
"fmt"
"io"
@@ -72,61 +70,6 @@ func Init(s *discordgo.Session) error {
return nil
}
// ticketCategoryCmd sets the category in which future ticket channels will be created
func ticketCategoryCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
category := data.Options[0].ChannelValue(s)
err := db.SetTicketCategory(i.GuildID, category.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set the ticket category to `%s`!", category.Name))
}
// modTicketCmd handles the mod_ticket command. It opens a new ticket for the given user.
func modTicketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
chID, err := Open(s, i.GuildID, data.Options[0].UserValue(s), i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully opened a ticket at <#%s>!", chID))
}
// ticketCmd handles the ticket command. It opens a new ticket for the user that ran it.
func ticketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
chID, err := Open(s, i.GuildID, i.Member.User, i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully opened a ticket at <#%s>!", chID))
}
// closeTicketCmd handles the close_ticket command. It closes the ticket that the given user
// has open if it exists.
func closeTicketCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
user := data.Options[0].UserValue(s)
err := Close(s, i.GuildID, user, i.Member.User)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully closed ticket for <@%s>", user.ID))
}
// onMemberLeave closes any tickets a user had open when they leave
func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
// If the user had a ticket open when they left, make sure to close it.
err := Close(s, gmr.GuildID, gmr.User, s.State.User)
if errors.Is(err, sql.ErrNoRows) {
// If the error is ErrNoRows, the user didn't have a ticket, so just return
return
} else if err != nil {
log.Warn("Error removing ticket after user left").Err(err).Send()
return
}
}
// Open opens a new ticket. It checks if a ticket already exists, and if not, creates a new channel for it,
// allows the user it's for to see and send messages in it, adds it to the database, and logs the ticket open.
func Open(s *discordgo.Session, guildID string, user, executor *discordgo.User) (string, error) {
@@ -135,6 +78,10 @@ func Open(s *discordgo.Session, guildID string, user, executor *discordgo.User)
return "", fmt.Errorf("ticket already exists for %s at <#%s>", user.Mention(), channelID)
}
if executor == nil {
executor = s.State.User
}
guild, err := db.GuildByID(guildID)
if err != nil {
return "", err
@@ -188,6 +135,10 @@ func Close(s *discordgo.Session, guildID string, user, executor *discordgo.User)
return err
}
if executor == nil {
executor = s.State.User
}
guild, err := db.GuildByID(guildID)
if err != nil {
return err

View File

@@ -0,0 +1,169 @@
package vetting
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/bwmarrin/discordgo"
"go.elara.ws/owobot/internal/cache"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/systems/eventlog"
"go.elara.ws/owobot/internal/systems/tickets"
"go.elara.ws/owobot/internal/util"
)
// vettingCmd handles the `/vetting` command and routes it to the correct subcommand.
func vettingCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "role":
return vettingRoleCmd(s, i)
case "req_channel":
return vettingReqChannelCmd(s, i)
case "welcome_channel":
return welcomeChannelCmd(s, i)
case "welcome_msg":
return welcomeMsgCmd(s, i)
default:
return fmt.Errorf("unknown vetting subcommand: %s", name)
}
}
// vettingRoleCmd handles the `/vetting role` command.
func vettingRoleCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
role := args[0].RoleValue(s, i.GuildID)
err := db.SetVettingRoleID(i.GuildID, role.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the vetting role!", role.Mention()))
}
// vettingReqChannelCmd handles the `/vetting req_channel` command.
func vettingReqChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
channel := args[0].ChannelValue(s)
err := db.SetVettingReqChannel(i.GuildID, channel.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the vetting request channel!", channel.Mention()))
}
// welcomeChannelCmd handles the `/vetting welcome_channel` command.
func welcomeChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
channel := args[0].ChannelValue(s)
err := db.SetWelcomeChannel(i.GuildID, channel.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the welcome channel!", channel.Mention()))
}
// welcomeMsgCmd handles the `/vetting welcome_msg` command.
func welcomeMsgCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
err := db.SetWelcomeMsg(i.GuildID, args[0].StringValue())
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully set the welcome message!")
}
// approveCmd handles the `/approve` command.
func approveCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
guild, err := db.GuildByID(i.GuildID)
if err != nil {
return err
}
if guild.VettingRoleID == "" {
return errors.New("vetting role id is not set for this guild")
}
data := i.ApplicationCommandData()
user := data.Options[0].UserValue(s)
role := data.Options[1].RoleValue(s, i.GuildID)
_, err = db.TicketChannelID(i.GuildID, user.ID)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%s has no open ticket", user.Mention())
}
roleSetAllowed := false
for _, roleID := range i.Member.Roles {
executorRole, err := cache.Role(s, i.GuildID, roleID)
if err != nil {
return err
}
if executorRole.Position >= role.Position {
roleSetAllowed = true
break
}
}
if !roleSetAllowed {
return errors.New("you don't have permission to approve a user as a role higher than your own")
}
err = s.GuildMemberRoleAdd(i.GuildID, user.ID, role.ID)
if err != nil {
return err
}
err = s.GuildMemberRoleRemove(i.GuildID, user.ID, guild.VettingRoleID)
if err != nil {
return err
}
err = tickets.Close(s, i.GuildID, user, i.Member.User)
if err != nil {
return err
}
err = db.RemoveVettingReq(i.GuildID, user.ID)
if err != nil {
return err
}
err = eventlog.Log(s, i.GuildID, eventlog.Entry{
Title: "New Member Approved!",
Description: fmt.Sprintf("**User:** %s\n**Role:** %s\n**Approved By:** %s", user.Mention(), role.Mention(), i.Member.User.Mention()),
Author: user,
})
if err != nil {
return err
}
err = welcomeUser(s, guild, user)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully approved "+user.Mention()+" as "+role.Mention()+"!")
}
func welcomeUser(s *discordgo.Session, guild db.Guild, user *discordgo.User) error {
if guild.WelcomeChanID != "" && guild.WelcomeMsg != "" {
msg := strings.Replace(guild.WelcomeMsg, "$user", user.Mention(), 1)
_, err := s.ChannelMessageSend(guild.WelcomeChanID, msg)
return err
}
return nil
}

View File

@@ -34,78 +34,6 @@ import (
"go.elara.ws/owobot/internal/util"
)
// vettingCmd runs the correct subcommand handler for the vetting command
func vettingCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
switch name := data.Options[0].Name; name {
case "role":
return vettingRoleCmd(s, i)
case "req_channel":
return vettingReqChannelCmd(s, i)
case "welcome_channel":
return welcomeChannelCmd(s, i)
case "welcome_msg":
return welcomeMsgCmd(s, i)
default:
return fmt.Errorf("unknown vetting subcommand: %s", name)
}
}
// vettingRoleCmd sets the vetting role for a guild
func vettingRoleCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
role := args[0].RoleValue(s, i.GuildID)
err := db.SetVettingRoleID(i.GuildID, role.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the vetting role!", role.Mention()))
}
// vettingReqChannelCmd sets the vetting request channel for a guild
func vettingReqChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
channel := args[0].ChannelValue(s)
err := db.SetVettingReqChannel(i.GuildID, channel.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the vetting request channel!", channel.Mention()))
}
// welcomeChannelCmd sets the welcome channel command for a guild
func welcomeChannelCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
channel := args[0].ChannelValue(s)
err := db.SetWelcomeChannel(i.GuildID, channel.ID)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, fmt.Sprintf("Successfully set %s as the welcome channel!", channel.Mention()))
}
// welcomeChannelCmd sets the welcome message for a guild
func welcomeMsgCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
args := data.Options[0].Options
err := db.SetWelcomeMsg(i.GuildID, args[0].StringValue())
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully set the welcome message!")
}
// onMemberJoin adds the vetting role to a user when they join in order to allow them
// to access the vetting questions
func onMemberJoin(s *discordgo.Session, gma *discordgo.GuildMemberAdd) {
@@ -146,7 +74,7 @@ func onMakeVettingMsg(s *discordgo.Session, i *discordgo.InteractionCreate) erro
Label: "Request Vetting",
Style: discordgo.SuccessButton,
Disabled: false,
Emoji: discordgo.ComponentEmoji{Name: clipboardEmoji},
Emoji: &discordgo.ComponentEmoji{Name: clipboardEmoji},
CustomID: "vetting-req",
},
}},
@@ -207,13 +135,13 @@ func onVettingRequest(s *discordgo.Session, i *discordgo.InteractionCreate) erro
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Emoji: discordgo.ComponentEmoji{Name: checkEmoji},
Emoji: &discordgo.ComponentEmoji{Name: checkEmoji},
Style: discordgo.SuccessButton,
CustomID: "vetting-accept:" + i.Member.User.ID,
},
discordgo.Button{
Label: "Reject",
Emoji: discordgo.ComponentEmoji{Name: crossEmoji},
Emoji: &discordgo.ComponentEmoji{Name: crossEmoji},
Style: discordgo.DangerButton,
CustomID: "vetting-reject:" + i.Member.User.ID,
},
@@ -232,90 +160,6 @@ func onVettingRequest(s *discordgo.Session, i *discordgo.InteractionCreate) erro
return util.RespondEphemeral(s, i.Interaction, "Successfully sent your vetting request!")
}
// approveCmd approves a user in vetting. It removes their vetting role, assigns a
// role of the approver's choosing, closes the user's vetting ticket, and logs
// the approval.
func approveCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
guild, err := db.GuildByID(i.GuildID)
if err != nil {
return err
}
if guild.VettingRoleID == "" {
return errors.New("vetting role id is not set for this guild")
}
data := i.ApplicationCommandData()
user := data.Options[0].UserValue(s)
role := data.Options[1].RoleValue(s, i.GuildID)
_, err = db.TicketChannelID(i.GuildID, user.ID)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%s has no open ticket", user.Mention())
}
roleSetAllowed := false
for _, roleID := range i.Member.Roles {
executorRole, err := cache.Role(s, i.GuildID, roleID)
if err != nil {
return err
}
if executorRole.Position >= role.Position {
roleSetAllowed = true
break
}
}
if !roleSetAllowed {
return errors.New("you don't have permission to approve a user as a role higher than your own")
}
err = s.GuildMemberRoleAdd(i.GuildID, user.ID, role.ID)
if err != nil {
return err
}
err = s.GuildMemberRoleRemove(i.GuildID, user.ID, guild.VettingRoleID)
if err != nil {
return err
}
err = tickets.Close(s, i.GuildID, user, i.Member.User)
if err != nil {
return err
}
err = db.RemoveVettingReq(i.GuildID, i.Message.ID)
if err != nil {
return err
}
err = eventlog.Log(s, i.GuildID, eventlog.Entry{
Title: "New Member Approved!",
Description: fmt.Sprintf("**User:** %s\n**Role:** %s\n**Approved By:** %s", user.Mention(), role.Mention(), i.Member.User.Mention()),
Author: user,
})
if err != nil {
return err
}
err = welcomeUser(s, guild, user)
if err != nil {
return err
}
return util.RespondEphemeral(s, i.Interaction, "Successfully approved "+user.Mention()+" as "+role.Mention()+"!")
}
func welcomeUser(s *discordgo.Session, guild db.Guild, user *discordgo.User) error {
if guild.WelcomeChanID != "" && guild.WelcomeMsg != "" {
msg := strings.Replace(guild.WelcomeMsg, "$user", user.Mention(), 1)
_, err := s.ChannelMessageSend(guild.WelcomeChanID, msg)
return err
}
return nil
}
// onVettingResponse handles responses to vetting requests. If the user was accepted,
// it creates a vetting ticket for them. If they were rejected, it kicks them from the server.
func onVettingResponse(s *discordgo.Session, i *discordgo.InteractionCreate) error {
@@ -403,6 +247,7 @@ func onVettingResponse(s *discordgo.Session, i *discordgo.InteractionCreate) err
return nil
}
// onMemberLeave handles users leaving the server. It closes any tickets they might've had open.
func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
msgID, err := db.VettingReqMsgID(gmr.GuildID, gmr.Member.User.ID)
if errors.Is(err, sql.ErrNoRows) {
@@ -425,7 +270,7 @@ func onMemberLeave(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) {
}
}
err = db.RemoveVettingReq(gmr.GuildID, msgID)
err = db.RemoveVettingReq(gmr.GuildID, gmr.Member.User.ID)
if err != nil {
log.Error("Error removing vetting request after member leave").Str("user-id", gmr.Member.User.ID).Err(err).Send()
}

View File

@@ -33,6 +33,7 @@ import (
"go.elara.ws/owobot/internal/systems/eventlog"
"go.elara.ws/owobot/internal/systems/guilds"
"go.elara.ws/owobot/internal/systems/members"
"go.elara.ws/owobot/internal/systems/plugins"
"go.elara.ws/owobot/internal/systems/polls"
"go.elara.ws/owobot/internal/systems/reactions"
"go.elara.ws/owobot/internal/systems/roles"
@@ -85,6 +86,11 @@ func main() {
}
}
err = plugins.Load(cfg.PluginDir, s)
if err != nil {
log.Error("Error running plugin file").Err(err).Send()
}
initSystems(
s,
starboard.Init,
@@ -97,6 +103,7 @@ func main() {
reactions.Init,
roles.Init,
about.Init,
plugins.Init,
commands.Init, // The commands system should always go last
)

View File

@@ -1,5 +1,6 @@
token = "CHANGE ME"
db_path = "/etc/owobot/owobot.db"
plugin_dir = "/etc/owobot/plugins"
[activity]
type = -1