From 5d327f3fd2fe8543968a8ab60a6fc79d82c73ed3 Mon Sep 17 00:00:00 2001 From: Elara6331 Date: Tue, 23 Apr 2024 18:25:37 -0700 Subject: [PATCH] Add plugin system --- .gitignore | 1 + config.go | 12 +- go.mod | 21 +- go.sum | 83 ++++- internal/db/db.go | 5 + internal/db/guilds.go | 58 ++- internal/db/migrations/2024020300.sql | 11 + internal/db/plugins.go | 42 +++ internal/db/sqltabler/sqltabler.go | 156 ++++++++ internal/systems/plugins/api.go | 107 ++++++ internal/systems/plugins/builtins/discord.go | 346 ++++++++++++++++++ internal/systems/plugins/builtins/fetch.go | 118 ++++++ internal/systems/plugins/builtins/internal.go | 42 +++ internal/systems/plugins/builtins/register.go | 19 + internal/systems/plugins/builtins/sql.go | 59 +++ internal/systems/plugins/builtins/vercmp.go | 21 ++ internal/systems/plugins/commands.go | 253 +++++++++++++ internal/systems/plugins/enable.go | 45 +++ internal/systems/plugins/field_mapper.go | 34 ++ internal/systems/plugins/handlers.go | 134 +++++++ internal/systems/plugins/init.go | 171 +++++++++ internal/systems/polls/handlers.go | 2 +- internal/systems/roles/commands.go | 4 +- internal/systems/tickets/init.go | 8 + main.go | 7 + owobot.toml | 1 + 26 files changed, 1725 insertions(+), 35 deletions(-) create mode 100644 internal/db/migrations/2024020300.sql create mode 100644 internal/db/plugins.go create mode 100644 internal/db/sqltabler/sqltabler.go create mode 100644 internal/systems/plugins/api.go create mode 100644 internal/systems/plugins/builtins/discord.go create mode 100644 internal/systems/plugins/builtins/fetch.go create mode 100644 internal/systems/plugins/builtins/internal.go create mode 100644 internal/systems/plugins/builtins/register.go create mode 100644 internal/systems/plugins/builtins/sql.go create mode 100644 internal/systems/plugins/builtins/vercmp.go create mode 100644 internal/systems/plugins/commands.go create mode 100644 internal/systems/plugins/enable.go create mode 100644 internal/systems/plugins/field_mapper.go create mode 100644 internal/systems/plugins/handlers.go create mode 100644 internal/systems/plugins/init.go diff --git a/.gitignore b/.gitignore index fe2e2ca..23098ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /dist/ +/plugins/ /owobot /owobot.db diff --git a/config.go b/config.go index 70d8d39..7806550 100644 --- a/config.go +++ b/config.go @@ -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: "", diff --git a/go.mod b/go.mod index 277dd5a..65732e9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3693ba2..22336aa 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/db/db.go b/internal/db/db.go index b7b4973..79efeb6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) diff --git a/internal/db/guilds.go b/internal/db/guilds.go index 11413f1..5537d3f 100644 --- a/internal/db/guilds.go +++ b/internal/db/guilds.go @@ -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) diff --git a/internal/db/migrations/2024020300.sql b/internal/db/migrations/2024020300.sql new file mode 100644 index 0000000..bd6bc0b --- /dev/null +++ b/internal/db/migrations/2024020300.sql @@ -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 ''; diff --git a/internal/db/plugins.go b/internal/db/plugins.go new file mode 100644 index 0000000..2b89f2a --- /dev/null +++ b/internal/db/plugins.go @@ -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 . + */ + +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 +} diff --git a/internal/db/sqltabler/sqltabler.go b/internal/db/sqltabler/sqltabler.go new file mode 100644 index 0000000..88687df --- /dev/null +++ b/internal/db/sqltabler/sqltabler.go @@ -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) + } +} diff --git a/internal/systems/plugins/api.go b/internal/systems/plugins/api.go new file mode 100644 index 0000000..c2a66ff --- /dev/null +++ b/internal/systems/plugins/api.go @@ -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() + } + }, + }) +} diff --git a/internal/systems/plugins/builtins/discord.go b/internal/systems/plugins/builtins/discord.go new file mode 100644 index 0000000..51d8e5a --- /dev/null +++ b/internal/systems/plugins/builtins/discord.go @@ -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, + }, +} diff --git a/internal/systems/plugins/builtins/fetch.go b/internal/systems/plugins/builtins/fetch.go new file mode 100644 index 0000000..9bbe3fc --- /dev/null +++ b/internal/systems/plugins/builtins/fetch.go @@ -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) +} diff --git a/internal/systems/plugins/builtins/internal.go b/internal/systems/plugins/builtins/internal.go new file mode 100644 index 0000000..dc92351 --- /dev/null +++ b/internal/systems/plugins/builtins/internal.go @@ -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) +} diff --git a/internal/systems/plugins/builtins/register.go b/internal/systems/plugins/builtins/register.go new file mode 100644 index 0000000..2aefda2 --- /dev/null +++ b/internal/systems/plugins/builtins/register.go @@ -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)), + ) +} diff --git a/internal/systems/plugins/builtins/sql.go b/internal/systems/plugins/builtins/sql.go new file mode 100644 index 0000000..2182462 --- /dev/null +++ b/internal/systems/plugins/builtins/sql.go @@ -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() +} diff --git a/internal/systems/plugins/builtins/vercmp.go b/internal/systems/plugins/builtins/vercmp.go new file mode 100644 index 0000000..d6ded7b --- /dev/null +++ b/internal/systems/plugins/builtins/vercmp.go @@ -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) +} diff --git a/internal/systems/plugins/commands.go b/internal/systems/plugins/commands.go new file mode 100644 index 0000000..cae584d --- /dev/null +++ b/internal/systems/plugins/commands.go @@ -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 +} diff --git a/internal/systems/plugins/enable.go b/internal/systems/plugins/enable.go new file mode 100644 index 0000000..f9b4513 --- /dev/null +++ b/internal/systems/plugins/enable.go @@ -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) +} diff --git a/internal/systems/plugins/field_mapper.go b/internal/systems/plugins/field_mapper.go new file mode 100644 index 0000000..697cd74 --- /dev/null +++ b/internal/systems/plugins/field_mapper.go @@ -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 +} diff --git a/internal/systems/plugins/handlers.go b/internal/systems/plugins/handlers.go new file mode 100644 index 0000000..6a066a8 --- /dev/null +++ b/internal/systems/plugins/handlers.go @@ -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 +} diff --git a/internal/systems/plugins/init.go b/internal/systems/plugins/init.go new file mode 100644 index 0000000..a1c6e46 --- /dev/null +++ b/internal/systems/plugins/init.go @@ -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 + }) +} diff --git a/internal/systems/polls/handlers.go b/internal/systems/polls/handlers.go index bb97819..5c893fb 100644 --- a/internal/systems/polls/handlers.go +++ b/internal/systems/polls/handlers.go @@ -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", diff --git a/internal/systems/roles/commands.go b/internal/systems/roles/commands.go index 3227734..46f39fd 100644 --- a/internal/systems/roles/commands.go +++ b/internal/systems/roles/commands.go @@ -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 } diff --git a/internal/systems/tickets/init.go b/internal/systems/tickets/init.go index be3bd49..97df9b8 100644 --- a/internal/systems/tickets/init.go +++ b/internal/systems/tickets/init.go @@ -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 diff --git a/main.go b/main.go index e970cec..e5bd408 100644 --- a/main.go +++ b/main.go @@ -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 ) diff --git a/owobot.toml b/owobot.toml index 4a6cd51..5e25d03 100644 --- a/owobot.toml +++ b/owobot.toml @@ -1,5 +1,6 @@ token = "CHANGE ME" db_path = "/etc/owobot/owobot.db" +plugin_dir = "/etc/owobot/plugins" [activity] type = -1