diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e313d24..c60b857a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,53 +1,29 @@ [VERSION] +> This is a hotfix patch. If you want to see the changelog for release 1.30.0, please look [**here**](https://github.com/zekroTJA/shinpuru/releases/tag/1.30.0). --> -# Birthday Notifications [#349] +# New Login Method -shinpuru now features birthday notifications! +On the main page, when clicking on `Login`, you can now choose between logging in to the web interface via OAauth2 using your Discord account or log in via sending the code, which is displayed in the web interface, to shinpuru via DM. -![](https://user-images.githubusercontent.com/16734205/153576590-28b51ce9-e11f-4aa1-b86b-41fc7d6d6a31.gif) +![](https://user-images.githubusercontent.com/16734205/154697491-b0aa34d3-ff79-40ee-9b49-ec77cfc23cee.gif) -Simply, set your birthday using the `/birthday set` slash command. The date must be in the format `YYYY-MM-DD[+-]TIMEZONE`. You can also use `/` or `.` as separator. The timezone must be set to your local time zone in offset hours from UTC. So, for example, for `CET` this would be `+1`, for `EST`, this would be `-5` and so on. +# Deployment -It is also possible to just set a month and day if you don't want to store your birth year. If you want to show your age in the notification, you can enable it by setting `show-year` to `True`. Otherwise, your age will be hidden in the notification. +The generated frontend files are now directly embedded into the binary of shinpuru. Therefore, downloading and providing the frontend files in the same directory of the binary is no more necessary. -![](https://user-images.githubusercontent.com/16734205/153576393-73616bd2-b21d-4813-bdb8-ad146cecc542.png) - -*With age hidden, it would look like this.* -![](https://user-images.githubusercontent.com/16734205/153580431-e5a0f4b9-1b51-473f-bfa4-40c3fa650c89.gif) - -You can, of course, also unset your birthday at any time. - -![](https://user-images.githubusercontent.com/16734205/153577075-e191e2d8-1e39-4ab4-9a24-31e5939d887f.png) - -To enable birthday notifications in your guild, you need to specify a birthday channel. This requires the permission `sp.guild.config.birthday` - -![](https://user-images.githubusercontent.com/16734205/153576456-9c184ae0-4408-4ded-9dce-9f69ee36f5e9.png) - -To unset this setting, simply use the `/birthday unset-cannel` command. - -To enable Gifs in the birthday message, the bot needs a Giphy API key. You can get one by creating a Giphy acount and going to the [Developer Dashboard](https://developers.giphy.com/dashboard/). There you can create an app. After that, copy the API key and add it ti shinpuru's config. -```yaml -giphy: - apikey: dWl3ZXF6diBzZGR0NnczNDg5NTZuZG4w -``` - -# Sharding [#238] - -If you are running shinpuru of a lot of Guilds, you might want to split up your single instance into multiple instances to split up the load. This is now possible using Discord's Gateway sharding. - -> I **strongly** recommend taking a look into [Discord's Documentation](https://discord.com/developers/docs/topics/gateway#sharding) about sharding when you want to split up your instance. +# Bug Fixes -You can simply spin up multiple instances of shinpuru behind a load balancer which all connect to the same database and redis instance. This distributes a common synced persistent state between all instances. +- The birtdhay command will now only send command responses to the user who invoked the command. [#354] +- The embed builder now only shows available text channels where the logged in user has read and write permissions. [#353] +- The embed preview now shows a placeholder title and description when empty. [#353] -If you want to set up sharding and load balancing, you can find more information on how to set up and configure shinpuru [**here**](https://github.com/zekroTJA/shinpuru/tree/dev/docs/sharding). There you can also find an example deploymet using docker swarm. +# Acknowledgements -# Bug Fixes +Big thanks to the following people who contributed to this release. -- Fix a bug where guild settings were not saved to database. -- Properly bubble up errors when setting guild settings. +- @zordem # Docker diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4be0211..fca1c271 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ shinpuru mainly uses MySQL/MariaDB as database. You *can* also use SQLite3 for d - Linux: https://opensource.com/article/20/10/mariadb-mysql-linux - Docker: https://hub.docker.com/_/mariadb/ -Redis is used as database cache. The [`RedisMiddleware`](https://github.com/zekroTJA/shinpuru/blob/master/internal/services/database/redis/redis.go) generaly inherits functionalities from the specified database middleware instance and only overwrites using the specified functions. The database cache always keeps the cache as well as the database hot and always first tries to get objects from cache and, if not available there, from database. +Redis is used as database cache. The [`RedisMiddleware`](https://github.com/zekroTJA/shinpuru/blob/master/internal/services/database/redis/redis.go) generally inherits functionalities from the specified database middleware instance and only overwrites using the specified functions. The database cache always keeps the cache as well as the database hot and always first tries to get objects from cache and, if not available there, from database. ![](https://i.imgur.com/TgkuhUY.png) @@ -65,7 +65,7 @@ If you want to add API endpoints, just add the endpoints to one of the controlle Also, fiber works a lot with middlewares, which can be chained anywhere into the fiber route chain. In shinpuru's implementation, there are three main types of middlewares. 1. The high level middlewares like the rate limiter, CORS or file system middleware, which are set before all incomming requests. -2. Controller specific middlewares which are defined in the router. Mainly, this is used for the authorizeation middleware, which checks for auth tokens in the requests. This middleware is required by some controllers and not required for others. +2. Controller specific middlewares which are defined in the router. Mainly, this is used for the authorization middleware, which checks for auth tokens in the requests. This middleware is required by some controllers and not required for others. 3. Endpoint specific middlewares which are defined for specific endpoints only. Mainly, this is used for the permission middleware which checks for required user permissions to execute specific endpoints. Here you can see a simple overview over the routing structure of the shinpuru webserver. @@ -98,7 +98,7 @@ As you can see, all service identifiers are registered in the [`internal/util/st After building the `diBuilder`, you will have a `di.Container` to work with where you can get any service registered. Because all services are registered in the `App` scope, once they are initialized, all requests are getting the same instance of the service. This makes service development very easy, because every service is getting passed the same service container and every service can grab the instance of any other registered service instance. -When you want to use a service, just take it from the passed service conatiner by the specified identifier. Let's take a look at the [`starboard` listener](https://github.com/zekroTJA/shinpuru/blob/master/internal/listeners/starboard.go) , for example: +When you want to use a service, just take it from the passed service container by the specified identifier. Let's take a look at the [`starboard` listener](https://github.com/zekroTJA/shinpuru/blob/master/internal/listeners/starboard.go), for example: ```go func NewListenerStarboard(container di.Container) *ListenerStarboard { @@ -134,7 +134,7 @@ This package is using a [crontab styled syntax](https://pkg.go.dev/github.com/ro The shinpuru web frontend is a compiled [**Angular**](https://angular.io) SPA, which is directly hosted form the shinpuru web server. The source files are located at [`/web`](https://github.com/zekroTJA/shinpuru/blob/master/web) Stylesheets are written in [**SCSS**](https://sass-lang.com/documentation/syntax) because SCSS has huge advantages to default CSS like nesting, mixins and variables, which are widely used in stylesheets. -The Angular web app is built like a typical Angular application with reusable components, routes, services and pipes. The communication with the REST API is handled by the [`APIService`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.service.ts). API models are specified in the [`api.models.ts`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.models.ts) file. Also, the API stores some objects like member information in a [`CacheBucket`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.cache.ts) for short-time caching them on the client side to reduce the load on the REST API. Also, an [interceptor](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/auth.interceptor.ts) is chained before the API service which adds the collected `accessToken` to each reqeust. If the `accessToken` is not existent, expired or invalid, the `accessToken` will be collected using the `refreshToken` set as cookie. The access token is then stored and the request is retried with the now existent access token. +The Angular web app is built like a typical Angular application with reusable components, routes, services and pipes. The communication with the REST API is handled by the [`APIService`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.service.ts). API models are specified in the [`api.models.ts`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.models.ts) file. Also, the API stores some objects like member information in a [`CacheBucket`](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/api.cache.ts) for short-time caching them on the client side to reduce the load on the REST API. Also, an [interceptor](https://github.com/zekroTJA/shinpuru/blob/master/web/src/app/api/auth.interceptor.ts) is chained before the API service which adds the collected `accessToken` to each request. If the `accessToken` is not existent, expired or invalid, the `accessToken` will be collected using the `refreshToken` set as cookie. The access token is then stored and the request is retried with the now existent access token. ## Preparing a Development Environment @@ -160,4 +160,4 @@ So, you want to contribute to shinpuru but you don't know what exactly you want ## Any Questions? -If you have any questions, please hit me on my [**Dev Discord**](https://discord.zekro.de) (`zekro#0001`) or on [**Twitter**](https://twitter.com/zekrotja). You can also simply send me an [e-mail](mailto:contact@zekro.de). 😉 \ No newline at end of file +If you have any questions, please hit me on my [**Dev Discord**](https://discord.zekro.de) (`zekro#0001`) or on [**Twitter**](https://twitter.com/zekrotja). You can also simply send me an [e-mail](mailto:contact@zekro.de). 😉 diff --git a/README.md b/README.md index 4d990e01..06a50e7c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The code is picked up and sent to a code execution engine, which safely executes ### Karma -shinpuru featues a Karma system which is inspired by Reddit. You can define specific emotes which, when attached to a message, increase or reduce the karma points of a member. You can also specify the amount of "tokens" which can be spent each hour as well as a penalty for giving negative karma, which also takes karma from the executor to prevent downvote spam. +shinpuru features a Karma system which is inspired by Reddit. You can define specific emotes which, when attached to a message, increase or reduce the karma points of a member. You can also specify the amount of "tokens" which can be spent each hour as well as a penalty for giving negative karma, which also takes karma from the executor to prevent downvote spam. It is also possible to execute actions when passing specific amounts of karma. For example, you can add or remove roles, send messages or even kick/ban members depending on their karma points. @@ -147,7 +147,7 @@ The last 10 backups are stored and can be reviewed in the web interface. ### Raid Alerting -This system allows you to set a threshold of new user ingress reate. When this rate exceeds, for example when a lot of (bot) accounts flush in to your guild (aka `raiding`), all admins of the guild will be allerted via DM. Also, the guilds moderation setting will be raised to `Highest` so that only users with roles or a valid phone number can chat. +This system allows you to set a threshold of new user ingress rate. When this rate exceeds, for example when a lot of (bot) accounts flush in to your guild (aka `raiding`), all admins of the guild will be alerted via DM. Also, the guilds moderation setting will be raised to `Highest` so that only users with roles or a valid phone number can chat. ![image](https://user-images.githubusercontent.com/16734205/140644018-9652d8c9-2716-43ae-bf5b-c1b2c17f895a.png) diff --git a/bughunters.md b/bughunters.md index 6bde70a3..f4c22f44 100644 --- a/bughunters.md +++ b/bughunters.md @@ -2,7 +2,7 @@ A list to honor all people who found some bugs, had some great ideas or contributed directly to shinpuru. ❤️ -In total, **31** different wonderful people contributed a sum of **73** issues and **14** pull requests (with 631 added and 81 deleted lines of code in 46 different files)! 🎉 +In total, **31** different wonderful people contributed a sum of **75** issues and **17** pull requests (with 645 added and 95 deleted lines of code in 51 different files)! 🎉 | GitHub | Issues | PRs | Points* | |--------|--------|-----|---------| @@ -10,8 +10,10 @@ In total, **31** different wonderful people contributed a sum of **73** issues a | 🥈 [voxain](https://github.com/voxain) | [#52](https://github.com/zekroTJA/shinpuru/issues/52), [#61](https://github.com/zekroTJA/shinpuru/issues/61), [#67](https://github.com/zekroTJA/shinpuru/issues/67), [#147](https://github.com/zekroTJA/shinpuru/issues/147), [#148](https://github.com/zekroTJA/shinpuru/issues/148), [#150](https://github.com/zekroTJA/shinpuru/issues/150), [#153](https://github.com/zekroTJA/shinpuru/issues/153), [#159](https://github.com/zekroTJA/shinpuru/issues/159), [#163](https://github.com/zekroTJA/shinpuru/issues/163), [#165](https://github.com/zekroTJA/shinpuru/issues/165), [#183](https://github.com/zekroTJA/shinpuru/issues/183), [#187](https://github.com/zekroTJA/shinpuru/issues/187), [#203](https://github.com/zekroTJA/shinpuru/issues/203), [#210](https://github.com/zekroTJA/shinpuru/issues/210), [#249](https://github.com/zekroTJA/shinpuru/issues/249), [#250](https://github.com/zekroTJA/shinpuru/issues/250) | | `16` | | 🥉 [error2507](https://github.com/error2507) | [#28](https://github.com/zekroTJA/shinpuru/issues/28), [#29](https://github.com/zekroTJA/shinpuru/issues/29), [#55](https://github.com/zekroTJA/shinpuru/issues/55) | [#1](https://github.com/zekroTJA/shinpuru/pull/1), [#2](https://github.com/zekroTJA/shinpuru/pull/2) | `9` | | [Ron31](https://github.com/Ron31) | [#197](https://github.com/zekroTJA/shinpuru/issues/197), [#224](https://github.com/zekroTJA/shinpuru/issues/224) | [#32](https://github.com/zekroTJA/shinpuru/pull/32), [#181](https://github.com/zekroTJA/shinpuru/pull/181) | `8` | +| [SCDerox](https://github.com/SCDerox) | [#280](https://github.com/zekroTJA/shinpuru/issues/280) | [#352](https://github.com/zekroTJA/shinpuru/pull/352), [#355](https://github.com/zekroTJA/shinpuru/pull/355) | `7` | | [ShowMeYourSkil](https://github.com/ShowMeYourSkil) | [#140](https://github.com/zekroTJA/shinpuru/issues/140), [#171](https://github.com/zekroTJA/shinpuru/issues/171), [#191](https://github.com/zekroTJA/shinpuru/issues/191), [#192](https://github.com/zekroTJA/shinpuru/issues/192), [#211](https://github.com/zekroTJA/shinpuru/issues/211), [#240](https://github.com/zekroTJA/shinpuru/issues/240) | | `6` | | [Skillkiller](https://github.com/Skillkiller) | [#180](https://github.com/zekroTJA/shinpuru/issues/180), [#270](https://github.com/zekroTJA/shinpuru/issues/270), [#284](https://github.com/zekroTJA/shinpuru/issues/284) | [#79](https://github.com/zekroTJA/shinpuru/pull/79) | `6` | +| [zordem](https://github.com/zordem) | [#346](https://github.com/zekroTJA/shinpuru/issues/346), [#353](https://github.com/zekroTJA/shinpuru/issues/353), [#354](https://github.com/zekroTJA/shinpuru/issues/354) | [#351](https://github.com/zekroTJA/shinpuru/pull/351) | `6` | | [Not-Nik](https://github.com/Not-Nik) | [#53](https://github.com/zekroTJA/shinpuru/issues/53) | [#56](https://github.com/zekroTJA/shinpuru/pull/56) | `4` | | [cloudybyte](https://github.com/cloudybyte) | [#179](https://github.com/zekroTJA/shinpuru/issues/179), [#195](https://github.com/zekroTJA/shinpuru/issues/195), [#219](https://github.com/zekroTJA/shinpuru/issues/219), [#241](https://github.com/zekroTJA/shinpuru/issues/241) | | `4` | | [luxtracon](https://github.com/luxtracon) | [#248](https://github.com/zekroTJA/shinpuru/issues/248) | [#228](https://github.com/zekroTJA/shinpuru/pull/228) | `4` | @@ -32,10 +34,8 @@ In total, **31** different wonderful people contributed a sum of **73** issues a | [maxcutie](https://github.com/maxcutie) | [#239](https://github.com/zekroTJA/shinpuru/issues/239) | | `1` | | [PushkarOP](https://github.com/PushkarOP) | [#269](https://github.com/zekroTJA/shinpuru/issues/269) | | `1` | | [enkeyz](https://github.com/enkeyz) | [#279](https://github.com/zekroTJA/shinpuru/issues/279) | | `1` | -| [SCDerox](https://github.com/SCDerox) | [#280](https://github.com/zekroTJA/shinpuru/issues/280) | | `1` | | [MeerBiene](https://github.com/MeerBiene) | [#308](https://github.com/zekroTJA/shinpuru/issues/308) | | `1` | | [shiipou](https://github.com/shiipou) | [#317](https://github.com/zekroTJA/shinpuru/issues/317) | | `1` | -| [zordem](https://github.com/zordem) | [#346](https://github.com/zekroTJA/shinpuru/issues/346) | | `1` | | [kindh0623](https://github.com/kindh0623) | [#348](https://github.com/zekroTJA/shinpuru/issues/348) | | `1` | diff --git a/docs/restapi/README.md b/docs/restapi/README.md index ae9e93b1..8db2859a 100644 --- a/docs/restapi/README.md +++ b/docs/restapi/README.md @@ -36,11 +36,11 @@ There are two ways around this. 1. Use pointers for everything. 2. Specify that **all** values are defined as "set". -The first solution was not suitable in my opinion, because it would reqire a lot code around `nil` and proper value checking of each model property, which would also introduce a lot new fault sources. Also, because shinpuru's API utilizes a lot of the original models of [discordgo](https://github.com/bwmarrin/discordgo), this would require a lot of model wrapping and double definitions. +The first solution was not suitable in my opinion, because it would require a lot code around `nil` and proper value checking of each model property, which would also introduce a lot new fault sources. Also, because shinpuru's API utilizes a lot of the original models of [discordgo](https://github.com/bwmarrin/discordgo), this would require a lot of model wrapping and double definitions. So, I went for the second solution*. -Every property is specified as "set" by the API on update. That means, if you pass `null` as value of a string, that means the valaue of the property will be updated to `""`, which is the default zero value of a string. Long story short, even if you want to update only single properties of a model, you must pass the whole model on update to ensure consistency, even if this means that you need to get the current values before you can update them. +Every property is specified as "set" by the API on update. That means, if you pass `null` as value of a string, that means the value of the property will be updated to `""`, which is the default zero value of a string. Long story short, even if you want to update only single properties of a model, you must pass the whole model on update to ensure consistency, even if this means that you need to get the current values before you can update them. Let's take the [`/settings/presence` endpoint](), for example. We want to update the `game` value. diff --git a/docs/restapi/v1/restapi.md b/docs/restapi/v1/restapi.md index 33922c81..40fcf2e5 100644 --- a/docs/restapi/v1/restapi.md +++ b/docs/restapi/v1/restapi.md @@ -48,7 +48,7 @@ Logout ##### Description -Reovkes the currently used access token and clears the refresh token. +Revokes the currently used access token and clears the refresh token. ##### Responses diff --git a/internal/commands/cmdautovc.go b/internal/commands/cmdautovc.go new file mode 100644 index 00000000..e56aa57d --- /dev/null +++ b/internal/commands/cmdautovc.go @@ -0,0 +1,146 @@ +package commands + +import ( + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/util" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/acceptmsg" + "github.com/zekroTJA/shinpuru/pkg/fetch" + "github.com/zekroTJA/shinpuru/pkg/stringutil" + "github.com/zekroTJA/shireikan" + "github.com/zekrotja/dgrs" +) + +type CmdAutovc struct { +} + +func (c *CmdAutovc) GetInvokes() []string { + return []string{"autovoice", "autovc", "avc", "avcs"} +} + +func (c *CmdAutovc) GetDescription() string { + return "Set the auto voicechannel for the current guild." +} + +func (c *CmdAutovc) GetHelp() string { + return "`autovc` - display currently set auto voicechannel(s)\n" + + "`autovc ( ( (...)))` - set an auto voicechannel for the current guild\n" + + "`autovc reset` - disable auto voicechannels" +} + +func (c *CmdAutovc) GetGroup() string { + return shireikan.GroupGuildConfig +} + +func (c *CmdAutovc) GetDomainName() string { + return "sp.guild.config.autovc" +} + +func (c *CmdAutovc) GetSubPermissionRules() []shireikan.SubPermission { + return nil +} + +func (c *CmdAutovc) IsExecutableInDMChannels() bool { + return false +} + +func (c *CmdAutovc) Exec(ctx shireikan.Context) error { + db, _ := ctx.GetObject(static.DiDatabase).(database.Database) + st, _ := ctx.GetObject(static.DiState).(*dgrs.State) + + if len(ctx.GetArgs()) == 0 { + return c.list(ctx, db, st) + } + if ctx.GetArgs().Get(0).AsString() == "reset" { + return c.reset(ctx, db) + } + return c.set(ctx, db, st) +} + +func (c *CmdAutovc) list(ctx shireikan.Context, db database.Database, st *dgrs.State) (err error) { + autoVCIDs, err := db.GetGuildAutoVC(ctx.GetGuild().ID) + if err != nil && !database.IsErrDatabaseNotFound(err) { + return + } + + if len(autoVCIDs) == 0 { + return util.SendEmbed(ctx.GetSession(), ctx.GetChannel().ID, + "No auto voicechannels are set.", "", 0).DeleteAfter(10 * time.Second).Error() + } + + guildChannels, err := st.Channels(ctx.GetGuild().ID, true) + if err != nil { + return err + } + guildChannelIDs := make([]string, len(guildChannels)) + for i, channel := range guildChannels { + guildChannelIDs[i] = channel.ID + } + + if nc := stringutil.NotContained(autoVCIDs, guildChannelIDs); len(nc) > 0 { + autoVCIDs = stringutil.Contained(autoVCIDs, guildChannelIDs) + am, err := acceptmsg.New(). + WithSession(ctx.GetSession()). + DeleteAfterAnswer(). + LockOnUser(ctx.GetUser().ID). + WithContent(fmt.Sprintf( + "%d %s are not existent anymore. "+ + "Do you want to remove them now from the list of auto voicechannels?", + len(nc), util.Pluralize(len(nc), "autovoicechannel"))). + DoOnAccept(func(_ *discordgo.Message) error { + return db.SetGuildAutoVC(ctx.GetGuild().ID, autoVCIDs) + }). + Send(ctx.GetChannel().ID) + if err != nil { + return err + } + if err = am.Error(); err != nil { + return err + } + } + + var vcNames strings.Builder + vcNames.WriteString("Following auto voicechannel(s) are set:\n") + for _, vcid := range autoVCIDs { + vcNames.WriteString(fmt.Sprintf(" - <@&%s> (%s)\n", vcid, vcid)) + } + + return util.SendEmbed(ctx.GetSession(), ctx.GetChannel().ID, + vcNames.String(), "", 0).DeleteAfter(12 * time.Second).Error() +} + +func (c *CmdAutovc) set(ctx shireikan.Context, db database.Database, st *dgrs.State) (err error) { + autoVCIDs := make([]string, 0, len(ctx.GetArgs())) + + for _, arg := range ctx.GetArgs() { + if len(arg) == 0 { + continue + } + vc, err := fetch.FetchChannel(fetch.WrapDrgs(st), ctx.GetGuild().ID, arg) + if err != nil { + return err + } + autoVCIDs = append(autoVCIDs, vc.ID) + } + + if err = db.SetGuildAutoVC(ctx.GetGuild().ID, autoVCIDs); err != nil { + return err + } + + return util.SendEmbed(ctx.GetSession(), ctx.GetChannel().ID, + "Auto voicechannels set.", "", 0).DeleteAfter(5 * time.Second).Error() +} + +func (c *CmdAutovc) reset(ctx shireikan.Context, db database.Database) (err error) { + if err = db.SetGuildAutoVC(ctx.GetGuild().ID, []string{}); err != nil { + return err + } + + return util.SendEmbed(ctx.GetSession(), ctx.GetChannel().ID, + "Auto voicechannels reseted.", "", 0).DeleteAfter(5 * time.Second).Error() +} diff --git a/internal/inits/botsession.go b/internal/inits/botsession.go index 151bb4f4..1d25ffc4 100644 --- a/internal/inits/botsession.go +++ b/internal/inits/botsession.go @@ -93,6 +93,7 @@ func InitDiscordBotSession(container di.Container) (release func()) { session.AddHandler(listeners.NewListenerVote(container).Handler) session.AddHandler(listeners.NewListenerChannelCreate(container).Handler) session.AddHandler(listeners.NewListenerVoiceUpdate(container).Handler) + session.AddHandler(listeners.NewListenerAutoVoice(container).Handler) session.AddHandler(listeners.NewListenerKarma(container).Handler) session.AddHandler(listeners.NewListenerAntiraid(container).HandlerMemberAdd) session.AddHandler(listeners.NewListenerBotMention(container).Listener) diff --git a/internal/inits/commandhandler.go b/internal/inits/commandhandler.go index cca11be2..4c8678e9 100644 --- a/internal/inits/commandhandler.go +++ b/internal/inits/commandhandler.go @@ -54,6 +54,7 @@ func InitCommandHandler(container di.Container) (k *ken.Ken, err error) { new(messagecommands.Quote), new(slashcommands.Autorole), + new(slashcommands.Autovc), new(slashcommands.Backup), new(slashcommands.Bug), new(slashcommands.Clear), diff --git a/internal/inits/legacycmdhandler.go b/internal/inits/legacycmdhandler.go index d5078c8c..a8fd8e9b 100644 --- a/internal/inits/legacycmdhandler.go +++ b/internal/inits/legacycmdhandler.go @@ -75,6 +75,7 @@ func InitLegacyCommandHandler(container di.Container) shireikan.Handler { cmdHandler.RegisterCommand(&commands.CmdQuote{}) cmdHandler.RegisterCommand(&commands.CmdGame{}) cmdHandler.RegisterCommand(&commands.CmdAutorole{}) + cmdHandler.RegisterCommand(&commands.CmdAutovc{}) cmdHandler.RegisterCommand(&commands.CmdReport{}) cmdHandler.RegisterCommand(&commands.CmdModlog{}) cmdHandler.RegisterCommand(&commands.CmdKick{}) diff --git a/internal/listeners/autovc.go b/internal/listeners/autovc.go new file mode 100644 index 00000000..d8225d8c --- /dev/null +++ b/internal/listeners/autovc.go @@ -0,0 +1,124 @@ +package listeners + +import ( + "github.com/bwmarrin/discordgo" + "github.com/sarulabs/di/v2" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekrotja/dgrs" + "strings" +) + +type ListenerAutoVoice struct { + db database.Database + st *dgrs.State + pmw *permissions.Permissions + autovcCache map[string]string + voiceStateCache map[string]*discordgo.VoiceState +} + +func NewListenerAutoVoice(container di.Container) *ListenerAutoVoice { + return &ListenerAutoVoice{ + db: container.Get(static.DiDatabase).(database.Database), + st: container.Get(static.DiState).(*dgrs.State), + pmw: container.Get(static.DiPermissions).(*permissions.Permissions), + autovcCache: map[string]string{}, + voiceStateCache: map[string]*discordgo.VoiceState{}, + } +} + +func (l *ListenerAutoVoice) Handler(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { + + allowed, _, err := l.pmw.CheckPermissions(s, e.GuildID, e.UserID, "sp.chat.autochannel") + if err != nil || !allowed { + return + } + vsOld, _ := l.voiceStateCache[e.UserID] + vsNew := e.VoiceState + + l.voiceStateCache[e.UserID] = vsNew + + ids, err := l.db.GetGuildAutoVC(e.GuildID) + if err != nil { + return + } + idString := strings.Join(ids, ";") + + if vsOld == nil || (vsOld != nil && vsOld.ChannelID == "") { + + if !strings.Contains(idString, vsNew.ChannelID) { + return + } + + if err := l.createAutoVC(s, e.UserID, e.GuildID, vsNew.ChannelID); err != nil { + return + } + + } else if vsOld != nil && vsNew.ChannelID != "" && vsOld.ChannelID != vsNew.ChannelID { + + // we don't want to delete the channel, if the user get's moved to their auto voicechannel + if vsNew.ChannelID == l.autovcCache[e.UserID] { + + } else if strings.Contains(idString, vsNew.ChannelID) && l.autovcCache[e.UserID] == "" { + if l.autovcCache[e.UserID] == "" { + if err := l.createAutoVC(s, e.UserID, e.GuildID, vsNew.ChannelID); err != nil { + return + } + } else { + if err := l.deleteAutoVC(s, e.UserID); err != nil { + return + } + } + } else if l.autovcCache[e.UserID] != "" { + if err := l.deleteAutoVC(s, e.UserID); err != nil { + return + } + } + + } else if vsOld != nil && vsNew.ChannelID == "" { + if l.autovcCache[e.UserID] != "" { + if err := l.deleteAutoVC(s, e.UserID); err != nil { + return + } + } + + } +} + +func (l *ListenerAutoVoice) createAutoVC(s *discordgo.Session, userID, guildID, parentChannelId string) error { + parentCh, err := l.st.Channel(parentChannelId) + if err != nil { + return err + } + user, err := l.st.User(userID) + if err != nil { + return err + } + ch, err := s.GuildChannelCreate(guildID, user.Username, discordgo.ChannelTypeGuildVoice) + if err != nil { + return err + } + ch, err = s.ChannelEditComplex(ch.ID, &discordgo.ChannelEdit{ + ParentID: parentCh.ParentID, + Position: parentCh.Position, + }) + if err != nil { + return err + } + l.autovcCache[userID] = ch.ID + if err := s.GuildMemberMove(guildID, userID, &ch.ID); err != nil { + return err + } + return nil +} + +func (l *ListenerAutoVoice) deleteAutoVC(s *discordgo.Session, userID string) error { + vcID := l.autovcCache[userID] + _, err := s.ChannelDelete(vcID) + if err != nil { + return err + } + delete(l.autovcCache, userID) + return nil +} diff --git a/internal/services/database/database.go b/internal/services/database/database.go index 7573720c..2ae15b2f 100644 --- a/internal/services/database/database.go +++ b/internal/services/database/database.go @@ -38,6 +38,9 @@ type Database interface { GetGuildAutoRole(guildID string) ([]string, error) SetGuildAutoRole(guildID string, autoRoleIDs []string) error + GetGuildAutoVC(guildID string) ([]string, error) + SetGuildAutoVC(guildID string, autoVCIDs []string) error + GetGuildModLog(guildID string) (string, error) SetGuildModLog(guildID, chanID string) error diff --git a/internal/services/database/mysql/migrations.go b/internal/services/database/mysql/migrations.go index e673ebc2..1ceeaf65 100644 --- a/internal/services/database/mysql/migrations.go +++ b/internal/services/database/mysql/migrations.go @@ -16,6 +16,7 @@ var migrationFuncs = []migrationFunc{ migration_8, migration_9, migration_10, + migration_11, } // VERSION 0: @@ -119,3 +120,11 @@ func migration_10(m *sql.Tx) (err error) { "guilds", "`birthdaychanID` text NOT NULL DEFAULT ''") return } + +// VERSION 11: +// - add property `autovc` to `guilds` +func migration_11(m *sql.Tx) (err error) { + err = createTableColumnIfNotExists(m, + "guilds", "`autovc` text NOT NULL DEFAULT ''") + return +} diff --git a/internal/services/database/mysql/mysql.go b/internal/services/database/mysql/mysql.go index efcb95fb..8848bbba 100644 --- a/internal/services/database/mysql/mysql.go +++ b/internal/services/database/mysql/mysql.go @@ -95,6 +95,7 @@ func (m *MysqlMiddleware) setup() (err error) { "`guildID` varchar(25) NOT NULL," + "`prefix` text NOT NULL DEFAULT ''," + "`autorole` text NOT NULL DEFAULT ''," + + "`autovc` text NOT NULL DEFAULT ''," + "`modlogchanID` text NOT NULL DEFAULT ''," + "`voicelogchanID` text NOT NULL DEFAULT ''," + "`notifyRoleID` text NOT NULL DEFAULT ''," + @@ -515,6 +516,18 @@ func (m *MysqlMiddleware) SetGuildAutoRole(guildID string, autoRoleIDs []string) return m.setGuildSetting(guildID, "autorole", strings.Join(autoRoleIDs, ";")) } +func (m *MysqlMiddleware) GetGuildAutoVC(guildID string) ([]string, error) { + val, err := m.getGuildSetting(guildID, "autovc") + if val == "" { + return []string{}, err + } + return strings.Split(val, ";"), err +} + +func (m *MysqlMiddleware) SetGuildAutoVC(guildID string, autoVCIDs []string) error { + return m.setGuildSetting(guildID, "autovc", strings.Join(autoVCIDs, ";")) +} + func (m *MysqlMiddleware) GetGuildModLog(guildID string) (string, error) { val, err := m.getGuildSetting(guildID, "modlogchanID") return val, err diff --git a/internal/services/database/redis/redis.go b/internal/services/database/redis/redis.go index 5af75118..5ae6c437 100644 --- a/internal/services/database/redis/redis.go +++ b/internal/services/database/redis/redis.go @@ -17,6 +17,7 @@ const ( keyGuildPrefix = "GUILD:PREFIX" keyGuildAutoRole = "GUILD:AUTOROLE" + keyGuildAutoVC = "GUILD:AUTOVC" keyGuildModLog = "GUILD:MODLOG" keyGuildVoiceLog = "GUILD:VOICELOG" keyGuildNotifyRole = "GUILD:NOTROLE" @@ -142,6 +143,41 @@ func (r *RedisMiddleware) SetGuildAutoRole(guildID string, autoRoleIDs []string) return r.Database.SetGuildAutoRole(guildID, autoRoleIDs) } +func (r *RedisMiddleware) GetGuildAutoVC(guildID string) ([]string, error) { + var key = fmt.Sprintf("%s:%s", keyGuildAutoVC, guildID) + + valC, err := r.client.Get(context.Background(), key).Result() + val := strings.Split(valC, ";") + if err == redis.Nil { + val, err = r.Database.GetGuildAutoVC(guildID) + if err != nil { + return nil, err + } + + err = r.client.Set(context.Background(), key, strings.Join(val, ";"), 0).Err() + return val, err + } + if err != nil { + return nil, err + } + + if valC == "" { + return []string{}, nil + } + + return val, nil +} + +func (r *RedisMiddleware) SetGuildAutoVC(guildID string, autoVCIDs []string) error { + var key = fmt.Sprintf("%s:%s", keyGuildAutoVC, guildID) + + if err := r.client.Set(context.Background(), key, strings.Join(autoVCIDs, ";"), 0).Err(); err != nil { + return err + } + + return r.Database.SetGuildAutoVC(guildID, autoVCIDs) +} + func (r *RedisMiddleware) GetGuildModLog(guildID string) (string, error) { var key = fmt.Sprintf("%s:%s", keyGuildModLog, guildID) return Get(r, key, func() (string, error) { diff --git a/internal/slashcommands/autovc.go b/internal/slashcommands/autovc.go new file mode 100644 index 00000000..fe29bd2a --- /dev/null +++ b/internal/slashcommands/autovc.go @@ -0,0 +1,201 @@ +package slashcommands + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/zekroTJA/shinpuru/internal/services/database" + "github.com/zekroTJA/shinpuru/internal/services/permissions" + "github.com/zekroTJA/shinpuru/internal/util/static" + "github.com/zekroTJA/shinpuru/pkg/stringutil" + "github.com/zekrotja/ken" +) + +type Autovc struct{} + +var ( + _ ken.SlashCommand = (*Autovc)(nil) + _ permissions.PermCommand = (*Autovc)(nil) +) + +func (c *Autovc) Name() string { + return "autovc" +} + +func (c *Autovc) Description() string { + return "Manage guild auto voicechannels." +} + +func (c *Autovc) Version() string { + return "1.0.0" +} + +func (c *Autovc) Type() discordgo.ApplicationCommandType { + return discordgo.ChatApplicationCommand +} + +func (c *Autovc) Options() []*discordgo.ApplicationCommandOption { + return []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "show", + Description: "Display the currently set auto voicechannels.", + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "add", + Description: "Add an auto voicechannel.", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "voicechannel", + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + Description: "The voicechannel to be set.", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "remove", + Description: "Remove an auto voicechannel.", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + Name: "voicechannel", + Description: "The voicechannel to be set.", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "purge", + Description: "Unset all auto voicechannels.", + }, + } +} + +func (c *Autovc) Domain() string { + return "sp.guild.config.autovc" +} + +func (c *Autovc) SubDomains() []permissions.SubPermission { + return nil +} + +func (c *Autovc) Run(ctx *ken.Ctx) (err error) { + if err = ctx.Defer(); err != nil { + return + } + + err = ctx.HandleSubCommands( + ken.SubCommandHandler{"show", c.show}, + ken.SubCommandHandler{"add", c.add}, + ken.SubCommandHandler{"remove", c.remove}, + ken.SubCommandHandler{"purge", c.purge}, + ) + + return +} + +func (c *Autovc) show(ctx *ken.SubCommandCtx) (err error) { + db := ctx.Get(static.DiDatabase).(database.Database) + + autovcs, err := db.GetGuildAutoVC(ctx.Event.GuildID) + if err != nil { + return + } + + if len(autovcs) == 0 { + err = ctx.FollowUpEmbed(&discordgo.MessageEmbed{ + Description: "Currently, no auto voicechannels are defined.", + }).Error + return + } + + var res strings.Builder + for _, id := range autovcs { + res.WriteString(fmt.Sprintf("- <#%s>\n", id)) + } + + err = ctx.FollowUpEmbed(&discordgo.MessageEmbed{ + Description: "Currently, following channels are set as auto voicechannels:\n" + res.String(), + }).Error + + return +} + +func (c *Autovc) add(ctx *ken.SubCommandCtx) (err error) { + db := ctx.Get(static.DiDatabase).(database.Database) + + vc := ctx.Options().Get(0). + ChannelValue(ctx.Ctx) + + autovcs, err := db.GetGuildAutoVC(ctx.Event.GuildID) + if err != nil { + return + } + + if stringutil.ContainsAny(vc.ID, autovcs) { + err = ctx.FollowUpError("The given voicechannel is already assigned.", "").Error + return + } + + if err = db.SetGuildAutoVC(ctx.Event.GuildID, append(autovcs, vc.ID)); err != nil { + return + } + + err = ctx.FollowUpEmbed(&discordgo.MessageEmbed{ + Color: static.ColorEmbedGreen, + Description: "Voicechannel was successfully assigned as auto voicechannel.", + }).Error + + return +} + +func (c *Autovc) remove(ctx *ken.SubCommandCtx) (err error) { + db := ctx.Get(static.DiDatabase).(database.Database) + + vc := ctx.Options().Get(0). + ChannelValue(ctx.Ctx) + + autovcs, err := db.GetGuildAutoVC(ctx.Event.GuildID) + if err != nil { + return + } + + if !stringutil.ContainsAny(vc.ID, autovcs) { + err = ctx.FollowUpError("The given voicechannel is not assigned as auto voicechannel.", "").Error + return + } + + autovcs = stringutil.Splice(autovcs, stringutil.IndexOf(vc.ID, autovcs)) + if err = db.SetGuildAutoVC(ctx.Event.GuildID, autovcs); err != nil { + return + } + + err = ctx.FollowUpEmbed(&discordgo.MessageEmbed{ + Color: static.ColorEmbedGreen, + Description: "Channel was successfully removed as autochannel.", + }).Error + + return +} + +func (c *Autovc) purge(ctx *ken.SubCommandCtx) (err error) { + db := ctx.Get(static.DiDatabase).(database.Database) + + if err = db.SetGuildAutoVC(ctx.Event.GuildID, []string{}); err != nil { + return + } + + err = ctx.FollowUpEmbed(&discordgo.MessageEmbed{ + Color: static.ColorEmbedGreen, + Description: "All auto voicechannels were successfully removed.", + }).Error + + return +} diff --git a/internal/slashcommands/birthday.go b/internal/slashcommands/birthday.go index df3af52f..bd68e01c 100644 --- a/internal/slashcommands/birthday.go +++ b/internal/slashcommands/birthday.go @@ -49,7 +49,7 @@ func (c *Birthday) Options() []*discordgo.ApplicationCommandOption { return []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set-cannel", + Name: "set-channel", Description: "Set birthday message channel.", Options: []*discordgo.ApplicationCommandOption{ { @@ -65,7 +65,7 @@ func (c *Birthday) Options() []*discordgo.ApplicationCommandOption { }, { Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "unset-cannel", + Name: "unset-channel", Description: "Unset birthday message channel.", }, { @@ -115,8 +115,8 @@ func (c *Birthday) Run(ctx *ken.Ctx) (err error) { } err = ctx.HandleSubCommands( - ken.SubCommandHandler{"set-cannel", c.setChannel}, - ken.SubCommandHandler{"unset-cannel", c.unsetChannel}, + ken.SubCommandHandler{"set-channel", c.setChannel}, + ken.SubCommandHandler{"unset-channel", c.unsetChannel}, ken.SubCommandHandler{"set", c.set}, ken.SubCommandHandler{"remove", c.remove}, )