Add plugin system
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/tag/woodpecker Pipeline was successful Details

This commit is contained in:
Elara 2024-04-23 18:25:37 -07:00
parent c28bc88939
commit 5d327f3fd2
26 changed files with 1725 additions and 35 deletions

1
.gitignore vendored
View File

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

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: "",

21
go.mod
View File

@ -3,33 +3,42 @@ module go.elara.ws/owobot
go 1.21.0
require (
github.com/bwmarrin/discordgo v0.27.2-0.20240104191117-afc57886f91a
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/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-20240312185922-ffac88a740bd
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/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/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/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.9.0 // indirect
golang.org/x/tools v0.1.12 // 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

83
go.sum
View File

@ -1,28 +1,52 @@
github.com/bwmarrin/discordgo v0.27.2-0.20240104191117-afc57886f91a h1:I1j/9FoqDN+W0ZXiSU91lJXwKCvnKBLgJKlBLYAbim4=
github.com/bwmarrin/discordgo v0.27.2-0.20240104191117-afc57886f91a/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/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=
@ -44,6 +68,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-20240312185922-ffac88a740bd h1:wW6BtayFoKaaDeIvXRE3SZVPOscSKlYD+X3bB749+zk=
github.com/rqlite/sql v0.0.0-20240312185922-ffac88a740bd/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=
@ -59,25 +86,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/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=

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, viee, 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

@ -0,0 +1,107 @@
package plugins
import (
"sync"
"github.com/bwmarrin/discordgo"
"github.com/dop251/goja"
"go.elara.ws/logger/log"
"go.elara.ws/owobot/internal/db"
"go.elara.ws/owobot/internal/util"
)
type lockableRuntime struct {
*sync.Mutex
*goja.Runtime
}
// Plugins is a list of plugins
var Plugins []Plugin
// Plugin represents an owobot plugin
type Plugin struct {
Info db.PluginInfo
Commands []Command
VM lockableRuntime
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
vm lockableRuntime
}
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()
this := oa.vm.ToValue(oa)
handlerMap[eventType] = append(handlerMap[eventType], Handler{
PluginName: oa.PluginInfo.Name,
Func: func(s *discordgo.Session, data any) {
oa.vm.Lock()
defer oa.vm.Unlock()
_, err := callable(this, oa.vm.ToValue(s), oa.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,253 @@
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"
)
// pluginCmd handles the `/plugin` command and routes it to the correct subcommand.
func pluginCmd(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 plugin 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")
}
_, err := callable(plugin.VM.ToValue(plugin.api), plugin.VM.ToValue(i.GuildID))
if 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")
}
plugin.VM.Lock()
defer plugin.VM.Unlock()
_, err := callable(plugin.VM.ToValue(plugin.api), plugin.VM.ToValue(i.GuildID))
if 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))
}
// phelpCmd handles the `/phelp` command.
func phelpCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
cmdStr := data.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])
}
// prunCmd handles the `/prunCmd` command.
func prunCmd(s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
cmdStr := data.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")
}
plugin.VM.Lock()
_, err = callable(
plugin.VM.ToValue(cmd),
plugin.VM.ToValue(s),
plugin.VM.ToValue(i),
plugin.VM.ToValue(newArgs),
)
plugin.VM.Unlock()
return err
}
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 != "prun" && data.Name != "phelp" {
return
}
cmdStr := data.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,171 @@
package plugins
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/dop251/goja"
"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, prunCmd, &discordgo.ApplicationCommand{
Name: "prun",
Description: "Run a plugin command",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "cmd",
Description: "The plugin command to run",
Required: true,
Autocomplete: true,
},
},
})
commands.Register(s, phelpCmd, &discordgo.ApplicationCommand{
Name: "phelp",
Description: "Display help for a plugin command",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "cmd",
Description: "The plugin command to display help for",
Required: true,
Autocomplete: true,
},
},
})
commands.Register(s, pluginCmd, &discordgo.ApplicationCommand{
Name: "plugin",
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) 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
}
vm := lockableRuntime{&sync.Mutex{}, goja.New()}
vm.SetFieldNameMapper(lowerCamelNameMapper{})
api := &owobotAPI{vm: vm, path: path}
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
}
_, err = vm.RunScript(path, string(data))
if 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
}
err = builtins.Register(vm.Runtime, api.PluginInfo.Name, api.PluginInfo.Version)
if err != nil {
return err
}
Plugins = append(Plugins, Plugin{
Info: api.PluginInfo,
Commands: api.Commands,
VM: vm,
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
}
_, err = callableInit(vm.ToValue(api), vm.ToValue(prev))
if err != nil {
return fmt.Errorf("%s init: %w", api.PluginInfo.Name, err)
}
}
return nil
})
}

View File

@ -305,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

@ -264,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

@ -78,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
@ -131,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

@ -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)
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